Browse Source

Merge CORS branch

microsub
J. King 6 years ago
parent
commit
34b508171b
  1. 6
      CHANGELOG
  2. 4
      README.md
  3. 8
      UPGRADING
  4. 4
      arsse.php
  5. 3
      composer.json
  6. 104
      composer.lock
  7. 7
      lib/Conf.php
  8. 2
      lib/Misc/Date.php
  9. 257
      lib/REST.php
  10. 4
      lib/REST/AbstractHandler.php
  11. 2
      lib/REST/Exception404.php
  12. 2
      lib/REST/Exception405.php
  13. 10
      lib/REST/Exception501.php
  14. 5
      lib/REST/Handler.php
  15. 305
      lib/REST/NextCloudNews/V1_2.php
  16. 43
      lib/REST/NextCloudNews/Versions.php
  17. 89
      lib/REST/Request.php
  18. 65
      lib/REST/Response.php
  19. 131
      lib/REST/Target.php
  20. 34
      lib/REST/TinyTinyRSS/API.php
  21. 14
      lib/REST/TinyTinyRSS/Icon.php
  22. 1
      tests/bootstrap.php
  23. 447
      tests/cases/REST/NextCloudNews/TestV1_2.php
  24. 57
      tests/cases/REST/NextCloudNews/TestVersions.php
  25. 334
      tests/cases/REST/TestREST.php
  26. 66
      tests/cases/REST/TestTarget.php
  27. 387
      tests/cases/REST/TinyTinyRSS/TestAPI.php
  28. 39
      tests/cases/REST/TinyTinyRSS/TestIcon.php
  29. 41
      tests/lib/AbstractTest.php
  30. 22
      tests/phpunit.xml
  31. 20
      vendor-bin/phpunit/composer.lock
  32. 76
      vendor-bin/robo/composer.lock

6
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

4
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

8
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
=============================

4
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);
}

3
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": "*"

104
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",

7
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() */

2
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) {

257
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 getHandler(string $className): REST\Handler {
// instantiate the API handler
return new $className();
}
public function apiMatch(string $url, array $map): string {
public function apiMatch(string $url): array {
$map = $this->apis;
// sort the API list so the longest URL prefixes come first
uasort($map, function ($a, $b) {
return (strlen($a['match']) <=> strlen($b['match'])) * -1;
});
// normalize the target URL
$url = REST\Target::normalize($url);
// find a match
foreach ($map as $id => $api) {
// first try a simple substring match
if (strpos($url, $api['match'])===0) {
return $id;
// if it matches, perform a more rigorous match and then strip off any defined prefix
$pattern = "<^".preg_quote($api['match'])."([/\?#]|$)>";
if ($url==$api['match'] || in_array(substr($api['match'], -1, 1), ["/", "?", "#"]) || preg_match($pattern, $url)) {
$target = substr($url, strlen($api['strip']));
} else {
// if the match fails we are not able to handle the request
throw new REST\Exception501();
}
// return the API name, stripped URL, and API class name
return [$id, $target, $api['class']];
}
}
// or throw an exception otherwise
// or throw an exception otherwise
throw new REST\Exception501();
}
public function 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 "";
}
}
}

4
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 = [];

2
lib/REST/NextCloudNews/Exception404.php → 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 {
}

2
lib/REST/NextCloudNews/Exception405.php → 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 {
}

10
lib/REST/Exception501.php

@ -0,0 +1,10 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
class Exception501 extends \Exception {
}

5
lib/REST/Handler.php

@ -6,7 +6,10 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
interface Handler {
public function __construct();
public function dispatch(Request $req): Response;
public function dispatch(ServerRequestInterface $req): ResponseInterface;
}

305
lib/REST/NextCloudNews/V1_2.php

