Browse Source

First tests for Miniflux

rpm
J. King 3 years ago
parent
commit
06dee77bac
  1. 9
      lib/Database.php
  2. 12
      lib/REST.php
  3. 19
      lib/REST/Miniflux/ErrorResponse.php
  4. 37
      lib/REST/Miniflux/Status.php
  5. 56
      lib/REST/Miniflux/V1.php
  6. 3
      lib/User.php
  7. 3
      locale/en.php
  8. 21
      tests/cases/Database/SeriesToken.php
  9. 22
      tests/cases/REST/Miniflux/TestErrorResponse.php
  10. 34
      tests/cases/REST/Miniflux/TestStatus.php
  11. 118
      tests/cases/REST/Miniflux/TestV1.php
  12. 21
      tests/cases/User/TestUser.php
  13. 6
      tests/phpunit.dist.xml

9
lib/Database.php

@ -400,7 +400,7 @@ class Database {
* @param \DateTimeInterface|null $expires An optional expiry date and time for the token
* @param string $data Application-specific data associated with a token
*/
public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null, string $data = null): string {
public function tokenCreate(string $user, string $class, string $id = null, ?\DateTimeInterface $expires = null, string $data = null): string {
if (!$this->userExists($user)) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
@ -418,7 +418,7 @@ class Database {
* @param string $class The class of the token e.g. the protocol name
* @param string|null $id The ID of a specific token, or null for all tokens in the class
*/
public function tokenRevoke(string $user, string $class, string $id = null): bool {
public function tokenRevoke(string $user, string $class, ?string $id = null): bool {
if (is_null($id)) {
$out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ?", "str", "str")->run($user, $class)->changes();
} else {
@ -436,6 +436,11 @@ class Database {
return $out;
}
/** List tokens associated with a user */
public function tokenList(string $user, string $class): Db\Result {
return $this->db->prepare("SELECT id,created,expires,data from arsse_tokens where class = ? and user = ? and (expires is null or expires > CURRENT_TIMESTAMP)", "str", "str")->run($class, $user);
}
/** Deletes expires tokens from the database, returning the number of deleted tokens */
public function tokenCleanup(): int {
return $this->db->query("DELETE FROM arsse_tokens where expires < CURRENT_TIMESTAMP")->changes();

12
lib/REST.php

@ -42,9 +42,19 @@ class REST {
],
'miniflux' => [ // Miniflux https://miniflux.app/docs/api.html
'match' => '/v1/',
'strip' => '/v1',
'strip' => '',
'class' => REST\Miniflux\V1::class,
],
'miniflux-version' => [ // Miniflux version report
'match' => '/version',
'strip' => '',
'class' => REST\Miniflux\Status::class,
],
'miniflux-healthcheck' => [ // Miniflux health check
'match' => '/healthcheck',
'strip' => '',
'class' => REST\Miniflux\Status::class,
],
// Other candidates:
// Microsub https://indieweb.org/Microsub
// Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html

19
lib/REST/Miniflux/ErrorResponse.php

@ -0,0 +1,19 @@
<?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\Miniflux;
use JKingWeb\Arsse\Arsse;
class ErrorResponse extends \Laminas\Diactoros\Response\JsonResponse {
public function __construct($data, int $status = 400, array $headers = [], int $encodingOptions = self::DEFAULT_JSON_FLAGS) {
assert(isset(Arsse::$lang) && Arsse::$lang instanceof \JKingWeb\Arsse\Lang, new \Exception("Language database must be initialized before use"));
$data = (array) $data;
$msg = array_shift($data);
$data = ["error_message" => Arsse::$lang->msg("API.Miniflux.Error.".$msg, $data)];
parent::__construct($data, $status, $headers, $encodingOptions);
}
}

37
lib/REST/Miniflux/Status.php

@ -0,0 +1,37 @@
<?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\Miniflux;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\TextResponse;
class Status extends \JKingWeb\Arsse\REST\AbstractHandler {
public function __construct() {
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
$target = parse_url($req->getRequestTarget())['path'] ?? "";
if (!in_array($target, ["/version", "/healthcheck"])) {
return new EmptyResponse(404);
}
$method = $req->getMethod();
if ($method === "OPTIONS") {
return new EmptyResponse(204, ['Allow' => "HEAD, GET"]);
} elseif ($method !== "GET") {
return new EmptyResponse(405, ['Allow' => "HEAD, GET"]);
}
$out = "";
if ($target === "/version") {
$out = V1::VERSION;
} elseif ($target === "/healthcheck") {
$out = "OK";
}
return new TextResponse($out);
}
}

56
lib/REST/Miniflux/V1.php

@ -13,6 +13,7 @@ use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\REST\Exception;
use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Exception405;
use JKingWeb\Arsse\User\ExceptionConflict as UserException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
@ -20,6 +21,8 @@ use Laminas\Diactoros\Response\EmptyResponse;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const ACCEPTED_TYPES_OPML = ["text/xml", "application/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json", "text/json"];
public const VERSION = "2.0.25";
protected $paths = [
'/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
'/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
@ -35,25 +38,41 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'/feeds/1/icon' => ['GET' => "getFeedIcon"],
'/feeds/1/refresh' => ['PUT' => "refreshFeed"],
'/feeds/refresh' => ['PUT' => "refreshAllFeeds"],
'/healthcheck' => ['GET' => "healthCheck"],
'/import' => ['POST' => "opmlImport"],
'/me' => ['GET' => "getCurrentUser"],
'/users' => ['GET' => "getUsers", 'POST' => "createUser"],
'/users/1' => ['GET' => "getUser", 'PUT' => "updateUser", 'DELETE' => "deleteUser"],
'/users/*' => ['GET' => "getUser"],
'/version' => ['GET' => "getVersion"],
];
public function __construct() {
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
// try to authenticate
protected function authenticate(ServerRequestInterface $req): bool {
// first check any tokens; this is what Miniflux does
foreach ($req->getHeader("X-Auth-Token") as $t) {
if (strlen($t)) {
// a non-empty header is authoritative, so we'll stop here one way or the other
try {
$d = Arsse::$db->tokenLookup("miniflux.login", $t);
} catch (ExceptionInput $e) {
return false;
}
Arsse::$user->id = $d->user;
return true;
}
}
// next check HTTP auth
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
} else {
// TODO: Handle X-Auth-Token authentication
return new EmptyResponse(401);
}
return false;
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
// try to authenticate
if (!$this->authenticate($req)) {
return new ErrorResponse("401", 401);
}
// get the request path only; this is assumed to already be normalized
$target = parse_url($req->getRequestTarget())['path'] ?? "";
@ -65,17 +84,14 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$func = $this->chooseCall($target, $method);
if ($func === "opmlImport") {
if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
return new EmptyResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
return new ErrorResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
}
$data = (string) $req->getBody();
} elseif ($method === "POST" || $method === "PUT") {
if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_JSON])) {
return new EmptyResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_JSON)]);
}
$data = @json_decode($data, true);
if (json_last_error() !== \JSON_ERROR_NONE) {
// if the body could not be parsed as JSON, return "400 Bad Request"
return new EmptyResponse(400);
return new ErrorResponse(["invalidBodyJSON", json_last_error_msg()], 400);
}
} else {
$data = null;
@ -154,4 +170,20 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
throw new Exception404();
}
}
public static function tokenGenerate(string $user, string $label): string {
$t = base64_encode(random_bytes(24));
return Arsse::$db->tokenCreate($user, "miniflux.login", $t, null, $label);
}
public static function tokenList(string $user): array {
if (!Arsse::$db->userExists($user)) {
throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$out = [];
foreach (Arsse::$db->tokenList($user, "miniflux.login") as $r) {
$out[] = ['label' => $r['data'], 'id' => $r['id']];
}
return $out;
}
}

