From 9eadd602bd22fe5fd21e376f9aa0764990efb6b6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 3 Jan 2018 23:13:08 -0500 Subject: [PATCH 01/11] Replace Response objects with PSR-7 response messages; improves #53 While the test suite passes, this commit yields a broken server: replacing ad hoc request objectss with PSR-7 ones is still required, as is emission of PSR-7 responses. Both will come in subsequent commits, with tests Diactoros was chosen specifically because it includes facilities for emitting responses, something which is awkward to test. The end of this refactoring should see both the Response and Request classes disappear, and the general REST class fully covered (as well as any speculative additions to AbstractHanlder). --- composer.json | 3 +- composer.lock | 104 +++++++++- lib/REST.php | 2 +- lib/REST/AbstractHandler.php | 2 +- lib/REST/Handler.php | 2 +- lib/REST/NextCloudNews/V1_2.php | 194 +++++++++--------- lib/REST/NextCloudNews/Versions.php | 13 +- lib/REST/TinyTinyRSS/API.php | 23 ++- lib/REST/TinyTinyRSS/Icon.php | 8 +- tests/cases/REST/NextCloudNews/TestV1_2.php | 162 +++++++-------- .../cases/REST/NextCloudNews/TestVersions.php | 25 +-- tests/cases/REST/TinyTinyRSS/TestAPI.php | 82 ++++---- tests/cases/REST/TinyTinyRSS/TestIcon.php | 26 +-- tests/lib/AbstractTest.php | 13 ++ vendor-bin/phpunit/composer.lock | 20 +- vendor-bin/robo/composer.lock | 76 +++++-- 16 files changed, 458 insertions(+), 297 deletions(-) diff --git a/composer.json b/composer.json index 02949a3..aa4ac4a 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "fguillot/picofeed": ">=0.1.31", "hosteurope/password-generator": "^1.0", "docopt/docopt": "^1.0", - "jkingweb/druuid": "^3.0" + "jkingweb/druuid": "^3.0", + "zendframework/zend-diactoros": "^1.6" }, "require-dev": { "bamarni/composer-bin-plugin": "*" diff --git a/composer.lock b/composer.lock index 8a76cbd..3c1262a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "8a3c7ff23f125a5fa3dac2e6a7244a90", + "content-hash": "7d381fa958169b7079c1d3c5b911f3bd", "packages": [ { "name": "docopt/docopt", @@ -190,6 +190,108 @@ ], "time": "2017-02-09T14:17:01+00:00" }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "zendframework/zend-diactoros", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/zendframework/zend-diactoros.git", + "reference": "c8664b92a6d5bc229e48b0923486c097e45a7877" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/c8664b92a6d5bc229e48b0923486c097e45a7877", + "reference": "c8664b92a6d5bc229e48b0923486c097e45a7877", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0", + "psr/http-message": "^1.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "ext-dom": "*", + "ext-libxml": "*", + "phpunit/phpunit": "^5.7.16 || ^6.0.8", + "zendframework/zend-coding-standard": "~1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6-dev", + "dev-develop": "1.7-dev" + } + }, + "autoload": { + "psr-4": { + "Zend\\Diactoros\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "description": "PSR HTTP Message implementations", + "homepage": "https://github.com/zendframework/zend-diactoros", + "keywords": [ + "http", + "psr", + "psr-7" + ], + "time": "2017-10-12T15:24:51+00:00" + }, { "name": "zendframework/zendxml", "version": "1.0.2", diff --git a/lib/REST.php b/lib/REST.php index d79c003..ea8c87d 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -48,7 +48,7 @@ class REST { public function __construct() { } - public function dispatch(REST\Request $req = null): REST\Response { + public function dispatch(REST\Request $req = null): \Psr\Http\Message\ResponseInterface { if ($req===null) { $req = new REST\Request(); } diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php index d471525..273e61a 100644 --- a/lib/REST/AbstractHandler.php +++ b/lib/REST/AbstractHandler.php @@ -11,7 +11,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; abstract class AbstractHandler implements Handler { abstract public function __construct(); - abstract public function dispatch(Request $req): Response; + abstract public function dispatch(Request $req): \Psr\Http\Message\ResponseInterface; protected function fieldMapNames(array $data, array $map): array { $out = []; diff --git a/lib/REST/Handler.php b/lib/REST/Handler.php index 05ddaff..4d4904a 100644 --- a/lib/REST/Handler.php +++ b/lib/REST/Handler.php @@ -8,5 +8,5 @@ namespace JKingWeb\Arsse\REST; interface Handler { public function __construct(); - public function dispatch(Request $req): Response; + public function dispatch(Request $req): \Psr\Http\Message\ResponseInterface; } diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 814ee57..bf39551 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -15,7 +15,9 @@ use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Feed\Exception as FeedException; -use JKingWeb\Arsse\REST\Response; +use \Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\EmptyResponse; class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { const REALM = "NextCloud News API v1-2"; @@ -72,10 +74,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { public function __construct() { } - public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { + public function dispatch(\JKingWeb\Arsse\REST\Request $req): ResponseInterface { // try to authenticate if (!Arsse::$user->authHTTP()) { - return new Response(401, "", "", ['WWW-Authenticate: Basic realm="'.self::REALM.'"']); + return new EmptyResponse(401, ['WWW-Authenticate' => 'Basic realm="'.self::REALM.'"']); } // handle HTTP OPTIONS requests if ($req->method=="OPTIONS") { @@ -85,12 +87,12 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { if ($req->body) { // if the entity body is not JSON according to content type, return "415 Unsupported Media Type" if (!preg_match("<^application/json\b|^$>", $req->type)) { - return new Response(415, "", "", ['Accept: application/json']); + return new EmptyResponse(415, ['Accept' => "application/json"]); } $data = @json_decode($req->body, true); if (json_last_error() != \JSON_ERROR_NONE) { // if the body could not be parsed as JSON, return "400 Bad Request" - return new Response(400); + return new EmptyResponse(400); } } else { $data = []; @@ -101,12 +103,12 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { try { $func = $this->chooseCall($req->paths, $req->method); } catch (Exception404 $e) { - return new Response(404); + return new EmptyResponse(404); } catch (Exception405 $e) { - return new Response(405, "", "", ["Allow: ".$e->getMessage()]); + return new EmptyResponse(405, ['Allow' => $e->getMessage()]); } if (!method_exists($this, $func)) { - return new Response(501); // @codeCoverageIgnore + return new EmptyResponse(501); // @codeCoverageIgnore } // dispatch try { @@ -114,10 +116,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // @codeCoverageIgnoreStart } catch (Exception $e) { // if there was a REST exception return 400 - return new Response(400); + return new EmptyResponse(400); } catch (AbstractException $e) { // if there was any other Arsse exception return 500 - return new Response(500); + return new EmptyResponse(500); } // @codeCoverageIgnoreEnd } @@ -242,7 +244,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { return $article; } - protected function handleHTTPOptions(array $url): Response { + protected function handleHTTPOptions(array $url): ResponseInterface { // normalize the URL path $url = $this->normalizePath($url); if (isset($this->paths[$url])) { @@ -252,81 +254,81 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { if (in_array("GET", $allowed)) { array_unshift($allowed, "HEAD"); } - return new Response(204, "", "", [ - "Allow: ".implode(",", $allowed), - "Accept: application/json", + return new EmptyResponse(204, [ + 'Allow' => implode(",", $allowed), + 'Accept' => "application/json", ]); } else { // if the path is not supported, return 404 - return new Response(404); + return new EmptyResponse(404); } } // list folders - protected function folderList(array $url, array $data): Response { + protected function folderList(array $url, array $data): ResponseInterface { $folders = []; foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $folder) { $folders[] = $this->folderTranslate($folder); } - return new Response(200, ['folders' => $folders]); + return new Response(['folders' => $folders]); } // create a folder - protected function folderAdd(array $url, array $data): Response { + protected function folderAdd(array $url, array $data): ResponseInterface { try { $folder = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => $data['name']]); } catch (ExceptionInput $e) { switch ($e->getCode()) { // folder already exists - case 10236: return new Response(409); + case 10236: return new EmptyResponse(409); // folder name not acceptable case 10231: - case 10232: return new Response(422); + case 10232: return new EmptyResponse(422); // other errors related to input - default: return new Response(400); // @codeCoverageIgnore + default: return new EmptyResponse(400); // @codeCoverageIgnore } } $folder = $this->folderTranslate(Arsse::$db->folderPropertiesGet(Arsse::$user->id, $folder)); - return new Response(200, ['folders' => [$folder]]); + return new Response(['folders' => [$folder]]); } // delete a folder - protected function folderRemove(array $url, array $data): Response { + protected function folderRemove(array $url, array $data): ResponseInterface { // perform the deletion try { Arsse::$db->folderRemove(Arsse::$user->id, (int) $url[1]); } catch (ExceptionInput $e) { // folder does not exist - return new Response(404); + return new EmptyResponse(404); } - return new Response(204); + return new EmptyResponse(204); } // rename a folder (also supports moving nesting folders, but this is not a feature of the API) - protected function folderRename(array $url, array $data): Response { + protected function folderRename(array $url, array $data): ResponseInterface { try { Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $url[1], ['name' => $data['name']]); } catch (ExceptionInput $e) { switch ($e->getCode()) { // folder does not exist - case 10239: return new Response(404); + case 10239: return new EmptyResponse(404); // folder already exists - case 10236: return new Response(409); + case 10236: return new EmptyResponse(409); // folder name not acceptable case 10231: - case 10232: return new Response(422); + case 10232: return new EmptyResponse(422); // other errors related to input - default: return new Response(400); // @codeCoverageIgnore + default: return new EmptyResponse(400); // @codeCoverageIgnore } } - return new Response(204); + return new EmptyResponse(204); } // mark all articles associated with a folder as read - protected function folderMarkRead(array $url, array $data): Response { + protected function folderMarkRead(array $url, array $data): ResponseInterface { if (!ValueInfo::id($data['newestItemId'])) { // if the item ID is invalid (i.e. not a positive integer), this is an error - return new Response(422); + return new EmptyResponse(422); } // build the context $c = new Context; @@ -337,16 +339,16 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); } catch (ExceptionInput $e) { // folder does not exist - return new Response(404); + return new EmptyResponse(404); } - return new Response(204); + return new EmptyResponse(204); } // return list of feeds which should be refreshed - protected function feedListStale(array $url, array $data): Response { + protected function feedListStale(array $url, array $data): ResponseInterface { // function requires admin rights per spec if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { - return new Response(403); + return new EmptyResponse(403); } // list stale feeds which should be checked for updates $feeds = Arsse::$db->feedListStale(); @@ -355,42 +357,42 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // since in our implementation feeds don't belong the users, the 'userId' field will always be an empty string $out[] = ['id' => $feed, 'userId' => ""]; } - return new Response(200, ['feeds' => $out]); + return new Response(['feeds' => $out]); } // refresh a feed - protected function feedUpdate(array $url, array $data): Response { + protected function feedUpdate(array $url, array $data): ResponseInterface { // function requires admin rights per spec if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { - return new Response(403); + return new EmptyResponse(403); } try { Arsse::$db->feedUpdate($data['feedId']); } catch (ExceptionInput $e) { switch ($e->getCode()) { case 10239: // feed does not exist - return new Response(404); + return new EmptyResponse(404); case 10237: // feed ID invalid - return new Response(422); + return new EmptyResponse(422); default: // other errors related to input - return new Response(400); // @codeCoverageIgnore + return new EmptyResponse(400); // @codeCoverageIgnore } } - return new Response(204); + return new EmptyResponse(204); } // add a new feed - protected function subscriptionAdd(array $url, array $data): Response { + protected function subscriptionAdd(array $url, array $data): ResponseInterface { // try to add the feed $tr = Arsse::$db->begin(); try { $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, (string) $data['url']); } catch (ExceptionInput $e) { // feed already exists - return new Response(409); + return new EmptyResponse(409); } catch (FeedException $e) { // feed could not be retrieved - return new Response(422); + return new EmptyResponse(422); } // if a folder was specified, move the feed to the correct folder; silently ignore errors if ($data['folderId']) { @@ -408,11 +410,11 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { if ($newest) { $out['newestItemId'] = $newest; } - return new Response(200, $out); + return new Response($out); } // return list of feeds for the logged-in user - protected function subscriptionList(array $url, array $data): Response { + protected function subscriptionList(array $url, array $data): ResponseInterface { $subs = Arsse::$db->subscriptionList(Arsse::$user->id); $out = []; foreach ($subs as $sub) { @@ -424,43 +426,43 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { if ($newest) { $out['newestItemId'] = $newest; } - return new Response(200, $out); + return new Response($out); } // delete a feed - protected function subscriptionRemove(array $url, array $data): Response { + protected function subscriptionRemove(array $url, array $data): ResponseInterface { try { Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $url[1]); } catch (ExceptionInput $e) { // feed does not exist - return new Response(404); + return new EmptyResponse(404); } - return new Response(204); + return new EmptyResponse(204); } // rename a feed - protected function subscriptionRename(array $url, array $data): Response { + protected function subscriptionRename(array $url, array $data): ResponseInterface { try { Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], ['title' => (string) $data['feedTitle']]); } catch (ExceptionInput $e) { switch ($e->getCode()) { // subscription does not exist - case 10239: return new Response(404); + case 10239: return new EmptyResponse(404); // name is invalid case 10231: - case 10232: return new Response(422); + case 10232: return new EmptyResponse(422); // other errors related to input - default: return new Response(400); // @codeCoverageIgnore + default: return new EmptyResponse(400); // @codeCoverageIgnore } } - return new Response(204); + return new EmptyResponse(204); } // move a feed to a folder - protected function subscriptionMove(array $url, array $data): Response { + protected function subscriptionMove(array $url, array $data): ResponseInterface { // if no folder is specified this is an error if (!isset($data['folderId'])) { - return new Response(422); + return new EmptyResponse(422); } // perform the move try { @@ -468,22 +470,22 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } catch (ExceptionInput $e) { switch ($e->getCode()) { case 10239: // subscription does not exist - return new Response(404); + return new EmptyResponse(404); case 10235: // folder does not exist case 10237: // folder ID is invalid - return new Response(422); + return new EmptyResponse(422); default: // other errors related to input - return new Response(400); // @codeCoverageIgnore + return new EmptyResponse(400); // @codeCoverageIgnore } } - return new Response(204); + return new EmptyResponse(204); } // mark all articles associated with a subscription as read - protected function subscriptionMarkRead(array $url, array $data): Response { + protected function subscriptionMarkRead(array $url, array $data): ResponseInterface { if (!ValueInfo::id($data['newestItemId'])) { // if the item ID is invalid (i.e. not a positive integer), this is an error - return new Response(422); + return new EmptyResponse(422); } // build the context $c = new Context; @@ -494,13 +496,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); } catch (ExceptionInput $e) { // subscription does not exist - return new Response(404); + return new EmptyResponse(404); } - return new Response(204); + return new EmptyResponse(204); } // list articles and their properties - protected function articleList(array $url, array $data): Response { + protected function articleList(array $url, array $data): ResponseInterface { // set the context options supplied by the client $c = new Context; // set the batch size @@ -553,32 +555,32 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $items = Arsse::$db->articleList(Arsse::$user->id, $c, Database::LIST_TYPICAL); } catch (ExceptionInput $e) { // ID of subscription or folder is not valid - return new Response(422); + return new EmptyResponse(422); } $out = []; foreach ($items as $item) { $out[] = $this->articleTranslate($item); } $out = ['items' => $out]; - return new Response(200, $out); + return new Response($out); } // mark all articles as read - protected function articleMarkReadAll(array $url, array $data): Response { + protected function articleMarkReadAll(array $url, array $data): ResponseInterface { if (!ValueInfo::id($data['newestItemId'])) { // if the item ID is invalid (i.e. not a positive integer), this is an error - return new Response(422); + return new EmptyResponse(422); } // build the context $c = new Context; $c->latestEdition((int) $data['newestItemId']); // perform the operation Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); - return new Response(204); + return new EmptyResponse(204); } // mark a single article as read - protected function articleMarkRead(array $url, array $data): Response { + protected function articleMarkRead(array $url, array $data): ResponseInterface { // initialize the matching context $c = new Context; $c->edition((int) $url[1]); @@ -588,13 +590,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c); } catch (ExceptionInput $e) { // ID is not valid - return new Response(404); + return new EmptyResponse(404); } - return new Response(204); + return new EmptyResponse(204); } // mark a single article as read - protected function articleMarkStarred(array $url, array $data): Response { + protected function articleMarkStarred(array $url, array $data): ResponseInterface { // initialize the matching context $c = new Context; $c->article((int) $url[2]); @@ -604,13 +606,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c); } catch (ExceptionInput $e) { // ID is not valid - return new Response(404); + return new EmptyResponse(404); } - return new Response(204); + return new EmptyResponse(204); } // mark an array of articles as read - protected function articleMarkReadMulti(array $url, array $data): Response { + protected function articleMarkReadMulti(array $url, array $data): ResponseInterface { // determine whether to mark read or unread $set = ($url[1]=="read"); // initialize the matching context @@ -620,11 +622,11 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c); } catch (ExceptionInput $e) { } - return new Response(204); + return new EmptyResponse(204); } // mark an array of articles as starred - protected function articleMarkStarredMulti(array $url, array $data): Response { + protected function articleMarkStarredMulti(array $url, array $data): ResponseInterface { // determine whether to mark starred or unstarred $set = ($url[1]=="star"); // initialize the matching context @@ -634,10 +636,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c); } catch (ExceptionInput $e) { } - return new Response(204); + return new EmptyResponse(204); } - protected function userStatus(array $url, array $data): Response { + protected function userStatus(array $url, array $data): ResponseInterface { $data = Arsse::$user->propertiesGet(Arsse::$user->id, true); // construct the avatar structure, if an image is available if (isset($data['avatar'])) { @@ -655,37 +657,37 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { 'lastLoginTimestamp' => time(), 'avatar' => $avatar, ]; - return new Response(200, $out); + return new Response($out); } - protected function cleanupBefore(array $url, array $data): Response { + protected function cleanupBefore(array $url, array $data): ResponseInterface { // function requires admin rights per spec if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { - return new Response(403); + return new EmptyResponse(403); } Service::cleanupPre(); - return new Response(204); + return new EmptyResponse(204); } - protected function cleanupAfter(array $url, array $data): Response { + protected function cleanupAfter(array $url, array $data): ResponseInterface { // function requires admin rights per spec if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { - return new Response(403); + return new EmptyResponse(403); } Service::cleanupPost(); - return new Response(204); + return new EmptyResponse(204); } // return the server version - protected function serverVersion(array $url, array $data): Response { - return new Response(200, [ + protected function serverVersion(array $url, array $data): ResponseInterface { + return new Response([ 'version' => self::VERSION, 'arsse_version' => Arsse::VERSION, ]); } - protected function serverStatus(array $url, array $data): Response { - return new Response(200, [ + protected function serverStatus(array $url, array $data): ResponseInterface { + return new Response([ 'version' => self::VERSION, 'arsse_version' => Arsse::VERSION, 'warnings' => [ diff --git a/lib/REST/NextCloudNews/Versions.php b/lib/REST/NextCloudNews/Versions.php index b51773f..1f97e77 100644 --- a/lib/REST/NextCloudNews/Versions.php +++ b/lib/REST/NextCloudNews/Versions.php @@ -6,22 +6,23 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\NextCloudNews; -use JKingWeb\Arsse\REST\Response; +use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\EmptyResponse; class Versions implements \JKingWeb\Arsse\REST\Handler { public function __construct() { } - public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { + public function dispatch(\JKingWeb\Arsse\REST\Request $req): \Psr\Http\Message\ResponseInterface { if (!preg_match("<^/?$>", $req->path)) { // if the request path is an empty string or just a slash, the client is probably trying a version we don't support - return new Response(404); + return new EmptyResponse(404); } elseif ($req->method=="OPTIONS") { // if the request method is OPTIONS, respond accordingly - return new Response(204, "", "", ["Allow: HEAD,GET"]); + return new EmptyResponse(204, ['Allow' => "HEAD,GET"]); } elseif ($req->method != "GET") { // if a method other than GET was used, this is an error - return new Response(405, "", "", ["Allow: HEAD,GET"]); + return new EmptyResponse(405, ['Allow' => "HEAD,GET"]); } else { // otherwise return the supported versions $out = [ @@ -29,7 +30,7 @@ class Versions implements \JKingWeb\Arsse\REST\Handler { 'v1-2', ] ]; - return new Response(200, $out); + return new Response($out); } } } diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 7435c80..2464954 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -19,7 +19,8 @@ use JKingWeb\Arsse\ExceptionType; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ResultEmpty; use JKingWeb\Arsse\Feed\Exception as FeedException; -use JKingWeb\Arsse\REST\Response; +use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; // emulated API level @@ -88,23 +89,23 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function __construct() { } - public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { + public function dispatch(\JKingWeb\Arsse\REST\Request $req): \Psr\Http\Message\ResponseInterface { if (!preg_match("<^(?:/(?:index\.php)?)?$>", $req->path)) { // reject paths other than the index - return new Response(404); + return new EmptyResponse(404); } if ($req->method=="OPTIONS") { // respond to OPTIONS rquests; the response is a fib, as we technically accept any type or method - return new Response(204, "", "", [ - "Allow: POST", - "Accept: application/json, text/json", + return new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/json, text/json", ]); } if ($req->body) { // only JSON entities are allowed, but Content-Type is ignored, as is request method $data = @json_decode($req->body, true); if (json_last_error() != \JSON_ERROR_NONE || !is_array($data)) { - return new Response(200, self::FATAL_ERR); + return new Response(self::FATAL_ERR); } try { // normalize input @@ -123,23 +124,23 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // TT-RSS operations are case-insensitive by dint of PHP method names being case-insensitive; this will only trigger if the method really doesn't exist throw new Exception("UNKNOWN_METHOD", ['method' => $data['op']]); } - return new Response(200, [ + return new Response([ 'seq' => $data['seq'], 'status' => 0, 'content' => $this->$method($data), ]); } catch (Exception $e) { - return new Response(200, [ + return new Response([ 'seq' => $data['seq'], 'status' => 1, 'content' => $e->getData(), ]); } catch (AbstractException $e) { - return new Response(500); + return new EmptyResponse(500); } } else { // absence of a request body indicates an error - return new Response(200, self::FATAL_ERR); + return new Response(self::FATAL_ERR); } } diff --git a/lib/REST/TinyTinyRSS/Icon.php b/lib/REST/TinyTinyRSS/Icon.php index aabb2c2..3641f17 100644 --- a/lib/REST/TinyTinyRSS/Icon.php +++ b/lib/REST/TinyTinyRSS/Icon.php @@ -7,16 +7,16 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\TinyTinyRSS; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\REST\Response; +use Zend\Diactoros\Response\EmptyResponse as Response; class Icon extends \JKingWeb\Arsse\REST\AbstractHandler { public function __construct() { } - public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { + public function dispatch(\JKingWeb\Arsse\REST\Request $req): \Psr\Http\Message\ResponseInterface { if ($req->method != "GET") { // only GET requests are allowed - return new Response(405, "", "", ["Allow: GET"]); + return new Response(405, ['Allow' => "GET"]); } elseif (!preg_match("<^(\d+)\.ico$>", $req->url, $match) || !((int) $match[1])) { return new Response(404); } @@ -26,7 +26,7 @@ class Icon extends \JKingWeb\Arsse\REST\AbstractHandler { if (($pos = strpos($url, "\r")) !== false || ($pos = strpos($url, "\n")) !== false) { $url = substr($url, 0, $pos); } - return new Response(301, "", "", ["Location: $url"]); + return new Response(301, ['Location' => $url]); } else { return new Response(404); } diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index 17c4679..d16e101 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -12,13 +12,14 @@ use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\REST\Request; -use JKingWeb\Arsse\REST\Response; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\NextCloudNews\V1_2; +use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\EmptyResponse; use Phake; /** @covers \JKingWeb\Arsse\REST\NextCloudNews\V1_2 */ @@ -317,14 +318,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $this->clearData(); } - protected function assertResponse(Response $exp, Response $act, string $text = null) { - $this->assertEquals($exp, $act, $text); - $this->assertSame($exp->payload, $act->payload, $text); - } - public function testSendAuthenticationChallenge() { Phake::when(Arsse::$user)->authHTTP->thenReturn(false); - $exp = new Response(401, "", "", ['WWW-Authenticate: Basic realm="'.V1_2::REALM.'"']); + $exp = new EmptyResponse(401, ['WWW-Authenticate' => 'Basic realm="'.V1_2::REALM.'"']); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/"))); } @@ -361,12 +357,12 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ], ]; foreach ($errs[404] as $req) { - $exp = new Response(404); + $exp = new EmptyResponse(404); list($method, $path) = $req; $this->assertResponse($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 404."); } foreach ($errs[405] as $allow => $cases) { - $exp = new Response(405, "", "", ['Allow: '.$allow]); + $exp = new EmptyResponse(405, ['Allow' => $allow]); foreach ($cases as $req) { list($method, $path) = $req; $this->assertResponse($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 405."); @@ -375,29 +371,29 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { } public function testRespondToInvalidInputTypes() { - $exp = new Response(415, "", "", ['Accept: application/json']); + $exp = new EmptyResponse(415, ['Accept' => "application/json"]); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '', 'application/xml'))); - $exp = new Response(400); + $exp = new EmptyResponse(400); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '', 'application/json'))); } public function testRespondToOptionsRequests() { - $exp = new Response(204, "", "", [ - "Allow: HEAD,GET,POST", - "Accept: application/json", + $exp = new EmptyResponse(204, [ + 'Allow' => "HEAD,GET,POST", + 'Accept' => "application/json", ]); $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/feeds"))); - $exp = new Response(204, "", "", [ - "Allow: DELETE", - "Accept: application/json", + $exp = new EmptyResponse(204, [ + 'Allow' => "DELETE", + 'Accept' => "application/json", ]); $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/feeds/2112"))); - $exp = new Response(204, "", "", [ - "Allow: HEAD,GET", - "Accept: application/json", + $exp = new EmptyResponse(204, [ + 'Allow' => "HEAD,GET", + 'Accept' => "application/json", ]); $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/user"))); - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/invalid/path"))); } @@ -411,9 +407,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ['id' => 12, 'name' => "Hardware"], ]; Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([]))->thenReturn(new Result($list)); - $exp = new Response(200, ['folders' => []]); + $exp = new Response(['folders' => []]); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/folders"))); - $exp = new Response(200, ['folders' => $out]); + $exp = new Response(['folders' => $out]); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/folders"))); } @@ -441,33 +437,33 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => ""])->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace")); // correctly add two folders, using different means - $exp = new Response(200, ['folders' => [$out[0]]]); + $exp = new Response(['folders' => [$out[0]]]); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[0]), 'application/json'))); - $exp = new Response(200, ['folders' => [$out[1]]]); + $exp = new Response(['folders' => [$out[1]]]); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders?name=Hardware"))); Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[0]); Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[1]); Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 1); Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2); // test bad folder names - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders"))); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":""}', 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":" "}', 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":{}}', 'application/json'))); // try adding the same two folders again - $exp = new Response(409); + $exp = new EmptyResponse(409); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders?name=Software"))); - $exp = new Response(409); + $exp = new EmptyResponse(409); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[1]), 'application/json'))); } public function testRemoveAFolder() { Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/folders/1"))); // fail on the second invocation because it no longer exists - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/folders/1"))); Phake::verify(Arsse::$db, Phake::times(2))->folderRemove(Arsse::$user->id, 1); } @@ -486,22 +482,22 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 1, $in[3])->thenThrow(new ExceptionInput("whitespace")); Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 1, $in[4])->thenReturn(true); // this should be stopped by the handler before the request gets to the database Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 3, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // folder ID 3 does not exist - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[0]), 'application/json'))); - $exp = new Response(409); + $exp = new EmptyResponse(409); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/2", json_encode($in[1]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[2]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[3]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[4]), 'application/json'))); - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/3", json_encode($in[0]), 'application/json'))); } public function testRetrieveServerVersion() { - $exp = new Response(200, [ + $exp = new Response([ 'version' => V1_2::VERSION, 'arsse_version' => Arsse::VERSION, ]); @@ -521,9 +517,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([]))->thenReturn(new Result($this->feeds['db'])); Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn(['total' => 0])->thenReturn(['total' => 5]); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915); - $exp = new Response(200, $exp1); + $exp = new Response($exp1); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds"))); - $exp = new Response(200, $exp2); + $exp = new Response($exp2); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds"))); } @@ -556,31 +552,31 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { // set up a mock for a bad feed which succeeds the second time Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.net/news.atom")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.net/news.atom", new \PicoFeed\Client\InvalidUrlException()))->thenReturn(47); // add the subscriptions - $exp = new Response(200, $out[0]); + $exp = new Response($out[0]); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); - $exp = new Response(200, $out[1]); + $exp = new Response($out[1]); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json'))); // try to add them a second time - $exp = new Response(409); + $exp = new EmptyResponse(409); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json'))); // try to add a bad feed - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[2]), 'application/json'))); // try again (this will succeed), with an invalid folder ID - $exp = new Response(200, $out[2]); + $exp = new Response($out[2]); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[3]), 'application/json'))); // try to add no feed - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[4]), 'application/json'))); } public function testRemoveASubscription() { Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1"))); // fail on the second invocation because it no longer exists - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1"))); Phake::verify(Arsse::$db, Phake::times(2))->subscriptionRemove(Arsse::$user->id, 1); } @@ -599,17 +595,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 2112])->thenThrow(new ExceptionInput("idMissing")); // folder does not exist Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder is invalid Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // subscription does not exist - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[0]), 'application/json'))); - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[1]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[2]), 'application/json'))); - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/move", json_encode($in[3]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[4]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[5]), 'application/json'))); } @@ -629,17 +625,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => ""]))->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => false]))->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[0]), 'application/json'))); - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[1]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[2]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[3]), 'application/json'))); - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/rename", json_encode($in[4]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[6]), 'application/json'))); } @@ -655,11 +651,11 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ], ]; Phake::when(Arsse::$db)->feedListStale->thenReturn(array_column($out, "id")); - $exp = new Response(200, ['feeds' => $out]); + $exp = new Response(['feeds' => $out]); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); // retrieving the list when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); - $exp = new Response(403); + $exp = new EmptyResponse(403); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); } @@ -674,17 +670,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->feedUpdate(42)->thenReturn(true); Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->feedUpdate($this->lessThan(1))->thenThrow(new ExceptionInput("typeViolation")); - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[1]), 'application/json'))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[2]), 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[3]), 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[4]), 'application/json'))); // updating a feed when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); - $exp = new Response(403); + $exp = new EmptyResponse(403); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); } @@ -710,12 +706,12 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("idMissing")); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); - $exp = new Response(200, ['items' => $this->articles['rest']]); + $exp = new Response(['items' => $this->articles['rest']]); // check the contents of the response $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items"))); // first instance of base context $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items/updated"))); // second instance of base context // check error conditions - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[0]), 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[1]), 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[2]), 'application/json'))); @@ -748,13 +744,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $in = json_encode(['newestItemId' => 2112]); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // folder doesn't exist - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read", $in, 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read?newestItemId=2112"))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read?newestItemId=ook"))); - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/42/read", $in, 'application/json'))); } @@ -763,13 +759,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $in = json_encode(['newestItemId' => 2112]); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // subscription doesn't exist - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read", $in, 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read?newestItemId=2112"))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read?newestItemId=ook"))); - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/read", $in, 'application/json'))); } @@ -777,10 +773,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $read = ['read' => true]; $in = json_encode(['newestItemId' => 2112]); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(42); - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read", $in, 'application/json'))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=2112"))); - $exp = new Response(422); + $exp = new EmptyResponse(422); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=ook"))); } @@ -798,12 +794,12 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $star, (new Context)->article(2112))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(4))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(1337))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/1/read"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/2/unread"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/1/3/star"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/4400/4/unstar"))); - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/42/read"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/47/unread"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/1/2112/star"))); @@ -829,7 +825,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->editions([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->articles([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple"))); $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple"))); @@ -882,29 +878,29 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ]; $arr2['warnings']['improperlyConfiguredCron'] = true; $arr2['warnings']['incorrectDbCharset'] = true; - $exp = new Response(200, $arr1); + $exp = new Response($arr1); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/status"))); } public function testCleanUpBeforeUpdate() { Phake::when(Arsse::$db)->feedCleanup()->thenReturn(true); - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); Phake::verify(Arsse::$db)->feedCleanup(); // performing a cleanup when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); - $exp = new Response(403); + $exp = new EmptyResponse(403); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); } public function testCleanUpAfterUpdate() { Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true); - $exp = new Response(204); + $exp = new EmptyResponse(204); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update"))); Phake::verify(Arsse::$db)->articleCleanup(); // performing a cleanup when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); - $exp = new Response(403); + $exp = new EmptyResponse(403); $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update"))); } } diff --git a/tests/cases/REST/NextCloudNews/TestVersions.php b/tests/cases/REST/NextCloudNews/TestVersions.php index 3081d57..6904d3e 100644 --- a/tests/cases/REST/NextCloudNews/TestVersions.php +++ b/tests/cases/REST/NextCloudNews/TestVersions.php @@ -8,7 +8,8 @@ namespace JKingWeb\Arsse\TestCase\REST\NextCloudNews; use JKingWeb\Arsse\REST\NextCloudNews\Versions; use JKingWeb\Arsse\REST\Request; -use JKingWeb\Arsse\REST\Response; +use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\EmptyResponse; /** @covers \JKingWeb\Arsse\REST\NextCloudNews\Versions */ class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest { @@ -17,43 +18,43 @@ class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest { } public function testFetchVersionList() { - $exp = new Response(200, ['apiLevels' => ['v1-2']]); + $exp = new Response(['apiLevels' => ['v1-2']]); $h = new Versions; $req = new Request("GET", "/"); $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $this->assertResponse($exp, $res); $req = new Request("GET", ""); $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $this->assertResponse($exp, $res); $req = new Request("GET", "/?id=1827"); $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $this->assertResponse($exp, $res); } public function testRespondToOptionsRequest() { - $exp = new Response(204, "", "", ["Allow: HEAD,GET"]); + $exp = new EmptyResponse(204, ['Allow' => "HEAD,GET"]); $h = new Versions; $req = new Request("OPTIONS", "/"); $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $this->assertResponse($exp, $res); } public function testUseIncorrectMethod() { - $exp = new Response(405, "", "", ["Allow: HEAD,GET"]); + $exp = new EmptyResponse(405, ['Allow' => "HEAD,GET"]); $h = new Versions; $req = new Request("POST", "/"); $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $this->assertResponse($exp, $res); } public function testUseIncorrectPath() { - $exp = new Response(404); + $exp = new EmptyResponse(404); $h = new Versions; $req = new Request("GET", "/ook"); $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $this->assertResponse($exp, $res); $req = new Request("OPTIONS", "/ook"); $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $this->assertResponse($exp, $res); } } diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 6844e9a..d2f1ff3 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -12,13 +12,15 @@ use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\REST\Request; -use JKingWeb\Arsse\REST\Response; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\TinyTinyRSS\API; +use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\EmptyResponse; use Phake; /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API @@ -122,12 +124,12 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { LONG_STRING; - protected function req($data) : Response { + protected function req($data): ResponseInterface { return $this->h->dispatch(new Request("POST", "", json_encode($data))); } protected function respGood($content = null, $seq = 0): Response { - return new Response(200, [ + return new Response([ 'seq' => $seq, 'status' => 0, 'content' => $content, @@ -136,18 +138,13 @@ LONG_STRING; protected function respErr(string $msg, $content = [], $seq = 0): Response { $err = ['error' => $msg]; - return new Response(200, [ + return new Response([ 'seq' => $seq, 'status' => 1, 'content' => array_merge($err, $content, $err), ]); } - protected function assertResponse(Response $exp, Response $act, string $text = null) { - $this->assertEquals($exp, $act, $text); - $this->assertSame($exp->payload, $act->payload, $text); - } - public function setUp() { $this->clearData(); Arsse::$conf = new Conf(); @@ -178,14 +175,14 @@ LONG_STRING; $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", ""))); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/", ""))); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/index.php", ""))); - $exp = new Response(404); + $exp = new EmptyResponse(404); $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/bad/path", ""))); } public function testHandleOptionsRequest() { - $exp = new Response(204, "", "", [ - "Allow: POST", - "Accept: application/json, text/json", + $exp = new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/json, text/json", ]); $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", ""))); } @@ -226,7 +223,7 @@ LONG_STRING; 'user' => Arsse::$user->id, 'password' => "secret", ]; - $exp = new Response(500); + $exp = new EmptyResponse(500); $this->assertResponse($exp, $this->req($data)); } @@ -1630,10 +1627,10 @@ LONG_STRING; $this->assertResponse($this->outputHeadlines(1), $test); // test 'show_content' $test = $this->req($in[1]); - $this->assertArrayHasKey("content", $test->payload['content'][0]); - $this->assertArrayHasKey("content", $test->payload['content'][1]); + $this->assertArrayHasKey("content", $test->getPayload()['content'][0]); + $this->assertArrayHasKey("content", $test->getPayload()['content'][1]); foreach ($this->generateHeadlines(1) as $key => $row) { - $this->assertSame($row['content'], $test->payload['content'][$key]['content']); + $this->assertSame($row['content'], $test->getPayload()['content'][$key]['content']); } // test 'include_attachments' $test = $this->req($in[2]); @@ -1649,25 +1646,23 @@ LONG_STRING; 'post_id' => "2112", ], ]; - $this->assertArrayHasKey("attachments", $test->payload['content'][0]); - $this->assertArrayHasKey("attachments", $test->payload['content'][1]); - $this->assertSame([], $test->payload['content'][0]['attachments']); - $this->assertSame($exp, $test->payload['content'][1]['attachments']); + $this->assertArrayHasKey("attachments", $test->getPayload()['content'][0]); + $this->assertArrayHasKey("attachments", $test->getPayload()['content'][1]); + $this->assertSame([], $test->getPayload()['content'][0]['attachments']); + $this->assertSame($exp, $test->getPayload()['content'][1]['attachments']); // test 'include_header' $test = $this->req($in[3]); - $exp = $this->outputHeadlines(1); - $exp->payload['content'] = [ + $exp = $this->respGood([ ['id' => -4, 'is_cat' => false, 'first_id' => 1], - $exp->payload['content'], - ]; + $this->outputHeadlines(1)->getPayload()['content'], + ]); $this->assertResponse($exp, $test); // test 'include_header' with a category $test = $this->req($in[4]); - $exp = $this->outputHeadlines(1); - $exp->payload['content'] = [ + $exp = $this->respGood([ ['id' => -3, 'is_cat' => true, 'first_id' => 1], - $exp->payload['content'], - ]; + $this->outputHeadlines(1)->getPayload()['content'], + ]); $this->assertResponse($exp, $test); // test 'include_header' with an empty result $test = $this->req($in[5]); @@ -1686,37 +1681,34 @@ LONG_STRING; $this->assertResponse($exp, $test); // test 'include_header' with ascending order $test = $this->req($in[7]); - $exp = $this->outputHeadlines(1); - $exp->payload['content'] = [ + $exp = $this->respGood([ ['id' => -4, 'is_cat' => false, 'first_id' => 0], - $exp->payload['content'], - ]; + $this->outputHeadlines(1)->getPayload()['content'], + ]); $this->assertResponse($exp, $test); // test 'include_header' with skip Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), Database::LIST_MINIMAL)->thenReturn($this->generateHeadlines(1867)); $test = $this->req($in[8]); - $exp = $this->outputHeadlines(1); - $exp->payload['content'] = [ + $exp = $this->respGood([ ['id' => 42, 'is_cat' => false, 'first_id' => 1867], - $exp->payload['content'], - ]; + $this->outputHeadlines(1)->getPayload()['content'], + ]); $this->assertResponse($exp, $test); // test 'include_header' with skip and ascending order $test = $this->req($in[9]); - $exp = $this->outputHeadlines(1); - $exp->payload['content'] = [ + $exp = $this->respGood([ ['id' => 42, 'is_cat' => false, 'first_id' => 0], - $exp->payload['content'], - ]; + $this->outputHeadlines(1)->getPayload()['content'], + ]); $this->assertResponse($exp, $test); // test 'show_excerpt' $exp1 = "“This & that, you know‽”"; $exp2 = "Pour vous faire mieux connaitre d’ou\u{300} vient l’erreur de ceux qui bla\u{302}ment la volupte\u{301}, et qui louent en…"; $test = $this->req($in[10]); - $this->assertArrayHasKey("excerpt", $test->payload['content'][0]); - $this->assertArrayHasKey("excerpt", $test->payload['content'][1]); - $this->assertSame($exp1, $test->payload['content'][0]['excerpt']); - $this->assertSame($exp2, $test->payload['content'][1]['excerpt']); + $this->assertArrayHasKey("excerpt", $test->getPayload()['content'][0]); + $this->assertArrayHasKey("excerpt", $test->getPayload()['content'][1]); + $this->assertSame($exp1, $test->getPayload()['content'][0]['excerpt']); + $this->assertSame($exp2, $test->getPayload()['content'][1]['excerpt']); } protected function generateHeadlines(int $id): Result { diff --git a/tests/cases/REST/TinyTinyRSS/TestIcon.php b/tests/cases/REST/TinyTinyRSS/TestIcon.php index 64f3358..c5c67bc 100644 --- a/tests/cases/REST/TinyTinyRSS/TestIcon.php +++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php @@ -12,7 +12,7 @@ use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\REST\TinyTinyRSS\Icon; use JKingWeb\Arsse\REST\Request; -use JKingWeb\Arsse\REST\Response; +use Zend\Diactoros\Response\EmptyResponse as Response; use Phake; /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Icon */ @@ -38,20 +38,20 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionFavicon(2112)->thenReturn("http://example.net/logo.png"); Phake::when(Arsse::$db)->subscriptionFavicon(1337)->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/"); // these requests should succeed - $exp = new Response(301, "", "", ["Location: http://example.com/favicon.ico"]); - $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "42.ico"))); - $exp = new Response(301, "", "", ["Location: http://example.net/logo.png"]); - $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.ico"))); - $exp = new Response(301, "", "", ["Location: http://example.org/icon.gif"]); - $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "1337.ico"))); + $exp = new Response(301, ['Location' => "http://example.com/favicon.ico"]); + $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "42.ico"))); + $exp = new Response(301, ['Location' => "http://example.net/logo.png"]); + $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "2112.ico"))); + $exp = new Response(301, ['Location' => "http://example.org/icon.gif"]); + $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "1337.ico"))); // these requests should fail $exp = new Response(404); - $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook.ico"))); - $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook"))); - $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "47.ico"))); - $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.png"))); + $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "ook.ico"))); + $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "ook"))); + $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "47.ico"))); + $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "2112.png"))); // only GET is allowed - $exp = new Response(405, "", "", ["Allow: GET"]); - $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "2112.ico"))); + $exp = new Response(405, ['Allow' => "GET"]); + $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "2112.ico"))); } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 85bb0eb..762e991 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -9,6 +9,9 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Misc\Date; +use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\Response\JsonResponse; +use Zend\Diactoros\Response\EmptyResponse; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { @@ -29,6 +32,16 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } } + protected function assertResponse(ResponseInterface $exp, ResponseInterface $act, string $text = null) { + $this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text); + $this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text); + $this->assertInstanceOf(get_class($exp), $act); + if ($exp instanceof JsonResponse) { + $this->assertEquals($exp->getPayload(), $act->getPayload(), $text); + $this->assertSame($exp->getPayload(), $act->getPayload(), $text); + } + } + public function approximateTime($exp, $act) { if (is_null($act)) { return null; diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index bb02864..26b133e 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -777,16 +777,16 @@ }, { "name": "phpunit/phpunit", - "version": "6.5.4", + "version": "6.5.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "1b2f933d5775f9237369deaa2d2bfbf9d652be4c" + "reference": "83d27937a310f2984fd575686138597147bdc7df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1b2f933d5775f9237369deaa2d2bfbf9d652be4c", - "reference": "1b2f933d5775f9237369deaa2d2bfbf9d652be4c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/83d27937a310f2984fd575686138597147bdc7df", + "reference": "83d27937a310f2984fd575686138597147bdc7df", "shasum": "" }, "require": { @@ -857,7 +857,7 @@ "testing", "xunit" ], - "time": "2017-12-10T08:06:19+00:00" + "time": "2017-12-17T06:31:19+00:00" }, { "name": "phpunit/phpunit-mock-objects", @@ -965,16 +965,16 @@ }, { "name": "sebastian/comparator", - "version": "2.1.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "1174d9018191e93cb9d719edec01257fc05f8158" + "reference": "b11c729f95109b56a0fe9650c6a63a0fcd8c439f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/1174d9018191e93cb9d719edec01257fc05f8158", - "reference": "1174d9018191e93cb9d719edec01257fc05f8158", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b11c729f95109b56a0fe9650c6a63a0fcd8c439f", + "reference": "b11c729f95109b56a0fe9650c6a63a0fcd8c439f", "shasum": "" }, "require": { @@ -1025,7 +1025,7 @@ "compare", "equality" ], - "time": "2017-11-03T07:16:52+00:00" + "time": "2017-12-22T14:50:35+00:00" }, { "name": "sebastian/diff", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index ee2d3df..3f95a03 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -59,28 +59,33 @@ }, { "name": "consolidation/config", - "version": "1.0.7", + "version": "1.0.9", "source": { "type": "git", "url": "https://github.com/consolidation/config.git", - "reference": "b59a3b9ea750c21397f26a68fd2e04d9580af42e" + "reference": "34ca8d7c1ee60a7b591b10617114cf1210a2e92c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/config/zipball/b59a3b9ea750c21397f26a68fd2e04d9580af42e", - "reference": "b59a3b9ea750c21397f26a68fd2e04d9580af42e", + "url": "https://api.github.com/repos/consolidation/config/zipball/34ca8d7c1ee60a7b591b10617114cf1210a2e92c", + "reference": "34ca8d7c1ee60a7b591b10617114cf1210a2e92c", "shasum": "" }, "require": { "dflydev/dot-access-data": "^1.1.0", - "grasmash/yaml-expander": "^1.1", + "grasmash/expander": "^1", "php": ">=5.4.0" }, "require-dev": { + "greg-1-anderson/composer-test-scenarios": "^1", "phpunit/phpunit": "^4", "satooshi/php-coveralls": "^1.0", "squizlabs/php_codesniffer": "2.*", - "symfony/console": "^2.5|^3" + "symfony/console": "^2.5|^3|^4", + "symfony/yaml": "^2.8.11|^3|^4" + }, + "suggest": { + "symfony/yaml": "Required to use Consolidation\\Config\\Loader\\YamlConfigLoader" }, "type": "library", "extra": { @@ -104,7 +109,7 @@ } ], "description": "Provide configuration services for a commandline tool.", - "time": "2017-10-25T05:50:10+00:00" + "time": "2017-12-22T17:28:19+00:00" }, { "name": "consolidation/log", @@ -205,16 +210,16 @@ }, { "name": "consolidation/robo", - "version": "1.2.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/consolidation/Robo.git", - "reference": "c46c13de3eca55e6b3635f363688ce85e845adf0" + "reference": "b6296f1cf1088f1a11b0b819f9e42ef6f00b79a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/Robo/zipball/c46c13de3eca55e6b3635f363688ce85e845adf0", - "reference": "c46c13de3eca55e6b3635f363688ce85e845adf0", + "url": "https://api.github.com/repos/consolidation/Robo/zipball/b6296f1cf1088f1a11b0b819f9e42ef6f00b79a9", + "reference": "b6296f1cf1088f1a11b0b819f9e42ef6f00b79a9", "shasum": "" }, "require": { @@ -278,7 +283,7 @@ } ], "description": "Modern task runner", - "time": "2017-12-13T02:10:49+00:00" + "time": "2017-12-29T06:48:35+00:00" }, { "name": "container-interop/container-interop", @@ -370,6 +375,53 @@ ], "time": "2017-01-20T21:14:22+00:00" }, + { + "name": "grasmash/expander", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/grasmash/expander.git", + "reference": "95d6037344a4be1dd5f8e0b0b2571a28c397578f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/grasmash/expander/zipball/95d6037344a4be1dd5f8e0b0b2571a28c397578f", + "reference": "95d6037344a4be1dd5f8e0b0b2571a28c397578f", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^1.1.0", + "php": ">=5.4" + }, + "require-dev": { + "greg-1-anderson/composer-test-scenarios": "^1", + "phpunit/phpunit": "^4|^5.5.4", + "satooshi/php-coveralls": "^1.0.2|dev-master", + "squizlabs/php_codesniffer": "^2.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Grasmash\\Expander\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthew Grasmick" + } + ], + "description": "Expands internal property references in PHP arrays file.", + "time": "2017-12-21T22:14:55+00:00" + }, { "name": "grasmash/yaml-expander", "version": "1.4.0", From 9ad0b47201ded3ea84d77d515c76288acb6db92b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Jan 2018 14:06:45 -0500 Subject: [PATCH 02/11] Add Target class to manipulate request traget URL parts The query part is not parsed for now because PSR-7 request objects/PHP take care of that parsing for us. --- lib/REST/Target.php | 131 ++++++++++++++++++++++++++++++++ tests/cases/REST/TestTarget.php | 66 ++++++++++++++++ tests/phpunit.xml | 19 ++--- 3 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 lib/REST/Target.php create mode 100644 tests/cases/REST/TestTarget.php diff --git a/lib/REST/Target.php b/lib/REST/Target.php new file mode 100644 index 0000000..bde4c6c --- /dev/null +++ b/lib/REST/Target.php @@ -0,0 +1,131 @@ +parseFragment($target); + $target = $this->parseQuery($target); + $this->path = $this->parsePath($target); + } + + public function __toString(): string { + $out = ""; + $path = []; + foreach ($this->path as $segment) { + if (is_null($segment)) { + if (!$path) { + $path[] = ".."; + } else { + continue; + } + } elseif ($segment==".") { + $path[] = "%2E"; + } elseif ($segment=="..") { + $path[] = "%2E%2E"; + } else { + $path[] = rawurlencode(ValueInfo::normalize($segment, ValueInfo::T_STRING)); + } + } + $path = implode("/", $path); + if (!$this->relative) { + $out .= "/"; + } + $out .= $path; + if ($this->index && strlen($path)) { + $out .= "/"; + } + if (strlen($this->query)) { + $out .= "?".$this->query; + } + if (strlen($this->fragment)) { + $out .= "#".rawurlencode($this->fragment); + } + return $out; + } + + public static function normalize(string $target): string { + return (string) new self($target); + } + + protected function parseFragment(string $target): string { + // store and strip off any fragment identifier and return the target without a fragment + $pos = strpos($target,"#"); + if ($pos !== false) { + $this->fragment = rawurldecode(substr($target, $pos + 1)); + $target = substr($target, 0, $pos); + } + return $target; + } + + protected function parseQuery(string $target): string { + // store and strip off any query string and return the target without a query + // note that the function assumes any fragment identifier has already been stripped off + // unlike the other parts the query string is currently neither parsed nor normalized + $pos = strpos($target,"?"); + if ($pos !== false) { + $this->query = substr($target, $pos + 1); + $target = substr($target, 0, $pos); + } + return $target; + } + + protected function parsePath(string $target): array { + // note that the function assumes any fragment identifier or query has already been stripped off + // syntax-based normalization is applied to the path segments (see RFC 3986 sec. 6.2.2) + // duplicate slashes are NOT collapsed + if (substr($target, 0, 1)=="/") { + // if the path starts with a slash, strip it off + $target = substr($target, 1); + } else { + // otherwise this is a relative target + $this->relative = true; + } + if (!strlen($target)) { + // if the target is an empty string, this is an index target + $this->index = true; + } elseif (substr($target, -1, 1)=="/") { + // if the path ends in a slash, this is an index target and the slash should be stripped off + $this->index = true; + $target = substr($target, 0, strlen($target) -1); + } + // after stripping, explode the path parts + if (strlen($target)) { + $target = explode("/", $target); + $out = []; + // resolve relative path segments and decode each retained segment + foreach($target as $index => $segment) { + if ($segment==".") { + // self-referential segments can be ignored + continue; + } elseif ($segment=="..") { + if ($index==0) { + // if the first path segment refers to its parent (which we don't know about) we cannot output a correct path, so we do the best we can + $out[] = null; + } else { + // for any other segments after the first we pop off the last stored segment + array_pop($out); + } + } else { + // any other segment is decoded and retained + $out[] = rawurldecode($segment); + } + } + return $out; + } else { + return []; + } + } +} \ No newline at end of file diff --git a/tests/cases/REST/TestTarget.php b/tests/cases/REST/TestTarget.php new file mode 100644 index 0000000..08555d8 --- /dev/null +++ b/tests/cases/REST/TestTarget.php @@ -0,0 +1,66 @@ + */ +class TestTarget extends \JKingWeb\Arsse\Test\AbstractTest { + + /** @dataProvider provideTargetUrls */ + public function testParseTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) { + $test = new Target($target); + $this->assertEquals($path, $test->path, "Path does not match"); + $this->assertSame($path, $test->path, "Path does not match exactly"); + $this->assertSame($relative, $test->relative, "Relative flag does not match"); + $this->assertSame($index, $test->index, "Index flag does not match"); + $this->assertSame($query, $test->query, "Query does not match"); + $this->assertSame($fragment, $test->fragment, "Fragment does not match"); + } + + /** @dataProvider provideTargetUrls */ + public function testNormalizeTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) { + $test = new Target(""); + $test->path = $path; + $test->relative = $relative; + $test->index = $index; + $test->query = $query; + $test->fragment = $fragment; + $this->assertSame($normalized, (string) $test); + $this->assertSame($normalized, Target::normalize($target)); + } + + public function provideTargetUrls() { + return [ + ["/", [], false, true, "", "", "/"], + ["", [], true, true, "", "", ""], + ["/index.php", ["index.php"], false, false, "", "", "/index.php"], + ["index.php", ["index.php"], true, false, "", "", "index.php"], + ["/ook/", ["ook"], false, true, "", "", "/ook/"], + ["ook/", ["ook"], true, true, "", "", "ook/"], + ["/eek/../ook/", ["ook"], false, true, "", "", "/ook/"], + ["eek/../ook/", ["ook"], true, true, "", "", "ook/"], + ["/./ook/", ["ook"], false, true, "", "", "/ook/"], + ["./ook/", ["ook"], true, true, "", "", "ook/"], + ["/../ook/", [null,"ook"], false, true, "", "", "/../ook/"], + ["../ook/", [null,"ook"], true, true, "", "", "../ook/"], + ["0", ["0"], true, false, "", "", "0"], + ["%6f%6F%6b", ["ook"], true, false, "", "", "ook"], + ["%2e%2E%2f%2E%2Fook%2f", [".././ook/"], true, false, "", "", "..%2F.%2Fook%2F"], + ["%2e%2E/%2E/ook%2f", ["..",".","ook/"], true, false, "", "", "%2E%2E/%2E/ook%2F"], + ["...", ["..."], true, false, "", "", "..."], + ["%2e%2e%2e", ["..."], true, false, "", "", "..."], + ["/?", [], false, true, "", "", "/"], + ["/#", [], false, true, "", "", "/"], + ["/?#", [], false, true, "", "", "/"], + ["#%2e", [], true, true, "", ".", "#."], + ["?%2e", [], true, true, "%2e", "", "?%2e"], + ["?%2e#%2f", [], true, true, "%2e", "/", "?%2e#%2F"], + ["#%2e?%2f", [], true, true, "", ".?/", "#.%3F%2F"], + ]; + } +} \ No newline at end of file diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 8ffa2b6..167ab87 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -65,15 +65,16 @@ cases/Db/SQLite3/Database/TestLabel.php cases/Db/SQLite3/Database/TestCleanup.php - - - cases/REST/NextCloudNews/TestVersions.php - cases/REST/NextCloudNews/TestV1_2.php - - - cases/REST/TinyTinyRSS/TestAPI.php - cases/REST/TinyTinyRSS/TestIcon.php - + + cases/REST/TestTarget.php + + + cases/REST/NextCloudNews/TestVersions.php + cases/REST/NextCloudNews/TestV1_2.php + + + cases/REST/TinyTinyRSS/TestAPI.php + cases/REST/TinyTinyRSS/TestIcon.php cases/Service/TestService.php From 890f9b07d40b6a2cde26329908021d03f86d92f2 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Jan 2018 23:08:53 -0500 Subject: [PATCH 03/11] Replace Resquest objects with PSR-7 request messages; improves #53 --- lib/REST/AbstractHandler.php | 4 +- lib/REST/Handler.php | 5 +- lib/REST/NextCloudNews/V1_2.php | 111 ++++--- lib/REST/NextCloudNews/Versions.php | 38 +-- lib/REST/Request.php | 89 ------ lib/REST/TinyTinyRSS/API.php | 13 +- lib/REST/TinyTinyRSS/Icon.php | 8 +- tests/cases/REST/NextCloudNews/TestV1_2.php | 278 ++++++++++-------- .../cases/REST/NextCloudNews/TestVersions.php | 46 ++- tests/cases/REST/TinyTinyRSS/TestAPI.php | 33 ++- tests/cases/REST/TinyTinyRSS/TestIcon.php | 29 +- tests/lib/AbstractTest.php | 2 +- 12 files changed, 327 insertions(+), 329 deletions(-) delete mode 100644 lib/REST/Request.php diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php index 273e61a..756ebe7 100644 --- a/lib/REST/AbstractHandler.php +++ b/lib/REST/AbstractHandler.php @@ -8,10 +8,12 @@ namespace JKingWeb\Arsse\REST; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; abstract class AbstractHandler implements Handler { abstract public function __construct(); - abstract public function dispatch(Request $req): \Psr\Http\Message\ResponseInterface; + abstract public function dispatch(ServerRequestInterface $req): ResponseInterface; protected function fieldMapNames(array $data, array $map): array { $out = []; diff --git a/lib/REST/Handler.php b/lib/REST/Handler.php index 4d4904a..3b2c88e 100644 --- a/lib/REST/Handler.php +++ b/lib/REST/Handler.php @@ -6,7 +6,10 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; + interface Handler { public function __construct(); - public function dispatch(Request $req): \Psr\Http\Message\ResponseInterface; + public function dispatch(ServerRequestInterface $req): ResponseInterface; } diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index bf39551..fe4b974 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -15,7 +15,9 @@ use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Feed\Exception as FeedException; -use \Psr\Http\Message\ResponseInterface; +use JKingWeb\Arsse\REST\Target; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; @@ -43,53 +45,61 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { 'items' => ValueInfo::T_MIXED | ValueInfo::M_ARRAY, ]; protected $paths = [ - 'folders' => ['GET' => "folderList", 'POST' => "folderAdd"], - 'folders/1' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"], - 'folders/1/read' => ['PUT' => "folderMarkRead"], - 'feeds' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"], - 'feeds/1' => ['DELETE' => "subscriptionRemove"], - 'feeds/1/move' => ['PUT' => "subscriptionMove"], - 'feeds/1/rename' => ['PUT' => "subscriptionRename"], - 'feeds/1/read' => ['PUT' => "subscriptionMarkRead"], - 'feeds/all' => ['GET' => "feedListStale"], - 'feeds/update' => ['GET' => "feedUpdate"], - 'items' => ['GET' => "articleList"], - 'items/updated' => ['GET' => "articleList"], - 'items/read' => ['PUT' => "articleMarkReadAll"], - 'items/1/read' => ['PUT' => "articleMarkRead"], - 'items/1/unread' => ['PUT' => "articleMarkRead"], - 'items/read/multiple' => ['PUT' => "articleMarkReadMulti"], - 'items/unread/multiple' => ['PUT' => "articleMarkReadMulti"], - 'items/1/1/star' => ['PUT' => "articleMarkStarred"], - 'items/1/1/unstar' => ['PUT' => "articleMarkStarred"], - 'items/star/multiple' => ['PUT' => "articleMarkStarredMulti"], - 'items/unstar/multiple' => ['PUT' => "articleMarkStarredMulti"], - 'cleanup/before-update' => ['GET' => "cleanupBefore"], - 'cleanup/after-update' => ['GET' => "cleanupAfter"], - 'version' => ['GET' => "serverVersion"], - 'status' => ['GET' => "serverStatus"], - 'user' => ['GET' => "userStatus"], + '/folders' => ['GET' => "folderList", 'POST' => "folderAdd"], + '/folders/1' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"], + '/folders/1/read' => ['PUT' => "folderMarkRead"], + '/feeds' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"], + '/feeds/1' => ['DELETE' => "subscriptionRemove"], + '/feeds/1/move' => ['PUT' => "subscriptionMove"], + '/feeds/1/rename' => ['PUT' => "subscriptionRename"], + '/feeds/1/read' => ['PUT' => "subscriptionMarkRead"], + '/feeds/all' => ['GET' => "feedListStale"], + '/feeds/update' => ['GET' => "feedUpdate"], + '/items' => ['GET' => "articleList"], + '/items/updated' => ['GET' => "articleList"], + '/items/read' => ['PUT' => "articleMarkReadAll"], + '/items/1/read' => ['PUT' => "articleMarkRead"], + '/items/1/unread' => ['PUT' => "articleMarkRead"], + '/items/read/multiple' => ['PUT' => "articleMarkReadMulti"], + '/items/unread/multiple' => ['PUT' => "articleMarkReadMulti"], + '/items/1/1/star' => ['PUT' => "articleMarkStarred"], + '/items/1/1/unstar' => ['PUT' => "articleMarkStarred"], + '/items/star/multiple' => ['PUT' => "articleMarkStarredMulti"], + '/items/unstar/multiple' => ['PUT' => "articleMarkStarredMulti"], + '/cleanup/before-update' => ['GET' => "cleanupBefore"], + '/cleanup/after-update' => ['GET' => "cleanupAfter"], + '/version' => ['GET' => "serverVersion"], + '/status' => ['GET' => "serverStatus"], + '/user' => ['GET' => "userStatus"], ]; public function __construct() { } - public function dispatch(\JKingWeb\Arsse\REST\Request $req): ResponseInterface { + public function dispatch(ServerRequestInterface $req): ResponseInterface { // try to authenticate if (!Arsse::$user->authHTTP()) { return new EmptyResponse(401, ['WWW-Authenticate' => 'Basic realm="'.self::REALM.'"']); } + // explode and normalize the URL path + $target = new Target($req->getRequestTarget()); // handle HTTP OPTIONS requests - if ($req->method=="OPTIONS") { - return $this->handleHTTPOptions($req->paths); + if ($req->getMethod()=="OPTIONS") { + return $this->handleHTTPOptions((string) $target); } // normalize the input - if ($req->body) { + $data = (string) $req->getBody(); + $type = ""; + if ($req->hasHeader("Content-Type")) { + $type = $req->getHeader("Content-Type"); + $type = array_pop($type); + } + if ($data) { // if the entity body is not JSON according to content type, return "415 Unsupported Media Type" - if (!preg_match("<^application/json\b|^$>", $req->type)) { + if (!preg_match("<^application/json\b|^$>", $type)) { return new EmptyResponse(415, ['Accept' => "application/json"]); } - $data = @json_decode($req->body, true); + $data = @json_decode($data, true); if (json_last_error() != \JSON_ERROR_NONE) { // if the body could not be parsed as JSON, return "400 Bad Request" return new EmptyResponse(400); @@ -98,10 +108,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $data = []; } // FIXME: Do query parameters take precedence in NextCloud? Is there a conflict error when values differ? - $data = $this->normalizeInput(array_merge($data, $req->query), $this->validInput, "unix"); + $data = $this->normalizeInput(array_merge($data, $req->getQueryParams()), $this->validInput, "unix"); // check to make sure the requested function is implemented try { - $func = $this->chooseCall($req->paths, $req->method); + $func = $this->chooseCall((string) $target, $req->getMethod()); } catch (Exception404 $e) { return new EmptyResponse(404); } catch (Exception405 $e) { @@ -112,7 +122,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { } // dispatch try { - return $this->$func($req->paths, $data); + return $this->$func($target->path, $data); // @codeCoverageIgnoreStart } catch (Exception $e) { // if there was a REST exception return 400 @@ -124,19 +134,24 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { // @codeCoverageIgnoreEnd } - protected function normalizePath(array $url): string { - // any URL components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID) - for ($a = 0; $a < sizeof($url); $a++) { - if (ValueInfo::id($url[$a])) { - $url[$a] = "1"; + protected function normalizePathIds(string $url): string { + // first parse the URL and perform syntactic normalization + $target = new Target($url); + // any path components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID) + for ($a = 0; $a < sizeof($target->path); $a++) { + if (ValueInfo::id($target->path[$a])) { + $target->path[$a] = "1"; } } - return implode("/", $url); + // discard any fragment ID (there shouldn't be any) and query string (the query is available in the request itself) + $target->fragment = ""; + $target->query = ""; + return (string) $target; } - protected function chooseCall(array $url, string $method): string { - // normalize the URL path - $url = $this->normalizePath($url); + protected function chooseCall(string $url, string $method): string { + // // normalize the URL path: change any IDs to 1 for easier comparison + $url = $this->normalizePathIds($url); // normalize the HTTP method to uppercase $method = strtoupper($method); // we now evaluate the supplied URL against every supported path for the selected scope @@ -244,9 +259,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { return $article; } - protected function handleHTTPOptions(array $url): ResponseInterface { - // normalize the URL path - $url = $this->normalizePath($url); + protected function handleHTTPOptions(string $url): ResponseInterface { + // normalize the URL path: change any IDs to 1 for easier comparison + $url = $this->normalizePathIDs($url); if (isset($this->paths[$url])) { // if the path is supported, respond with the allowed methods and other metadata $allowed = array_keys($this->paths[$url]); diff --git a/lib/REST/NextCloudNews/Versions.php b/lib/REST/NextCloudNews/Versions.php index 1f97e77..77924bd 100644 --- a/lib/REST/NextCloudNews/Versions.php +++ b/lib/REST/NextCloudNews/Versions.php @@ -6,6 +6,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\NextCloudNews; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; @@ -13,24 +15,26 @@ class Versions implements \JKingWeb\Arsse\REST\Handler { public function __construct() { } - public function dispatch(\JKingWeb\Arsse\REST\Request $req): \Psr\Http\Message\ResponseInterface { - if (!preg_match("<^/?$>", $req->path)) { - // if the request path is an empty string or just a slash, the client is probably trying a version we don't support + public function dispatch(ServerRequestInterface $req): ResponseInterface { + if (!preg_match("<^/?$>", $req->getRequestTarget())) { + // if the request path is more than an empty string or a slash, the client is probably trying a version we don't support return new EmptyResponse(404); - } elseif ($req->method=="OPTIONS") { - // if the request method is OPTIONS, respond accordingly - return new EmptyResponse(204, ['Allow' => "HEAD,GET"]); - } elseif ($req->method != "GET") { - // if a method other than GET was used, this is an error - return new EmptyResponse(405, ['Allow' => "HEAD,GET"]); - } else { - // otherwise return the supported versions - $out = [ - 'apiLevels' => [ - 'v1-2', - ] - ]; - return new Response($out); + } + switch ($req->getMethod()) { + case "OPTIONS": + // if the request method is OPTIONS, respond accordingly + return new EmptyResponse(204, ['Allow' => "HEAD,GET"]); + case "GET": + // otherwise return the supported versions + $out = [ + 'apiLevels' => [ + 'v1-2', + ] + ]; + return new Response($out); + default: + // if any other method was used, this is an error + return new EmptyResponse(405, ['Allow' => "HEAD,GET"]); } } } diff --git a/lib/REST/Request.php b/lib/REST/Request.php deleted file mode 100644 index 157027a..0000000 --- a/lib/REST/Request.php +++ /dev/null @@ -1,89 +0,0 @@ -method = strtoupper($method); - $this->url = $url; - $this->body = $body; - $this->type = $contentType; - if ($this->method=="HEAD") { - $this->head = true; - $this->method = "GET"; - } - $this->refreshURL(); - } - - public function refreshURL() { - $url = $this->parseURL($this->url); - $this->path = $url['path']; - $this->paths = $url['paths']; - $this->query = $url['query']; - } - - protected function parseURL(string $url): array { - // split the query string from the path - $parts = explode("?", $url); - $out = ['path' => $parts[0], 'paths' => [''], 'query' => []]; - // if there is a query string, parse it - if (isset($parts[1])) { - // split along & to get key-value pairs - $query = explode("&", $parts[1]); - for ($a = 0; $a < sizeof($query); $a++) { - // split each pair, into no more than two parts - $data = explode("=", $query[$a], 2); - // decode the key - $key = rawurldecode($data[0]); - // decode the value if there is one - $value = ""; - if (isset($data[1])) { - $value = rawurldecode($data[1]); - } - // add the pair to the query output, overwriting earlier values for the same key, is present - $out['query'][$key] = $value; - } - } - // also include the path as a set of decoded elements - // if the path is an empty string or just / nothing needs be done - if (!in_array($out['path'], ["/",""])) { - $paths = explode("/", $out['path']); - // remove the first and last empty elements, if present (they are artefacts of the splitting; others should remain) - if (!strlen($paths[0])) { - array_shift($paths); - } - if (!strlen($paths[sizeof($paths)-1])) { - array_pop($paths); - } - // %-decode each path element - $paths = array_map(function ($v) { - return rawurldecode($v); - }, $paths); - $out['paths'] = $paths; - } - return $out; - } -} diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 2464954..6f94bb4 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -19,6 +19,8 @@ use JKingWeb\Arsse\ExceptionType; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ResultEmpty; use JKingWeb\Arsse\Feed\Exception as FeedException; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; @@ -89,21 +91,22 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function __construct() { } - public function dispatch(\JKingWeb\Arsse\REST\Request $req): \Psr\Http\Message\ResponseInterface { - if (!preg_match("<^(?:/(?:index\.php)?)?$>", $req->path)) { + public function dispatch(ServerRequestInterface $req): ResponseInterface { + if (!preg_match("<^(?:/(?:index\.php)?)?$>", $req->getRequestTarget())) { // reject paths other than the index return new EmptyResponse(404); } - if ($req->method=="OPTIONS") { + if ($req->getMethod()=="OPTIONS") { // respond to OPTIONS rquests; the response is a fib, as we technically accept any type or method return new EmptyResponse(204, [ 'Allow' => "POST", 'Accept' => "application/json, text/json", ]); } - if ($req->body) { + $data = (string) $req->getBody(); + if ($data) { // only JSON entities are allowed, but Content-Type is ignored, as is request method - $data = @json_decode($req->body, true); + $data = @json_decode($data, true); if (json_last_error() != \JSON_ERROR_NONE || !is_array($data)) { return new Response(self::FATAL_ERR); } diff --git a/lib/REST/TinyTinyRSS/Icon.php b/lib/REST/TinyTinyRSS/Icon.php index 3641f17..ef2d0c0 100644 --- a/lib/REST/TinyTinyRSS/Icon.php +++ b/lib/REST/TinyTinyRSS/Icon.php @@ -7,17 +7,19 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\TinyTinyRSS; use JKingWeb\Arsse\Arsse; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\EmptyResponse as Response; class Icon extends \JKingWeb\Arsse\REST\AbstractHandler { public function __construct() { } - public function dispatch(\JKingWeb\Arsse\REST\Request $req): \Psr\Http\Message\ResponseInterface { - if ($req->method != "GET") { + public function dispatch(ServerRequestInterface $req): ResponseInterface { + if ($req->getMethod() != "GET") { // only GET requests are allowed return new Response(405, ['Allow' => "GET"]); - } elseif (!preg_match("<^(\d+)\.ico$>", $req->url, $match) || !((int) $match[1])) { + } elseif (!preg_match("<^(\d+)\.ico$>", $req->getRequestTarget(), $match) || !((int) $match[1])) { return new Response(404); } $url = Arsse::$db->subscriptionFavicon((int) $match[1]); diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index d16e101..fd20160 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -11,13 +11,14 @@ use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\NextCloudNews\V1_2; +use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; use Phake; @@ -300,6 +301,40 @@ 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"); + 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); + return $this->h->dispatch($req); + } + public function setUp() { $this->clearData(); Arsse::$conf = new Conf(); @@ -321,7 +356,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { public function testSendAuthenticationChallenge() { Phake::when(Arsse::$user)->authHTTP->thenReturn(false); $exp = new EmptyResponse(401, ['WWW-Authenticate' => 'Basic realm="'.V1_2::REALM.'"']); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/"))); + $this->assertResponse($exp, $this->req("GET", "/")); } public function testRespondToInvalidPaths() { @@ -359,22 +394,23 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { foreach ($errs[404] as $req) { $exp = new EmptyResponse(404); list($method, $path) = $req; - $this->assertResponse($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 404."); + $this->assertResponse($exp, $this->req($method, $path), "$method call to $path did not return 404."); } foreach ($errs[405] as $allow => $cases) { $exp = new EmptyResponse(405, ['Allow' => $allow]); foreach ($cases as $req) { list($method, $path) = $req; - $this->assertResponse($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 405."); + $this->assertResponse($exp, $this->req($method, $path), "$method call to $path did not return 405."); } } } public function testRespondToInvalidInputTypes() { $exp = new EmptyResponse(415, ['Accept' => "application/json"]); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '', 'application/xml'))); + $this->assertResponse($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => "application/xml"])); $exp = new EmptyResponse(400); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '', 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/folders/1", '')); + $this->assertResponse($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => null])); } public function testRespondToOptionsRequests() { @@ -382,19 +418,19 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { 'Allow' => "HEAD,GET,POST", 'Accept' => "application/json", ]); - $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/feeds"))); + $this->assertResponse($exp, $this->req("OPTIONS", "/feeds")); $exp = new EmptyResponse(204, [ 'Allow' => "DELETE", 'Accept' => "application/json", ]); - $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/feeds/2112"))); + $this->assertResponse($exp, $this->req("OPTIONS", "/feeds/2112")); $exp = new EmptyResponse(204, [ 'Allow' => "HEAD,GET", 'Accept' => "application/json", ]); - $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/user"))); + $this->assertResponse($exp, $this->req("OPTIONS", "/user")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/invalid/path"))); + $this->assertResponse($exp, $this->req("OPTIONS", "/invalid/path")); } public function testListFolders() { @@ -408,9 +444,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ]; Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([]))->thenReturn(new Result($list)); $exp = new Response(['folders' => []]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/folders"))); + $this->assertResponse($exp, $this->req("GET", "/folders")); $exp = new Response(['folders' => $out]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/folders"))); + $this->assertResponse($exp, $this->req("GET", "/folders")); } public function testAddAFolder() { @@ -438,33 +474,33 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace")); // correctly add two folders, using different means $exp = new Response(['folders' => [$out[0]]]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[0]), 'application/json'))); + $this->assertResponse($exp, $this->req("POST", "/folders", json_encode($in[0]))); $exp = new Response(['folders' => [$out[1]]]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders?name=Hardware"))); + $this->assertResponse($exp, $this->req("POST", "/folders?name=Hardware")); Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[0]); Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[1]); Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 1); Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2); // test bad folder names $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":""}', 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":" "}', 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":{}}', 'application/json'))); + $this->assertResponse($exp, $this->req("POST", "/folders")); + $this->assertResponse($exp, $this->req("POST", "/folders", '{"name":""}')); + $this->assertResponse($exp, $this->req("POST", "/folders", '{"name":" "}')); + $this->assertResponse($exp, $this->req("POST", "/folders", '{"name":{}}')); // try adding the same two folders again $exp = new EmptyResponse(409); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders?name=Software"))); + $this->assertResponse($exp, $this->req("POST", "/folders?name=Software")); $exp = new EmptyResponse(409); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[1]), 'application/json'))); + $this->assertResponse($exp, $this->req("POST", "/folders", json_encode($in[1]))); } public function testRemoveAFolder() { Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/folders/1"))); + $this->assertResponse($exp, $this->req("DELETE", "/folders/1")); // fail on the second invocation because it no longer exists $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/folders/1"))); + $this->assertResponse($exp, $this->req("DELETE", "/folders/1")); Phake::verify(Arsse::$db, Phake::times(2))->folderRemove(Arsse::$user->id, 1); } @@ -483,17 +519,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 1, $in[4])->thenReturn(true); // this should be stopped by the handler before the request gets to the database Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 3, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // folder ID 3 does not exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[0]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/folders/1", json_encode($in[0]))); $exp = new EmptyResponse(409); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/2", json_encode($in[1]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/folders/2", json_encode($in[1]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[2]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/folders/1", json_encode($in[2]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[3]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/folders/1", json_encode($in[3]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[4]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/folders/1", json_encode($in[4]))); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/3", json_encode($in[0]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/folders/3", json_encode($in[0]))); } public function testRetrieveServerVersion() { @@ -501,7 +537,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { 'version' => V1_2::VERSION, 'arsse_version' => Arsse::VERSION, ]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/version"))); + $this->assertResponse($exp, $this->req("GET", "/version")); } public function testListSubscriptions() { @@ -518,9 +554,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn(['total' => 0])->thenReturn(['total' => 5]); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915); $exp = new Response($exp1); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds"))); + $this->assertResponse($exp, $this->req("GET", "/feeds")); $exp = new Response($exp2); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds"))); + $this->assertResponse($exp, $this->req("GET", "/feeds")); } public function testAddASubscription() { @@ -553,31 +589,31 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.net/news.atom")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.net/news.atom", new \PicoFeed\Client\InvalidUrlException()))->thenReturn(47); // add the subscriptions $exp = new Response($out[0]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); + $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[0]))); $exp = new Response($out[1]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json'))); + $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[1]))); // try to add them a second time $exp = new EmptyResponse(409); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json'))); + $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[0]))); + $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[1]))); // try to add a bad feed $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[2]), 'application/json'))); + $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[2]))); // try again (this will succeed), with an invalid folder ID $exp = new Response($out[2]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[3]), 'application/json'))); + $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[3]))); // try to add no feed $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[4]), 'application/json'))); + $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[4]))); } public function testRemoveASubscription() { Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1"))); + $this->assertResponse($exp, $this->req("DELETE", "/feeds/1")); // fail on the second invocation because it no longer exists $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1"))); + $this->assertResponse($exp, $this->req("DELETE", "/feeds/1")); Phake::verify(Arsse::$db, Phake::times(2))->subscriptionRemove(Arsse::$user->id, 1); } @@ -596,17 +632,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder is invalid Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // subscription does not exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[0]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[0]))); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[1]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[1]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[2]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[2]))); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/move", json_encode($in[3]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/42/move", json_encode($in[3]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[4]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[4]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[5]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[5]))); } public function testRenameASubscription() { @@ -626,17 +662,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => false]))->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[0]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[0]))); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[1]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[1]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[2]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[2]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[3]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[3]))); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/rename", json_encode($in[4]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/42/rename", json_encode($in[4]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[6]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[6]))); } public function testListStaleFeeds() { @@ -652,11 +688,11 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ]; Phake::when(Arsse::$db)->feedListStale->thenReturn(array_column($out, "id")); $exp = new Response(['feeds' => $out]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); + $this->assertResponse($exp, $this->req("GET", "/feeds/all")); // retrieving the list when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); $exp = new EmptyResponse(403); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); + $this->assertResponse($exp, $this->req("GET", "/feeds/all")); } public function testUpdateAFeed() { @@ -671,17 +707,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->feedUpdate($this->lessThan(1))->thenThrow(new ExceptionInput("typeViolation")); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); + $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[0]))); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[1]), 'application/json'))); + $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[1]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[2]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[3]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[4]), 'application/json'))); + $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[2]))); + $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[3]))); + $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[4]))); // updating a feed when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); $exp = new EmptyResponse(403); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); + $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[0]))); } public function testListArticles() { @@ -708,23 +744,23 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); $exp = new Response(['items' => $this->articles['rest']]); // check the contents of the response - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items"))); // first instance of base context - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items/updated"))); // second instance of base context + $this->assertResponse($exp, $this->req("GET", "/items")); // first instance of base context + $this->assertResponse($exp, $this->req("GET", "/items/updated")); // second instance of base context // check error conditions $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[0]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[1]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[2]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[3]), 'application/json'))); + $this->assertResponse($exp, $this->req("GET", "/items", json_encode($in[0]))); + $this->assertResponse($exp, $this->req("GET", "/items", json_encode($in[1]))); + $this->assertResponse($exp, $this->req("GET", "/items", json_encode($in[2]))); + $this->assertResponse($exp, $this->req("GET", "/items", json_encode($in[3]))); // simply run through the remainder of the input for later method verification - $this->h->dispatch(new Request("GET", "/items", json_encode($in[4]), 'application/json')); - $this->h->dispatch(new Request("GET", "/items", json_encode($in[5]), 'application/json')); // third instance of base context - $this->h->dispatch(new Request("GET", "/items", json_encode($in[6]), 'application/json')); - $this->h->dispatch(new Request("GET", "/items", json_encode($in[7]), 'application/json')); - $this->h->dispatch(new Request("GET", "/items", json_encode($in[8]), 'application/json')); // fourth instance of base context - $this->h->dispatch(new Request("GET", "/items", json_encode($in[9]), 'application/json')); - $this->h->dispatch(new Request("GET", "/items", json_encode($in[10]), 'application/json')); - $this->h->dispatch(new Request("GET", "/items", json_encode($in[11]), 'application/json')); + $this->req("GET", "/items", json_encode($in[4])); + $this->req("GET", "/items", json_encode($in[5])); // third instance of base context + $this->req("GET", "/items", json_encode($in[6])); + $this->req("GET", "/items", json_encode($in[7])); + $this->req("GET", "/items", json_encode($in[8])); // fourth instance of base context + $this->req("GET", "/items", json_encode($in[9])); + $this->req("GET", "/items", json_encode($in[10])); + $this->req("GET", "/items", json_encode($in[11])); // perform method verifications Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true), Database::LIST_TYPICAL); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), Database::LIST_TYPICAL); @@ -745,13 +781,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // folder doesn't exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read", $in, 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read?newestItemId=2112"))); + $this->assertResponse($exp, $this->req("PUT", "/folders/1/read", $in)); + $this->assertResponse($exp, $this->req("PUT", "/folders/1/read?newestItemId=2112")); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1/read?newestItemId=ook"))); + $this->assertResponse($exp, $this->req("PUT", "/folders/1/read")); + $this->assertResponse($exp, $this->req("PUT", "/folders/1/read?newestItemId=ook")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/42/read", $in, 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/folders/42/read", $in)); } public function testMarkASubscriptionRead() { @@ -760,13 +796,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // subscription doesn't exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read", $in, 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read?newestItemId=2112"))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/read", $in)); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/read?newestItemId=2112")); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/read?newestItemId=ook"))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/read")); + $this->assertResponse($exp, $this->req("PUT", "/feeds/1/read?newestItemId=ook")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/read", $in, 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/feeds/42/read", $in)); } public function testMarkAllItemsRead() { @@ -774,11 +810,11 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $in = json_encode(['newestItemId' => 2112]); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(42); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read", $in, 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=2112"))); + $this->assertResponse($exp, $this->req("PUT", "/items/read", $in)); + $this->assertResponse($exp, $this->req("PUT", "/items/read?newestItemId=2112")); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=ook"))); + $this->assertResponse($exp, $this->req("PUT", "/items/read")); + $this->assertResponse($exp, $this->req("PUT", "/items/read?newestItemId=ook")); } public function testChangeMarksOfASingleArticle() { @@ -795,15 +831,15 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(4))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(1337))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/1/read"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/2/unread"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/1/3/star"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/4400/4/unstar"))); + $this->assertResponse($exp, $this->req("PUT", "/items/1/read")); + $this->assertResponse($exp, $this->req("PUT", "/items/2/unread")); + $this->assertResponse($exp, $this->req("PUT", "/items/1/3/star")); + $this->assertResponse($exp, $this->req("PUT", "/items/4400/4/unstar")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/42/read"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/47/unread"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/1/2112/star"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/4400/1337/unstar"))); + $this->assertResponse($exp, $this->req("PUT", "/items/42/read")); + $this->assertResponse($exp, $this->req("PUT", "/items/47/unread")); + $this->assertResponse($exp, $this->req("PUT", "/items/1/2112/star")); + $this->assertResponse($exp, $this->req("PUT", "/items/4400/1337/unstar")); Phake::verify(Arsse::$db, Phake::times(8))->articleMark(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -826,26 +862,26 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->editions([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->articles([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => "ook"]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => "ook"]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => "ook"]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => "ook"]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => []]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => []]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => $in[0]]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => $in[0]]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read/multiple", json_encode(['items' => $in[1]]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unread/multiple", json_encode(['items' => $in[1]]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => []]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => []]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => $inStar[0]]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[0]]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/star/multiple", json_encode(['items' => $inStar[1]]), 'application/json'))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[1]]), 'application/json'))); + $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple")); + $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple")); + $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple")); + $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple")); + $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => "ook"]))); + $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => "ook"]))); + $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => "ook"]))); + $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => "ook"]))); + $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => []]))); + $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => []]))); + $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => $in[0]]))); + $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => $in[0]]))); + $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => $in[1]]))); + $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => $in[1]]))); + $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => []]))); + $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => []]))); + $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => $inStar[0]]))); + $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[0]]))); + $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => $inStar[1]]))); + $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[1]]))); // ensure the data model was queried appropriately for read/unread Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions([])); Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[0])); @@ -879,28 +915,28 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $arr2['warnings']['improperlyConfiguredCron'] = true; $arr2['warnings']['incorrectDbCharset'] = true; $exp = new Response($arr1); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/status"))); + $this->assertResponse($exp, $this->req("GET", "/status")); } public function testCleanUpBeforeUpdate() { Phake::when(Arsse::$db)->feedCleanup()->thenReturn(true); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); + $this->assertResponse($exp, $this->req("GET", "/cleanup/before-update")); Phake::verify(Arsse::$db)->feedCleanup(); // performing a cleanup when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); $exp = new EmptyResponse(403); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); + $this->assertResponse($exp, $this->req("GET", "/cleanup/before-update")); } public function testCleanUpAfterUpdate() { Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update"))); + $this->assertResponse($exp, $this->req("GET", "/cleanup/after-update")); Phake::verify(Arsse::$db)->articleCleanup(); // performing a cleanup when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); $exp = new EmptyResponse(403); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update"))); + $this->assertResponse($exp, $this->req("GET", "/cleanup/after-update")); } } diff --git a/tests/cases/REST/NextCloudNews/TestVersions.php b/tests/cases/REST/NextCloudNews/TestVersions.php index 6904d3e..3f66b42 100644 --- a/tests/cases/REST/NextCloudNews/TestVersions.php +++ b/tests/cases/REST/NextCloudNews/TestVersions.php @@ -7,7 +7,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\REST\NextCloudNews; use JKingWeb\Arsse\REST\NextCloudNews\Versions; -use JKingWeb\Arsse\REST\Request; +use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; @@ -17,44 +18,37 @@ class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest { $this->clearData(); } + 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); + return (new Versions)->dispatch($req); + } + public function testFetchVersionList() { $exp = new Response(['apiLevels' => ['v1-2']]); - $h = new Versions; - $req = new Request("GET", "/"); - $res = $h->dispatch($req); - $this->assertResponse($exp, $res); - $req = new Request("GET", ""); - $res = $h->dispatch($req); - $this->assertResponse($exp, $res); - $req = new Request("GET", "/?id=1827"); - $res = $h->dispatch($req); - $this->assertResponse($exp, $res); + $this->assertResponse($exp, $this->req("GET", "/")); + $this->assertResponse($exp, $this->req("GET", "/")); + $this->assertResponse($exp, $this->req("GET", "/")); } public function testRespondToOptionsRequest() { $exp = new EmptyResponse(204, ['Allow' => "HEAD,GET"]); - $h = new Versions; - $req = new Request("OPTIONS", "/"); - $res = $h->dispatch($req); - $this->assertResponse($exp, $res); + $this->assertResponse($exp, $this->req("OPTIONS", "/")); } public function testUseIncorrectMethod() { $exp = new EmptyResponse(405, ['Allow' => "HEAD,GET"]); - $h = new Versions; - $req = new Request("POST", "/"); - $res = $h->dispatch($req); - $this->assertResponse($exp, $res); + $this->assertResponse($exp, $this->req("POST", "/")); } public function testUseIncorrectPath() { $exp = new EmptyResponse(404); - $h = new Versions; - $req = new Request("GET", "/ook"); - $res = $h->dispatch($req); - $this->assertResponse($exp, $res); - $req = new Request("OPTIONS", "/ook"); - $res = $h->dispatch($req); - $this->assertResponse($exp, $res); + $this->assertResponse($exp, $this->req("GET", "/ook")); + $this->assertResponse($exp, $this->req("OPTIONS", "/ook")); } } diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index d2f1ff3..988c2db 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -19,6 +19,7 @@ use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\TinyTinyRSS\API; use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; use Phake; @@ -124,8 +125,22 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { LONG_STRING; - protected function req($data): ResponseInterface { - return $this->h->dispatch(new Request("POST", "", json_encode($data))); + protected function req($data, string $method = "POST", string $target = "", string $strData = 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); + return $this->h->dispatch($req); } protected function respGood($content = null, $seq = 0): Response { @@ -172,11 +187,11 @@ LONG_STRING; public function testHandleInvalidPaths() { $exp = $this->respErr("MALFORMED_INPUT", [], null); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", ""))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/", ""))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/index.php", ""))); + $this->assertResponse($exp, $this->req(null, "POST", "", "")); + $this->assertResponse($exp, $this->req(null, "POST", "/", "")); + $this->assertResponse($exp, $this->req(null, "POST", "/index.php", "")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/bad/path", ""))); + $this->assertResponse($exp, $this->req(null, "POST", "/bad/path", "")); } public function testHandleOptionsRequest() { @@ -184,13 +199,13 @@ LONG_STRING; 'Allow' => "POST", 'Accept' => "application/json, text/json", ]); - $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", ""))); + $this->assertResponse($exp, $this->req(null, "OPTIONS", "", "")); } public function testHandleInvalidData() { $exp = $this->respErr("MALFORMED_INPUT", [], null); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", "This is not valid JSON data"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "", ""))); // lack of data is also an error + $this->assertResponse($exp, $this->req(null, "POST", "", "This is not valid JSON data")); + $this->assertResponse($exp, $this->req(null, "POST", "", "")); // lack of data is also an error } public function testLogIn() { diff --git a/tests/cases/REST/TinyTinyRSS/TestIcon.php b/tests/cases/REST/TinyTinyRSS/TestIcon.php index c5c67bc..548ab50 100644 --- a/tests/cases/REST/TinyTinyRSS/TestIcon.php +++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php @@ -12,6 +12,8 @@ use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\REST\TinyTinyRSS\Icon; use JKingWeb\Arsse\REST\Request; +use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\EmptyResponse as Response; use Phake; @@ -32,6 +34,17 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { $this->clearData(); } + protected function req(string $target, $method = "GET"): 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); + return $this->h->dispatch($req); + } + public function testRetrieveFavion() { Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn(""); Phake::when(Arsse::$db)->subscriptionFavicon(42)->thenReturn("http://example.com/favicon.ico"); @@ -39,19 +52,19 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionFavicon(1337)->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/"); // these requests should succeed $exp = new Response(301, ['Location' => "http://example.com/favicon.ico"]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "42.ico"))); + $this->assertResponse($exp, $this->req("42.ico")); $exp = new Response(301, ['Location' => "http://example.net/logo.png"]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "2112.ico"))); + $this->assertResponse($exp, $this->req("2112.ico")); $exp = new Response(301, ['Location' => "http://example.org/icon.gif"]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "1337.ico"))); + $this->assertResponse($exp, $this->req("1337.ico")); // these requests should fail $exp = new Response(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "ook.ico"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "ook"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "47.ico"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "2112.png"))); + $this->assertResponse($exp, $this->req("ook.ico")); + $this->assertResponse($exp, $this->req("ook")); + $this->assertResponse($exp, $this->req("47.ico")); + $this->assertResponse($exp, $this->req("2112.png")); // only GET is allowed $exp = new Response(405, ['Allow' => "GET"]); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "2112.ico"))); + $this->assertResponse($exp, $this->req("2112.ico", "PUT")); } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 762e991..c3188a6 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -33,13 +33,13 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } protected function assertResponse(ResponseInterface $exp, ResponseInterface $act, string $text = null) { - $this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text); $this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text); $this->assertInstanceOf(get_class($exp), $act); if ($exp instanceof JsonResponse) { $this->assertEquals($exp->getPayload(), $act->getPayload(), $text); $this->assertSame($exp->getPayload(), $act->getPayload(), $text); } + $this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text); } public function approximateTime($exp, $act) { From 3fa2d38f3137480f5d7a707584461f74b4b765bd Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 6 Jan 2018 12:02:45 -0500 Subject: [PATCH 04/11] Initial rewrite of REST class; needs more testing, but should be functional - improves #53 - improves #66 --- CHANGELOG | 5 + UPGRADING | 8 ++ arsse.php | 4 +- lib/REST.php | 97 +++++++++++++++---- lib/REST/{NextCloudNews => }/Exception404.php | 2 +- lib/REST/{NextCloudNews => }/Exception405.php | 2 +- lib/REST/Exception501.php | 10 ++ lib/REST/NextCloudNews/V1_2.php | 2 + lib/REST/Response.php | 65 ------------- tests/bootstrap.php | 1 + tests/cases/REST/TestREST.php | 50 ++++++++++ tests/cases/REST/TestTarget.php | 2 +- tests/lib/AbstractTest.php | 11 ++- tests/phpunit.xml | 1 + 14 files changed, 171 insertions(+), 89 deletions(-) rename lib/REST/{NextCloudNews => }/Exception404.php (80%) rename lib/REST/{NextCloudNews => }/Exception405.php (80%) create mode 100644 lib/REST/Exception501.php delete mode 100644 lib/REST/Response.php create mode 100644 tests/cases/REST/TestREST.php diff --git a/CHANGELOG b/CHANGELOG index 7330065..a446fd3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,11 @@ Version 0.3.0 (2018-??-??) ========================== +Bug fixes: +- Correctly handle %-encoded request URLs +- Overhaul protocol detection to fix various subtle bugs +- Overhaul HTTP response handling for more consistent results + Changes: - Make date strings in TTRSS explicitly UTC diff --git a/UPGRADING b/UPGRADING index df9448c..160574f 100644 --- a/UPGRADING +++ b/UPGRADING @@ -9,6 +9,14 @@ When upgrading between any two versions of The Arsse, the following are usually - If installing from source, update dependencies with `composer install -o --no-dev` +Upgrading from 0.2.1 to 0.3.0 +============================= + +- The following Composer dependencies have been added: + - zendframework/zend-diactoros + - psr/http-message + + Upgrading from 0.2.0 to 0.2.1 ============================= diff --git a/arsse.php b/arsse.php index 1da0966..6468f0b 100644 --- a/arsse.php +++ b/arsse.php @@ -24,5 +24,7 @@ if (\PHP_SAPI=="cli") { Arsse::$conf->importFile(BASE."config.php"); } // handle Web requests - (new REST)->dispatch()->output(); + $emitter = new \Zend\Diactoros\Response\SapiEmitter(); + $response = (new REST)->dispatch(); + $emitter->emit($response); } diff --git a/lib/REST.php b/lib/REST.php index ea8c87d..4ccb3a0 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -6,8 +6,14 @@ declare(strict_types=1); namespace JKingWeb\Arsse; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\ServerRequest; +use Zend\Diactoros\ServerRequestFactory; +use Zend\Diactoros\Response\EmptyResponse; + class REST { - protected $apis = [ + const API_LIST = [ // NextCloud News version enumerator 'ncn' => [ 'match' => '/index.php/apps/news/api', @@ -21,7 +27,7 @@ class REST { 'class' => REST\NextCloudNews\V1_2::class, ], 'ttrss_api' => [ // Tiny Tiny RSS https://git.tt-rss.org/git/tt-rss/wiki/ApiReference - 'match' => '/tt-rss/api/', + 'match' => '/tt-rss/api', 'strip' => '/tt-rss/api', 'class' => REST\TinyTinyRSS\API::class, ], @@ -44,40 +50,93 @@ class REST { // NewsBlur http://www.newsblur.com/api // Feedly https://developer.feedly.com/ ]; + protected $apis = []; - public function __construct() { + public function __construct(array $apis = null) { + $this->apis = $apis ?? self::API_LIST; } - public function dispatch(REST\Request $req = null): \Psr\Http\Message\ResponseInterface { - if ($req===null) { - $req = new REST\Request(); - } - $api = $this->apiMatch($req->url, $this->apis); - $req->url = substr($req->url, strlen($this->apis[$api]['strip'])); - $req->refreshURL(); - $class = $this->apis[$api]['class']; - $drv = new $class(); - if ($req->head) { - $res = $drv->dispatch($req); - $res->head = true; - return $res; + public function dispatch(ServerRequestInterface $req = null): ResponseInterface { + // create a request object if not provided + $req = $req ?? ServerRequestFactory::fromGlobals(); + // find the API to handle + list ($api, $target, $class) = $this->apiMatch($req->getRequestTarget(), $this->apis); + // modify the request to have a stripped target + $req = $req->withRequestTarget($target); + // generate a response + $res = $this->handOffRequest($class, $req); + // modify the response so that it has all the required metadata + $res = $this->normalizeResponse($res, $req); + } + + protected function handOffRequest(string $className, ServerRequestInterface $req): ResponseInterface { + // instantiate the API handler + $drv = new $className(); + // perform the request and return the response + if ($req->getMethod()=="HEAD") { + // if the request is a HEAD request, we act exactly as if it were a GET request, and simply remove the response body later + return $drv->dispatch($req->withMethod("GET")); } else { return $drv->dispatch($req); } } - public function apiMatch(string $url, array $map): string { + public function apiMatch(string $url): array { + $map = $this->apis; // sort the API list so the longest URL prefixes come first uasort($map, function ($a, $b) { return (strlen($a['match']) <=> strlen($b['match'])) * -1; }); + // normalize the target URL + $url = REST\Target::normalize($url); // find a match foreach ($map as $id => $api) { + // first try a simple substring match if (strpos($url, $api['match'])===0) { - return $id; + // if it matches, perform a more rigorous match and then strip off any defined prefix + $pattern = "<^".preg_quote($api['match'])."([/\?#]|$)>"; + if ($url==$api['match'] || in_array(substr($api['match'], -1, 1), ["/", "?", "#"]) || preg_match($pattern, $url)) { + $target = substr($url, strlen($api['strip'])); + } else { + // if the match fails we are not able to handle the request + throw new REST\Exception501(); + } + // return the API name, stripped URL, and API class name + return [$id, $target, $api['class']]; } } - // or throw an exception otherwise + // or throw an exception otherwise throw new REST\Exception501(); } + + public function normalizeResponse(ResponseInterface $res, RequestInterface $req = null): ResponseInterface { + // set or clear the Content-Length header field + $body = $res->getBody(); + $bodySize = $body->getSize(); + if ($bodySize || $res->getStatusCode()==200) { + // if there is a message body or the response is 200, make sure Content-Length is included + $res = $res->withHeader("Content-Length", (string) $bodySize); + } else { + // for empty responses of other statuses, omit it + $res = $res->withoutHeader("Content-Length"); + } + // if the response is to a HEAD request, the body should be omitted + if ($req->getMethod()=="HEAD") { + $res = new EmptyResponse($res->getStatusCode(), $res->getHeaders()); + } + // if an Allow header field is present, normalize it + if ($res->hasHeader("Allow")) { + $methods = preg_split("<\s+,\s+>", strtoupper($res->getHeaderLine())); + // if GET is allowed, HEAD should be allowed as well + if (in_array("GET", $methods) && !in_array("HEAD", $methods)) { + $methods[] = "HEAD"; + } + // OPTIONS requests are always allowed by our handlers + if (!in_array("OPTIONS", $methods)) { + $methods[] = "OPTIONS"; + } + $res = $res->withHeader("Allow", implode(", ", $methods)); + } + return $res; + } } diff --git a/lib/REST/NextCloudNews/Exception404.php b/lib/REST/Exception404.php similarity index 80% rename from lib/REST/NextCloudNews/Exception404.php rename to lib/REST/Exception404.php index 325a4f5..8bee192 100644 --- a/lib/REST/NextCloudNews/Exception404.php +++ b/lib/REST/Exception404.php @@ -4,7 +4,7 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\REST\NextCloudNews; +namespace JKingWeb\Arsse\REST; class Exception404 extends \Exception { } diff --git a/lib/REST/NextCloudNews/Exception405.php b/lib/REST/Exception405.php similarity index 80% rename from lib/REST/NextCloudNews/Exception405.php rename to lib/REST/Exception405.php index b41c0d5..842ccdb 100644 --- a/lib/REST/NextCloudNews/Exception405.php +++ b/lib/REST/Exception405.php @@ -4,7 +4,7 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\REST\NextCloudNews; +namespace JKingWeb\Arsse\REST; class Exception405 extends \Exception { } diff --git a/lib/REST/Exception501.php b/lib/REST/Exception501.php new file mode 100644 index 0000000..77d1e30 --- /dev/null +++ b/lib/REST/Exception501.php @@ -0,0 +1,10 @@ +code = $code; - $this->payload = $payload; - $this->type = $type; - $this->fields = $extraFields; - } - - public function output() { - if (!headers_sent()) { - foreach ($this->fields as $field) { - header($field); - } - $body = ""; - if (!is_null($this->payload)) { - switch ($this->type) { - case self::T_JSON: - $body = (string) json_encode($this->payload, \JSON_PRETTY_PRINT); - break; - default: - $body = (string) $this->payload; - break; - } - } - if (strlen($body)) { - header("Content-Type: ".$this->type); - header("Content-Length: ".strlen($body)); - } elseif ($this->code==200) { - $this->code = 204; - } - try { - $statusText = Arsse::$lang->msg("HTTP.Status.".$this->code); - } catch (\JKingWeb\Arsse\Lang\Exception $e) { - $statusText = ""; - } - header("Status: ".$this->code." ".$statusText); - if (!$this->head) { - echo $body; - } - } else { - throw new REST\Exception("headersSent"); - } - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 628bd3d..59c04a1 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -9,4 +9,5 @@ namespace JKingWeb\Arsse; const NS_BASE = __NAMESPACE__."\\"; define(NS_BASE."BASE", dirname(__DIR__).DIRECTORY_SEPARATOR); ini_set("memory_limit", "-1"); +error_reporting(\E_ALL); require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php"; diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php new file mode 100644 index 0000000..aef736e --- /dev/null +++ b/tests/cases/REST/TestREST.php @@ -0,0 +1,50 @@ +apiMatch($input); + } catch (Exception501 $e) { + $out = []; + } + $this->assertEquals($exp, $out); + } + + public function provideApiMatchData() { + $real = null; + $fake = [ + 'unstripped' => ['match' => "/full/url", 'strip' => "", 'class' => "UnstrippedProtocol"], + ]; + return [ + [$real, "/index.php/apps/news/api/v1-2/feeds", ["ncn_v1-2", "/feeds", \JKingWeb\Arsse\REST\NextCloudNews\V1_2::class]], + [$real, "/index.php/apps/news/api/v1-2", ["ncn", "/v1-2", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]], + [$real, "/index.php/apps/news/api/", ["ncn", "/", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]], + [$real, "/index%2Ephp/apps/news/api/", ["ncn", "/", \JKingWeb\Arsse\REST\NextCloudNews\Versions::class]], + [$real, "/index.php/apps/news/", []], + [$real, "/index!php/apps/news/api/", []], + [$real, "/tt-rss/api/index.php", ["ttrss_api", "/index.php", \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]], + [$real, "/tt-rss/api", ["ttrss_api", "", \JKingWeb\Arsse\REST\TinyTinyRSS\API::class]], + [$real, "/tt-rss/API", []], + [$real, "/tt-rss/api-bogus", []], + [$real, "/tt-rss/api bogus", []], + [$real, "/tt-rss/feed-icons/", ["ttrss_icon", "", \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]], + [$real, "/tt-rss/feed-icons/", ["ttrss_icon", "", \JKingWeb\Arsse\REST\TinyTinyRSS\Icon::class]], + [$real, "/tt-rss/feed-icons", []], + [$fake, "/full/url/", ["unstripped", "/full/url/", "UnstrippedProtocol"]], + [$fake, "/full/url-not", []], + ]; + } +} \ No newline at end of file diff --git a/tests/cases/REST/TestTarget.php b/tests/cases/REST/TestTarget.php index 08555d8..5577af8 100644 --- a/tests/cases/REST/TestTarget.php +++ b/tests/cases/REST/TestTarget.php @@ -8,7 +8,7 @@ namespace JKingWeb\Arsse\TestCase\REST; use JKingWeb\Arsse\REST\Target; -/** @covers \JKingWeb\Arsse\REST\Target */ +/** @covers \JKingWeb\Arsse\REST\Target */ class TestTarget extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideTargetUrls */ diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index c3188a6..13b5641 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -15,6 +15,14 @@ use Zend\Diactoros\Response\EmptyResponse; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { + public function setUp() { + $this->clearData(); + } + + public function tearDown() { + $this->clearData(); + } + public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { if (func_num_args()) { $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; @@ -34,10 +42,11 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { protected function assertResponse(ResponseInterface $exp, ResponseInterface $act, string $text = null) { $this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text); - $this->assertInstanceOf(get_class($exp), $act); if ($exp instanceof JsonResponse) { $this->assertEquals($exp->getPayload(), $act->getPayload(), $text); $this->assertSame($exp->getPayload(), $act->getPayload(), $text); + } else { + $this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text); } $this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text); } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 167ab87..2652d0a 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -67,6 +67,7 @@ cases/REST/TestTarget.php + cases/REST/TestREST.php cases/REST/NextCloudNews/TestVersions.php From 4b53c5e8b3569e1bb5ae0f2e0441e01a4b25b49e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 7 Jan 2018 12:59:10 -0500 Subject: [PATCH 05/11] Tests and fixes for REST class; fixes #53; improves #66 --- lib/REST.php | 39 ++++++++++-------- tests/cases/REST/TestREST.php | 78 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 17 deletions(-) diff --git a/lib/REST.php b/lib/REST.php index 4ccb3a0..1d3fa0a 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\ServerRequestFactory; @@ -60,25 +61,29 @@ class REST { // create a request object if not provided $req = $req ?? ServerRequestFactory::fromGlobals(); // find the API to handle - list ($api, $target, $class) = $this->apiMatch($req->getRequestTarget(), $this->apis); - // modify the request to have a stripped target - $req = $req->withRequestTarget($target); - // generate a response - $res = $this->handOffRequest($class, $req); + try { + list ($api, $target, $class) = $this->apiMatch($req->getRequestTarget(), $this->apis); + // modify the request to have a stripped target + $req = $req->withRequestTarget($target); + // fetch the correct handler + $drv = $this->getHandler($class); + // generate a response + if ($req->getMethod()=="HEAD") { + // if the request is a HEAD request, we act exactly as if it were a GET request, and simply remove the response body later + $res = $drv->dispatch($req->withMethod("GET")); + } else { + $res = $drv->dispatch($req); + } + } catch (REST\Exception501 $e) { + $res = new EmptyResponse(501); + } // modify the response so that it has all the required metadata - $res = $this->normalizeResponse($res, $req); + return $this->normalizeResponse($res, $req); } - protected function handOffRequest(string $className, ServerRequestInterface $req): ResponseInterface { + public function getHandler(string $className): REST\Handler { // instantiate the API handler - $drv = new $className(); - // perform the request and return the response - if ($req->getMethod()=="HEAD") { - // if the request is a HEAD request, we act exactly as if it were a GET request, and simply remove the response body later - return $drv->dispatch($req->withMethod("GET")); - } else { - return $drv->dispatch($req); - } + return new $className(); } public function apiMatch(string $url): array { @@ -121,12 +126,12 @@ class REST { $res = $res->withoutHeader("Content-Length"); } // if the response is to a HEAD request, the body should be omitted - if ($req->getMethod()=="HEAD") { + if ($req && $req->getMethod()=="HEAD") { $res = new EmptyResponse($res->getStatusCode(), $res->getHeaders()); } // if an Allow header field is present, normalize it if ($res->hasHeader("Allow")) { - $methods = preg_split("<\s+,\s+>", strtoupper($res->getHeaderLine())); + $methods = preg_split("<\s*,\s*>", strtoupper($res->getHeaderLine("Allow"))); // if GET is allowed, HEAD should be allowed as well if (in_array("GET", $methods) && !in_array("HEAD", $methods)) { $methods[] = "HEAD"; diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php index aef736e..10aa59d 100644 --- a/tests/cases/REST/TestREST.php +++ b/tests/cases/REST/TestREST.php @@ -7,7 +7,18 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\REST; use JKingWeb\Arsse\REST; +use JKingWeb\Arsse\REST\Handler; use JKingWeb\Arsse\REST\Exception501; +use JKingWeb\Arsse\REST\NextCloudNews\V1_2 as NCN; +use JKingWeb\Arsse\REST\TinyTinyRSS\API as TTRSS; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\Request; +use Zend\Diactoros\Response; +use Zend\Diactoros\ServerRequest; +use Zend\Diactoros\Response\TextResponse; +use Zend\Diactoros\Response\EmptyResponse; +use Phake; /** @covers \JKingWeb\Arsse\REST */ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { @@ -47,4 +58,71 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { [$fake, "/full/url-not", []], ]; } + + /** @dataProvider provideUnnormalizedResponses */ + public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, RequestInterface $req = null) { + $r = new REST(); + $act = $r->normalizeResponse($res, $req); + $this->assertResponse($exp, $act); + } + + public function provideUnnormalizedResponses() { + $stream = fopen("php://memory", "w+b"); + fwrite($stream,"ook"); + return [ + [new EmptyResponse(204), new EmptyResponse(204)], + [new EmptyResponse(204, ['Allow' => "PUT"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])], + [new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])], + [new EmptyResponse(204, ['Allow' => "PUT,OPTIONS"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])], + [new EmptyResponse(204, ['Allow' => ["PUT", "OPTIONS"]]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])], + [new EmptyResponse(204, ['Allow' => ["PUT, DELETE", "OPTIONS"]]), new EmptyResponse(204, ['Allow' => "PUT, DELETE, OPTIONS"])], + [new EmptyResponse(204, ['Allow' => "HEAD,GET"]), new EmptyResponse(204, ['Allow' => "HEAD, GET, OPTIONS"])], + [new EmptyResponse(204, ['Allow' => "GET"]), new EmptyResponse(204, ['Allow' => "GET, HEAD, OPTIONS"])], + [new TextResponse("ook", 200), new TextResponse("ook", 200, ['Content-Length' => "3"])], + [new TextResponse("", 200), new TextResponse("", 200, ['Content-Length' => "0"])], + [new TextResponse("ook", 404), new TextResponse("ook", 404, ['Content-Length' => "3"])], + [new TextResponse("", 404), new TextResponse("", 404)], + [new Response($stream, 200), new Response($stream, 200, ['Content-Length' => "3"]), new Request("", "GET")], + [new Response($stream, 200), new EmptyResponse(200, ['Content-Length' => "3"]), new Request("", "HEAD")], + ]; + } + + public function testCreateHandlers() { + $r = new REST(); + foreach (REST::API_LIST as $api) { + $class = $api['class']; + $this->assertInstanceOf(Handler::class, $r->getHandler($class)); + } + } + + /** @dataProvider provideMockRequests */ + public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target ="") { + $r = Phake::partialMock(REST::class); + if ($called) { + $h = Phake::mock($class); + Phake::when($r)->getHandler($class)->thenReturn($h); + Phake::when($h)->dispatch->thenReturn(new EmptyResponse(204)); + } + $out = $r->dispatch($req); + $this->assertInstanceOf(ResponseInterface::class, $out); + if ($called) { + Phake::verify($h)->dispatch(Phake::capture($in)); + $this->assertSame($method, $in->getMethod()); + $this->assertSame($target, $in->getRequestTarget()); + } else { + $this->assertSame(501, $out->getStatusCode()); + } + Phake::verify($r)->apiMatch; + Phake::verify($r)->normalizeResponse; + } + + public function provideMockRequests() { + return [ + [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "GET"), "GET", true, NCN::Class, "/feeds"], + [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "HEAD"), "GET", true, NCN::Class, "/feeds"], + [new ServerRequest([], [], "/tt-rss/api/", "POST"), "POST", true, TTRSS::Class, "/"], + [new ServerRequest([], [], "/no/such/api/", "HEAD"), "GET", false], + [new ServerRequest([], [], "/no/such/api/", "GET"), "GET", false], + ]; + } } \ No newline at end of file From 0ec0a5b085c97fe84a5c471b6df30369bd754e8e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Jan 2018 17:11:38 -0500 Subject: [PATCH 06/11] Ensure the request method is always uppercased --- lib/Misc/Date.php | 2 +- lib/REST.php | 9 +++++---- tests/cases/REST/TestREST.php | 2 ++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/Misc/Date.php b/lib/Misc/Date.php index b1afc5a..0eacf8c 100644 --- a/lib/Misc/Date.php +++ b/lib/Misc/Date.php @@ -6,7 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Misc; -class Date { +class Date { public static function transform($date, string $outFormat = null, string $inFormat = null) { $date = ValueInfo::normalize($date, ValueInfo::T_DATE, $inFormat); if (!$date) { diff --git a/lib/REST.php b/lib/REST.php index 1d3fa0a..865e450 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -41,12 +41,13 @@ class REST { // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html // Fever https://feedafever.com/api // Feedbin v2 https://github.com/feedbin/feedbin-api - // Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9 - // Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown // CommaFeed https://www.commafeed.com/api/ + // Unclear if clients exist: + // Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown // NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md // Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access // BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md + // Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9 // Proprietary (centralized) entities: // NewsBlur http://www.newsblur.com/api // Feedly https://developer.feedly.com/ @@ -63,8 +64,8 @@ class REST { // find the API to handle try { list ($api, $target, $class) = $this->apiMatch($req->getRequestTarget(), $this->apis); - // modify the request to have a stripped target - $req = $req->withRequestTarget($target); + // modify the request to have an uppercase method and a stripped target + $req = $req->withMethod(strtoupper($req->getMethod()))->withRequestTarget($target); // fetch the correct handler $drv = $this->getHandler($class); // generate a response diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php index 10aa59d..203565c 100644 --- a/tests/cases/REST/TestREST.php +++ b/tests/cases/REST/TestREST.php @@ -120,6 +120,8 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { return [ [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "GET"), "GET", true, NCN::Class, "/feeds"], [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "HEAD"), "GET", true, NCN::Class, "/feeds"], + [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "get"), "GET", true, NCN::Class, "/feeds"], + [new ServerRequest([], [], "/index.php/apps/news/api/v1-2/feeds", "head"), "GET", true, NCN::Class, "/feeds"], [new ServerRequest([], [], "/tt-rss/api/", "POST"), "POST", true, TTRSS::Class, "/"], [new ServerRequest([], [], "/no/such/api/", "HEAD"), "GET", false], [new ServerRequest([], [], "/no/such/api/", "GET"), "GET", false], From 90dfeb727acb718136ab146379abd001e874a12e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 9 Jan 2018 12:31:40 -0500 Subject: [PATCH 07/11] Implement CORS; fixes #126 --- lib/Conf.php | 5 ++ lib/REST.php | 111 ++++++++++++++++++++++++ tests/cases/REST/TestREST.php | 153 +++++++++++++++++++++++++++++++++- tests/lib/AbstractTest.php | 5 ++ 4 files changed, 273 insertions(+), 1 deletion(-) diff --git a/lib/Conf.php b/lib/Conf.php index 0fe1052..bd046ff 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -72,6 +72,11 @@ class Conf { * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $purgeArticlesUnread = "P21D"; + /** @var string Space-separated list of origins from which to allow cross-origin resource sharing */ + public $httpOriginsAllowed = "*"; + /** @var string Space-separated list of origins from which to deny cross-origin resource sharing */ + public $httpOriginsDenied = ""; + /** Creates a new configuration object * @param string $import_file Optional file to read configuration data from * @see self::importFile() */ diff --git a/lib/REST.php b/lib/REST.php index 865e450..fc4cfac 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -52,6 +52,10 @@ class REST { // NewsBlur http://www.newsblur.com/api // Feedly https://developer.feedly.com/ ]; + const DEFAULT_PORTS = [ + 'http' => 80, + 'https' => 443, + ]; protected $apis = []; public function __construct(array $apis = null) { @@ -143,6 +147,113 @@ class REST { } $res = $res->withHeader("Allow", implode(", ", $methods)); } + // add CORS header fields if the request origin is specified and allowed + if ($req && $this->corsNegotiate($req)) { + $res = $this->corsApply($res, $req); + } return $res; } + + public function corsApply(ResponseInterface $res, RequestInterface $req = null): ResponseInterface { + if ($req && $req->getMethod()=="OPTIONS") { + if ($res->hasHeader("Allow")) { + $res = $res->withHeader("Access-Control-Allow-Methods", $res->getHeaderLine("Allow")); + } + if ($req->hasHeader("Access-Control-Request-Headers")) { + $res = $res->withHeader("Access-Control-Allow-Headers", $req->getHeaderLine("Access-Control-Request-Headers")); + } + $res = $res->withHeader("Access-Control-Max-Age", (string) (60 *60 *24) ); // one day + } + $res = $res->withHeader("Access-Control-Allow-Origin", $req->getHeaderLine("Origin")); + $res = $res->withHeader("Access-Control-Allow-Credentials", "true"); + return $res->withAddedHeader("Vary", "Origin"); + } + + public function corsNegotiate(RequestInterface $req, string $allowed = null, string $denied = null): bool { + $allowed = trim($allowed ?? Arsse::$conf->httpOriginsAllowed ?? ""); + $denied = trim($denied ?? Arsse::$conf->httpOriginsDenied ?? ""); + // continue if at least one origin is allowed + if ($allowed) { + // continue if the request has exactly one Origin header + $origin = $req->getHeader("Origin"); + if (sizeof($origin)==1) { + // continue if the origin is syntactically valid + $origin = $this->corsNormalizeOrigin($origin[0]); + if ($origin) { + // the special "null" origin should not be matched by the wildcard origin + $null = ($origin=="null"); + // pad all strings for simpler comparison + $allowed = " ".$allowed." "; + $denied = " ".$denied." "; + $origin = " ".$origin." "; + $any = " * "; + if (strpos($denied, $origin) !== false) { + // first check the denied list for the origin + return false; + } elseif (strpos($allowed, $origin) !== false) { + // next check the allowed list for the origin + return true; + } elseif (!$null && strpos($denied, $any) !== false) { + // next check the denied list for the wildcard origin + return false; + } elseif (!$null && strpos($allowed, $any) !== false) { + // finally check the allowed list for the wildcard origin + return true; + } + } + } + } + return false; + } + + public function corsNormalizeOrigin(string $origin, array $ports = null): string { + $origin = trim($origin); + if ($origin=="null") { + // if the origin is the special value "null", use it + return "null"; + } + if (preg_match("<^([^:]+)://(\[[^\]]+\]|[^\[\]:/\?#@]+)((?::.*)?)$>i", $origin, $match)) { + // if the origin sort-of matches the syntax in a general sense, continue + $scheme = $match[1]; + $host = $match[2]; + $port = $match[3]; + // decode and normalize the scheme and port (the port may be blank) + $scheme = strtolower(rawurldecode($scheme)); + $port = rawurldecode($port); + if (!preg_match("<^(?::[0-9]+)?$>", $port) || !preg_match("<^[a-z](?:[a-z0-9\+\-\.])*$>", $scheme)) { + // if the normalized port contains anything but numbers, or the scheme does not follow the generic URL syntax, the origin is invalid + return ""; + } + if ($host[0]=="[") { + // if the host appears to be an IPv6 address, validate it + $host = rawurldecode(substr($host, 1, strlen($host) - 2)); + if (!filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { + return ""; + } else { + $host = "[".inet_ntop(inet_pton($host))."]"; + } + } else { + // if the host is a domain name or IP address, split it along dots and just perform URL decoding + $host = explode(".", $host); + $host = array_map(function ($segment) { + return str_replace(".", "%2E", rawurlencode(strtolower(rawurldecode($segment)))); + }, $host); + $host = implode(".", $host); + } + // suppress default ports + if (strlen($port)) { + $port = (int) substr($port, 1); + $list = array_merge($ports ?? [], self::DEFAULT_PORTS); + if (isset($list[$scheme]) && $port==$list[$scheme]) { + $port = ""; + } else { + $port = ":".$port; + } + } + // return the reconstructed result + return $scheme."://".$host.$port; + } else { + return ""; + } + } } diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php index 203565c..ed67e44 100644 --- a/tests/cases/REST/TestREST.php +++ b/tests/cases/REST/TestREST.php @@ -59,9 +59,157 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { ]; } + /** @dataProvider provideUnnormalizedOrigins */ + public function testNormalizeOrigins(string $origin, string $exp, array $ports = null) { + $r = new REST(); + $act = $r->corsNormalizeOrigin($origin, $ports); + $this->assertSame($exp, $act); + } + + public function provideUnnormalizedOrigins() { + return [ + ["null", "null"], + ["http://example.com", "http://example.com"], + ["http://example.com:80", "http://example.com"], + ["http://example.com:8%30", "http://example.com"], + ["http://example.com:8080", "http://example.com:8080"], + ["http://[2001:0db8:0:0:0:0:2:1]", "http://[2001:db8::2:1]"], + ["http://example", "http://example"], + ["http://ex%41mple", "http://example"], + ["http://ex%41mple.co.uk", "http://example.co.uk"], + ["http://ex%41mple.co%2euk", "http://example.co%2Euk"], + ["http://example/", ""], + ["http://example?", ""], + ["http://example#", ""], + ["http://user@example", ""], + ["http://user:pass@example", ""], + ["http://[example", ""], + ["http://[2bef]", ""], + ["http://example%2F", "http://example%2F"], + ["HTTP://example", "http://example"], + ["HTTP://EXAMPLE", "http://example"], + ["%48%54%54%50://example", "http://example"], + ["http:%2F%2Fexample", ""], + ["https://example", "https://example"], + ["https://example:443", "https://example"], + ["https://example:80", "https://example:80"], + ["ssh://example", "ssh://example"], + ["ssh://example:22", "ssh://example:22"], + ["ssh://example:22", "ssh://example", ['ssh' => 22]], + ["SSH://example:22", "ssh://example", ['ssh' => 22]], + ["ssh://example:22", "ssh://example", ['ssh' => "22"]], + ["ssh://example:22", "ssh://example:22", ['SSH' => "22"]], + ]; + } + + /** @dataProvider provideCorsNegotiations */ + public function testNegotiateCors($origin, bool $exp, string $allowed = null, string $denied = null) { + $this->setConf(); + $r = Phake::partialMock(REST::class); + Phake::when($r)->corsNormalizeOrigin->thenReturnCallback(function ($origin) { + return $origin; + }); + $req = new Request("", "GET", "php://memory", ['Origin' => $origin]); + $act = $r->corsNegotiate($req, $allowed, $denied); + $this->assertSame($exp, $act); + } + + public function provideCorsNegotiations() { + return [ + ["http://example", true ], + ["http://example", true, "http://example", "*" ], + ["http://example", false, "http://example", "http://example"], + ["http://example", false, "https://example", "*" ], + ["http://example", false, "*", "*" ], + ["http://example", true, "*", "" ], + ["http://example", false, "", "" ], + ["null", false ], + ["null", true, "null", "*" ], + ["null", false, "null", "null" ], + ["null", false, "*", "*" ], + ["null", false, "*", "" ], + ["null", false, "", "" ], + ["", false ], + ["", false, "", "*" ], + ["", false, "", "" ], + ["", false, "*", "*" ], + ["", false, "*", "" ], + [["null", "http://example"], false, "*", "" ], + [[], false, "*", "" ], + ]; + } + + /** @dataProvider provideCorsHeaders */ + public function testAddCorsHeaders(string $reqMethod, array $reqHeaders, array $resHeaders, array $expHeaders) { + $r = new REST(); + $req = new Request("", $reqMethod, "php://memory", $reqHeaders); + $res = new EmptyResponse(204, $resHeaders); + $exp = new EmptyResponse(204, $expHeaders); + $act = $r->corsApply($res, $req); + $this->assertResponse($exp, $act); + } + + public function provideCorsHeaders() { + return [ + ["GET", ['Origin' => "null"], [], [ + 'Access-Control-Allow-Origin' => "null", + 'Access-Control-Allow-Credentials' => "true", + 'Vary' => "Origin", + ]], + ["GET", ['Origin' => "http://example"], [], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Vary' => "Origin", + ]], + ["GET", ['Origin' => "http://example"], ['Content-Type' => "text/plain; charset=utf-8"], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Vary' => "Origin", + 'Content-Type' => "text/plain; charset=utf-8", + ]], + ["GET", ['Origin' => "http://example"], ['Vary' => "Content-Type"], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Vary' => ["Content-Type", "Origin"], + ]], + ["OPTIONS", ['Origin' => "http://example"], [], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Access-Control-Max-Age' => (string) (60 *60 *24), + 'Vary' => "Origin", + ]], + ["OPTIONS", ['Origin' => "http://example"], ['Allow' => "GET, PUT, HEAD, OPTIONS"], [ + 'Allow' => "GET, PUT, HEAD, OPTIONS", + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Access-Control-Allow-Methods' => "GET, PUT, HEAD, OPTIONS", + 'Access-Control-Max-Age' => (string) (60 *60 *24), + 'Vary' => "Origin", + ]], + ["OPTIONS", ['Origin' => "http://example", 'Access-Control-Request-Headers' => "Content-Type, If-None-Match"], [], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Access-Control-Allow-Headers' => "Content-Type, If-None-Match", + 'Access-Control-Max-Age' => (string) (60 *60 *24), + 'Vary' => "Origin", + ]], + ["OPTIONS", ['Origin' => "http://example", 'Access-Control-Request-Headers' => ["Content-Type", "If-None-Match"]], [], [ + 'Access-Control-Allow-Origin' => "http://example", + 'Access-Control-Allow-Credentials' => "true", + 'Access-Control-Allow-Headers' => "Content-Type,If-None-Match", + 'Access-Control-Max-Age' => (string) (60 *60 *24), + 'Vary' => "Origin", + ]], + ]; + } + /** @dataProvider provideUnnormalizedResponses */ public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, RequestInterface $req = null) { - $r = new REST(); + $r = Phake::partialMock(REST::class); + Phake::when($r)->corsNegotiate->thenReturn(true); + Phake::when($r)->corsApply->thenReturnCallback(function ($res) { + return $res; + }); $act = $r->normalizeResponse($res, $req); $this->assertResponse($exp, $act); } @@ -98,6 +246,9 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideMockRequests */ public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target ="") { $r = Phake::partialMock(REST::class); + Phake::when($r)->normalizeResponse->thenReturnCallback(function ($res) { + return $res; + }); if ($called) { $h = Phake::mock($class); Phake::when($r)->getHandler($class)->thenReturn($h); diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 13b5641..cf3c777 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -8,6 +8,7 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\Misc\Date; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; @@ -23,6 +24,10 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $this->clearData(); } + public function setConf(array $conf = []) { + Arsse::$conf = (new Conf)->import($conf); + } + public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { if (func_num_args()) { $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; From d61fc0c3590c0a675bea468baf1eb9121845b0e9 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 9 Jan 2018 16:44:03 -0500 Subject: [PATCH 08/11] Documentation edits for CORS --- CHANGELOG | 3 +++ README.md | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index a446fd3..e08dc22 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,9 @@ Version 0.3.0 (2018-??-??) ========================== +New features: +- Support for cross-origin resource sharing in all protocols + Bug fixes: - Correctly handle %-encoded request URLs - Overhaul protocol detection to fix various subtle bugs diff --git a/README.md b/README.md index 3e94eae..f95c76d 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,6 @@ The Arsse makes use of the [picoFeed] newsfeed parsing library to sanitize artic As a general rule, The Arsse should yield the same output as the reference implementation for all valid inputs (otherwise you've found [a bug][newIssue]), but there are exception, either because the NextCloud News (hereafter "NCN") [protocol description][NCNv1] is at times ambiguous or incomplete, or because implementation details necessitate it differ; this section along with the General section above detail these differences. -#### Missing features - -- The Arsse does not implement [Cross-Origin Resource Sharing][CORS] - #### Differences - Article GUID hashes are not hashes like in NCN; they are integers rendered as strings From daea0ceb27fd55c65deee68ffbacffe9b8178e9f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 11 Jan 2018 11:09:25 -0500 Subject: [PATCH 09/11] Make HTTP message testing generic assertMessage will test the method and target URL of requests, attributes of server requests, as well as the existing testing of a response's status code. All messages' bodies and header fields are tested for equivalence (with a special case for JSON response bodies). --- tests/cases/REST/NextCloudNews/TestV1_2.php | 226 +++++++------- .../cases/REST/NextCloudNews/TestVersions.php | 14 +- tests/cases/REST/TestREST.php | 4 +- tests/cases/REST/TinyTinyRSS/TestAPI.php | 288 +++++++++--------- tests/cases/REST/TinyTinyRSS/TestIcon.php | 16 +- tests/lib/AbstractTest.php | 18 +- 6 files changed, 290 insertions(+), 276 deletions(-) diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index fd20160..ec93028 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -356,7 +356,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { public function testSendAuthenticationChallenge() { Phake::when(Arsse::$user)->authHTTP->thenReturn(false); $exp = new EmptyResponse(401, ['WWW-Authenticate' => 'Basic realm="'.V1_2::REALM.'"']); - $this->assertResponse($exp, $this->req("GET", "/")); + $this->assertMessage($exp, $this->req("GET", "/")); } public function testRespondToInvalidPaths() { @@ -394,23 +394,23 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { foreach ($errs[404] as $req) { $exp = new EmptyResponse(404); list($method, $path) = $req; - $this->assertResponse($exp, $this->req($method, $path), "$method call to $path did not return 404."); + $this->assertMessage($exp, $this->req($method, $path), "$method call to $path did not return 404."); } foreach ($errs[405] as $allow => $cases) { $exp = new EmptyResponse(405, ['Allow' => $allow]); foreach ($cases as $req) { list($method, $path) = $req; - $this->assertResponse($exp, $this->req($method, $path), "$method call to $path did not return 405."); + $this->assertMessage($exp, $this->req($method, $path), "$method call to $path did not return 405."); } } } public function testRespondToInvalidInputTypes() { $exp = new EmptyResponse(415, ['Accept' => "application/json"]); - $this->assertResponse($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => "application/xml"])); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => "application/xml"])); $exp = new EmptyResponse(400); - $this->assertResponse($exp, $this->req("PUT", "/folders/1", '')); - $this->assertResponse($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => null])); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", '')); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => null])); } public function testRespondToOptionsRequests() { @@ -418,19 +418,19 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { 'Allow' => "HEAD,GET,POST", 'Accept' => "application/json", ]); - $this->assertResponse($exp, $this->req("OPTIONS", "/feeds")); + $this->assertMessage($exp, $this->req("OPTIONS", "/feeds")); $exp = new EmptyResponse(204, [ 'Allow' => "DELETE", 'Accept' => "application/json", ]); - $this->assertResponse($exp, $this->req("OPTIONS", "/feeds/2112")); + $this->assertMessage($exp, $this->req("OPTIONS", "/feeds/2112")); $exp = new EmptyResponse(204, [ 'Allow' => "HEAD,GET", 'Accept' => "application/json", ]); - $this->assertResponse($exp, $this->req("OPTIONS", "/user")); + $this->assertMessage($exp, $this->req("OPTIONS", "/user")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("OPTIONS", "/invalid/path")); + $this->assertMessage($exp, $this->req("OPTIONS", "/invalid/path")); } public function testListFolders() { @@ -444,9 +444,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ]; Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result([]))->thenReturn(new Result($list)); $exp = new Response(['folders' => []]); - $this->assertResponse($exp, $this->req("GET", "/folders")); + $this->assertMessage($exp, $this->req("GET", "/folders")); $exp = new Response(['folders' => $out]); - $this->assertResponse($exp, $this->req("GET", "/folders")); + $this->assertMessage($exp, $this->req("GET", "/folders")); } public function testAddAFolder() { @@ -474,33 +474,33 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace")); // correctly add two folders, using different means $exp = new Response(['folders' => [$out[0]]]); - $this->assertResponse($exp, $this->req("POST", "/folders", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("POST", "/folders", json_encode($in[0]))); $exp = new Response(['folders' => [$out[1]]]); - $this->assertResponse($exp, $this->req("POST", "/folders?name=Hardware")); + $this->assertMessage($exp, $this->req("POST", "/folders?name=Hardware")); Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[0]); Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $in[1]); Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 1); Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2); // test bad folder names $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("POST", "/folders")); - $this->assertResponse($exp, $this->req("POST", "/folders", '{"name":""}')); - $this->assertResponse($exp, $this->req("POST", "/folders", '{"name":" "}')); - $this->assertResponse($exp, $this->req("POST", "/folders", '{"name":{}}')); + $this->assertMessage($exp, $this->req("POST", "/folders")); + $this->assertMessage($exp, $this->req("POST", "/folders", '{"name":""}')); + $this->assertMessage($exp, $this->req("POST", "/folders", '{"name":" "}')); + $this->assertMessage($exp, $this->req("POST", "/folders", '{"name":{}}')); // try adding the same two folders again $exp = new EmptyResponse(409); - $this->assertResponse($exp, $this->req("POST", "/folders?name=Software")); + $this->assertMessage($exp, $this->req("POST", "/folders?name=Software")); $exp = new EmptyResponse(409); - $this->assertResponse($exp, $this->req("POST", "/folders", json_encode($in[1]))); + $this->assertMessage($exp, $this->req("POST", "/folders", json_encode($in[1]))); } public function testRemoveAFolder() { Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("DELETE", "/folders/1")); + $this->assertMessage($exp, $this->req("DELETE", "/folders/1")); // fail on the second invocation because it no longer exists $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("DELETE", "/folders/1")); + $this->assertMessage($exp, $this->req("DELETE", "/folders/1")); Phake::verify(Arsse::$db, Phake::times(2))->folderRemove(Arsse::$user->id, 1); } @@ -519,17 +519,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 1, $in[4])->thenReturn(true); // this should be stopped by the handler before the request gets to the database Phake::when(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, 3, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // folder ID 3 does not exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("PUT", "/folders/1", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[0]))); $exp = new EmptyResponse(409); - $this->assertResponse($exp, $this->req("PUT", "/folders/2", json_encode($in[1]))); + $this->assertMessage($exp, $this->req("PUT", "/folders/2", json_encode($in[1]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/folders/1", json_encode($in[2]))); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[2]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/folders/1", json_encode($in[3]))); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[3]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/folders/1", json_encode($in[4]))); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[4]))); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("PUT", "/folders/3", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("PUT", "/folders/3", json_encode($in[0]))); } public function testRetrieveServerVersion() { @@ -537,7 +537,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { 'version' => V1_2::VERSION, 'arsse_version' => Arsse::VERSION, ]); - $this->assertResponse($exp, $this->req("GET", "/version")); + $this->assertMessage($exp, $this->req("GET", "/version")); } public function testListSubscriptions() { @@ -554,9 +554,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn(['total' => 0])->thenReturn(['total' => 5]); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915); $exp = new Response($exp1); - $this->assertResponse($exp, $this->req("GET", "/feeds")); + $this->assertMessage($exp, $this->req("GET", "/feeds")); $exp = new Response($exp2); - $this->assertResponse($exp, $this->req("GET", "/feeds")); + $this->assertMessage($exp, $this->req("GET", "/feeds")); } public function testAddASubscription() { @@ -589,31 +589,31 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.net/news.atom")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.net/news.atom", new \PicoFeed\Client\InvalidUrlException()))->thenReturn(47); // add the subscriptions $exp = new Response($out[0]); - $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[0]))); $exp = new Response($out[1]); - $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[1]))); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[1]))); // try to add them a second time $exp = new EmptyResponse(409); - $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[0]))); - $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[1]))); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[1]))); // try to add a bad feed $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[2]))); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[2]))); // try again (this will succeed), with an invalid folder ID $exp = new Response($out[2]); - $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[3]))); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[3]))); // try to add no feed $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("POST", "/feeds", json_encode($in[4]))); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[4]))); } public function testRemoveASubscription() { Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("DELETE", "/feeds/1")); + $this->assertMessage($exp, $this->req("DELETE", "/feeds/1")); // fail on the second invocation because it no longer exists $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("DELETE", "/feeds/1")); + $this->assertMessage($exp, $this->req("DELETE", "/feeds/1")); Phake::verify(Arsse::$db, Phake::times(2))->subscriptionRemove(Arsse::$user->id, 1); } @@ -632,17 +632,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder is invalid Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // subscription does not exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[0]))); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[1]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[1]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[2]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[2]))); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("PUT", "/feeds/42/move", json_encode($in[3]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/42/move", json_encode($in[3]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[4]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[4]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[5]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[5]))); } public function testRenameASubscription() { @@ -662,17 +662,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => false]))->thenThrow(new ExceptionInput("missing")); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[0]))); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[1]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[1]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[2]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[2]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[3]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[3]))); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("PUT", "/feeds/42/rename", json_encode($in[4]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/42/rename", json_encode($in[4]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[6]))); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[6]))); } public function testListStaleFeeds() { @@ -688,11 +688,11 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ]; Phake::when(Arsse::$db)->feedListStale->thenReturn(array_column($out, "id")); $exp = new Response(['feeds' => $out]); - $this->assertResponse($exp, $this->req("GET", "/feeds/all")); + $this->assertMessage($exp, $this->req("GET", "/feeds/all")); // retrieving the list when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); $exp = new EmptyResponse(403); - $this->assertResponse($exp, $this->req("GET", "/feeds/all")); + $this->assertMessage($exp, $this->req("GET", "/feeds/all")); } public function testUpdateAFeed() { @@ -707,17 +707,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->feedUpdate($this->lessThan(1))->thenThrow(new ExceptionInput("typeViolation")); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[0]))); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[1]))); + $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[1]))); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[2]))); - $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[3]))); - $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[4]))); + $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[2]))); + $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[3]))); + $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[4]))); // updating a feed when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); $exp = new EmptyResponse(403); - $this->assertResponse($exp, $this->req("GET", "/feeds/update", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[0]))); } public function testListArticles() { @@ -744,14 +744,14 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), Database::LIST_TYPICAL)->thenThrow(new ExceptionInput("typeViolation")); $exp = new Response(['items' => $this->articles['rest']]); // check the contents of the response - $this->assertResponse($exp, $this->req("GET", "/items")); // first instance of base context - $this->assertResponse($exp, $this->req("GET", "/items/updated")); // second instance of base context + $this->assertMessage($exp, $this->req("GET", "/items")); // first instance of base context + $this->assertMessage($exp, $this->req("GET", "/items/updated")); // second instance of base context // check error conditions $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("GET", "/items", json_encode($in[0]))); - $this->assertResponse($exp, $this->req("GET", "/items", json_encode($in[1]))); - $this->assertResponse($exp, $this->req("GET", "/items", json_encode($in[2]))); - $this->assertResponse($exp, $this->req("GET", "/items", json_encode($in[3]))); + $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[0]))); + $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[1]))); + $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[2]))); + $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[3]))); // simply run through the remainder of the input for later method verification $this->req("GET", "/items", json_encode($in[4])); $this->req("GET", "/items", json_encode($in[5])); // third instance of base context @@ -781,13 +781,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // folder doesn't exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("PUT", "/folders/1/read", $in)); - $this->assertResponse($exp, $this->req("PUT", "/folders/1/read?newestItemId=2112")); + $this->assertMessage($exp, $this->req("PUT", "/folders/1/read", $in)); + $this->assertMessage($exp, $this->req("PUT", "/folders/1/read?newestItemId=2112")); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/folders/1/read")); - $this->assertResponse($exp, $this->req("PUT", "/folders/1/read?newestItemId=ook")); + $this->assertMessage($exp, $this->req("PUT", "/folders/1/read")); + $this->assertMessage($exp, $this->req("PUT", "/folders/1/read?newestItemId=ook")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("PUT", "/folders/42/read", $in)); + $this->assertMessage($exp, $this->req("PUT", "/folders/42/read", $in)); } public function testMarkASubscriptionRead() { @@ -796,13 +796,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // subscription doesn't exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/read", $in)); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/read?newestItemId=2112")); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read", $in)); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read?newestItemId=2112")); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/read")); - $this->assertResponse($exp, $this->req("PUT", "/feeds/1/read?newestItemId=ook")); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read")); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read?newestItemId=ook")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("PUT", "/feeds/42/read", $in)); + $this->assertMessage($exp, $this->req("PUT", "/feeds/42/read", $in)); } public function testMarkAllItemsRead() { @@ -810,11 +810,11 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $in = json_encode(['newestItemId' => 2112]); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(42); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("PUT", "/items/read", $in)); - $this->assertResponse($exp, $this->req("PUT", "/items/read?newestItemId=2112")); + $this->assertMessage($exp, $this->req("PUT", "/items/read", $in)); + $this->assertMessage($exp, $this->req("PUT", "/items/read?newestItemId=2112")); $exp = new EmptyResponse(422); - $this->assertResponse($exp, $this->req("PUT", "/items/read")); - $this->assertResponse($exp, $this->req("PUT", "/items/read?newestItemId=ook")); + $this->assertMessage($exp, $this->req("PUT", "/items/read")); + $this->assertMessage($exp, $this->req("PUT", "/items/read?newestItemId=ook")); } public function testChangeMarksOfASingleArticle() { @@ -831,15 +831,15 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(4))->thenReturn(42); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->article(1337))->thenThrow(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("PUT", "/items/1/read")); - $this->assertResponse($exp, $this->req("PUT", "/items/2/unread")); - $this->assertResponse($exp, $this->req("PUT", "/items/1/3/star")); - $this->assertResponse($exp, $this->req("PUT", "/items/4400/4/unstar")); + $this->assertMessage($exp, $this->req("PUT", "/items/1/read")); + $this->assertMessage($exp, $this->req("PUT", "/items/2/unread")); + $this->assertMessage($exp, $this->req("PUT", "/items/1/3/star")); + $this->assertMessage($exp, $this->req("PUT", "/items/4400/4/unstar")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("PUT", "/items/42/read")); - $this->assertResponse($exp, $this->req("PUT", "/items/47/unread")); - $this->assertResponse($exp, $this->req("PUT", "/items/1/2112/star")); - $this->assertResponse($exp, $this->req("PUT", "/items/4400/1337/unstar")); + $this->assertMessage($exp, $this->req("PUT", "/items/42/read")); + $this->assertMessage($exp, $this->req("PUT", "/items/47/unread")); + $this->assertMessage($exp, $this->req("PUT", "/items/1/2112/star")); + $this->assertMessage($exp, $this->req("PUT", "/items/4400/1337/unstar")); Phake::verify(Arsse::$db, Phake::times(8))->articleMark(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -862,26 +862,26 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->editions([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->articles([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple")); - $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple")); - $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple")); - $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple")); - $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => "ook"]))); - $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => "ook"]))); - $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => "ook"]))); - $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => "ook"]))); - $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => []]))); - $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => []]))); - $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => $in[0]]))); - $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => $in[0]]))); - $this->assertResponse($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => $in[1]]))); - $this->assertResponse($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => $in[1]]))); - $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => []]))); - $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => []]))); - $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => $inStar[0]]))); - $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[0]]))); - $this->assertResponse($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => $inStar[1]]))); - $this->assertResponse($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[1]]))); + $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple")); + $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple")); + $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple")); + $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple")); + $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => "ook"]))); + $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => "ook"]))); + $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => "ook"]))); + $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => "ook"]))); + $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => []]))); + $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => []]))); + $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => $in[0]]))); + $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => $in[0]]))); + $this->assertMessage($exp, $this->req("PUT", "/items/read/multiple", json_encode(['items' => $in[1]]))); + $this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple", json_encode(['items' => $in[1]]))); + $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => []]))); + $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => []]))); + $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => $inStar[0]]))); + $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[0]]))); + $this->assertMessage($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => $inStar[1]]))); + $this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[1]]))); // ensure the data model was queried appropriately for read/unread Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions([])); Phake::verify(Arsse::$db, Phake::atLeast(1))->articleMark(Arsse::$user->id, $read, (new Context)->editions($in[0])); @@ -915,28 +915,28 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $arr2['warnings']['improperlyConfiguredCron'] = true; $arr2['warnings']['incorrectDbCharset'] = true; $exp = new Response($arr1); - $this->assertResponse($exp, $this->req("GET", "/status")); + $this->assertMessage($exp, $this->req("GET", "/status")); } public function testCleanUpBeforeUpdate() { Phake::when(Arsse::$db)->feedCleanup()->thenReturn(true); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("GET", "/cleanup/before-update")); + $this->assertMessage($exp, $this->req("GET", "/cleanup/before-update")); Phake::verify(Arsse::$db)->feedCleanup(); // performing a cleanup when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); $exp = new EmptyResponse(403); - $this->assertResponse($exp, $this->req("GET", "/cleanup/before-update")); + $this->assertMessage($exp, $this->req("GET", "/cleanup/before-update")); } public function testCleanUpAfterUpdate() { Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true); $exp = new EmptyResponse(204); - $this->assertResponse($exp, $this->req("GET", "/cleanup/after-update")); + $this->assertMessage($exp, $this->req("GET", "/cleanup/after-update")); Phake::verify(Arsse::$db)->articleCleanup(); // performing a cleanup when not an admin fails Phake::when(Arsse::$user)->rightsGet->thenReturn(0); $exp = new EmptyResponse(403); - $this->assertResponse($exp, $this->req("GET", "/cleanup/after-update")); + $this->assertMessage($exp, $this->req("GET", "/cleanup/after-update")); } } diff --git a/tests/cases/REST/NextCloudNews/TestVersions.php b/tests/cases/REST/NextCloudNews/TestVersions.php index 3f66b42..28c6e0c 100644 --- a/tests/cases/REST/NextCloudNews/TestVersions.php +++ b/tests/cases/REST/NextCloudNews/TestVersions.php @@ -31,24 +31,24 @@ class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest { public function testFetchVersionList() { $exp = new Response(['apiLevels' => ['v1-2']]); - $this->assertResponse($exp, $this->req("GET", "/")); - $this->assertResponse($exp, $this->req("GET", "/")); - $this->assertResponse($exp, $this->req("GET", "/")); + $this->assertMessage($exp, $this->req("GET", "/")); + $this->assertMessage($exp, $this->req("GET", "/")); + $this->assertMessage($exp, $this->req("GET", "/")); } public function testRespondToOptionsRequest() { $exp = new EmptyResponse(204, ['Allow' => "HEAD,GET"]); - $this->assertResponse($exp, $this->req("OPTIONS", "/")); + $this->assertMessage($exp, $this->req("OPTIONS", "/")); } public function testUseIncorrectMethod() { $exp = new EmptyResponse(405, ['Allow' => "HEAD,GET"]); - $this->assertResponse($exp, $this->req("POST", "/")); + $this->assertMessage($exp, $this->req("POST", "/")); } public function testUseIncorrectPath() { $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req("GET", "/ook")); - $this->assertResponse($exp, $this->req("OPTIONS", "/ook")); + $this->assertMessage($exp, $this->req("GET", "/ook")); + $this->assertMessage($exp, $this->req("OPTIONS", "/ook")); } } diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php index ed67e44..7851c1e 100644 --- a/tests/cases/REST/TestREST.php +++ b/tests/cases/REST/TestREST.php @@ -146,7 +146,7 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { $res = new EmptyResponse(204, $resHeaders); $exp = new EmptyResponse(204, $expHeaders); $act = $r->corsApply($res, $req); - $this->assertResponse($exp, $act); + $this->assertMessage($exp, $act); } public function provideCorsHeaders() { @@ -211,7 +211,7 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { return $res; }); $act = $r->normalizeResponse($res, $req); - $this->assertResponse($exp, $act); + $this->assertMessage($exp, $act); } public function provideUnnormalizedResponses() { diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 988c2db..734fc49 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -187,11 +187,11 @@ LONG_STRING; public function testHandleInvalidPaths() { $exp = $this->respErr("MALFORMED_INPUT", [], null); - $this->assertResponse($exp, $this->req(null, "POST", "", "")); - $this->assertResponse($exp, $this->req(null, "POST", "/", "")); - $this->assertResponse($exp, $this->req(null, "POST", "/index.php", "")); + $this->assertMessage($exp, $this->req(null, "POST", "", "")); + $this->assertMessage($exp, $this->req(null, "POST", "/", "")); + $this->assertMessage($exp, $this->req(null, "POST", "/index.php", "")); $exp = new EmptyResponse(404); - $this->assertResponse($exp, $this->req(null, "POST", "/bad/path", "")); + $this->assertMessage($exp, $this->req(null, "POST", "/bad/path", "")); } public function testHandleOptionsRequest() { @@ -199,13 +199,13 @@ LONG_STRING; 'Allow' => "POST", 'Accept' => "application/json, text/json", ]); - $this->assertResponse($exp, $this->req(null, "OPTIONS", "", "")); + $this->assertMessage($exp, $this->req(null, "OPTIONS", "", "")); } public function testHandleInvalidData() { $exp = $this->respErr("MALFORMED_INPUT", [], null); - $this->assertResponse($exp, $this->req(null, "POST", "", "This is not valid JSON data")); - $this->assertResponse($exp, $this->req(null, "POST", "", "")); // lack of data is also an error + $this->assertMessage($exp, $this->req(null, "POST", "", "This is not valid JSON data")); + $this->assertMessage($exp, $this->req(null, "POST", "", "")); // lack of data is also an error } public function testLogIn() { @@ -218,15 +218,15 @@ LONG_STRING; 'password' => "secret", ]; $exp = $this->respGood(['session_id' => "PriestsOfSyrinx", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); // base64 passwords are also accepted $data['password'] = base64_encode($data['password']); $exp = $this->respGood(['session_id' => "SolarFederation", 'api_level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); // test a failed log-in $data['password'] = "superman"; $exp = $this->respErr("LOGIN_ERROR"); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); // logging in should never try to resume a session Phake::verify(Arsse::$db, Phake::times(0))->sessionResume($this->anything()); } @@ -239,7 +239,7 @@ LONG_STRING; 'password' => "secret", ]; $exp = new EmptyResponse(500); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); } public function testLogOut() { @@ -249,7 +249,7 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['status' => "OK"]); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); Phake::verify(Arsse::$db)->sessionDestroy(Arsse::$user->id, "PriestsOfSyrinx"); } @@ -259,10 +259,10 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['status' => true]); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); $data['sid'] = "SolarFederation"; $exp = $this->respErr("NOT_LOGGED_IN"); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); } public function testHandleUnknownMethods() { @@ -271,7 +271,7 @@ LONG_STRING; 'op' => "thisMethodDoesNotExist", 'sid' => "PriestsOfSyrinx", ]; - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); } public function testHandleMixedCaseMethods() { @@ -280,13 +280,13 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['status' => true]); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); $data['op'] = "isloggedin"; - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); $data['op'] = "ISLOGGEDIN"; - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); $data['op'] = "iSlOgGeDiN"; - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); } public function testRetrieveServerVersion() { @@ -298,7 +298,7 @@ LONG_STRING; 'version' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::VERSION, 'arsse_version' => Arsse::VERSION, ]); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); } public function testRetrieveProtocolLevel() { @@ -307,7 +307,7 @@ LONG_STRING; 'sid' => "PriestsOfSyrinx", ]; $exp = $this->respGood(['level' => \JKingWeb\Arsse\REST\TinyTinyRSS\API::LEVEL]); - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); } public function testAddACategory() { @@ -341,24 +341,24 @@ LONG_STRING; Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " ", 'parent' => null])->thenThrow(new ExceptionInput("whitespace")); // correctly add two folders $exp = $this->respGood("2"); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); $exp = $this->respGood("3"); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); // attempt to add the two folders again $exp = $this->respGood("2"); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); $exp = $this->respGood("3"); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, null, false); Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false); // add a folder to a missing parent (silently fails) $exp = $this->respGood(false); - $this->assertResponse($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[2])); // add some invalid folders $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[3])); - $this->assertResponse($exp, $this->req($in[4])); - $this->assertResponse($exp, $this->req($in[5])); + $this->assertMessage($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[4])); + $this->assertMessage($exp, $this->req($in[5])); } public function testRemoveACategory() { @@ -371,16 +371,16 @@ LONG_STRING; Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); // succefully delete a folder $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // try deleting it again (this should silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // delete a folder which does not exist (this should also silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); // delete an invalid folder (causes an error) $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[2])); Phake::verify(Arsse::$db, Phake::times(3))->folderRemove(Arsse::$user->id, $this->anything()); } @@ -418,21 +418,21 @@ LONG_STRING; Phake::when(Arsse::$db)->folderPropertiesSet(...$db[8])->thenThrow(new ExceptionInput("typeViolation")); // succefully move a folder $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // move a folder which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); // move a folder causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[2])); - $this->assertResponse($exp, $this->req($in[3])); - $this->assertResponse($exp, $this->req($in[6])); + $this->assertMessage($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[6])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[4])); - $this->assertResponse($exp, $this->req($in[5])); - $this->assertResponse($exp, $this->req($in[7])); - $this->assertResponse($exp, $this->req($in[8])); + $this->assertMessage($exp, $this->req($in[4])); + $this->assertMessage($exp, $this->req($in[5])); + $this->assertMessage($exp, $this->req($in[7])); + $this->assertMessage($exp, $this->req($in[8])); Phake::verify(Arsse::$db, Phake::times(5))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -458,21 +458,21 @@ LONG_STRING; Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); // succefully rename a folder $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // rename a folder which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); // rename a folder causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[2])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[3])); - $this->assertResponse($exp, $this->req($in[4])); - $this->assertResponse($exp, $this->req($in[5])); - $this->assertResponse($exp, $this->req($in[6])); - $this->assertResponse($exp, $this->req($in[7])); - $this->assertResponse($exp, $this->req($in[8])); + $this->assertMessage($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[4])); + $this->assertMessage($exp, $this->req($in[5])); + $this->assertMessage($exp, $this->req($in[6])); + $this->assertMessage($exp, $this->req($in[7])); + $this->assertMessage($exp, $this->req($in[8])); Phake::verify(Arsse::$db, Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -542,11 +542,11 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($list)); for ($a = 0; $a < (sizeof($in) - 4); $a++) { $exp = $this->respGood($out[$a]); - $this->assertResponse($exp, $this->req($in[$a]), "Failed test $a"); + $this->assertMessage($exp, $this->req($in[$a]), "Failed test $a"); } $exp = $this->respErr("INCORRECT_USAGE"); for ($a = (sizeof($in) - 4); $a < sizeof($in); $a++) { - $this->assertResponse($exp, $this->req($in[$a]), "Failed test $a"); + $this->assertMessage($exp, $this->req($in[$a]), "Failed test $a"); } Phake::verify(Arsse::$db, Phake::times(0))->subscriptionPropertiesSet(Arsse::$user->id, 4, ['folder' => 1]); } @@ -563,13 +563,13 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); // succefully delete a folder $exp = $this->respGood(['status' => "OK"]); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // try deleting it again (this should noisily fail, as should everything else) $exp = $this->respErr("FEED_NOT_FOUND"); - $this->assertResponse($exp, $this->req($in[0])); - $this->assertResponse($exp, $this->req($in[1])); - $this->assertResponse($exp, $this->req($in[2])); - $this->assertResponse($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[3])); Phake::verify(Arsse::$db, Phake::times(5))->subscriptionRemove(Arsse::$user->id, $this->anything()); } @@ -597,21 +597,21 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("constraintViolation")); // succefully move a subscription $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // move a subscription which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); // move a subscription causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[2])); - $this->assertResponse($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[3])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[4])); - $this->assertResponse($exp, $this->req($in[5])); - $this->assertResponse($exp, $this->req($in[6])); - $this->assertResponse($exp, $this->req($in[7])); - $this->assertResponse($exp, $this->req($in[8])); + $this->assertMessage($exp, $this->req($in[4])); + $this->assertMessage($exp, $this->req($in[5])); + $this->assertMessage($exp, $this->req($in[6])); + $this->assertMessage($exp, $this->req($in[7])); + $this->assertMessage($exp, $this->req($in[8])); Phake::verify(Arsse::$db, Phake::times(4))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -637,21 +637,21 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); // succefully rename a subscription $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // rename a subscription which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); // rename a subscription causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[2])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[3])); - $this->assertResponse($exp, $this->req($in[4])); - $this->assertResponse($exp, $this->req($in[5])); - $this->assertResponse($exp, $this->req($in[6])); - $this->assertResponse($exp, $this->req($in[7])); - $this->assertResponse($exp, $this->req($in[8])); + $this->assertMessage($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[4])); + $this->assertMessage($exp, $this->req($in[5])); + $this->assertMessage($exp, $this->req($in[6])); + $this->assertMessage($exp, $this->req($in[7])); + $this->assertMessage($exp, $this->req($in[8])); Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[0]); Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[1]); Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[2]); @@ -665,7 +665,7 @@ LONG_STRING; ['id' => 3, 'unread' => 47], ])); $exp = $this->respGood(['unread' => (string) (2112 + 42 + 47)]); - $this->assertResponse($exp, $this->req($in)); + $this->assertMessage($exp, $this->req($in)); } public function testRetrieveTheServerConfiguration() { @@ -679,8 +679,8 @@ LONG_STRING; ['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => true, 'num_feeds' => 12], ['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => false, 'num_feeds' => 2], ]; - $this->assertResponse($this->respGood($exp[0]), $this->req($in)); - $this->assertResponse($this->respGood($exp[1]), $this->req($in)); + $this->assertMessage($this->respGood($exp[0]), $this->req($in)); + $this->assertMessage($this->respGood($exp[1]), $this->req($in)); } public function testUpdateAFeed() { @@ -694,13 +694,13 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1)->thenReturn(['id' => 1, 'feed' => 11]); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2)->thenThrow(new ExceptionInput("subjectMissing")); $exp = $this->respGood(['status' => "OK"]); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); Phake::verify(Arsse::$db)->feedUpdate(11); $exp = $this->respErr("FEED_NOT_FOUND"); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[2])); - $this->assertResponse($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[3])); } public function testAddALabel() { @@ -731,21 +731,21 @@ LONG_STRING; Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace")); // correctly add two labels $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); // attempt to add the two labels again $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 2); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); $exp = $this->respGood((-1 * API::LABEL_OFFSET) - 3); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true); Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true); // add some invalid labels $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[2])); - $this->assertResponse($exp, $this->req($in[3])); - $this->assertResponse($exp, $this->req($in[4])); + $this->assertMessage($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[4])); } public function testRemoveALabel() { @@ -760,18 +760,18 @@ LONG_STRING; Phake::when(Arsse::$db)->labelRemove(Arsse::$user->id, 18)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); // succefully delete a label $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // try deleting it again (this should silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // delete a label which does not exist (this should also silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); // delete some invalid labels (causes an error) $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[2])); - $this->assertResponse($exp, $this->req($in[3])); - $this->assertResponse($exp, $this->req($in[4])); + $this->assertMessage($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[4])); Phake::verify(Arsse::$db, Phake::times(2))->labelRemove(Arsse::$user->id, 18); Phake::verify(Arsse::$db)->labelRemove(Arsse::$user->id, 1088); } @@ -804,21 +804,21 @@ LONG_STRING; Phake::when(Arsse::$db)->labelPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation")); // succefully rename a label $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); // rename a label which does not exist (this should silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); // rename a label causing a duplication (this should also silently fail) $exp = $this->respGood(); - $this->assertResponse($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[2])); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[3])); - $this->assertResponse($exp, $this->req($in[4])); - $this->assertResponse($exp, $this->req($in[5])); - $this->assertResponse($exp, $this->req($in[6])); - $this->assertResponse($exp, $this->req($in[7])); - $this->assertResponse($exp, $this->req($in[8])); + $this->assertMessage($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[4])); + $this->assertMessage($exp, $this->req($in[5])); + $this->assertMessage($exp, $this->req($in[6])); + $this->assertMessage($exp, $this->req($in[7])); + $this->assertMessage($exp, $this->req($in[8])); Phake::verify(Arsse::$db, Phake::times(6))->labelPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } @@ -890,7 +890,7 @@ LONG_STRING; ], ]; for ($a = 0; $a < sizeof($in); $a++) { - $this->assertResponse($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); + $this->assertMessage($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); } } @@ -926,7 +926,7 @@ LONG_STRING; ['id' => 0, 'kind' => "cat", 'counter' => 0], ['id' => -2, 'kind' => "cat", 'counter' => 6], ]; - $this->assertResponse($this->respGood($exp), $this->req($in)); + $this->assertMessage($this->respGood($exp), $this->req($in)); } public function testRetrieveTheLabelList() { @@ -970,7 +970,7 @@ LONG_STRING; ], ]; for ($a = 0; $a < sizeof($in); $a++) { - $this->assertResponse($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); + $this->assertMessage($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed"); } } @@ -996,20 +996,20 @@ LONG_STRING; Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false)->thenReturn(5); Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false)->thenReturn(2); $exp = $this->respGood(['status' => "OK", 'updated' => 89]); - $this->assertResponse($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[0])); Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true); Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true); $exp = $this->respGood(['status' => "OK", 'updated' => 7]); - $this->assertResponse($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[1])); Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false); Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false); $exp = $this->respGood(['status' => "OK", 'updated' => 0]); - $this->assertResponse($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[2])); $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[3])); - $this->assertResponse($exp, $this->req($in[4])); - $this->assertResponse($exp, $this->req($in[5])); - $this->assertResponse($exp, $this->req($in[6])); + $this->assertMessage($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[4])); + $this->assertMessage($exp, $this->req($in[5])); + $this->assertMessage($exp, $this->req($in[6])); } public function testRetrieveFeedTree() { @@ -1024,9 +1024,9 @@ LONG_STRING; Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->starred); // the expectations are packed tightly since they're very verbose; one can use var_export() (or convert to JSON) to pretty-print them $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['name'=>'Special','id'=>'CAT:-1','bare_id'=>-1,'type'=>'category','unread'=>0,'items'=>[['name'=>'All articles','id'=>'FEED:-4','bare_id'=>-4,'icon'=>'images/folder.png','unread'=>35,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Fresh articles','id'=>'FEED:-3','bare_id'=>-3,'icon'=>'images/fresh.png','unread'=>7,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Starred articles','id'=>'FEED:-1','bare_id'=>-1,'icon'=>'images/star.png','unread'=>4,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Published articles','id'=>'FEED:-2','bare_id'=>-2,'icon'=>'images/feed.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Archived articles','id'=>'FEED:0','bare_id'=>0,'icon'=>'images/archive.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Recently read','id'=>'FEED:-6','bare_id'=>-6,'icon'=>'images/time.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],],],['name'=>'Labels','id'=>'CAT:-2','bare_id'=>-2,'type'=>'category','unread'=>6,'items'=>[['name'=>'Fascinating','id'=>'FEED:-1027','bare_id'=>-1027,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Interesting','id'=>'FEED:-1029','bare_id'=>-1029,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Logical','id'=>'FEED:-1025','bare_id'=>-1025,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],],],['name'=>'Photography','id'=>'CAT:4','bare_id'=>4,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(0 feeds)','items'=>[],],['name'=>'Politics','id'=>'CAT:3','bare_id'=>3,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(3 feeds)','items'=>[['name'=>'Local','id'=>'CAT:5','bare_id'=>5,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'Toronto Star','id'=>'FEED:2','bare_id'=>2,'icon'=>'feed-icons/2.ico','error'=>'oops','param'=>'2011-11-11T11:11:11Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'National','id'=>'CAT:6','bare_id'=>6,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'CBC News','id'=>'FEED:4','bare_id'=>4,'icon'=>'feed-icons/4.ico','error'=>'','param'=>'2017-10-09T15:58:34Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],['name'=>'Ottawa Citizen','id'=>'FEED:5','bare_id'=>5,'icon'=>false,'error'=>'','param'=>'2017-07-07T17:07:17Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],['name'=>'Science','id'=>'CAT:1','bare_id'=>1,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'Rocketry','id'=>'CAT:2','bare_id'=>2,'parent_id'=>1,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'NASA JPL','id'=>'FEED:1','bare_id'=>1,'icon'=>false,'error'=>'','param'=>'2017-09-15T22:54:16Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Ars Technica','id'=>'FEED:3','bare_id'=>3,'icon'=>'feed-icons/3.ico','error'=>'argh','param'=>'2016-05-23T06:40:02Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Uncategorized','id'=>'CAT:0','bare_id'=>0,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'parent_id'=>null,'param'=>'(1 feed)','items'=>[['name'=>'Eurogamer','id'=>'FEED:6','bare_id'=>6,'icon'=>'feed-icons/6.ico','error'=>'','param'=>'2010-02-12T20:08:47Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],]; - $this->assertResponse($this->respGood($exp), $this->req($in[0])); + $this->assertMessage($this->respGood($exp), $this->req($in[0])); $exp = ['categories'=>['identifier'=>'id','label'=>'name','items'=>[['name'=>'Special','id'=>'CAT:-1','bare_id'=>-1,'type'=>'category','unread'=>0,'items'=>[['name'=>'All articles','id'=>'FEED:-4','bare_id'=>-4,'icon'=>'images/folder.png','unread'=>35,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Fresh articles','id'=>'FEED:-3','bare_id'=>-3,'icon'=>'images/fresh.png','unread'=>7,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Starred articles','id'=>'FEED:-1','bare_id'=>-1,'icon'=>'images/star.png','unread'=>4,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Published articles','id'=>'FEED:-2','bare_id'=>-2,'icon'=>'images/feed.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Archived articles','id'=>'FEED:0','bare_id'=>0,'icon'=>'images/archive.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],['name'=>'Recently read','id'=>'FEED:-6','bare_id'=>-6,'icon'=>'images/time.png','unread'=>0,'type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'',],],],['name'=>'Labels','id'=>'CAT:-2','bare_id'=>-2,'type'=>'category','unread'=>6,'items'=>[['name'=>'Fascinating','id'=>'FEED:-1027','bare_id'=>-1027,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Interesting','id'=>'FEED:-1029','bare_id'=>-1029,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],['name'=>'Logical','id'=>'FEED:-1025','bare_id'=>-1025,'unread'=>0,'icon'=>'images/label.png','type'=>'feed','auxcounter'=>0,'error'=>'','updated'=>'','fg_color'=>'','bg_color'=>'',],],],['name'=>'Politics','id'=>'CAT:3','bare_id'=>3,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(3 feeds)','items'=>[['name'=>'Local','id'=>'CAT:5','bare_id'=>5,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'Toronto Star','id'=>'FEED:2','bare_id'=>2,'icon'=>'feed-icons/2.ico','error'=>'oops','param'=>'2011-11-11T11:11:11Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'National','id'=>'CAT:6','bare_id'=>6,'parent_id'=>3,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'CBC News','id'=>'FEED:4','bare_id'=>4,'icon'=>'feed-icons/4.ico','error'=>'','param'=>'2017-10-09T15:58:34Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],['name'=>'Ottawa Citizen','id'=>'FEED:5','bare_id'=>5,'icon'=>false,'error'=>'','param'=>'2017-07-07T17:07:17Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],['name'=>'Science','id'=>'CAT:1','bare_id'=>1,'parent_id'=>null,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(2 feeds)','items'=>[['name'=>'Rocketry','id'=>'CAT:2','bare_id'=>2,'parent_id'=>1,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'param'=>'(1 feed)','items'=>[['name'=>'NASA JPL','id'=>'FEED:1','bare_id'=>1,'icon'=>false,'error'=>'','param'=>'2017-09-15T22:54:16Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Ars Technica','id'=>'FEED:3','bare_id'=>3,'icon'=>'feed-icons/3.ico','error'=>'argh','param'=>'2016-05-23T06:40:02Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],['name'=>'Uncategorized','id'=>'CAT:0','bare_id'=>0,'type'=>'category','auxcounter'=>0,'unread'=>0,'child_unread'=>0,'checkbox'=>false,'parent_id'=>null,'param'=>'(1 feed)','items'=>[['name'=>'Eurogamer','id'=>'FEED:6','bare_id'=>6,'icon'=>'feed-icons/6.ico','error'=>'','param'=>'2010-02-12T20:08:47Z','unread'=>0,'auxcounter'=>0,'checkbox'=>false,],],],],],]; - $this->assertResponse($this->respGood($exp), $this->req($in[1])); + $this->assertMessage($this->respGood($exp), $this->req($in[1])); } public function testMarkFeedsAsRead() { @@ -1058,12 +1058,12 @@ LONG_STRING; $exp = $this->respGood(['status' => "OK"]); // verify the above are in fact no-ops for ($a = 0; $a < sizeof($in1); $a++) { - $this->assertResponse($exp, $this->req($in1[$a]), "Test $a failed"); + $this->assertMessage($exp, $this->req($in1[$a]), "Test $a failed"); } Phake::verify(Arsse::$db, Phake::times(0))->articleMark; // verify the simple contexts for ($a = 0; $a < sizeof($in2); $a++) { - $this->assertResponse($exp, $this->req($in2[$a]), "Test $a failed"); + $this->assertMessage($exp, $this->req($in2[$a]), "Test $a failed"); } Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], new Context); Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->starred(true)); @@ -1075,7 +1075,7 @@ LONG_STRING; // verify the time-based mock $t = Date::sub("PT24H"); for ($a = 0; $a < sizeof($in3); $a++) { - $this->assertResponse($exp, $this->req($in3[$a]), "Test $a failed"); + $this->assertMessage($exp, $this->req($in3[$a]), "Test $a failed"); } Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->modifiedSince($t)); } @@ -1210,10 +1210,10 @@ LONG_STRING; ], ]; for ($a = 0; $a < sizeof($in1); $a++) { - $this->assertResponse($this->respGood($exp[$a]), $this->req($in1[$a]), "Test $a failed"); + $this->assertMessage($this->respGood($exp[$a]), $this->req($in1[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in2); $a++) { - $this->assertResponse($this->respGood([]), $this->req($in2[$a]), "Test $a failed"); + $this->assertMessage($this->respGood([]), $this->req($in2[$a]), "Test $a failed"); } } @@ -1323,7 +1323,7 @@ LONG_STRING; $this->respErr("INCORRECT_USAGE"), ]; for ($a = 0; $a < sizeof($in); $a++) { - $this->assertResponse($out[$a], $this->req($in[$a]), "Test $a failed"); + $this->assertMessage($out[$a], $this->req($in[$a]), "Test $a failed"); } } @@ -1347,10 +1347,10 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]))->thenReturn(new Result([$this->articles[0]])); Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]))->thenReturn(new Result([$this->articles[1]])); $exp = $this->respErr("INCORRECT_USAGE"); - $this->assertResponse($exp, $this->req($in[0])); - $this->assertResponse($exp, $this->req($in[1])); - $this->assertResponse($exp, $this->req($in[2])); - $this->assertResponse($exp, $this->req($in[3])); + $this->assertMessage($exp, $this->req($in[0])); + $this->assertMessage($exp, $this->req($in[1])); + $this->assertMessage($exp, $this->req($in[2])); + $this->assertMessage($exp, $this->req($in[3])); $exp = [ [ 'id' => "101", @@ -1407,13 +1407,13 @@ LONG_STRING; 'content' => '