@ -15,7 +15,13 @@ use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\REST\Response;
use JKingWeb\Arsse\REST\Target;
use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Exception405;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse as Response;
use Zend\Diactoros\Response\EmptyResponse;
class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
const REALM = "NextCloud News API v1-2";
@ -41,100 +47,115 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
'items' => ValueInfo::T_MIXED | ValueInfo::M_ARRAY,
];
protected $paths = [
'folders' => ['GET' => "folderList", 'POST' => "folderAdd"],
'folders/1' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"],
'folders/1/read' => ['PUT' => "folderMarkRead"],
'feeds' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"],
'feeds/1' => ['DELETE' => "subscriptionRemove"],
'feeds/1/move' => ['PUT' => "subscriptionMove"],
'feeds/1/rename' => ['PUT' => "subscriptionRename"],
'feeds/1/read' => ['PUT' => "subscriptionMarkRead"],
'feeds/all' => ['GET' => "feedListStale"],
'feeds/update' => ['GET' => "feedUpdate"],
'items' => ['GET' => "articleList"],
'items/updated' => ['GET' => "articleList"],
'items/read' => ['PUT' => "articleMarkReadAll"],
'items/1/read' => ['PUT' => "articleMarkRead"],
'items/1/unread' => ['PUT' => "articleMarkRead"],
'items/read/multiple' => ['PUT' => "articleMarkReadMulti"],
'items/unread/multiple' => ['PUT' => "articleMarkReadMulti"],
'items/1/1/star' => ['PUT' => "articleMarkStarred"],
'items/1/1/unstar' => ['PUT' => "articleMarkStarred"],
'items/star/multiple' => ['PUT' => "articleMarkStarredMulti"],
'items/unstar/multiple' => ['PUT' => "articleMarkStarredMulti"],
'cleanup/before-update' => ['GET' => "cleanupBefore"],
'cleanup/after-update' => ['GET' => "cleanupAfter"],
'version' => ['GET' => "serverVersion"],
'status' => ['GET' => "serverStatus"],
'user' => ['GET' => "userStatus"],
'/folders' => ['GET' => "folderList", 'POST' => "folderAdd"],
'/folders/1' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"],
'/folders/1/read' => ['PUT' => "folderMarkRead"],
'/feeds' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"],
'/feeds/1' => ['DELETE' => "subscriptionRemove"],
'/feeds/1/move' => ['PUT' => "subscriptionMove"],
'/feeds/1/rename' => ['PUT' => "subscriptionRename"],
'/feeds/1/read' => ['PUT' => "subscriptionMarkRead"],
'/feeds/all' => ['GET' => "feedListStale"],
'/feeds/update' => ['GET' => "feedUpdate"],
'/items' => ['GET' => "articleList"],
'/items/updated' => ['GET' => "articleList"],
'/items/read' => ['PUT' => "articleMarkReadAll"],
'/items/1/read' => ['PUT' => "articleMarkRead"],
'/items/1/unread' => ['PUT' => "articleMarkRead"],
'/items/read/multiple' => ['PUT' => "articleMarkReadMulti"],
'/items/unread/multiple' => ['PUT' => "articleMarkReadMulti"],
'/items/1/1/star' => ['PUT' => "articleMarkStarred"],
'/items/1/1/unstar' => ['PUT' => "articleMarkStarred"],
'/items/star/multiple' => ['PUT' => "articleMarkStarredMulti"],
'/items/unstar/multiple' => ['PUT' => "articleMarkStarredMulti"],
'/cleanup/before-update' => ['GET' => "cleanupBefore"],
'/cleanup/after-update' => ['GET' => "cleanupAfter"],
'/version' => ['GET' => "serverVersion"],
'/status' => ['GET' => "serverStatus"],
'/user' => ['GET' => "userStatus"],
];
public function __construct() {
}
public function dispatch(\JKingWeb\Arsse\REST\Request $req): 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' => [

43
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"]);
}
}
}

89
lib/REST/Request.php

@ -1,89 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
class Request {
public $method = "GET";
public $head = false;
public $url = "";
public $path ="";
public $paths = [];
public $query = "";
public $type ="";
public $body = "";
public function __construct(string $method = null, string $url = null, string $body = null, string $contentType = null) {
$method = $method ?? $_SERVER['REQUEST_METHOD'];
$url = $url ?? $_SERVER['REQUEST_URI'];
$body = $body ?? file_get_contents("php://input");
if (is_null($contentType)) {
if (isset($_SERVER['HTTP_CONTENT_TYPE'])) {
$contentType = $_SERVER['HTTP_CONTENT_TYPE'];
} else {
$contentType = "";
}
}
$this->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;
}
}

65
lib/REST/Response.php

@ -1,65 +0,0 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
use JKingWeb\Arsse\Arsse;
class Response {
const T_JSON = "application/json";
const T_XML = "application/xml";
const T_TEXT = "text/plain";
public $head = false;
public $code;
public $payload;
public $type;
public $fields;
public function __construct(int $code, $payload = null, string $type = self::T_JSON, array $extraFields = []) {
$this->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");
}
}
}

131
lib/REST/Target.php

@ -0,0 +1,131 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
use JKingWeb\Arsse\Misc\ValueInfo;
class Target {
public $relative = false;
public $index = false;
public $path = [];
public $query = "";
public $fragment = "";
public function __construct(string $target) {
$target = $this->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 [];
}
}
}

34
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);
}
}

14
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);
}

1
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";

447
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<extended> */
@ -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", '<data/>', 'application/xml')));
$exp = new Response(400);
$this->assertResponse($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '<data/>', 'application/json')));
$exp = new EmptyResponse(415, ['Accept' => "application/json"]);
$this->assertMessage($exp, $this->req("PUT", "/folders/1", '<data/>', ['Content-Type' => "application/xml"]));
$exp = new EmptyResponse(400);
$this->assertMessage($exp, $this->req("PUT", "/folders/1", '<data/>'));
$this->assertMessage($exp, $this->req("PUT", "/folders/1", '<data/>', ['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"));
}
}

57
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"));
}
}

334
tests/cases/REST/TestREST.php