3
lib/User.php

@ -69,7 +69,6 @@ class User {
}
return $out;
}
public function remove(string $user): bool {
try {
@ -143,7 +142,7 @@ class User {
if (array_key_exists("tz", $data)) {
if (!is_string($data['tz'])) {
throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => ""]);
} elseif(!@timezone_open($data['tz'])) {
} elseif (!@timezone_open($data['tz'])) {
throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => $data['tz']]);
}
$in['tz'] = $data['tz'];

3
locale/en.php

@ -7,6 +7,9 @@ return [
'CLI.Auth.Success' => 'Authentication successful',
'CLI.Auth.Failure' => 'Authentication failed',
'API.Miniflux.Error.401' => 'Access Unauthorized',
'API.Miniflux.Error.invalidBodyJSON' => 'Invalid JSON payload: {0}',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
'API.TTRSS.Category.Labels' => 'Labels',

21
tests/cases/Database/SeriesToken.php

@ -33,12 +33,16 @@ trait SeriesToken {
'class' => "str",
'user' => "str",
'expires' => "datetime",
'data' => "str",
],
'rows' => [
["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff],
["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past], // expired
["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null],
["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future],
["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff, null],
["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past, null], // expired
["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null, null],
["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future, null],
["A", "miniflux.login", "jane.doe@example.com", null, "Label 1"],
["B", "miniflux.login", "jane.doe@example.com", null, "Label 2"],
["C", "miniflux.login", "john.doe@example.com", null, "Label 1"],
],
],
];
@ -127,4 +131,13 @@ trait SeriesToken {
// revoking tokens which do not exist is not an error
$this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class"));
}
public function testListTokens(): void {
$user = "jane.doe@example.com";
$exp = [
['id' => "A", 'data' => "Label 1"],
['id' => "B", 'data' => "Label 2"],
];
$this->assertResult($exp, Arsse::$db->tokenList($user, "miniflux.login"));
}
}

22
tests/cases/REST/Miniflux/TestErrorResponse.php

@ -0,0 +1,22 @@
<?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\Miniflux;
use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
/** @covers \JKingWeb\Arsse\REST\Miniflux\ErrorResponse */
class TestErrorResponse extends \JKingWeb\Arsse\Test\AbstractTest {
public function testCreateConstantResponse(): void {
$act = new ErrorResponse("401", 401);
$this->assertSame('{"error_message":"Access Unauthorized"}', (string) $act->getBody());
}
public function testCreateVariableResponse(): void {
$act = new ErrorResponse(["invalidBodyJSON", "Doh!"], 401);
$this->assertSame('{"error_message":"Invalid JSON payload: Doh!"}', (string) $act->getBody());
}
}

34
tests/cases/REST/Miniflux/TestStatus.php

@ -0,0 +1,34 @@
<?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\Miniflux;
use JKingWeb\Arsse\REST\Miniflux\Status;
use JKingWeb\Arsse\REST\Miniflux\V1;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\TextResponse;
/** @covers \JKingWeb\Arsse\REST\Miniflux\Status */
class TestStatus extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideRequests */
public function testAnswerStatusRequests(string $url, string $method, ResponseInterface $exp): void {
$act = (new Status)->dispatch($this->serverRequest($method, $url, ""));
$this->assertMessage($exp, $act);
}
public function provideRequests(): iterable {
return [
["/version", "GET", new TextResponse(V1::VERSION)],
["/version", "POST", new EmptyResponse(405, ['Allow' => "HEAD, GET"])],
["/version", "OPTIONS", new EmptyResponse(204, ['Allow' => "HEAD, GET"])],
["/healthcheck", "GET", new TextResponse("OK")],
["/healthcheck", "POST", new EmptyResponse(405, ['Allow' => "HEAD, GET"])],
["/healthcheck", "OPTIONS", new EmptyResponse(204, ['Allow' => "HEAD, GET"])],
["/version/", "GET", new EmptyResponse(404)],
];
}
}

118
tests/cases/REST/Miniflux/TestV1.php

@ -0,0 +1,118 @@
<?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\NextcloudNews;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\Miniflux\V1;
use Psr\Http\Message\ResponseInterface;
/** @covers \JKingWeb\Arsse\REST\Miniflux\V1<extended> */
class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
protected $h;
protected $transaction;
protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
$prefix = "/v1";
$url = $prefix.$target;
if ($body) {
$params = [];
} else {
$params = $data;
$data = [];
}
$req = $this->serverRequest($method, $url, $prefix, $headers, [], $data, "application/json", $params, $authenticated ? "john.doe@example.com" : "");
return $this->h->dispatch($req);
}
public function setUp(): void {
self::clearData();
self::setConf();
// create a mock user manager
Arsse::$user = \Phake::mock(User::class);
Arsse::$user->id = "john.doe@example.com";
// create a mock database interface
Arsse::$db = \Phake::mock(Database::class);
$this->transaction = \Phake::mock(Transaction::class);
\Phake::when(Arsse::$db)->begin->thenReturn($this->transaction);
//initialize a handler
$this->h = new V1();
}
public function tearDown(): void {
self::clearData();
}
protected function v($value) {
return $value;
}
public function testSendAuthenticationChallenge(): void {
$exp = new EmptyResponse(401);
$this->assertMessage($exp, $this->req("GET", "/", "", [], false));
}
/** @dataProvider provideInvalidPaths */
public function testRespondToInvalidPaths($path, $method, $code, $allow = null): void {
$exp = new EmptyResponse($code, $allow ? ['Allow' => $allow] : []);
$this->assertMessage($exp, $this->req($method, $path));
}
public function provideInvalidPaths(): array {
return [
["/", "GET", 404],
["/", "POST", 404],
["/", "PUT", 404],
["/", "DELETE", 404],
["/", "OPTIONS", 404],
["/version/invalid", "GET", 404],
["/version/invalid", "POST", 404],
["/version/invalid", "PUT", 404],
["/version/invalid", "DELETE", 404],
["/version/invalid", "OPTIONS", 404],
["/folders/1/invalid", "GET", 404],
["/folders/1/invalid", "POST", 404],
["/folders/1/invalid", "PUT", 404],
["/folders/1/invalid", "DELETE", 404],
["/folders/1/invalid", "OPTIONS", 404],
["/version", "POST", 405, "GET"],
["/version", "PUT", 405, "GET"],
["/version", "DELETE", 405, "GET"],
["/folders", "PUT", 405, "GET, POST"],
["/folders", "DELETE", 405, "GET, POST"],
["/folders/1", "GET", 405, "PUT, DELETE"],
["/folders/1", "POST", 405, "PUT, DELETE"],
];
}
public function testRespondToInvalidInputTypes(): void {
$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]));
}
/** @dataProvider provideOptionsRequests */
public function testRespondToOptionsRequests(string $url, string $allow, string $accept): void {
$exp = new EmptyResponse(204, [
'Allow' => $allow,
'Accept' => $accept,
]);
$this->assertMessage($exp, $this->req("OPTIONS", $url));
}
public function provideOptionsRequests(): array {
return [
["/feeds", "HEAD,GET,POST", "application/json"],
["/feeds/2112", "DELETE", "application/json"],
["/user", "HEAD,GET", "application/json"],
];
}
}

