diff --git a/CHANGELOG b/CHANGELOG index 6606a7da..604e5a1d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,12 @@ Version 0.3.0 (2018-??-??) New features: - Support for SQLite3 via PDO +- Support for cross-origin resource sharing in all protocols + +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/README.md b/README.md index 67bc369f..2d18b0a8 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 diff --git a/UPGRADING b/UPGRADING index df9448cc..160574f9 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 1da09663..6468f0b0 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/composer.json b/composer.json index 02949a3b..aa4ac4a8 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 8a76cbdf..3c1262ab 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/Conf.php b/lib/Conf.php index 0fe10529..cc0a1834 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -72,6 +72,13 @@ 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 = ""; + /** Creates a new configuration object * @param string $import_file Optional file to read configuration data from * @see self::importFile() */ diff --git a/lib/Misc/Date.php b/lib/Misc/Date.php index b1afc5ad..0eacf8c0 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 d79c0034..3820308e 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -6,8 +6,17 @@ 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; +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 +30,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, ], @@ -34,50 +43,252 @@ 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/ ]; + const DEFAULT_PORTS = [ + 'http' => 80, + 'https' => 443, + ]; + protected $apis = []; - public function __construct() { + public function __construct(array $apis = null) { + $this->apis = $apis ?? self::API_LIST; } - public function dispatch(REST\Request $req = null): REST\Response { - 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; - } else { - return $drv->dispatch($req); + public function dispatch(ServerRequestInterface $req = null): ResponseInterface { + // create a request object if not provided + $req = $req ?? ServerRequestFactory::fromGlobals(); + // 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 + $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 + return $this->normalizeResponse($res, $req); } - public function apiMatch(string $url, array $map): string { + public function getHandler(string $className): REST\Handler { + // instantiate the API handler + return new $className(); + } + + 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 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(); + 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 && $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("Allow"))); + // 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)); + } + // 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/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php index d471525b..756ebe7d 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): Response; + abstract public function dispatch(ServerRequestInterface $req): ResponseInterface; protected function fieldMapNames(array $data, array $map): array { $out = []; 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 325a4f59..8bee1922 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 b41c0d54..842ccdbb 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 00000000..77d1e306 --- /dev/null +++ b/lib/REST/Exception501.php @@ -0,0 +1,10 @@ + 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): Response { + public function dispatch(ServerRequestInterface $req): ResponseInterface { // try to authenticate - if (!Arsse::$user->authHTTP()) { - return new Response(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()); // 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)) { - return new Response(415, "", "", ['Accept: application/json']); + 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 Response(400); + return new EmptyResponse(400); } } else { $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 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 { - return $this->$func($req->paths, $data); + return $this->$func($target->path, $data); // @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 } - 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 @@ -242,9 +263,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { return $article; } - protected function handleHTTPOptions(array $url): Response { - // 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]); @@ -252,81 +273,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 +358,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 +376,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' => (int) $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 +429,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 +445,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 +489,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 +515,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 +574,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 +609,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 +625,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 +641,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 +655,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 +676,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 b51773fb..77924bd4 100644 --- a/lib/REST/NextCloudNews/Versions.php +++ b/lib/REST/NextCloudNews/Versions.php @@ -6,30 +6,35 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\NextCloudNews; -use JKingWeb\Arsse\REST\Response; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +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 { - 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); - } elseif ($req->method=="OPTIONS") { - // if the request method is OPTIONS, respond accordingly - return new Response(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"]); - } else { - // otherwise return the supported versions - $out = [ - 'apiLevels' => [ - 'v1-2', - ] - ]; - return new Response(200, $out); + 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); + } + 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 157027a1..00000000 --- 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/Response.php b/lib/REST/Response.php deleted file mode 100644 index 819bf0cf..00000000 --- a/lib/REST/Response.php +++ /dev/null @@ -1,65 +0,0 @@ -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/lib/REST/Target.php b/lib/REST/Target.php new file mode 100644 index 00000000..bde4c6c1 --- /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/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index d3f7728b..6e1935a6 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -19,7 +19,10 @@ 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 Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +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 +91,24 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function __construct() { } - public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { - 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 Response(404); + 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 Response(204, "", "", [ - "Allow: POST", - "Accept: application/json, text/json", + 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(200, self::FATAL_ERR); + return new Response(self::FATAL_ERR); } try { // normalize input @@ -123,23 +127,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 aabb2c28..ef2d0c07 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 JKingWeb\Arsse\REST\Response; +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): Response { - 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])) { + return new Response(405, ['Allow' => "GET"]); + } elseif (!preg_match("<^(\d+)\.ico$>", $req->getRequestTarget(), $match) || !((int) $match[1])) { return new Response(404); } $url = Arsse::$db->subscriptionFavicon((int) $match[1]); @@ -26,7 +28,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/bootstrap.php b/tests/bootstrap.php index 628bd3d8..59c04a1f 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/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index f73e7852..a1b8a828 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -11,14 +11,16 @@ use JKingWeb\Arsse\Conf; 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 Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\ServerRequest; +use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\EmptyResponse; use Phake; /** @covers \JKingWeb\Arsse\REST\NextCloudNews\V1_2 */ @@ -299,12 +301,49 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ], ]; + protected function req(string $method, string $target, string $data = "", array $headers = []): ResponseInterface { + $url = "/index.php/apps/news/api/v1-2".$target; + $server = [ + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $url, + 'PHP_AUTH_USER' => "john.doe@example.com", + 'PHP_AUTH_PW' => "secret", + 'REMOTE_USER' => "john.doe@example.com", + ]; + if (strlen($data)) { + $server['HTTP_CONTENT_TYPE'] = "application/json"; + } + $req = new ServerRequest($server, [], $url, $method, "php://memory"); + if (Arsse::$user->auth()) { + $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", "john.doe@example.com"); + } + foreach($headers as $key => $value) { + if (!is_null($value)) { + $req = $req->withHeader($key, $value); + } else { + $req = $req->withoutHeader($key); + } + } + if (strlen($data)) { + $body = $req->getBody(); + $body->write($data); + $req = $req->withBody($body); + } + $q = $req->getUri()->getQuery(); + if (strlen($q)) { + parse_str($q, $q); + $req = $req->withQueryParams($q); + } + $req = $req->withRequestTarget($target); + return $this->h->dispatch($req); + } + public function setUp() { $this->clearData(); 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 @@ -321,15 +360,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { return $value; } - 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.'"']); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/"))); + Phake::when(Arsse::$user)->auth->thenReturn(false); + $exp = new EmptyResponse(401); + $this->assertMessage($exp, $this->req("GET", "/")); } public function testRespondToInvalidPaths() { @@ -365,44 +399,45 @@ 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."); + $this->assertMessage($exp, $this->req($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."); + $this->assertMessage($exp, $this->req($method, $path), "$method call to $path did not return 405."); } } } public function testRespondToInvalidInputTypes() { - $exp = new Response(415, "", "", ['Accept: application/json']); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '', 'application/xml'))); - $exp = new Response(400); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '', 'application/json'))); + $exp = new EmptyResponse(415, ['Accept' => "application/json"]); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => "application/xml"])); + $exp = new EmptyResponse(400); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", '')); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => null])); } 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", + $this->assertMessage($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"))); - $exp = new Response(204, "", "", [ - "Allow: HEAD,GET", - "Accept: application/json", + $this->assertMessage($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"))); - $exp = new Response(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("OPTIONS", "/invalid/path"))); + $this->assertMessage($exp, $this->req("OPTIONS", "/user")); + $exp = new EmptyResponse(404); + $this->assertMessage($exp, $this->req("OPTIONS", "/invalid/path")); } public function testListFolders() { @@ -415,10 +450,10 @@ 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($this->v($list))); - $exp = new Response(200, ['folders' => []]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/folders"))); - $exp = new Response(200, ['folders' => $out]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/folders"))); + $exp = new Response(['folders' => []]); + $this->assertMessage($exp, $this->req("GET", "/folders")); + $exp = new Response(['folders' => $out]); + $this->assertMessage($exp, $this->req("GET", "/folders")); } public function testAddAFolder() { @@ -445,34 +480,34 @@ 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]]]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[0]), 'application/json'))); - $exp = new Response(200, ['folders' => [$out[1]]]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders?name=Hardware"))); + $exp = new Response(['folders' => [$out[0]]]); + $this->assertMessage($exp, $this->req("POST", "/folders", json_encode($in[0]))); + $exp = new Response(['folders' => [$out[1]]]); + $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 Response(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'))); + $exp = new EmptyResponse(422); + $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 Response(409); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders?name=Software"))); - $exp = new Response(409); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[1]), 'application/json'))); + $exp = new EmptyResponse(409); + $this->assertMessage($exp, $this->req("POST", "/folders?name=Software")); + $exp = new EmptyResponse(409); + $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 Response(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/folders/1"))); + $exp = new EmptyResponse(204); + $this->assertMessage($exp, $this->req("DELETE", "/folders/1")); // fail on the second invocation because it no longer exists - $exp = new Response(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/folders/1"))); + $exp = new EmptyResponse(404); + $this->assertMessage($exp, $this->req("DELETE", "/folders/1")); Phake::verify(Arsse::$db, Phake::times(2))->folderRemove(Arsse::$user->id, 1); } @@ -490,26 +525,26 @@ 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); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[0]), 'application/json'))); - $exp = new Response(409); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/2", json_encode($in[1]), 'application/json'))); - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[2]), 'application/json'))); - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[3]), 'application/json'))); - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", json_encode($in[4]), 'application/json'))); - $exp = new Response(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/3", json_encode($in[0]), 'application/json'))); + $exp = new EmptyResponse(204); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[0]))); + $exp = new EmptyResponse(409); + $this->assertMessage($exp, $this->req("PUT", "/folders/2", json_encode($in[1]))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[2]))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[3]))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/folders/1", json_encode($in[4]))); + $exp = new EmptyResponse(404); + $this->assertMessage($exp, $this->req("PUT", "/folders/3", json_encode($in[0]))); } public function testRetrieveServerVersion() { - $exp = new Response(200, [ + $exp = new Response([ 'version' => V1_2::VERSION, 'arsse_version' => Arsse::VERSION, ]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/version"))); + $this->assertMessage($exp, $this->req("GET", "/version")); } public function testListSubscriptions() { @@ -525,10 +560,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([]))->thenReturn(new Result($this->v($this->feeds['db']))); Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn($this->v(['total' => 0]))->thenReturn($this->v(['total' => 5])); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915); - $exp = new Response(200, $exp1); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds"))); - $exp = new Response(200, $exp2); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds"))); + $exp = new Response($exp1); + $this->assertMessage($exp, $this->req("GET", "/feeds")); + $exp = new Response($exp2); + $this->assertMessage($exp, $this->req("GET", "/feeds")); } public function testAddASubscription() { @@ -560,32 +595,32 @@ 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]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); - $exp = new Response(200, $out[1]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json'))); + $exp = new Response($out[0]); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[0]))); + $exp = new Response($out[1]); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[1]))); // try to add them a second time - $exp = new Response(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'))); + $exp = new EmptyResponse(409); + $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 Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[2]), 'application/json'))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[2]))); // try again (this will succeed), with an invalid folder ID - $exp = new Response(200, $out[2]); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[3]), 'application/json'))); + $exp = new Response($out[2]); + $this->assertMessage($exp, $this->req("POST", "/feeds", json_encode($in[3]))); // try to add no feed - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[4]), 'application/json'))); + $exp = new EmptyResponse(422); + $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 Response(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1"))); + $exp = new EmptyResponse(204); + $this->assertMessage($exp, $this->req("DELETE", "/feeds/1")); // fail on the second invocation because it no longer exists - $exp = new Response(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1"))); + $exp = new EmptyResponse(404); + $this->assertMessage($exp, $this->req("DELETE", "/feeds/1")); Phake::verify(Arsse::$db, Phake::times(2))->subscriptionRemove(Arsse::$user->id, 1); } @@ -603,18 +638,18 @@ 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); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[0]), 'application/json'))); - $exp = new Response(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[1]), 'application/json'))); - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[2]), 'application/json'))); - $exp = new Response(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/move", json_encode($in[3]), 'application/json'))); - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[4]), 'application/json'))); - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[5]), 'application/json'))); + $exp = new EmptyResponse(204); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[0]))); + $exp = new EmptyResponse(204); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[1]))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[2]))); + $exp = new EmptyResponse(404); + $this->assertMessage($exp, $this->req("PUT", "/feeds/42/move", json_encode($in[3]))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[4]))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[5]))); } public function testRenameASubscription() { @@ -633,18 +668,18 @@ 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); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[0]), 'application/json'))); - $exp = new Response(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[1]), 'application/json'))); - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[2]), 'application/json'))); - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[3]), 'application/json'))); - $exp = new Response(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/rename", json_encode($in[4]), 'application/json'))); - $exp = new Response(422); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[6]), 'application/json'))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[0]))); + $exp = new EmptyResponse(204); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[1]))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[2]))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[3]))); + $exp = new EmptyResponse(404); + $this->assertMessage($exp, $this->req("PUT", "/feeds/42/rename", json_encode($in[4]))); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[6]))); } public function testListStaleFeeds() { @@ -659,12 +694,12 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ], ]; Phake::when(Arsse::$db)->feedListStale->thenReturn($this->v(array_column($out, "id"))); - $exp = new Response(200, ['feeds' => $out]); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); + $exp = new Response(['feeds' => $out]); + $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 Response(403); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); + $exp = new EmptyResponse(403); + $this->assertMessage($exp, $this->req("GET", "/feeds/all")); } public function testUpdateAFeed() { @@ -678,18 +713,18 @@ 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); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); - $exp = new Response(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[1]), 'application/json'))); - $exp = new Response(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'))); + $exp = new EmptyResponse(204); + $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[0]))); + $exp = new EmptyResponse(404); + $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[1]))); + $exp = new EmptyResponse(422); + $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 Response(403); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); + $exp = new EmptyResponse(403); + $this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[0]))); } public function testListArticles() { @@ -713,25 +748,25 @@ 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 + $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 Response(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'))); + $exp = new EmptyResponse(422); + $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->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); @@ -751,14 +786,14 @@ 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); - $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); - $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); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/42/read", $in, 'application/json'))); + $exp = new EmptyResponse(204); + $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->assertMessage($exp, $this->req("PUT", "/folders/1/read")); + $this->assertMessage($exp, $this->req("PUT", "/folders/1/read?newestItemId=ook")); + $exp = new EmptyResponse(404); + $this->assertMessage($exp, $this->req("PUT", "/folders/42/read", $in)); } public function testMarkASubscriptionRead() { @@ -766,26 +801,26 @@ 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); - $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); - $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); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/read", $in, 'application/json'))); + $exp = new EmptyResponse(204); + $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->assertMessage($exp, $this->req("PUT", "/feeds/1/read")); + $this->assertMessage($exp, $this->req("PUT", "/feeds/1/read?newestItemId=ook")); + $exp = new EmptyResponse(404); + $this->assertMessage($exp, $this->req("PUT", "/feeds/42/read", $in)); } public function testMarkAllItemsRead() { $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); - $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); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read"))); - $this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=ook"))); + $exp = new EmptyResponse(204); + $this->assertMessage($exp, $this->req("PUT", "/items/read", $in)); + $this->assertMessage($exp, $this->req("PUT", "/items/read?newestItemId=2112")); + $exp = new EmptyResponse(422); + $this->assertMessage($exp, $this->req("PUT", "/items/read")); + $this->assertMessage($exp, $this->req("PUT", "/items/read?newestItemId=ook")); } public function testChangeMarksOfASingleArticle() { @@ -801,16 +836,16 @@ 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); - $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); - $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"))); + $exp = new EmptyResponse(204); + $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->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()); } @@ -832,27 +867,27 @@ 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); - $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'))); + $exp = new EmptyResponse(204); + $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])); @@ -885,29 +920,29 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ]; $arr2['warnings']['improperlyConfiguredCron'] = true; $arr2['warnings']['incorrectDbCharset'] = true; - $exp = new Response(200, $arr1); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/status"))); + $exp = new Response($arr1); + $this->assertMessage($exp, $this->req("GET", "/status")); } public function testCleanUpBeforeUpdate() { Phake::when(Arsse::$db)->feedCleanup()->thenReturn(true); - $exp = new Response(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); + $exp = new EmptyResponse(204); + $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 Response(403); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); + $exp = new EmptyResponse(403); + $this->assertMessage($exp, $this->req("GET", "/cleanup/before-update")); } public function testCleanUpAfterUpdate() { Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true); - $exp = new Response(204); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update"))); + $exp = new EmptyResponse(204); + $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 Response(403); - $this->assertResponse($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update"))); + $exp = new EmptyResponse(403); + $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 3081d57a..28c6e0ca 100644 --- a/tests/cases/REST/NextCloudNews/TestVersions.php +++ b/tests/cases/REST/NextCloudNews/TestVersions.php @@ -7,8 +7,10 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\REST\NextCloudNews; use JKingWeb\Arsse\REST\NextCloudNews\Versions; -use JKingWeb\Arsse\REST\Request; -use JKingWeb\Arsse\REST\Response; +use Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\ServerRequest; +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 { @@ -16,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(200, ['apiLevels' => ['v1-2']]); - $h = new Versions; - $req = new Request("GET", "/"); - $res = $h->dispatch($req); - $this->assertEquals($exp, $res); - $req = new Request("GET", ""); - $res = $h->dispatch($req); - $this->assertEquals($exp, $res); - $req = new Request("GET", "/?id=1827"); - $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $exp = new Response(['apiLevels' => ['v1-2']]); + $this->assertMessage($exp, $this->req("GET", "/")); + $this->assertMessage($exp, $this->req("GET", "/")); + $this->assertMessage($exp, $this->req("GET", "/")); } public function testRespondToOptionsRequest() { - $exp = new Response(204, "", "", ["Allow: HEAD,GET"]); - $h = new Versions; - $req = new Request("OPTIONS", "/"); - $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $exp = new EmptyResponse(204, ['Allow' => "HEAD,GET"]); + $this->assertMessage($exp, $this->req("OPTIONS", "/")); } public function testUseIncorrectMethod() { - $exp = new Response(405, "", "", ["Allow: HEAD,GET"]); - $h = new Versions; - $req = new Request("POST", "/"); - $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $exp = new EmptyResponse(405, ['Allow' => "HEAD,GET"]); + $this->assertMessage($exp, $this->req("POST", "/")); } public function testUseIncorrectPath() { - $exp = new Response(404); - $h = new Versions; - $req = new Request("GET", "/ook"); - $res = $h->dispatch($req); - $this->assertEquals($exp, $res); - $req = new Request("OPTIONS", "/ook"); - $res = $h->dispatch($req); - $this->assertEquals($exp, $res); + $exp = new EmptyResponse(404); + $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 new file mode 100644 index 00000000..6cb7d91a --- /dev/null +++ b/tests/cases/REST/TestREST.php @@ -0,0 +1,334 @@ +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", []], + ]; + } + + /** @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(); + $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->assertMessage($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 = 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; + }); + $act = $r->normalizeResponse($res, $req); + $this->assertMessage($exp, $act); + } + + public function provideUnnormalizedResponses() { + $stream = fopen("php://memory", "w+b"); + 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"])], + [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); + 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); + Phake::when($h)->dispatch->thenReturn(new EmptyResponse(204)); + } + $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()); + } 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([], [], "/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 diff --git a/tests/cases/REST/TestTarget.php b/tests/cases/REST/TestTarget.php new file mode 100644 index 00000000..5577af85 --- /dev/null +++ b/tests/cases/REST/TestTarget.php @@ -0,0 +1,66 @@ +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/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index c4be5139..12a9056f 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -12,13 +12,16 @@ 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\ServerRequest; +use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\EmptyResponse; use Phake; /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API @@ -126,12 +129,26 @@ LONG_STRING; return $value; } - protected function req($data) : Response { - 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 { - return new Response(200, [ + return new Response([ 'seq' => $seq, 'status' => 0, 'content' => $content, @@ -140,18 +157,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(); @@ -179,25 +191,25 @@ 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", ""))); - $exp = new Response(404); - $this->assertResponse($exp, $this->h->dispatch(new Request("POST", "/bad/path", ""))); + $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->assertMessage($exp, $this->req(null, "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", ""))); + $this->assertMessage($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->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() { @@ -210,15 +222,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()); } @@ -230,8 +242,8 @@ LONG_STRING; 'user' => Arsse::$user->id, 'password' => "secret", ]; - $exp = new Response(500); - $this->assertResponse($exp, $this->req($data)); + $exp = new EmptyResponse(500); + $this->assertMessage($exp, $this->req($data)); } public function testLogOut() { @@ -241,7 +253,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"); } @@ -251,10 +263,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() { @@ -263,7 +275,7 @@ LONG_STRING; 'op' => "thisMethodDoesNotExist", 'sid' => "PriestsOfSyrinx", ]; - $this->assertResponse($exp, $this->req($data)); + $this->assertMessage($exp, $this->req($data)); } public function testHandleMixedCaseMethods() { @@ -272,13 +284,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() { @@ -290,7 +302,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() { @@ -299,7 +311,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() { @@ -333,24 +345,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() { @@ -363,16 +375,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()); } @@ -410,21 +422,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()); } @@ -450,21 +462,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()); } @@ -534,11 +546,11 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($this->v($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]); } @@ -555,13 +567,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()); } @@ -589,21 +601,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()); } @@ -629,21 +641,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]); @@ -657,7 +669,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() { @@ -671,8 +683,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() { @@ -686,13 +698,13 @@ LONG_STRING; Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1)->thenReturn($this->v(['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() { @@ -723,21 +735,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() { @@ -752,18 +764,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); } @@ -796,21 +808,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()); } @@ -882,7 +894,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"); } } @@ -918,7 +930,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() { @@ -962,7 +974,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"); } } @@ -988,20 +1000,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() { @@ -1016,9 +1028,9 @@ LONG_STRING; Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($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() { @@ -1050,12 +1062,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)); @@ -1067,7 +1079,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)); } @@ -1202,10 +1214,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"); } } @@ -1315,7 +1327,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"); } } @@ -1339,10 +1351,10 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]))->thenReturn(new Result($this->v([$this->articles[0]]))); Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]))->thenReturn(new Result($this->v([$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", @@ -1399,13 +1411,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() { @@ -1484,13 +1496,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($this->v([['id' => 1001]]))); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result($this->v([['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($this->v([['id' => 1003]]))); - $this->assertResponse($out2[$a], $this->req($in2[$a]), "Test $a failed"); + $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed"); } } @@ -1592,16 +1604,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"); } } @@ -1631,13 +1643,13 @@ 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->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]); @@ -1653,33 +1665,31 @@ 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->assertResponse($exp, $test); + $this->outputHeadlines(1)->getPayload()['content'], + ]); + $this->assertMessage($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->assertResponse($exp, $test); + $this->outputHeadlines(1)->getPayload()['content'], + ]); + $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]); @@ -1687,40 +1697,37 @@ 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->outputHeadlines(1); - $exp->payload['content'] = [ + $exp = $this->respGood([ ['id' => -4, 'is_cat' => false, 'first_id' => 0], - $exp->payload['content'], - ]; - $this->assertResponse($exp, $test); + $this->outputHeadlines(1)->getPayload()['content'], + ]); + $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]); - $exp = $this->outputHeadlines(1); - $exp->payload['content'] = [ + $exp = $this->respGood([ ['id' => 42, 'is_cat' => false, 'first_id' => 1867], - $exp->payload['content'], - ]; - $this->assertResponse($exp, $test); + $this->outputHeadlines(1)->getPayload()['content'], + ]); + $this->assertMessage($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->assertResponse($exp, $test); + $this->outputHeadlines(1)->getPayload()['content'], + ]); + $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…"; $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 64f33588..fd0fef7e 100644 --- a/tests/cases/REST/TinyTinyRSS/TestIcon.php +++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php @@ -12,7 +12,9 @@ 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 Psr\Http\Message\ResponseInterface; +use Zend\Diactoros\ServerRequest; +use Zend\Diactoros\Response\EmptyResponse as Response; use Phake; /** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Icon */ @@ -32,26 +34,37 @@ 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"); 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->assertMessage($exp, $this->req("42.ico")); + $exp = new Response(301, ['Location' => "http://example.net/logo.png"]); + $this->assertMessage($exp, $this->req("2112.ico")); + $exp = new Response(301, ['Location' => "http://example.org/icon.gif"]); + $this->assertMessage($exp, $this->req("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->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->assertEquals($exp, $this->h->dispatch(new Request("PUT", "2112.ico"))); + $exp = new Response(405, ['Allow' => "GET"]); + $this->assertMessage($exp, $this->req("2112.ico", "PUT")); } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index dd63b4df..7661381a 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -8,10 +8,29 @@ 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\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; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { + public function setUp() { + $this->clearData(); + } + + public function tearDown() { + $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; @@ -29,6 +48,28 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } } + 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->getMethod(), $act->getMethod(), $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); + } else { + $this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text); + } + $this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text); + } + public function approximateTime($exp, $act) { if (is_null($act)) { return null; diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 0c814a8b..b58b0cd0 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -82,17 +82,19 @@ cases/Db/SQLite3PDO/Database/TestLabel.php cases/Db/SQLite3PDO/Database/TestCleanup.php - - - cases/REST/NextCloudNews/TestVersions.php - cases/REST/NextCloudNews/TestV1_2.php + + cases/REST/TestTarget.php + cases/REST/TestREST.php + + + cases/REST/NextCloudNews/TestVersions.php + cases/REST/NextCloudNews/TestV1_2.php cases/REST/NextCloudNews/PDO/TestV1_2.php - - - cases/REST/TinyTinyRSS/TestAPI.php - cases/REST/TinyTinyRSS/TestIcon.php - cases/REST/TinyTinyRSS/PDO/TestAPI.php - + + + cases/REST/TinyTinyRSS/TestAPI.php + cases/REST/TinyTinyRSS/TestIcon.php + cases/REST/TinyTinyRSS/PDO/TestAPI.php cases/Service/TestService.php diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index bb028642..26b133e0 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 ee2d3df9..3f95a030 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",