@ -0,0 +1,334 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\REST;
use JKingWeb\Arsse\REST\Handler;
use JKingWeb\Arsse\REST\Exception501;
use JKingWeb\Arsse\REST\NextCloudNews\V1_2 as NCN;
use JKingWeb\Arsse\REST\TinyTinyRSS\API as TTRSS;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Request;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Response\TextResponse;
use Zend\Diactoros\Response\EmptyResponse;
use Phake;
/** @covers \JKingWeb\Arsse\REST */
class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideApiMatchData */
public function testMatchAUrlToAnApi($apiList, string $input, array $exp) {
$r = new REST($apiList);
try {
$out = $r->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],
];
}
}

66
tests/cases/REST/TestTarget.php

@ -0,0 +1,66 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST;
use JKingWeb\Arsse\REST\Target;
/** @covers \JKingWeb\Arsse\REST\Target */
class TestTarget extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideTargetUrls */
public function testParseTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) {
$test = new Target($target);
$this->assertEquals($path, $test->path, "Path does not match");
$this->assertSame($path, $test->path, "Path does not match exactly");
$this->assertSame($relative, $test->relative, "Relative flag does not match");
$this->assertSame($index, $test->index, "Index flag does not match");
$this->assertSame($query, $test->query, "Query does not match");
$this->assertSame($fragment, $test->fragment, "Fragment does not match");
}
/** @dataProvider provideTargetUrls */
public function testNormalizeTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) {
$test = new Target("");
$test->path = $path;
$test->relative = $relative;
$test->index = $index;
$test->query = $query;
$test->fragment = $fragment;
$this->assertSame($normalized, (string) $test);
$this->assertSame($normalized, Target::normalize($target));
}
public function provideTargetUrls() {
return [
["/", [], false, true, "", "", "/"],
["", [], true, true, "", "", ""],
["/index.php", ["index.php"], false, false, "", "", "/index.php"],
["index.php", ["index.php"], true, false, "", "", "index.php"],
["/ook/", ["ook"], false, true, "", "", "/ook/"],
["ook/", ["ook"], true, true, "", "", "ook/"],
["/eek/../ook/", ["ook"], false, true, "", "", "/ook/"],
["eek/../ook/", ["ook"], true, true, "", "", "ook/"],
["/./ook/", ["ook"], false, true, "", "", "/ook/"],
["./ook/", ["ook"], true, true, "", "", "ook/"],
["/../ook/", [null,"ook"], false, true, "", "", "/../ook/"],
["../ook/", [null,"ook"], true, true, "", "", "../ook/"],
["0", ["0"], true, false, "", "", "0"],
["%6f%6F%6b", ["ook"], true, false, "", "", "ook"],
["%2e%2E%2f%2E%2Fook%2f", [".././ook/"], true, false, "", "", "..%2F.%2Fook%2F"],
["%2e%2E/%2E/ook%2f", ["..",".","ook/"], true, false, "", "", "%2E%2E/%2E/ook%2F"],
["...", ["..."], true, false, "", "", "..."],
["%2e%2e%2e", ["..."], true, false, "", "", "..."],
["/?", [], false, true, "", "", "/"],
["/#", [], false, true, "", "", "/"],
["/?#", [], false, true, "", "", "/"],
["#%2e", [], true, true, "", ".", "#."],
["?%2e", [], true, true, "%2e", "", "?%2e"],
["?%2e#%2f", [], true, true, "%2e", "/", "?%2e#%2F"],
["#%2e?%2f", [], true, true, "", ".?/", "#.%3F%2F"],
];
}
}

387
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<extended>
@ -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' => '<p>Article content 2</p>',
],
];
$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 {

39
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<extended> */
@ -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"));
}
}

41
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;

22
tests/phpunit.xml

@ -82,17 +82,19 @@
<file>cases/Db/SQLite3PDO/Database/TestLabel.php</file>
<file>cases/Db/SQLite3PDO/Database/TestCleanup.php</file>
</testsuite>
<testsuite name="Controllers">
<testsuite name="NCNv1">
<file>cases/REST/NextCloudNews/TestVersions.php</file>
<file>cases/REST/NextCloudNews/TestV1_2.php</file>
<testsuite name="REST">
<file>cases/REST/TestTarget.php</file>
<file>cases/REST/TestREST.php</file>
</testsuite>
<testsuite name="NCNv1">
<file>cases/REST/NextCloudNews/TestVersions.php</file>
<file>cases/REST/NextCloudNews/TestV1_2.php</file>
<file>cases/REST/NextCloudNews/PDO/TestV1_2.php</file>
</testsuite>
<testsuite name="TTRSS">
<file>cases/REST/TinyTinyRSS/TestAPI.php</file>
<file>cases/REST/TinyTinyRSS/TestIcon.php</file>
<file>cases/REST/TinyTinyRSS/PDO/TestAPI.php</file>
</testsuite>
</testsuite>
<testsuite name="TTRSS">
<file>cases/REST/TinyTinyRSS/TestAPI.php</file>
<file>cases/REST/TinyTinyRSS/TestIcon.php</file>
<file>cases/REST/TinyTinyRSS/PDO/TestAPI.php</file>
</testsuite>
<testsuite name="Refresh service">
<file>cases/Service/TestService.php</file>

20
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",

76
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",

Loading…
Cancel
Save