21
tests/cases/User/TestUser.php

@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\TestCase\User;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\AbstractException as Exception;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\ExceptionInput;
use JKingWeb\Arsse\User\Driver;
@ -25,7 +24,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
// create a mock user driver
$this->drv = \Phake::mock(Driver::class);
}
public function tearDown(): void {
\Phake::verifyNoOtherInteractions($this->drv);
\Phake::verifyNoOtherInteractions(Arsse::$db);
@ -43,7 +42,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$u->id = null;
$this->assertSame("", (string) $u);
}
public function testGeneratePasswords(): void {
$u = new User($this->drv);
$pass1 = $u->generatePassword();
@ -166,7 +165,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userAdd($user, $pass);
\Phake::verify(Arsse::$db)->userExists($user);
}
public function testRemoveAUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@ -178,7 +177,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userRemove($user);
\Phake::verify($this->drv)->userRemove($user);
}
public function testRemoveAUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
@ -189,7 +188,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists($user);
\Phake::verify($this->drv)->userRemove($user);
}
public function testRemoveAMissingUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@ -205,7 +204,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userRemove($user);
}
}
public function testRemoveAMissingUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
@ -381,7 +380,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userPropertiesGet($user);
}
}
/** @dataProvider providePropertyChanges */
public function testSetThePropertiesOfAUser(array $in, $out): void {
$user = "john.doe@example.com";
@ -399,7 +398,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists($user);
}
}
/** @dataProvider providePropertyChanges */
public function testSetThePropertiesOfAUserWeDoNotKnow(array $in, $out): void {
$user = "john.doe@example.com";
@ -418,7 +417,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userAdd($user, null);
}
}
public function providePropertyChanges(): iterable {
return [
[['admin' => true], ['admin' => true]],
@ -431,7 +430,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
[['lang' => null], ['lang' => null]],
];
}
public function testSetThePropertiesOfAMissingUser(): void {
$user = "john.doe@example.com";
$in = ['admin' => true];

6
tests/phpunit.dist.xml

@ -112,10 +112,14 @@
<testsuite name="REST">
<file>cases/REST/TestREST.php</file>
</testsuite>
<testsuite name="Miniflux">
<file>cases/REST/Miniflux/TestErrorResponse.php</file>
<file>cases/REST/Miniflux/TestStatus.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>
<file>cases/REST/NextcloudNews/PDO/TestV1_2.php</file>
</testsuite>
<testsuite name="TTRSS">
<file>cases/REST/TinyTinyRSS/TestSearch.php</file>

Loading…
Cancel
Save