Article content 2

', ], ]; - $this->assertResponse($this->respGood($exp), $this->req($in[4])); - $this->assertResponse($this->respGood([$exp[0]]), $this->req($in[5])); - $this->assertResponse($this->respGood([$exp[1]]), $this->req($in[6])); + $this->assertMessage($this->respGood($exp), $this->req($in[4])); + $this->assertMessage($this->respGood([$exp[0]]), $this->req($in[5])); + $this->assertMessage($this->respGood([$exp[1]]), $this->req($in[6])); // test the special case when labels are not used Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result([])); Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result([])); - $this->assertResponse($this->respGood([$exp[0]]), $this->req($in[5])); + $this->assertMessage($this->respGood([$exp[0]]), $this->req($in[5])); } public function testRetrieveCompactHeadlines() { @@ -1492,13 +1492,13 @@ LONG_STRING; $this->respGood([['id' => 1003]]), ]; for ($a = 0; $a < sizeof($in1); $a++) { - $this->assertResponse($out1[$a], $this->req($in1[$a]), "Test $a failed"); + $this->assertMessage($out1[$a], $this->req($in1[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in2); $a++) { Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1001]])); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1002]])); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1003]])); - $this->assertResponse($out2[$a], $this->req($in2[$a]), "Test $a failed"); + $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } } @@ -1600,16 +1600,16 @@ LONG_STRING; $this->outputHeadlines(1003), ]; for ($a = 0; $a < sizeof($in1); $a++) { - $this->assertResponse($this->respGood([]), $this->req($in1[$a]), "Test $a failed"); + $this->assertMessage($this->respGood([]), $this->req($in1[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in2); $a++) { - $this->assertResponse($out2[$a], $this->req($in2[$a]), "Test $a failed"); + $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } for ($a = 0; $a < sizeof($in3); $a++) { Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1001)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1002)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_FULL)->thenReturn($this->generateHeadlines(1003)); - $this->assertResponse($out3[$a], $this->req($in3[$a]), "Test $a failed"); + $this->assertMessage($out3[$a], $this->req($in3[$a]), "Test $a failed"); } } @@ -1639,7 +1639,7 @@ LONG_STRING; Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1); // sanity check; this makes sure extra fields are not included in default situations $test = $this->req($in[0]); - $this->assertResponse($this->outputHeadlines(1), $test); + $this->assertMessage($this->outputHeadlines(1), $test); // test 'show_content' $test = $this->req($in[1]); $this->assertArrayHasKey("content", $test->getPayload()['content'][0]); @@ -1671,21 +1671,21 @@ LONG_STRING; ['id' => -4, 'is_cat' => false, 'first_id' => 1], $this->outputHeadlines(1)->getPayload()['content'], ]); - $this->assertResponse($exp, $test); + $this->assertMessage($exp, $test); // test 'include_header' with a category $test = $this->req($in[4]); $exp = $this->respGood([ ['id' => -3, 'is_cat' => true, 'first_id' => 1], $this->outputHeadlines(1)->getPayload()['content'], ]); - $this->assertResponse($exp, $test); + $this->assertMessage($exp, $test); // test 'include_header' with an empty result $test = $this->req($in[5]); $exp = $this->respGood([ ['id' => -1, 'is_cat' => true, 'first_id' => 0], [], ]); - $this->assertResponse($exp, $test); + $this->assertMessage($exp, $test); // test 'include_header' with an erroneous result Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->reverse(true)->subscription(2112), $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); $test = $this->req($in[6]); @@ -1693,14 +1693,14 @@ LONG_STRING; ['id' => 2112, 'is_cat' => false, 'first_id' => 0], [], ]); - $this->assertResponse($exp, $test); + $this->assertMessage($exp, $test); // test 'include_header' with ascending order $test = $this->req($in[7]); $exp = $this->respGood([ ['id' => -4, 'is_cat' => false, 'first_id' => 0], $this->outputHeadlines(1)->getPayload()['content'], ]); - $this->assertResponse($exp, $test); + $this->assertMessage($exp, $test); // test 'include_header' with skip Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), Database::LIST_MINIMAL)->thenReturn($this->generateHeadlines(1867)); $test = $this->req($in[8]); @@ -1708,14 +1708,14 @@ LONG_STRING; ['id' => 42, 'is_cat' => false, 'first_id' => 1867], $this->outputHeadlines(1)->getPayload()['content'], ]); - $this->assertResponse($exp, $test); + $this->assertMessage($exp, $test); // test 'include_header' with skip and ascending order $test = $this->req($in[9]); $exp = $this->respGood([ ['id' => 42, 'is_cat' => false, 'first_id' => 0], $this->outputHeadlines(1)->getPayload()['content'], ]); - $this->assertResponse($exp, $test); + $this->assertMessage($exp, $test); // test 'show_excerpt' $exp1 = "“This & that, you know‽”"; $exp2 = "Pour vous faire mieux connaitre d’ou\u{300} vient l’erreur de ceux qui bla\u{302}ment la volupte\u{301}, et qui louent en…"; diff --git a/tests/cases/REST/TinyTinyRSS/TestIcon.php b/tests/cases/REST/TinyTinyRSS/TestIcon.php index 548ab50..fd0fef7 100644 --- a/tests/cases/REST/TinyTinyRSS/TestIcon.php +++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php @@ -52,19 +52,19 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionFavicon(1337)->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/"); // these requests should succeed $exp = new Response(301, ['Location' => "http://example.com/favicon.ico"]); - $this->assertResponse($exp, $this->req("42.ico")); + $this->assertMessage($exp, $this->req("42.ico")); $exp = new Response(301, ['Location' => "http://example.net/logo.png"]); - $this->assertResponse($exp, $this->req("2112.ico")); + $this->assertMessage($exp, $this->req("2112.ico")); $exp = new Response(301, ['Location' => "http://example.org/icon.gif"]); - $this->assertResponse($exp, $this->req("1337.ico")); + $this->assertMessage($exp, $this->req("1337.ico")); // these requests should fail $exp = new Response(404); - $this->assertResponse($exp, $this->req("ook.ico")); - $this->assertResponse($exp, $this->req("ook")); - $this->assertResponse($exp, $this->req("47.ico")); - $this->assertResponse($exp, $this->req("2112.png")); + $this->assertMessage($exp, $this->req("ook.ico")); + $this->assertMessage($exp, $this->req("ook")); + $this->assertMessage($exp, $this->req("47.ico")); + $this->assertMessage($exp, $this->req("2112.png")); // only GET is allowed $exp = new Response(405, ['Allow' => "GET"]); - $this->assertResponse($exp, $this->req("2112.ico", "PUT")); + $this->assertMessage($exp, $this->req("2112.ico", "PUT")); } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index cf3c777..fbcb227 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -10,6 +10,9 @@ use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\Misc\Date; +use Psr\Http\Message\MessageInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\EmptyResponse; @@ -45,8 +48,19 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } } - protected function assertResponse(ResponseInterface $exp, ResponseInterface $act, string $text = null) { - $this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text); + protected function assertMessage(MessageInterface $exp, MessageInterface $act, string $text = null) { + if ($exp instanceof ResponseInterface) { + $this->assertInstanceOf(ResponseInterface::class, $act, $text); + $this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text); + } elseif ($exp instanceof RequestInterface) { + if ($exp instanceof ServerRequestInterface) { + $this->assertInstanceOf(ServerRequestInterface::class, $act, $text); + $this->assertEquals($exp->getAttributes(), $act->getAttributes(), $text); + } + $this->assertInstanceOf(RequestInterface::class, $act, $text); + $this->assertSame($exp->getRequestMethod(), $act->getRequestMethod(), $text); + $this->assertSame($exp->getRequestTarget(), $act->getRequestTarget(), $text); + } if ($exp instanceof JsonResponse) { $this->assertEquals($exp->getPayload(), $act->getPayload(), $text); $this->assertSame($exp->getPayload(), $act->getPayload(), $text); From aa572270979b2322e0951d1e1a3d85da70b3f3b3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 11 Jan 2018 15:48:29 -0500 Subject: [PATCH 10/11] Use PSR-7 for authentication; fixes #53 --- lib/Conf.php | 4 ++- lib/REST.php | 35 ++++++++++++++++++++++ lib/REST/NextCloudNews/V1_2.php | 6 ++-- tests/cases/REST/TestREST.php | 53 +++++++++++++++++++++++++++++++++ tests/lib/AbstractTest.php | 2 +- 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/lib/Conf.php b/lib/Conf.php index bd046ff..cc0a183 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -72,10 +72,12 @@ class Conf { * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $purgeArticlesUnread = "P21D"; + /** @var string Application name to present to clients during authentication */ + public $httpRealm = "The Advanced RSS Environment"; /** @var string Space-separated list of origins from which to allow cross-origin resource sharing */ public $httpOriginsAllowed = "*"; /** @var string Space-separated list of origins from which to deny cross-origin resource sharing */ - public $httpOriginsDenied = ""; + public $httpOriginsDenied = ""; /** Creates a new configuration object * @param string $import_file Optional file to read configuration data from diff --git a/lib/REST.php b/lib/REST.php index fc4cfac..3820308 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -6,6 +6,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse; + +use JKingWeb\Arsse\Arsse; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; @@ -68,6 +70,8 @@ class REST { // find the API to handle try { list ($api, $target, $class) = $this->apiMatch($req->getRequestTarget(), $this->apis); + // authenticate the request pre-emptively + $req = $this->authenticateRequest($req); // modify the request to have an uppercase method and a stripped target $req = $req->withMethod(strtoupper($req->getMethod()))->withRequestTarget($target); // fetch the correct handler @@ -119,7 +123,38 @@ class REST { throw new REST\Exception501(); } + public function authenticateRequest(ServerRequestInterface $req): ServerRequestInterface { + $user = ""; + $password = ""; + $env = $req->getServerParams(); + if (isset($env['PHP_AUTH_USER'])) { + $user = $env['PHP_AUTH_USER']; + if (isset($env['PHP_AUTH_PW'])) { + $password = $env['PHP_AUTH_PW']; + } + } elseif (isset($env['REMOTE_USER'])) { + $user = $env['REMOTE_USER']; + } + if (strlen($user)) { + $valid = Arsse::$user->auth($user, $password); + } + if ($valid) { + $req = $req->withAttribute("authenticated", true); + $req = $req->withAttribute("authenticatedUser", $user); + } + return $req; + } + + public function challenge(ResponseInterface $res, string $realm = null): ResponseInterface { + $realm = $realm ?? Arsse::$conf->httpRealm ?? "Default"; + return $res->withAddedHeader("WWW-Authenticate", 'Basic realm="'.$realm.'"'); + } + public function normalizeResponse(ResponseInterface $res, RequestInterface $req = null): ResponseInterface { + // if the response code is 401, issue an HTTP authentication challenge + if ($res->getStatusCode()==401) { + $res = $this->challenge($res); + } // set or clear the Content-Length header field $body = $res->getBody(); $bodySize = $body->getSize(); diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 0983ebd..6b556ff 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -80,8 +80,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { public function dispatch(ServerRequestInterface $req): ResponseInterface { // try to authenticate - if (!Arsse::$user->authHTTP()) { - return new EmptyResponse(401, ['WWW-Authenticate' => 'Basic realm="'.self::REALM.'"']); + if ($req->getAttribute("authenticated", false)) { + Arsse::$user->id = $req->getAttribute("authenticatedUser"); + } else { + return new EmptyResponse(401); } // explode and normalize the URL path $target = new Target($req->getRequestTarget()); diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php index 7851c1e..6cb7d91 100644 --- a/tests/cases/REST/TestREST.php +++ b/tests/cases/REST/TestREST.php @@ -6,6 +6,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\REST; +use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\User; use JKingWeb\Arsse\REST; use JKingWeb\Arsse\REST\Handler; use JKingWeb\Arsse\REST\Exception501; @@ -58,7 +60,50 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { [$fake, "/full/url-not", []], ]; } + + /** @dataProvider provideAuthenticableRequests */ + public function testAuthenticateRequests(array $serverParams, array $expAttr) { + $r = new REST(); + // create a mock user manager + Arsse::$user = Phake::mock(User::class); + Phake::when(Arsse::$user)->auth->thenReturn(true); + Phake::when(Arsse::$user)->auth($this->anything(), "superman")->thenReturn(false); + Phake::when(Arsse::$user)->auth("jane.doe@example.com", $this->anything())->thenReturn(false); + // create an input server request + $req = new ServerRequest($serverParams); + // create the expected output + $exp = $req; + foreach ($expAttr as $key => $value) { + $exp = $exp->withAttribute($key, $value); + } + $act = $r->authenticateRequest($req); + $this->assertMessage($exp, $act); + } + + public function provideAuthenticableRequests() { + return [ + [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "secret"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]], + [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "secret", 'REMOTE_USER' => "jane.doe@example.com"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]], + [['PHP_AUTH_USER' => "jane.doe@example.com", 'PHP_AUTH_PW' => "secret"], []], + [['PHP_AUTH_USER' => "john.doe@example.com", 'PHP_AUTH_PW' => "superman"], []], + [['REMOTE_USER' => "john.doe@example.com"], ['authenticated' => true, 'authenticatedUser' => "john.doe@example.com"]], + [['REMOTE_USER' => "someone.else@example.com"], ['authenticated' => true, 'authenticatedUser' => "someone.else@example.com"]], + [['REMOTE_USER' => "jane.doe@example.com"], []], + ]; + } + public function testSendAuthenticationChallenges() { + $this->setConf(); + $r = new REST(); + $in = new EmptyResponse(401); + $exp = $in->withHeader("WWW-Authenticate", 'Basic realm="OOK"'); + $act = $r->challenge($in, "OOK"); + $this->assertMessage($exp, $act); + $exp = $in->withHeader("WWW-Authenticate", 'Basic realm="'.Arsse::$conf->httpRealm.'"'); + $act = $r->challenge($in); + $this->assertMessage($exp, $act); + } + /** @dataProvider provideUnnormalizedOrigins */ public function testNormalizeOrigins(string $origin, string $exp, array $ports = null) { $r = new REST(); @@ -207,6 +252,9 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, RequestInterface $req = null) { $r = Phake::partialMock(REST::class); Phake::when($r)->corsNegotiate->thenReturn(true); + Phake::when($r)->challenge->thenReturnCallback(function ($res) { + return $res->withHeader("WWW-Authenticate", "Fake Value"); + }); Phake::when($r)->corsApply->thenReturnCallback(function ($res) { return $res; }); @@ -219,6 +267,7 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { fwrite($stream,"ook"); return [ [new EmptyResponse(204), new EmptyResponse(204)], + [new EmptyResponse(401), new EmptyResponse(401, ['WWW-Authenticate' => "Fake Value"])], [new EmptyResponse(204, ['Allow' => "PUT"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])], [new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])], [new EmptyResponse(204, ['Allow' => "PUT,OPTIONS"]), new EmptyResponse(204, ['Allow' => "PUT, OPTIONS"])], @@ -249,6 +298,9 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when($r)->normalizeResponse->thenReturnCallback(function ($res) { return $res; }); + Phake::when($r)->authenticateRequest->thenReturnCallback(function ($req) { + return $req; + }); if ($called) { $h = Phake::mock($class); Phake::when($r)->getHandler($class)->thenReturn($h); @@ -257,6 +309,7 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest { $out = $r->dispatch($req); $this->assertInstanceOf(ResponseInterface::class, $out); if ($called) { + Phake::verify($r)->authenticateRequest; Phake::verify($h)->dispatch(Phake::capture($in)); $this->assertSame($method, $in->getMethod()); $this->assertSame($target, $in->getRequestTarget()); diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index fbcb227..4986c1a 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -58,7 +58,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $this->assertEquals($exp->getAttributes(), $act->getAttributes(), $text); } $this->assertInstanceOf(RequestInterface::class, $act, $text); - $this->assertSame($exp->getRequestMethod(), $act->getRequestMethod(), $text); + $this->assertSame($exp->getMethod(), $act->getMethod(), $text); $this->assertSame($exp->getRequestTarget(), $act->getRequestTarget(), $text); } if ($exp instanceof JsonResponse) { From cef061f6cdd97f2f5c58215af4cc41ad6d4a9666 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 11 Jan 2018 16:00:56 -0500 Subject: [PATCH 11/11] Fix tests --- tests/cases/REST/NextCloudNews/TestV1_2.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index ec93028..f886298 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -314,6 +314,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $server['HTTP_CONTENT_TYPE'] = "application/json"; } $req = new ServerRequest($server, [], $url, $method, "php://memory"); + if (Arsse::$user->auth()) { + $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); @@ -340,7 +343,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$conf = new Conf(); // create a mock user manager Arsse::$user = Phake::mock(User::class); - Phake::when(Arsse::$user)->authHTTP->thenReturn(true); + Phake::when(Arsse::$user)->auth->thenReturn(true); Phake::when(Arsse::$user)->rightsGet->thenReturn(100); Arsse::$user->id = "john.doe@example.com"; // create a mock database interface @@ -354,8 +357,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { } public function testSendAuthenticationChallenge() { - Phake::when(Arsse::$user)->authHTTP->thenReturn(false); - $exp = new EmptyResponse(401, ['WWW-Authenticate' => 'Basic realm="'.V1_2::REALM.'"']); + Phake::when(Arsse::$user)->auth->thenReturn(false); + $exp = new EmptyResponse(401); $this->assertMessage($exp, $this->req("GET", "/")); }