Browse Source

Passed code through linter

microsub
J. King 7 years ago
parent
commit
f7e50fe95d
  1. 25
      .php_cs.dist
  2. 5
      arsse.php
  3. 5
      lib/AbstractException.php
  4. 2
      lib/Arsse.php
  5. 32
      lib/CLI.php
  6. 29
      lib/Conf.php
  7. 271
      lib/Database.php
  8. 42
      lib/Db/AbstractDriver.php
  9. 28
      lib/Db/AbstractStatement.php
  10. 24
      lib/Db/Driver.php
  11. 20
      lib/Db/Result.php
  12. 49
      lib/Db/SQLite3/Driver.php
  13. 5
      lib/Db/SQLite3/ExceptionBuilder.php
  14. 9
      lib/Db/SQLite3/Result.php
  15. 18
      lib/Db/SQLite3/Statement.php
  16. 8
      lib/Db/Statement.php
  17. 16
      lib/Db/Transaction.php
  18. 111
      lib/Feed.php
  19. 76
      lib/Lang.php
  20. 47
      lib/Misc/Context.php
  21. 33
      lib/Misc/Date.php
  22. 56
      lib/Misc/Query.php
  23. 18
      lib/REST.php
  24. 42
      lib/REST/AbstractHandler.php
  25. 4
      lib/REST/Handler.php
  26. 146
      lib/REST/NextCloudNews/V1_2.php
  27. 9
      lib/REST/NextCloudNews/Versions.php
  28. 28
      lib/REST/Request.php
  29. 17
      lib/REST/Response.php
  30. 31
      lib/Service.php
  31. 17
      lib/Service/Curl/Driver.php
  32. 10
      lib/Service/Driver.php
  33. 17
      lib/Service/Forking/Driver.php
  34. 15
      lib/Service/Internal/Driver.php
  35. 143
      lib/User.php
  36. 28
      lib/User/Driver.php
  37. 6
      lib/User/Internal/Driver.php
  38. 25
      lib/User/Internal/InternalFunctions.php
  39. 35
      tests/Conf/TestConf.php
  40. 33
      tests/Db/SQLite3/TestDbDriverCreationSQLite3.php
  41. 70
      tests/Db/SQLite3/TestDbDriverSQLite3.php
  42. 29
      tests/Db/SQLite3/TestDbResultSQLite3.php
  43. 26
      tests/Db/SQLite3/TestDbStatementSQLite3.php
  44. 32
      tests/Db/SQLite3/TestDbUpdateSQLite3.php
  45. 9
      tests/Db/TestTransaction.php
  46. 18
      tests/Exception/TestException.php
  47. 40
      tests/Feed/TestFeed.php
  48. 26
      tests/Feed/TestFeedFetching.php
  49. 12
      tests/Lang/TestLang.php
  50. 21
      tests/Lang/TestLangErrors.php
  51. 25
      tests/Lang/testLangComplex.php
  52. 20
      tests/Misc/TestContext.php
  53. 79
      tests/REST/NextCloudNews/TestNCNV1_2.php
  54. 9
      tests/REST/NextCloudNews/TestNCNVersionDiscovery.php
  55. 17
      tests/Service/TestService.php
  56. 135
      tests/User/TestAuthorization.php
  57. 2
      tests/User/TestUserMockInternal.php
  58. 2
      tests/docroot/Feed/Caching/304Random.php
  59. 2
      tests/docroot/Feed/NextFetch/NotModified.php
  60. 16
      tests/lib/AbstractTest.php
  61. 7
      tests/lib/Database/DriverSQLite3.php
  62. 99
      tests/lib/Database/SeriesArticle.php
  63. 30
      tests/lib/Database/SeriesCleanup.php
  64. 27
      tests/lib/Database/SeriesFeed.php
  65. 71
      tests/lib/Database/SeriesFolder.php
  66. 13
      tests/lib/Database/SeriesMeta.php
  67. 8
      tests/lib/Database/SeriesMiscellany.php
  68. 61
      tests/lib/Database/SeriesSubscription.php
  69. 65
      tests/lib/Database/SeriesUser.php
  70. 51
      tests/lib/Database/Setup.php
  71. 29
      tests/lib/Db/BindingTests.php
  72. 15
      tests/lib/Lang/Setup.php
  73. 2
      tests/lib/Lang/TestLang.php
  74. 2
      tests/lib/Result.php
  75. 54
      tests/lib/User/CommonTests.php
  76. 108
      tests/lib/User/Database.php
  77. 80
      tests/lib/User/DriverExternalMock.php
  78. 24
      tests/lib/User/DriverInternalMock.php
  79. 24
      tests/lib/User/DriverSkeleton.php
  80. 19
      tests/server.php

25
.php_cs.dist

@ -0,0 +1,25 @@
<?php
namespace JKingWeb\Arsse;
require_once __DIR__.DIRECTORY_SEPARATOR."bootstrap.php";
$paths = [
__FILE__,
BASE."arsse.php",
BASE."lib",
BASE."tests",
];
$rules = [
'@PSR2' => true,
'braces' => ['position_after_functions_and_oop_constructs' => "same"],
];
$finder = \PhpCsFixer\Finder::create();
foreach ($paths as $path) {
if (is_file($path)) {
$finder = $finder->path($path);
} else {
$finder = $finder->in($path);
}
}
return \PhpCsFixer\Config::create()->setRules($rules)->setFinder($finder);

5
arsse.php

@ -1,8 +1,9 @@
<?php <?php
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
require_once __DIR__.DIRECTORY_SEPARATOR."bootstrap.php"; require_once __DIR__.DIRECTORY_SEPARATOR."bootstrap.php";
if(\PHP_SAPI=="cli") { if (\PHP_SAPI=="cli") {
// initialize the CLI; this automatically handles --help and --version // initialize the CLI; this automatically handles --help and --version
$cli = new CLI; $cli = new CLI;
// handle other CLI requests; some do not require configuration // handle other CLI requests; some do not require configuration
@ -10,7 +11,7 @@ if(\PHP_SAPI=="cli") {
} else { } else {
// load configuration // load configuration
Arsse::load(new Conf()); Arsse::load(new Conf());
if(file_exists(BASE."config.php")) { if (file_exists(BASE."config.php")) {
Arsse::$conf->importFile(BASE."config.php"); Arsse::$conf->importFile(BASE."config.php");
} }
// handle Web requests // handle Web requests

5
lib/AbstractException.php

@ -3,7 +3,6 @@ declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
abstract class AbstractException extends \Exception { abstract class AbstractException extends \Exception {
const CODES = [ const CODES = [
"Exception.uncoded" => -1, "Exception.uncoded" => -1,
"Exception.unknown" => 10000, "Exception.unknown" => 10000,
@ -71,13 +70,13 @@ abstract class AbstractException extends \Exception {
]; ];
public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) { public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) {
if($msgID=="") { if ($msgID=="") {
$msg = "Exception.unknown"; $msg = "Exception.unknown";
$code = 10000; $code = 10000;
} else { } else {
$class = get_called_class(); $class = get_called_class();
$codeID = str_replace("\\", "/", str_replace(NS_BASE, "", $class)).".$msgID"; $codeID = str_replace("\\", "/", str_replace(NS_BASE, "", $class)).".$msgID";
if(!array_key_exists($codeID, self::CODES)) { if (!array_key_exists($codeID, self::CODES)) {
throw new Exception("uncoded", $codeID); throw new Exception("uncoded", $codeID);
} else { } else {
$code = self::CODES[$codeID]; $code = self::CODES[$codeID];

2
lib/Arsse.php

@ -12,7 +12,7 @@ class Arsse {
/** @var User */ /** @var User */
public static $user; public static $user;
static function load(Conf $conf) { public static function load(Conf $conf) {
static::$lang = new Lang(); static::$lang = new Lang();
static::$conf = $conf; static::$conf = $conf;
static::$lang->set($conf->lang); static::$lang->set($conf->lang);

32
lib/CLI.php

@ -22,8 +22,8 @@ configuration to a sample file.
USAGE_TEXT; USAGE_TEXT;
} }
function __construct(array $argv = null) { public function __construct(array $argv = null) {
if(is_null($argv)) { if (is_null($argv)) {
$argv = array_slice($_SERVER['argv'], 1); $argv = array_slice($_SERVER['argv'], 1);
} }
$this->args = \Docopt::handle($this->usage(), [ $this->args = \Docopt::handle($this->usage(), [
@ -36,7 +36,7 @@ USAGE_TEXT;
protected function loadConf(): bool { protected function loadConf(): bool {
// FIXME: this should be a method of the Conf class // FIXME: this should be a method of the Conf class
Arsse::load(new Conf()); Arsse::load(new Conf());
if(file_exists(BASE."config.php")) { if (file_exists(BASE."config.php")) {
Arsse::$conf->importFile(BASE."config.php"); Arsse::$conf->importFile(BASE."config.php");
} }
// command-line operations will never respect authorization // command-line operations will never respect authorization
@ -44,50 +44,50 @@ USAGE_TEXT;
return true; return true;
} }
function dispatch(array $args = null): int { public function dispatch(array $args = null): int {
// act on command line // act on command line
if(is_null($args)) { if (is_null($args)) {
$args = $this->args; $args = $this->args;
} }
if($this->command("daemon", $args)) { if ($this->command("daemon", $args)) {
$this->loadConf(); $this->loadConf();
return $this->daemon(); return $this->daemon();
} else if($this->command("feed refresh", $args)) { } elseif ($this->command("feed refresh", $args)) {
$this->loadConf(); $this->loadConf();
return $this->feedRefresh((int) $args['<n>']); return $this->feedRefresh((int) $args['<n>']);
} else if($this->command("conf save-defaults", $args)) { } elseif ($this->command("conf save-defaults", $args)) {
return $this->confSaveDefaults($args['<file>']); return $this->confSaveDefaults($args['<file>']);
} else if($this->command("user add", $args)) { } elseif ($this->command("user add", $args)) {
$this->loadConf(); $this->loadConf();
return $this->userAdd($args['<username>'], $args['<password>']); return $this->userAdd($args['<username>'], $args['<password>']);
} }
} }
protected function command($cmd, $args): bool { protected function command($cmd, $args): bool {
foreach(explode(" ", $cmd) as $part) { foreach (explode(" ", $cmd) as $part) {
if(!$args[$part]) { if (!$args[$part]) {
return false; return false;
} }
} }
return true; return true;
} }
function daemon(bool $loop = true): int { public function daemon(bool $loop = true): int {
(new Service)->watch($loop); (new Service)->watch($loop);
return 0; // FIXME: should return the exception code of thrown exceptions return 0; // FIXME: should return the exception code of thrown exceptions
} }
function feedRefresh(int $id): int { public function feedRefresh(int $id): int {
return (int) !Arsse::$db->feedUpdate($id); // FIXME: exception error codes should be returned here return (int) !Arsse::$db->feedUpdate($id); // FIXME: exception error codes should be returned here
} }
function confSaveDefaults(string $file): int { public function confSaveDefaults(string $file): int {
return (int) !(new Conf)->exportFile($file, true); return (int) !(new Conf)->exportFile($file, true);
} }
function userAdd(string $user, string $password = null): int { public function userAdd(string $user, string $password = null): int {
$passwd = Arsse::$user->add($user, $password); $passwd = Arsse::$user->add($user, $password);
if(is_null($password)) { if (is_null($password)) {
echo $passwd; echo $passwd;
} }
return 0; return 0;

29
lib/Conf.php

@ -66,7 +66,7 @@ class Conf {
* @param string $import_file Optional file to read configuration data from * @param string $import_file Optional file to read configuration data from
* @see self::importFile() */ * @see self::importFile() */
public function __construct(string $import_file = "") { public function __construct(string $import_file = "") {
if($import_file != "") { if ($import_file != "") {
$this->importFile($import_file); $this->importFile($import_file);
} }
} }
@ -76,20 +76,20 @@ class Conf {
* The file must be a PHP script which return an array with keys that match the properties of the Conf class. Malformed files will throw an exception; unknown keys are silently ignored. Files may be imported is succession, though this is not currently used. * The file must be a PHP script which return an array with keys that match the properties of the Conf class. Malformed files will throw an exception; unknown keys are silently ignored. Files may be imported is succession, though this is not currently used.
* @param string $file Full path and file name for the file to import */ * @param string $file Full path and file name for the file to import */
public function importFile(string $file): self { public function importFile(string $file): self {
if(!file_exists($file)) { if (!file_exists($file)) {
throw new Conf\Exception("fileMissing", $file); throw new Conf\Exception("fileMissing", $file);
} else if(!is_readable($file)) { } elseif (!is_readable($file)) {
throw new Conf\Exception("fileUnreadable", $file); throw new Conf\Exception("fileUnreadable", $file);
} }
try { try {
ob_start(); ob_start();
$arr = (@include $file); $arr = (@include $file);
} catch(\Throwable $e) { } catch (\Throwable $e) {
$arr = null; $arr = null;
} finally { } finally {
ob_end_clean(); ob_end_clean();
} }
if(!is_array($arr)) { if (!is_array($arr)) {
throw new Conf\Exception("fileCorrupt", $file); throw new Conf\Exception("fileCorrupt", $file);
} }
return $this->import($arr); return $this->import($arr);
@ -100,7 +100,7 @@ class Conf {
* The input array must have keys that match the properties of the Conf class; unknown keys are silently ignored. Arrays may be imported is succession, though this is not currently used. * The input array must have keys that match the properties of the Conf class; unknown keys are silently ignored. Arrays may be imported is succession, though this is not currently used.
* @param mixed[] $arr Array of configuration parameters to export */ * @param mixed[] $arr Array of configuration parameters to export */
public function import(array $arr): self { public function import(array $arr): self {
foreach($arr as $key => $value) { foreach ($arr as $key => $value) {
$this->$key = $value; $this->$key = $value;
} }
return $this; return $this;
@ -112,13 +112,13 @@ class Conf {
$ref = new self; $ref = new self;
$out = []; $out = [];
$conf = new \ReflectionObject($this); $conf = new \ReflectionObject($this);
foreach($conf->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { foreach ($conf->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) {
$name = $prop->name; $name = $prop->name;
// add the property to the output if the value is scalar and either: // add the property to the output if the value is scalar and either:
// 1. full output has been requested // 1. full output has been requested
// 2. the property is not defined in the class // 2. the property is not defined in the class
// 3. it differs from the default // 3. it differs from the default
if(is_scalar($this->$name) && ($full || !$prop->isDefault() || $this->$name !== $ref->$name)) { if (is_scalar($this->$name) && ($full || !$prop->isDefault() || $this->$name !== $ref->$name)) {
$out[$name] = $this->$name; $out[$name] = $this->$name;
} }
} }
@ -132,27 +132,28 @@ class Conf {
$arr = $this->export($full); $arr = $this->export($full);
$conf = new \ReflectionObject($this); $conf = new \ReflectionObject($this);
$out = "<?php return [".PHP_EOL; $out = "<?php return [".PHP_EOL;
foreach($arr as $prop => $value) { foreach ($arr as $prop => $value) {
$match = null; $match = null;
$doc = $comment = ""; $doc = $comment = "";
// retrieve the property's docblock, if it exists // retrieve the property's docblock, if it exists
try { try {
$doc = (new \ReflectionProperty(self::class, $prop))->getDocComment(); $doc = (new \ReflectionProperty(self::class, $prop))->getDocComment();
} catch(\ReflectionException $e) {} } catch (\ReflectionException $e) {
if($doc) { }
if ($doc) {
// parse the docblock to extract the property description // parse the docblock to extract the property description
if(preg_match("<@var\s+\S+\s+(.+?)(?:\s*\*/)?$>m", $doc, $match)) { if (preg_match("<@var\s+\S+\s+(.+?)(?:\s*\*/)?$>m", $doc, $match)) {
$comment = $match[1]; $comment = $match[1];
} }
} }
// append the docblock description if there is one, or an empty comment otherwise // append the docblock description if there is one, or an empty comment otherwise
$out .= " // ".$comment.PHP_EOL; $out .= " // ".$comment.PHP_EOL;
// append the property and an export of its value to the output // append the property and an export of its value to the output
$out .= " ".var_export($prop, true)." => ".var_export($value,true).",".PHP_EOL; $out .= " ".var_export($prop, true)." => ".var_export($value, true).",".PHP_EOL;
} }
$out .= "];".PHP_EOL; $out .= "];".PHP_EOL;
// write the configuration representation to the requested file // write the configuration representation to the requested file
if(!@file_put_contents($file,$out)) { if (!@file_put_contents($file, $out)) {
// if it fails throw an exception // if it fails throw an exception
$err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable";
throw new Conf\Exception($err, $file); throw new Conf\Exception($err, $file);

271
lib/Database.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use PasswordGenerator\Generator as PassGen; use PasswordGenerator\Generator as PassGen;
use JKingWeb\Arsse\Misc\Query; use JKingWeb\Arsse\Misc\Query;
use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Context;
@ -16,7 +17,7 @@ class Database {
$driver = Arsse::$conf->dbDriver; $driver = Arsse::$conf->dbDriver;
$this->db = new $driver(); $this->db = new $driver();
$ver = $this->db->schemaVersion(); $ver = $this->db->schemaVersion();
if($initialize && $ver < self::SCHEMA_VERSION) { if ($initialize && $ver < self::SCHEMA_VERSION) {
$this->db->schemaUpdate(self::SCHEMA_VERSION); $this->db->schemaUpdate(self::SCHEMA_VERSION);
} }
} }
@ -25,11 +26,11 @@ class Database {
return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function'];
} }
static public function driverList(): array { public static function driverList(): array {
$sep = \DIRECTORY_SEPARATOR; $sep = \DIRECTORY_SEPARATOR;
$path = __DIR__.$sep."Db".$sep; $path = __DIR__.$sep."Db".$sep;
$classes = []; $classes = [];
foreach(glob($path."*".$sep."Driver.php") as $file) { foreach (glob($path."*".$sep."Driver.php") as $file) {
$name = basename(dirname($file)); $name = basename(dirname($file));
$class = NS_BASE."Db\\$name\\Driver"; $class = NS_BASE."Db\\$name\\Driver";
$classes[$class] = $class::driverName(); $classes[$class] = $class::driverName();
@ -42,7 +43,7 @@ class Database {
} }
public function driverSchemaUpdate(): bool { public function driverSchemaUpdate(): bool {
if($this->db->schemaVersion() < self::SCHEMA_VERSION) { if ($this->db->schemaVersion() < self::SCHEMA_VERSION) {
return $this->db->schemaUpdate(self::SCHEMA_VERSION); return $this->db->schemaUpdate(self::SCHEMA_VERSION);
} }
return false; return false;
@ -54,8 +55,8 @@ class Database {
[], // binding types [], // binding types
[], // binding values [], // binding values
]; ];
foreach($valid as $prop => $type) { foreach ($valid as $prop => $type) {
if(!array_key_exists($prop, $props)) { if (!array_key_exists($prop, $props)) {
continue; continue;
} }
$out[0][] = "$prop = ?"; $out[0][] = "$prop = ?";
@ -72,9 +73,9 @@ class Database {
[], // binding types [], // binding types
]; ];
// the query clause is just a series of question marks separated by commas // the query clause is just a series of question marks separated by commas
$out[0] = implode(",",array_fill(0,sizeof($values),"?")); $out[0] = implode(",", array_fill(0, sizeof($values), "?"));
// the binding types are just a repetition of the supplied type // the binding types are just a repetition of the supplied type
$out[1] = array_fill(0,sizeof($values),$type); $out[1] = array_fill(0, sizeof($values), $type);
return $out; return $out;
} }
@ -88,7 +89,7 @@ class Database {
public function metaSet(string $key, $value, string $type = "str"): bool { public function metaSet(string $key, $value, string $type = "str"): bool {
$out = $this->db->prepare("UPDATE arsse_meta set value = ? where key is ?", $type, "str")->run($value, $key)->changes(); $out = $this->db->prepare("UPDATE arsse_meta set value = ? where key is ?", $type, "str")->run($value, $key)->changes();
if(!$out) { if (!$out) {
$out = $this->db->prepare("INSERT INTO arsse_meta(key,value) values(?,?)", "str", $type)->run($key, $value)->changes(); $out = $this->db->prepare("INSERT INTO arsse_meta(key,value) values(?,?)", "str", $type)->run($key, $value)->changes();
} }
return (bool) $out; return (bool) $out;
@ -99,23 +100,23 @@ class Database {
} }
public function userExists(string $user): bool { public function userExists(string $user): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
return (bool) $this->db->prepare("SELECT count(*) from arsse_users where id is ?", "str")->run($user)->getValue(); return (bool) $this->db->prepare("SELECT count(*) from arsse_users where id is ?", "str")->run($user)->getValue();
} }
public function userAdd(string $user, string $password = null): string { public function userAdd(string $user, string $password = null): string {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} else if($this->userExists($user)) { } elseif ($this->userExists($user)) {
throw new User\Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]); throw new User\Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
} }
if($password===null) { if ($password===null) {
$password = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); $password = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
} }
$hash = ""; $hash = "";
if(strlen($password) > 0) { if (strlen($password) > 0) {
$hash = password_hash($password, \PASSWORD_DEFAULT); $hash = password_hash($password, \PASSWORD_DEFAULT);
} }
$this->db->prepare("INSERT INTO arsse_users(id,password) values(?,?)", "str", "str")->runArray([$user,$hash]); $this->db->prepare("INSERT INTO arsse_users(id,password) values(?,?)", "str", "str")->runArray([$user,$hash]);
@ -123,10 +124,10 @@ class Database {
} }
public function userRemove(string $user): bool { public function userRemove(string $user): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
if($this->db->prepare("DELETE from arsse_users where id is ?", "str")->run($user)->changes() < 1) { if ($this->db->prepare("DELETE from arsse_users where id is ?", "str")->run($user)->changes() < 1) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
} }
return true; return true;
@ -134,20 +135,20 @@ class Database {
public function userList(string $domain = null): array { public function userList(string $domain = null): array {
$out = []; $out = [];
if($domain !== null) { if ($domain !== null) {
if(!Arsse::$user->authorize("@".$domain, __FUNCTION__)) { if (!Arsse::$user->authorize("@".$domain, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $domain]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $domain]);
} }
$domain = str_replace(["\\","%","_"],["\\\\", "\\%", "\\_"], $domain); $domain = str_replace(["\\","%","_"], ["\\\\", "\\%", "\\_"], $domain);
$domain = "%@".$domain; $domain = "%@".$domain;
foreach($this->db->prepare("SELECT id from arsse_users where id like ?", "str")->run($domain) as $user) { foreach ($this->db->prepare("SELECT id from arsse_users where id like ?", "str")->run($domain) as $user) {
$out[] = $user['id']; $out[] = $user['id'];
} }
} else { } else {
if(!Arsse::$user->authorize("", __FUNCTION__)) { if (!Arsse::$user->authorize("", __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => "global"]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => "global"]);
} }
foreach($this->db->query("SELECT id from arsse_users") as $user) { foreach ($this->db->query("SELECT id from arsse_users") as $user) {
$out[] = $user['id']; $out[] = $user['id'];
} }
} }
@ -155,25 +156,25 @@ class Database {
} }
public function userPasswordGet(string $user): string { public function userPasswordGet(string $user): string {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} else if(!$this->userExists($user)) { } elseif (!$this->userExists($user)) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
} }
return (string) $this->db->prepare("SELECT password from arsse_users where id is ?", "str")->run($user)->getValue(); return (string) $this->db->prepare("SELECT password from arsse_users where id is ?", "str")->run($user)->getValue();
} }
public function userPasswordSet(string $user, string $password = null): string { public function userPasswordSet(string $user, string $password = null): string {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} else if(!$this->userExists($user)) { } elseif (!$this->userExists($user)) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
} }
if($password===null) { if ($password===null) {
$password = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); $password = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
} }
$hash = ""; $hash = "";
if(strlen($password) > 0) { if (strlen($password) > 0) {
$hash = password_hash($password, \PASSWORD_DEFAULT); $hash = password_hash($password, \PASSWORD_DEFAULT);
} }
$this->db->prepare("UPDATE arsse_users set password = ? where id is ?", "str", "str")->run($hash, $user); $this->db->prepare("UPDATE arsse_users set password = ? where id is ?", "str", "str")->run($hash, $user);
@ -181,20 +182,20 @@ class Database {
} }
public function userPropertiesGet(string $user): array { public function userPropertiesGet(string $user): array {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
$prop = $this->db->prepare("SELECT name,rights from arsse_users where id is ?", "str")->run($user)->getRow(); $prop = $this->db->prepare("SELECT name,rights from arsse_users where id is ?", "str")->run($user)->getRow();
if(!$prop) { if (!$prop) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
} }
return $prop; return $prop;
} }
public function userPropertiesSet(string $user, array $properties): array { public function userPropertiesSet(string $user, array $properties): array {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} else if(!$this->userExists($user)) { } elseif (!$this->userExists($user)) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
} }
$valid = [ // FIXME: add future properties $valid = [ // FIXME: add future properties
@ -206,16 +207,16 @@ class Database {
} }
public function userRightsGet(string $user): int { public function userRightsGet(string $user): int {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
return (int) $this->db->prepare("SELECT rights from arsse_users where id is ?", "str")->run($user)->getValue(); return (int) $this->db->prepare("SELECT rights from arsse_users where id is ?", "str")->run($user)->getValue();
} }
public function userRightsSet(string $user, int $rights): bool { public function userRightsSet(string $user, int $rights): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__, $rights)) { if (!Arsse::$user->authorize($user, __FUNCTION__, $rights)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} else if(!$this->userExists($user)) { } elseif (!$this->userExists($user)) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
} }
$this->db->prepare("UPDATE arsse_users set rights = ? where id is ?", "int", "str")->run($rights, $user); $this->db->prepare("UPDATE arsse_users set rights = ? where id is ?", "int", "str")->run($rights, $user);
@ -224,30 +225,30 @@ class Database {
public function folderAdd(string $user, array $data): int { public function folderAdd(string $user, array $data): int {
// If the user isn't authorized to perform this action then throw an exception. // If the user isn't authorized to perform this action then throw an exception.
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
// if the desired folder name is missing or invalid, throw an exception // if the desired folder name is missing or invalid, throw an exception
if(!array_key_exists("name", $data) || $data['name']=="") { if (!array_key_exists("name", $data) || $data['name']=="") {
throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "name"]); throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "name"]);
} else if(!strlen(trim($data['name']))) { } elseif (!strlen(trim($data['name']))) {
throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "name"]); throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "name"]);
} }
// normalize folder's parent, if there is one // normalize folder's parent, if there is one
$parent = array_key_exists("parent", $data) ? (int) $data['parent'] : 0; $parent = array_key_exists("parent", $data) ? (int) $data['parent'] : 0;
if($parent===0) { if ($parent===0) {
// if no parent is specified, do nothing // if no parent is specified, do nothing
$parent = null; $parent = null;
} else { } else {
// if a parent is specified, make sure it exists and belongs to the user; get its root (first-level) folder if it's a nested folder // if a parent is specified, make sure it exists and belongs to the user; get its root (first-level) folder if it's a nested folder
$p = $this->db->prepare("SELECT id from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $parent)->getValue(); $p = $this->db->prepare("SELECT id from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $parent)->getValue();
if(!$p) { if (!$p) {
throw new Db\ExceptionInput("idMissing", ["action" => __FUNCTION__, "field" => "parent", 'id' => $parent]); throw new Db\ExceptionInput("idMissing", ["action" => __FUNCTION__, "field" => "parent", 'id' => $parent]);
} }
} }
// check if a folder by the same name already exists, because nulls are wonky in SQL // check if a folder by the same name already exists, because nulls are wonky in SQL
// FIXME: How should folder name be compared? Should a Unicode normalization be applied before comparison and insertion? // FIXME: How should folder name be compared? Should a Unicode normalization be applied before comparison and insertion?
if($this->db->prepare("SELECT count(*) from arsse_folders where owner is ? and parent is ? and name is ?", "str", "int", "str")->run($user, $parent, $data['name'])->getValue() > 0) { if ($this->db->prepare("SELECT count(*) from arsse_folders where owner is ? and parent is ? and name is ?", "str", "int", "str")->run($user, $parent, $data['name'])->getValue() > 0) {
throw new Db\ExceptionInput("constraintViolation"); // FIXME: There needs to be a practical message here throw new Db\ExceptionInput("constraintViolation"); // FIXME: There needs to be a practical message here
} }
// actually perform the insert (!) // actually perform the insert (!)
@ -256,17 +257,17 @@ class Database {
public function folderList(string $user, int $parent = null, bool $recursive = true): Db\Result { public function folderList(string $user, int $parent = null, bool $recursive = true): Db\Result {
// if the user isn't authorized to perform this action then throw an exception. // if the user isn't authorized to perform this action then throw an exception.
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
// check to make sure the parent exists, if one is specified // check to make sure the parent exists, if one is specified
if(!is_null($parent)) { if (!is_null($parent)) {
if(!$this->db->prepare("SELECT count(*) from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $parent)->getValue()) { if (!$this->db->prepare("SELECT count(*) from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $parent)->getValue()) {
throw new Db\ExceptionInput("idMissing", ["action" => __FUNCTION__, "field" => "parent", 'id' => $parent]); throw new Db\ExceptionInput("idMissing", ["action" => __FUNCTION__, "field" => "parent", 'id' => $parent]);
} }
} }
// if we're not returning a recursive list we can use a simpler query // if we're not returning a recursive list we can use a simpler query
if(!$recursive) { if (!$recursive) {
return $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and parent is ?", "str", "int")->run($user, $parent); return $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and parent is ?", "str", "int")->run($user, $parent);
} else { } else {
return $this->db->prepare( return $this->db->prepare(
@ -277,45 +278,45 @@ class Database {
} }
public function folderRemove(string $user, int $id): bool { public function folderRemove(string $user, int $id): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
$changes = $this->db->prepare("DELETE FROM arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->changes(); $changes = $this->db->prepare("DELETE FROM arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->changes();
if(!$changes) { if (!$changes) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id]); throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id]);
} }
return true; return true;
} }
public function folderPropertiesGet(string $user, int $id): array { public function folderPropertiesGet(string $user, int $id): array {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
$props = $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->getRow(); $props = $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->getRow();
if(!$props) { if (!$props) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id]); throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id]);
} }
return $props; return $props;
} }
public function folderPropertiesSet(string $user, int $id, array $data): bool { public function folderPropertiesSet(string $user, int $id, array $data): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
// validate the folder ID and, if specified, the parent to move it to // validate the folder ID and, if specified, the parent to move it to
$parent = null; $parent = null;
if(array_key_exists("parent", $data)) { if (array_key_exists("parent", $data)) {
$parent = $data['parent']; $parent = $data['parent'];
} }
$f = $this->folderValidateId($user, $id, $parent, true); $f = $this->folderValidateId($user, $id, $parent, true);
// if a new name is specified, validate it // if a new name is specified, validate it
if(array_key_exists("name", $data)) { if (array_key_exists("name", $data)) {
$this->folderValidateName($data['name']); $this->folderValidateName($data['name']);
} }
$data = array_merge($f, $data); $data = array_merge($f, $data);
// check to make sure the target folder name/location would not create a duplicate (we must do this check because null is not distinct in SQL) // check to make sure the target folder name/location would not create a duplicate (we must do this check because null is not distinct in SQL)
$existing = $this->db->prepare("SELECT id from arsse_folders where owner is ? and parent is ? and name is ?", "str", "int", "str")->run($user, $data['parent'], $data['name'])->getValue(); $existing = $this->db->prepare("SELECT id from arsse_folders where owner is ? and parent is ? and name is ?", "str", "int", "str")->run($user, $data['parent'], $data['name'])->getValue();
if(!is_null($existing) && $existing != $id) { if (!is_null($existing) && $existing != $id) {
throw new Db\ExceptionInput("constraintViolation"); // FIXME: There needs to be a practical message here throw new Db\ExceptionInput("constraintViolation"); // FIXME: There needs to be a practical message here
} }
$valid = [ $valid = [
@ -327,32 +328,32 @@ class Database {
} }
protected function folderValidateId(string $user, int $id = null, int $parent = null, bool $subject = false): array { protected function folderValidateId(string $user, int $id = null, int $parent = null, bool $subject = false): array {
if(is_null($id)) { if (is_null($id)) {
// if no ID is specified this is a no-op, unless a parent is specified, which is always a circular dependence (the root cannot be moved) // if no ID is specified this is a no-op, unless a parent is specified, which is always a circular dependence (the root cannot be moved)
if(!is_null($parent)) { if (!is_null($parent)) {
throw new Db\ExceptionInput("circularDependence", ["action" => $this->caller(), "field" => "parent", 'id' => $parent]); // @codeCoverageIgnore throw new Db\ExceptionInput("circularDependence", ["action" => $this->caller(), "field" => "parent", 'id' => $parent]); // @codeCoverageIgnore
} }
return ['name' => null, 'parent' => null]; return ['name' => null, 'parent' => null];
} }
// check whether the folder exists and is owned by the user // check whether the folder exists and is owned by the user
$f = $this->db->prepare("SELECT name,parent from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->getRow(); $f = $this->db->prepare("SELECT name,parent from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->getRow();
if(!$f) { if (!$f) {
throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "folder", 'id' => $parent]); throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "folder", 'id' => $parent]);
} }
// if we're moving a folder to a new parent, check that the parent is valid // if we're moving a folder to a new parent, check that the parent is valid
if(!is_null($parent)) { if (!is_null($parent)) {
// make sure both that the parent exists, and that the parent is not either the folder itself or one of its children (a circular dependence) // make sure both that the parent exists, and that the parent is not either the folder itself or one of its children (a circular dependence)
$p = $this->db->prepare( $p = $this->db->prepare(
"WITH RECURSIVE folders(id) as (SELECT id from arsse_folders where owner is ? and id is ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id) ". "WITH RECURSIVE folders(id) as (SELECT id from arsse_folders where owner is ? and id is ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id) ".
"SELECT id,(id not in (select id from folders)) as valid from arsse_folders where owner is ? and id is ?", "SELECT id,(id not in (select id from folders)) as valid from arsse_folders where owner is ? and id is ?",
"str", "int", "str", "int" "str", "int", "str", "int"
)->run($user, $id, $user, $parent)->getRow(); )->run($user, $id, $user, $parent)->getRow();
if(!$p) { if (!$p) {
// if the parent doesn't exist or doesn't below to the user, throw an exception // if the parent doesn't exist or doesn't below to the user, throw an exception
throw new Db\ExceptionInput("idMissing", ["action" => $this->caller(), "field" => "parent", 'id' => $parent]); throw new Db\ExceptionInput("idMissing", ["action" => $this->caller(), "field" => "parent", 'id' => $parent]);
} else { } else {
// if using the desired parent would create a circular dependence, throw a different exception // if using the desired parent would create a circular dependence, throw a different exception
if(!$p['valid']) { if (!$p['valid']) {
throw new Db\ExceptionInput("circularDependence", ["action" => $this->caller(), "field" => "parent", 'id' => $parent]); throw new Db\ExceptionInput("circularDependence", ["action" => $this->caller(), "field" => "parent", 'id' => $parent]);
} }
} }
@ -362,9 +363,9 @@ class Database {
protected function folderValidateName($name): bool { protected function folderValidateName($name): bool {
$name = (string) $name; $name = (string) $name;
if(!strlen($name)) { if (!strlen($name)) {
throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]); throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]);
} else if(!strlen(trim($name))) { } elseif (!strlen(trim($name))) {
throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]); throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]);
} else { } else {
return true; return true;
@ -372,18 +373,18 @@ class Database {
} }
public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = ""): int { public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = ""): int {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
// check to see if the feed exists // check to see if the feed exists
$feedID = $this->db->prepare("SELECT id from arsse_feeds where url is ? and username is ? and password is ?", "str", "str", "str")->run($url, $fetchUser, $fetchPassword)->getValue(); $feedID = $this->db->prepare("SELECT id from arsse_feeds where url is ? and username is ? and password is ?", "str", "str", "str")->run($url, $fetchUser, $fetchPassword)->getValue();
if(is_null($feedID)) { if (is_null($feedID)) {
// if the feed doesn't exist add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible // if the feed doesn't exist add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible
$feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId(); $feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId();
try { try {
// perform an initial update on the newly added feed // perform an initial update on the newly added feed
$this->feedUpdate($feedID, true); $this->feedUpdate($feedID, true);
} catch(\Throwable $e) { } catch (\Throwable $e) {
// if the update fails, delete the feed we just added // if the update fails, delete the feed we just added
$this->db->prepare('DELETE from arsse_feeds where id is ?', 'int')->run($feedID); $this->db->prepare('DELETE from arsse_feeds where id is ?', 'int')->run($feedID);
throw $e; throw $e;
@ -394,7 +395,7 @@ class Database {
} }
public function subscriptionList(string $user, int $folder = null, int $id = null): Db\Result { public function subscriptionList(string $user, int $folder = null, int $id = null): Db\Result {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
// create a complex query // create a complex query
@ -415,11 +416,11 @@ class Database {
$q->setCTE("user(user)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once $q->setCTE("user(user)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once
// topmost folders belonging to the user // topmost folders belonging to the user
$q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join user on owner is user where parent is null union select id,top from arsse_folders join topmost on parent=f_id"); $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join user on owner is user where parent is null union select id,top from arsse_folders join topmost on parent=f_id");
if(!is_null($id)) { if (!is_null($id)) {
// this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
// if an ID is specified, add a suitable WHERE condition and bindings // if an ID is specified, add a suitable WHERE condition and bindings
$q->setWhere("arsse_subscriptions.id is ?", "int", $id); $q->setWhere("arsse_subscriptions.id is ?", "int", $id);
} else if(!is_null($folder)) { } elseif (!is_null($folder)) {
// if a folder is specified, make sure it exists // if a folder is specified, make sure it exists
$this->folderValidateId($user, $folder); $this->folderValidateId($user, $folder);
// if it does exist, add a common table expression to list it and its children so that we select from the entire subtree // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree
@ -431,50 +432,50 @@ class Database {
} }
public function subscriptionRemove(string $user, int $id): bool { public function subscriptionRemove(string $user, int $id): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
$changes = $this->db->prepare("DELETE from arsse_subscriptions where owner is ? and id is ?", "str", "int")->run($user, $id)->changes(); $changes = $this->db->prepare("DELETE from arsse_subscriptions where owner is ? and id is ?", "str", "int")->run($user, $id)->changes();
if(!$changes) { if (!$changes) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id]); throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id]);
} }
return true; return true;
} }
public function subscriptionPropertiesGet(string $user, int $id): array { public function subscriptionPropertiesGet(string $user, int $id): array {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
// disable authorization checks for the list call // disable authorization checks for the list call
Arsse::$user->authorizationEnabled(false); Arsse::$user->authorizationEnabled(false);
$sub = $this->subscriptionList($user, null, $id)->getRow(); $sub = $this->subscriptionList($user, null, $id)->getRow();
Arsse::$user->authorizationEnabled(true); Arsse::$user->authorizationEnabled(true);
if(!$sub) { if (!$sub) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]); throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]);
} }
return $sub; return $sub;
} }
public function subscriptionPropertiesSet(string $user, int $id, array $data): bool { public function subscriptionPropertiesSet(string $user, int $id, array $data): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
$tr = $this->db->begin(); $tr = $this->db->begin();
if(!$this->db->prepare("SELECT count(*) from arsse_subscriptions where owner is ? and id is ?", "str", "int")->run($user, $id)->getValue()) { if (!$this->db->prepare("SELECT count(*) from arsse_subscriptions where owner is ? and id is ?", "str", "int")->run($user, $id)->getValue()) {
// if the ID doesn't exist or doesn't belong to the user, throw an exception // if the ID doesn't exist or doesn't belong to the user, throw an exception
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]); throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]);
} }
if(array_key_exists("folder", $data)) { if (array_key_exists("folder", $data)) {
// ensure the target folder exists and belong to the user // ensure the target folder exists and belong to the user
$this->folderValidateId($user, $data['folder']); $this->folderValidateId($user, $data['folder']);
} }
if(array_key_exists("title", $data)) { if (array_key_exists("title", $data)) {
// if the title is null, this signals intended use of the default title; otherwise make sure it's not effectively an empty string // if the title is null, this signals intended use of the default title; otherwise make sure it's not effectively an empty string
if(!is_null($data['title'])) { if (!is_null($data['title'])) {
$title = (string) $data['title']; $title = (string) $data['title'];
if(!strlen($title)) { if (!strlen($title)) {
throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]); throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]);
} else if(!strlen(trim($title))) { } elseif (!strlen(trim($title))) {
throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]); throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]);
} }
$data['title'] = $title; $data['title'] = $title;
@ -494,7 +495,7 @@ class Database {
protected function subscriptionValidateId(string $user, int $id): array { protected function subscriptionValidateId(string $user, int $id): array {
$out = $this->db->prepare("SELECT feed from arsse_subscriptions where id is ? and owner is ?", "int", "str")->run($id, $user)->getRow(); $out = $this->db->prepare("SELECT feed from arsse_subscriptions where id is ? and owner is ?", "int", "str")->run($id, $user)->getRow();
if(!$out) { if (!$out) {
throw new Db\ExceptionInput("idMissing", ["action" => $this->caller(), "field" => "subscription", 'id' => $id]); throw new Db\ExceptionInput("idMissing", ["action" => $this->caller(), "field" => "subscription", 'id' => $id]);
} }
return $out; return $out;
@ -502,14 +503,14 @@ class Database {
public function feedListStale(): array { public function feedListStale(): array {
$feeds = $this->db->query("SELECT id from arsse_feeds where next_fetch <= CURRENT_TIMESTAMP")->getAll(); $feeds = $this->db->query("SELECT id from arsse_feeds where next_fetch <= CURRENT_TIMESTAMP")->getAll();
return array_column($feeds,'id'); return array_column($feeds, 'id');
} }
public function feedUpdate(int $feedID, bool $throwError = false): bool { public function feedUpdate(int $feedID, bool $throwError = false): bool {
$tr = $this->db->begin(); $tr = $this->db->begin();
// check to make sure the feed exists // check to make sure the feed exists
$f = $this->db->prepare("SELECT url, username, password, modified, etag, err_count, scrape FROM arsse_feeds where id is ?", "int")->run($feedID)->getRow(); $f = $this->db->prepare("SELECT url, username, password, modified, etag, err_count, scrape FROM arsse_feeds where id is ?", "int")->run($feedID)->getRow();
if(!$f) { if (!$f) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID]); throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID]);
} }
// determine whether the feed's items should be scraped for full content from the source Web site // determine whether the feed's items should be scraped for full content from the source Web site
@ -519,7 +520,7 @@ class Database {
// error instead of failing; if other exceptions are thrown, we should simply roll back // error instead of failing; if other exceptions are thrown, we should simply roll back
try { try {
$feed = new Feed($feedID, $f['url'], (string) Date::transform($f['modified'], "http", "sql"), $f['etag'], $f['username'], $f['password'], $scrape); $feed = new Feed($feedID, $f['url'], (string) Date::transform($f['modified'], "http", "sql"), $f['etag'], $f['username'], $f['password'], $scrape);
if(!$feed->modified) { if (!$feed->modified) {
// if the feed hasn't changed, just compute the next fetch time and record it // if the feed hasn't changed, just compute the next fetch time and record it
$this->db->prepare("UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ? WHERE id is ?", 'datetime', 'int')->run($feed->nextFetch, $feedID); $this->db->prepare("UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ? WHERE id is ?", 'datetime', 'int')->run($feed->nextFetch, $feedID);
$tr->commit(); $tr->commit();
@ -530,26 +531,26 @@ class Database {
$this->db->prepare( $this->db->prepare(
"UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ?, err_count = err_count + 1, err_msg = ? WHERE id is ?", "UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ?, err_count = err_count + 1, err_msg = ? WHERE id is ?",
'datetime', 'str', 'int' 'datetime', 'str', 'int'
)->run(Feed::nextFetchOnError($f['err_count']), $e->getMessage(),$feedID); )->run(Feed::nextFetchOnError($f['err_count']), $e->getMessage(), $feedID);
$tr->commit(); $tr->commit();
if($throwError) { if ($throwError) {
throw $e; throw $e;
} }
return false; return false;
} }
//prepare the necessary statements to perform the update //prepare the necessary statements to perform the update
if(sizeof($feed->newItems) || sizeof($feed->changedItems)) { if (sizeof($feed->newItems) || sizeof($feed->changedItems)) {
$qInsertEnclosure = $this->db->prepare("INSERT INTO arsse_enclosures(article,url,type) values(?,?,?)", 'int', 'str', 'str'); $qInsertEnclosure = $this->db->prepare("INSERT INTO arsse_enclosures(article,url,type) values(?,?,?)", 'int', 'str', 'str');
$qInsertCategory = $this->db->prepare("INSERT INTO arsse_categories(article,name) values(?,?)", 'int', 'str'); $qInsertCategory = $this->db->prepare("INSERT INTO arsse_categories(article,name) values(?,?)", 'int', 'str');
$qInsertEdition = $this->db->prepare("INSERT INTO arsse_editions(article) values(?)", 'int'); $qInsertEdition = $this->db->prepare("INSERT INTO arsse_editions(article) values(?)", 'int');
} }
if(sizeof($feed->newItems)) { if (sizeof($feed->newItems)) {
$qInsertArticle = $this->db->prepare( $qInsertArticle = $this->db->prepare(
"INSERT INTO arsse_articles(url,title,author,published,edited,guid,content,url_title_hash,url_content_hash,title_content_hash,feed) values(?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO arsse_articles(url,title,author,published,edited,guid,content,url_title_hash,url_content_hash,title_content_hash,feed) values(?,?,?,?,?,?,?,?,?,?,?)",
'str', 'str', 'str', 'datetime', 'datetime', 'str', 'str', 'str', 'str', 'str', 'int' 'str', 'str', 'str', 'datetime', 'datetime', 'str', 'str', 'str', 'str', 'str', 'int'
); );
} }
if(sizeof($feed->changedItems)) { if (sizeof($feed->changedItems)) {
$qDeleteEnclosures = $this->db->prepare("DELETE FROM arsse_enclosures WHERE article is ?", 'int'); $qDeleteEnclosures = $this->db->prepare("DELETE FROM arsse_enclosures WHERE article is ?", 'int');
$qDeleteCategories = $this->db->prepare("DELETE FROM arsse_categories WHERE article is ?", 'int'); $qDeleteCategories = $this->db->prepare("DELETE FROM arsse_categories WHERE article is ?", 'int');
$qClearReadMarks = $this->db->prepare("UPDATE arsse_marks SET read = 0, modified = CURRENT_TIMESTAMP WHERE article is ? and read is 1", 'int'); $qClearReadMarks = $this->db->prepare("UPDATE arsse_marks SET read = 0, modified = CURRENT_TIMESTAMP WHERE article is ? and read is 1", 'int');
@ -559,7 +560,7 @@ class Database {
); );
} }
// actually perform updates // actually perform updates
foreach($feed->newItems as $article) { foreach ($feed->newItems as $article) {
$articleID = $qInsertArticle->run( $articleID = $qInsertArticle->run(
$article->url, $article->url,
$article->title, $article->title,
@ -573,15 +574,15 @@ class Database {
$article->titleContentHash, $article->titleContentHash,
$feedID $feedID
)->lastId(); )->lastId();
if($article->enclosureUrl) { if ($article->enclosureUrl) {
$qInsertEnclosure->run($articleID,$article->enclosureUrl,$article->enclosureType); $qInsertEnclosure->run($articleID, $article->enclosureUrl, $article->enclosureType);
} }
foreach($article->categories as $c) { foreach ($article->categories as $c) {
$qInsertCategory->run($articleID, $c); $qInsertCategory->run($articleID, $c);
} }
$qInsertEdition->run($articleID); $qInsertEdition->run($articleID);
} }
foreach($feed->changedItems as $articleID => $article) { foreach ($feed->changedItems as $articleID => $article) {
$qUpdateArticle->run( $qUpdateArticle->run(
$article->url, $article->url,
$article->title, $article->title,
@ -597,10 +598,10 @@ class Database {
); );
$qDeleteEnclosures->run($articleID); $qDeleteEnclosures->run($articleID);
$qDeleteCategories->run($articleID); $qDeleteCategories->run($articleID);
if($article->enclosureUrl) { if ($article->enclosureUrl) {
$qInsertEnclosure->run($articleID,$article->enclosureUrl,$article->enclosureType); $qInsertEnclosure->run($articleID, $article->enclosureUrl, $article->enclosureType);
} }
foreach($article->categories as $c) { foreach ($article->categories as $c) {
$qInsertCategory->run($articleID, $c); $qInsertCategory->run($articleID, $c);
} }
$qInsertEdition->run($articleID); $qInsertEdition->run($articleID);
@ -633,7 +634,7 @@ class Database {
$this->db->query("UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where orphaned is null and not exists(SELECT id from arsse_subscriptions where feed is arsse_feeds.id)"); $this->db->query("UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where orphaned is null and not exists(SELECT id from arsse_subscriptions where feed is arsse_feeds.id)");
// finally delete feeds that have been orphaned longer than the retention period // finally delete feeds that have been orphaned longer than the retention period
$limit = Date::normalize("now"); $limit = Date::normalize("now");
if(Arsse::$conf->purgeFeeds) { if (Arsse::$conf->purgeFeeds) {
// if there is a retention period specified, compute it; otherwise feed are deleted immediatelty // if there is a retention period specified, compute it; otherwise feed are deleted immediatelty
$limit->sub(new \DateInterval(Arsse::$conf->purgeFeeds)); $limit->sub(new \DateInterval(Arsse::$conf->purgeFeeds));
} }
@ -664,10 +665,10 @@ class Database {
} }
public function articleList(string $user, Context $context = null): Db\Result { public function articleList(string $user, Context $context = null): Db\Result {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
if(!$context) { if (!$context) {
$context = new Context; $context = new Context;
} }
$q = new Query( $q = new Query(
@ -696,12 +697,12 @@ class Database {
$q->setOrder("edition".($context->reverse ? " desc" : "")); $q->setOrder("edition".($context->reverse ? " desc" : ""));
$q->setLimit($context->limit, $context->offset); $q->setLimit($context->limit, $context->offset);
$q->setCTE("user(user)", "SELECT ?", "str", $user); $q->setCTE("user(user)", "SELECT ?", "str", $user);
if($context->subscription()) { if ($context->subscription()) {
// if a subscription is specified, make sure it exists // if a subscription is specified, make sure it exists
$id = $this->subscriptionValidateId($user, $context->subscription)['feed']; $id = $this->subscriptionValidateId($user, $context->subscription)['feed'];
// add a basic CTE that will join in only the requested subscription // add a basic CTE that will join in only the requested subscription
$q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription]); $q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription]);
} else if($context->folder()) { } elseif ($context->folder()) {
// if a folder is specified, make sure it exists // if a folder is specified, make sure it exists
$this->folderValidateId($user, $context->folder); $this->folderValidateId($user, $context->folder);
// if it does exist, add a common table expression to list it and its children so that we select from the entire subtree // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree
@ -713,24 +714,24 @@ class Database {
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner"); $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner");
} }
// filter based on edition offset // filter based on edition offset
if($context->oldestEdition()) { if ($context->oldestEdition()) {
$q->setWhere("edition >= ?", "int", $context->oldestEdition); $q->setWhere("edition >= ?", "int", $context->oldestEdition);
} }
if($context->latestEdition()) { if ($context->latestEdition()) {
$q->setWhere("edition <= ?", "int", $context->latestEdition); $q->setWhere("edition <= ?", "int", $context->latestEdition);
} }
// filter based on lastmod time // filter based on lastmod time
if($context->modifiedSince()) { if ($context->modifiedSince()) {
$q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince); $q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince);
} }
if($context->notModifiedSince()) { if ($context->notModifiedSince()) {
$q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince); $q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince);
} }
// filter for un/read and un/starred status if specified // filter for un/read and un/starred status if specified
if($context->unread()) { if ($context->unread()) {
$q->setWhere("unread is ?", "bool", $context->unread); $q->setWhere("unread is ?", "bool", $context->unread);
} }
if($context->starred()) { if ($context->starred()) {
$q->setWhere("starred is ?", "bool", $context->starred); $q->setWhere("starred is ?", "bool", $context->starred);
} }
// perform the query and return results // perform the query and return results
@ -738,10 +739,10 @@ class Database {
} }
public function articleMark(string $user, array $data, Context $context = null): bool { public function articleMark(string $user, array $data, Context $context = null): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
if(!$context) { if (!$context) {
$context = new Context; $context = new Context;
} }
// sanitize input // sanitize input
@ -771,19 +772,19 @@ class Database {
// wrap this UPDATE and INSERT together into a transaction // wrap this UPDATE and INSERT together into a transaction
$tr = $this->begin(); $tr = $this->begin();
// if an edition context is specified, make sure it's valid // if an edition context is specified, make sure it's valid
if($context->edition()) { if ($context->edition()) {
// make sure the edition exists // make sure the edition exists
$edition = $this->articleValidateEdition($user, $context->edition); $edition = $this->articleValidateEdition($user, $context->edition);
// if the edition is not the latest, do not mark the read flag // if the edition is not the latest, do not mark the read flag
if(!$edition['current']) { if (!$edition['current']) {
$values[0] = null; $values[0] = null;
} }
} else if($context->article()) { } elseif ($context->article()) {
// otherwise if an article context is specified, make sure it's valid // otherwise if an article context is specified, make sure it's valid
$this->articleValidateId($user, $context->article); $this->articleValidateId($user, $context->article);
} }
// execute each query in sequence // execute each query in sequence
foreach($queries as $query) { foreach ($queries as $query) {
// first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles // first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles
$q = new Query( $q = new Query(
"SELECT "SELECT
@ -802,12 +803,12 @@ class Database {
$q->setCTE("user(user)", "SELECT ?", "str", $user); $q->setCTE("user(user)", "SELECT ?", "str", $user);
// common table expression with the values to set // common table expression with the values to set
$q->setCTE("target_values(read,starred)", "SELECT ?,?", ["bool","bool"], $values); $q->setCTE("target_values(read,starred)", "SELECT ?,?", ["bool","bool"], $values);
if($context->subscription()) { if ($context->subscription()) {
// if a subscription is specified, make sure it exists // if a subscription is specified, make sure it exists
$id = $this->subscriptionValidateId($user, $context->subscription)['feed']; $id = $this->subscriptionValidateId($user, $context->subscription)['feed'];
// add a basic CTE that will join in only the requested subscription // add a basic CTE that will join in only the requested subscription
$q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed is subscribed_feeds.id"); $q->setCTE("subscribed_feeds(id,sub)", "SELECT ?,?", ["int","int"], [$id,$context->subscription], "join subscribed_feeds on feed is subscribed_feeds.id");
} else if($context->folder()) { } elseif ($context->folder()) {
// if a folder is specified, make sure it exists // if a folder is specified, make sure it exists
$this->folderValidateId($user, $context->folder); $this->folderValidateId($user, $context->folder);
// if it does exist, add a common table expression to list it and its children so that we select from the entire subtree // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree
@ -818,18 +819,18 @@ class Database {
// otherwise add a CTE for all the user's subscriptions // otherwise add a CTE for all the user's subscriptions
$q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner", [], [], "join subscribed_feeds on feed is subscribed_feeds.id"); $q->setCTE("subscribed_feeds(id,sub)", "SELECT feed,id from arsse_subscriptions join user on user is owner", [], [], "join subscribed_feeds on feed is subscribed_feeds.id");
} }
if($context->edition()) { if ($context->edition()) {
// if an edition is specified, filter for its previously identified article // if an edition is specified, filter for its previously identified article
$q->setWhere("arsse_articles.id is ?", "int", $edition['article']); $q->setWhere("arsse_articles.id is ?", "int", $edition['article']);
} else if($context->article()) { } elseif ($context->article()) {
// if an article is specified, filter for it (it has already been validated above) // if an article is specified, filter for it (it has already been validated above)
$q->setWhere("arsse_articles.id is ?", "int", $context->article); $q->setWhere("arsse_articles.id is ?", "int", $context->article);
} }
if($context->editions()) { if ($context->editions()) {
// if multiple specific editions have been requested, prepare a CTE to list them and their articles // if multiple specific editions have been requested, prepare a CTE to list them and their articles
if(!$context->editions) { if (!$context->editions) {
throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element
} else if(sizeof($context->editions) > 50) { } elseif (sizeof($context->editions) > 50) {
throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements
} }
list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); list($inParams, $inTypes) = $this->generateIn($context->editions, "int");
@ -839,11 +840,11 @@ class Database {
$context->editions $context->editions
); );
$q->setWhere("arsse_articles.id in (select id from requested_articles)"); $q->setWhere("arsse_articles.id in (select id from requested_articles)");
} else if($context->articles()) { } elseif ($context->articles()) {
// if multiple specific articles have been requested, prepare a CTE to list them and their articles // if multiple specific articles have been requested, prepare a CTE to list them and their articles
if(!$context->articles) { if (!$context->articles) {
throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element
} else if(sizeof($context->articles) > 50) { } elseif (sizeof($context->articles) > 50) {
throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => 50]); // must not have more than 50 array elements
} }
list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); list($inParams, $inTypes) = $this->generateIn($context->articles, "int");
@ -858,17 +859,17 @@ class Database {
$q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 is 0"); $q->setCTE("requested_articles(id,edition)", "SELECT 'empty','table' where 1 is 0");
} }
// filter based on edition offset // filter based on edition offset
if($context->oldestEdition()) { if ($context->oldestEdition()) {
$q->setWhere("edition >= ?", "int", $context->oldestEdition); $q->setWhere("edition >= ?", "int", $context->oldestEdition);
} }
if($context->latestEdition()) { if ($context->latestEdition()) {
$q->setWhere("edition <= ?", "int", $context->latestEdition); $q->setWhere("edition <= ?", "int", $context->latestEdition);
} }
// filter based on lastmod time // filter based on lastmod time
if($context->modifiedSince()) { if ($context->modifiedSince()) {
$q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince); $q->setWhere("modified_date >= ?", "datetime", $context->modifiedSince);
} }
if($context->notModifiedSince()) { if ($context->notModifiedSince()) {
$q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince); $q->setWhere("modified_date <= ?", "datetime", $context->notModifiedSince);
} }
// push the current query onto the CTE stack and execute the query we're actually interested in // push the current query onto the CTE stack and execute the query we're actually interested in
@ -882,7 +883,7 @@ class Database {
} }
public function articleStarredCount(string $user): int { public function articleStarredCount(string $user): int {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
return $this->db->prepare("SELECT count(*) from arsse_marks where starred is 1 and subscription in (select id from arsse_subscriptions where owner is ?)", "str")->run($user)->getValue(); return $this->db->prepare("SELECT count(*) from arsse_marks where starred is 1 and subscription in (select id from arsse_subscriptions where owner is ?)", "str")->run($user)->getValue();
@ -913,14 +914,14 @@ class Database {
); );
$limitRead = null; $limitRead = null;
$limitUnread = null; $limitUnread = null;
if(Arsse::$conf->purgeArticlesRead) { if (Arsse::$conf->purgeArticlesRead) {
$limitRead = Date::sub(Arsse::$conf->purgeArticlesRead); $limitRead = Date::sub(Arsse::$conf->purgeArticlesRead);
} }
if(Arsse::$conf->purgeArticlesUnread) { if (Arsse::$conf->purgeArticlesUnread) {
$limitUnread = Date::sub(Arsse::$conf->purgeArticlesUnread); $limitUnread = Date::sub(Arsse::$conf->purgeArticlesUnread);
} }
$feeds = $this->db->query("SELECT id, size from arsse_feeds")->getAll(); $feeds = $this->db->query("SELECT id, size from arsse_feeds")->getAll();
foreach($feeds as $feed) { foreach ($feeds as $feed) {
$query->run($feed['id'], $feed['size'], $limitUnread, $limitRead); $query->run($feed['id'], $feed['size'], $limitUnread, $limitRead);
} }
return true; return true;
@ -938,7 +939,7 @@ class Database {
arsse_articles.id is ? and arsse_subscriptions.owner is ?", arsse_articles.id is ? and arsse_subscriptions.owner is ?",
"int", "str" "int", "str"
)->run($id, $user)->getRow(); )->run($id, $user)->getRow();
if(!$out) { if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "article", 'id' => $id]); throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "article", 'id' => $id]);
} }
return $out; return $out;
@ -958,21 +959,21 @@ class Database {
edition is ? and arsse_subscriptions.owner is ?", edition is ? and arsse_subscriptions.owner is ?",
"int", "str" "int", "str"
)->run($id, $user)->getRow(); )->run($id, $user)->getRow();
if(!$out) { if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "edition", 'id' => $id]); throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "edition", 'id' => $id]);
} }
return $out; return $out;
} }
public function editionLatest(string $user, Context $context = null): int { public function editionLatest(string $user, Context $context = null): int {
if(!Arsse::$user->authorize($user, __FUNCTION__)) { if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
} }
if(!$context) { if (!$context) {
$context = new Context; $context = new Context;
} }
$q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article is arsse_articles.id left join arsse_feeds on arsse_articles.feed is arsse_feeds.id"); $q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article is arsse_articles.id left join arsse_feeds on arsse_articles.feed is arsse_feeds.id");
if($context->subscription()) { if ($context->subscription()) {
// if a subscription is specified, make sure it exists // if a subscription is specified, make sure it exists
$id = $this->subscriptionValidateId($user, $context->subscription)['feed']; $id = $this->subscriptionValidateId($user, $context->subscription)['feed'];
// a simple WHERE clause is required here // a simple WHERE clause is required here

42
lib/Db/AbstractDriver.php

@ -7,16 +7,16 @@ abstract class AbstractDriver implements Driver {
protected $transDepth = 0; protected $transDepth = 0;
protected $transStatus = []; protected $transStatus = [];
public abstract function prepareArray(string $query, array $paramTypes): Statement; abstract public function prepareArray(string $query, array $paramTypes): Statement;
protected abstract function lock(): bool; abstract protected function lock(): bool;
protected abstract function unlock(bool $rollback = false) : bool; abstract protected function unlock(bool $rollback = false) : bool;
/** @codeCoverageIgnore */ /** @codeCoverageIgnore */
public function schemaVersion(): int { public function schemaVersion(): int {
// FIXME: generic schemaVersion() will need to be covered for database engines other than SQLite // FIXME: generic schemaVersion() will need to be covered for database engines other than SQLite
try { try {
return (int) $this->query("SELECT value from arsse_meta where key is schema_version")->getValue(); return (int) $this->query("SELECT value from arsse_meta where key is schema_version")->getValue();
} catch(Exception $e) { } catch (Exception $e) {
return 0; return 0;
} }
} }
@ -26,7 +26,7 @@ abstract class AbstractDriver implements Driver {
} }
public function savepointCreate(bool $lock = false): int { public function savepointCreate(bool $lock = false): int {
if($lock && !$this->transDepth) { if ($lock && !$this->transDepth) {
$this->lock(); $this->lock();
$this->locked = true; $this->locked = true;
} }
@ -36,17 +36,17 @@ abstract class AbstractDriver implements Driver {
} }
public function savepointRelease(int $index = null): bool { public function savepointRelease(int $index = null): bool {
if(is_null($index)) { if (is_null($index)) {
$index = $this->transDepth; $index = $this->transDepth;
} }
if(array_key_exists($index, $this->transStatus)) { if (array_key_exists($index, $this->transStatus)) {
switch($this->transStatus[$index]) { switch ($this->transStatus[$index]) {
case self::TR_PEND: case self::TR_PEND:
$this->exec("RELEASE SAVEPOINT arsse_".$index); $this->exec("RELEASE SAVEPOINT arsse_".$index);
$this->transStatus[$index] = self::TR_COMMIT; $this->transStatus[$index] = self::TR_COMMIT;
$a = $index; $a = $index;
while(++$a && $a <= $this->transDepth) { while (++$a && $a <= $this->transDepth) {
if($this->transStatus[$a] <= self::TR_PEND) { if ($this->transStatus[$a] <= self::TR_PEND) {
$this->transStatus[$a] = self::TR_PEND_COMMIT; $this->transStatus[$a] = self::TR_PEND_COMMIT;
} }
} }
@ -66,13 +66,13 @@ abstract class AbstractDriver implements Driver {
default: default:
throw new Exception("unknownSavepointStatus", $this->transStatus[$index]); //@codeCoverageIgnore throw new Exception("unknownSavepointStatus", $this->transStatus[$index]); //@codeCoverageIgnore
} }
if($index==$this->transDepth) { if ($index==$this->transDepth) {
while($this->transDepth > 0 && $this->transStatus[$this->transDepth] > self::TR_PEND) { while ($this->transDepth > 0 && $this->transStatus[$this->transDepth] > self::TR_PEND) {
array_pop($this->transStatus); array_pop($this->transStatus);
$this->transDepth--; $this->transDepth--;
} }
} }
if(!$this->transDepth && $this->locked) { if (!$this->transDepth && $this->locked) {
$this->unlock(); $this->unlock();
$this->locked = false; $this->locked = false;
} }
@ -83,18 +83,18 @@ abstract class AbstractDriver implements Driver {
} }
public function savepointUndo(int $index = null): bool { public function savepointUndo(int $index = null): bool {
if(is_null($index)) { if (is_null($index)) {
$index = $this->transDepth; $index = $this->transDepth;
} }
if(array_key_exists($index, $this->transStatus)) { if (array_key_exists($index, $this->transStatus)) {
switch($this->transStatus[$index]) { switch ($this->transStatus[$index]) {
case self::TR_PEND: case self::TR_PEND:
$this->exec("ROLLBACK TRANSACTION TO SAVEPOINT arsse_".$index); $this->exec("ROLLBACK TRANSACTION TO SAVEPOINT arsse_".$index);
$this->exec("RELEASE SAVEPOINT arsse_".$index); $this->exec("RELEASE SAVEPOINT arsse_".$index);
$this->transStatus[$index] = self::TR_ROLLBACK; $this->transStatus[$index] = self::TR_ROLLBACK;
$a = $index; $a = $index;
while(++$a && $a <= $this->transDepth) { while (++$a && $a <= $this->transDepth) {
if($this->transStatus[$a] <= self::TR_PEND) { if ($this->transStatus[$a] <= self::TR_PEND) {
$this->transStatus[$a] = self::TR_PEND_ROLLBACK; $this->transStatus[$a] = self::TR_PEND_ROLLBACK;
} }
} }
@ -114,13 +114,13 @@ abstract class AbstractDriver implements Driver {
default: default:
throw new Exception("unknownSavepointStatus", $this->transStatus[$index]); //@codeCoverageIgnore throw new Exception("unknownSavepointStatus", $this->transStatus[$index]); //@codeCoverageIgnore
} }
if($index==$this->transDepth) { if ($index==$this->transDepth) {
while($this->transDepth > 0 && $this->transStatus[$this->transDepth] > self::TR_PEND) { while ($this->transDepth > 0 && $this->transStatus[$this->transDepth] > self::TR_PEND) {
array_pop($this->transStatus); array_pop($this->transStatus);
$this->transDepth--; $this->transDepth--;
} }
} }
if(!$this->transDepth && $this->locked) { if (!$this->transDepth && $this->locked) {
$this->unlock(true); $this->unlock(true);
$this->locked = false; $this->locked = false;
} }

28
lib/Db/AbstractStatement.php

@ -1,15 +1,15 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Db; namespace JKingWeb\Arsse\Db;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
abstract class AbstractStatement implements Statement { abstract class AbstractStatement implements Statement {
protected $types = []; protected $types = [];
protected $isNullable = []; protected $isNullable = [];
protected $values = ['pre' => [], 'post' => []]; protected $values = ['pre' => [], 'post' => []];
abstract function runArray(array $values = []): Result; abstract public function runArray(array $values = []): Result;
public function run(...$values): Result { public function run(...$values): Result {
return $this->runArray($values); return $this->runArray($values);
@ -20,23 +20,23 @@ abstract class AbstractStatement implements Statement {
} }
public function rebindArray(array $bindings, bool $append = false): bool { public function rebindArray(array $bindings, bool $append = false): bool {
if(!$append) { if (!$append) {
$this->types = []; $this->types = [];
} }
foreach($bindings as $binding) { foreach ($bindings as $binding) {
if(is_array($binding)) { if (is_array($binding)) {
// recursively flatten any arrays, which may be provided for SET or IN() clauses // recursively flatten any arrays, which may be provided for SET or IN() clauses
$this->rebindArray($binding, true); $this->rebindArray($binding, true);
} else { } else {
$binding = trim(strtolower($binding)); $binding = trim(strtolower($binding));
if(strpos($binding, "strict ")===0) { if (strpos($binding, "strict ")===0) {
// "strict" types' values may never be null; null values will later be cast to the type specified // "strict" types' values may never be null; null values will later be cast to the type specified
$this->isNullable[] = false; $this->isNullable[] = false;
$binding = substr($binding, 7); $binding = substr($binding, 7);
} else { } else {
$this->isNullable[] = true; $this->isNullable[] = true;
} }
if(!array_key_exists($binding, self::TYPES)) { if (!array_key_exists($binding, self::TYPES)) {
throw new Exception("paramTypeInvalid", $binding); // @codeCoverageIgnore throw new Exception("paramTypeInvalid", $binding); // @codeCoverageIgnore
} }
$this->types[] = self::TYPES[$binding]; $this->types[] = self::TYPES[$binding];
@ -46,19 +46,19 @@ abstract class AbstractStatement implements Statement {
} }
protected function cast($v, string $t, bool $nullable) { protected function cast($v, string $t, bool $nullable) {
switch($t) { switch ($t) {
case "date": case "date":
if(is_null($v) && !$nullable) { if (is_null($v) && !$nullable) {
$v = 0; $v = 0;
} }
return Date::transform($v, "date"); return Date::transform($v, "date");
case "time": case "time":
if(is_null($v) && !$nullable) { if (is_null($v) && !$nullable) {
$v = 0; $v = 0;
} }
return Date::transform($v, "time"); return Date::transform($v, "time");
case "datetime": case "datetime":
if(is_null($v) && !$nullable) { if (is_null($v) && !$nullable) {
$v = 0; $v = 0;
} }
return Date::transform($v, "sql"); return Date::transform($v, "sql");
@ -68,11 +68,11 @@ abstract class AbstractStatement implements Statement {
case "binary": case "binary":
case "string": case "string":
case "boolean": case "boolean":
if($t=="binary") { if ($t=="binary") {
$t = "string"; $t = "string";
} }
if($v instanceof \DateTimeInterface) { if ($v instanceof \DateTimeInterface) {
if($t=="string") { if ($t=="string") {
return Date::transform($v, "sql"); return Date::transform($v, "sql");
} else { } else {
$v = $v->getTimestamp(); $v = $v->getTimestamp();

24
lib/Db/Driver.php

@ -9,26 +9,26 @@ interface Driver {
const TR_PEND_COMMIT = -1; const TR_PEND_COMMIT = -1;
const TR_PEND_ROLLBACK = -2; const TR_PEND_ROLLBACK = -2;
function __construct(); public function __construct();
// returns a human-friendly name for the driver (for display in installer, for example) // returns a human-friendly name for the driver (for display in installer, for example)
static function driverName(): string; public static function driverName(): string;
// returns the version of the scheme of the opened database; if uninitialized should return 0 // returns the version of the scheme of the opened database; if uninitialized should return 0
function schemaVersion(): int; public function schemaVersion(): int;
// return a Transaction object // return a Transaction object
function begin(bool $lock = false): Transaction; public function begin(bool $lock = false): Transaction;
// manually begin a real or synthetic transactions, with real or synthetic nesting // manually begin a real or synthetic transactions, with real or synthetic nesting
function savepointCreate(): int; public function savepointCreate(): int;
// manually commit either the latest or all pending nested transactions // manually commit either the latest or all pending nested transactions
function savepointRelease(int $index = null): bool; public function savepointRelease(int $index = null): bool;
// manually rollback either the latest or all pending nested transactions // manually rollback either the latest or all pending nested transactions
function savepointUndo(int $index = null): bool; public function savepointUndo(int $index = null): bool;
// attempt to perform an in-place upgrade of the database schema; this may be a no-op which always throws an exception // attempt to perform an in-place upgrade of the database schema; this may be a no-op which always throws an exception
function schemaUpdate(int $to): bool; public function schemaUpdate(int $to): bool;
// execute one or more unsanitized SQL queries and return an indication of success // execute one or more unsanitized SQL queries and return an indication of success
function exec(string $query): bool; public function exec(string $query): bool;
// perform a single unsanitized query and return a result set // perform a single unsanitized query and return a result set
function query(string $query): Result; public function query(string $query): Result;
// ready a prepared statement for later execution // ready a prepared statement for later execution
function prepare(string $query, ...$paramType): Statement; public function prepare(string $query, ...$paramType): Statement;
function prepareArray(string $query, array $paramTypes): Statement; public function prepareArray(string $query, array $paramTypes): Statement;
} }

20
lib/Db/Result.php

@ -3,16 +3,16 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\Db; namespace JKingWeb\Arsse\Db;
interface Result extends \Iterator { interface Result extends \Iterator {
function current(); public function current();
function key(); public function key();
function next(); public function next();
function rewind(); public function rewind();
function valid(); public function valid();
function getRow(); public function getRow();
function getAll(): array; public function getAll(): array;
function getValue(); public function getValue();
function changes(); public function changes();
function lastId(); public function lastId();
} }

49
lib/Db/SQLite3/Driver.php

@ -1,12 +1,12 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Db\SQLite3; namespace JKingWeb\Arsse\Db\SQLite3;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Db\Exception; use JKingWeb\Arsse\Db\Exception;
use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\ExceptionTimeout; use JKingWeb\Arsse\Db\ExceptionTimeout;
class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
use ExceptionBuilder; use ExceptionBuilder;
@ -18,11 +18,11 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
public function __construct() { public function __construct() {
// check to make sure required extension is loaded // check to make sure required extension is loaded
if(!class_exists("SQLite3")) { if (!class_exists("SQLite3")) {
throw new Exception("extMissing", self::driverName()); // @codeCoverageIgnore throw new Exception("extMissing", self::driverName()); // @codeCoverageIgnore
} }
$dbFile = Arsse::$conf->dbSQLite3File; $dbFile = Arsse::$conf->dbSQLite3File;
if(is_null($dbFile)) { if (is_null($dbFile)) {
// if no database file is specified in the configuration, use a suitable default // if no database file is specified in the configuration, use a suitable default
$dbFile = \JKingWeb\Arsse\BASE."arsse.db"; $dbFile = \JKingWeb\Arsse\BASE."arsse.db";
} }
@ -34,21 +34,21 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
$this->db->enableExceptions(true); $this->db->enableExceptions(true);
$this->exec("PRAGMA journal_mode = wal"); $this->exec("PRAGMA journal_mode = wal");
$this->exec("PRAGMA foreign_keys = yes"); $this->exec("PRAGMA foreign_keys = yes");
} catch(\Throwable $e) { } catch (\Throwable $e) {
// if opening the database doesn't work, check various pre-conditions to find out what the problem might be // if opening the database doesn't work, check various pre-conditions to find out what the problem might be
$files = [ $files = [
$dbFile, // main database file $dbFile, // main database file
$dbFile."-wal", // write-ahead log journal $dbFile."-wal", // write-ahead log journal
$dbFile."-shm", // shared memory index $dbFile."-shm", // shared memory index
]; ];
foreach($files as $file) { foreach ($files as $file) {
if(!file_exists($file) && !is_writable(dirname($file))) { if (!file_exists($file) && !is_writable(dirname($file))) {
throw new Exception("fileUncreatable", $file); throw new Exception("fileUncreatable", $file);
} else if(!is_readable($file) && !is_writable($file)) { } elseif (!is_readable($file) && !is_writable($file)) {
throw new Exception("fileUnusable", $file); throw new Exception("fileUnusable", $file);
} else if(!is_readable($file)) { } elseif (!is_readable($file)) {
throw new Exception("fileUnreadable", $file); throw new Exception("fileUnreadable", $file);
} else if(!is_writable($file)) { } elseif (!is_writable($file)) {
throw new Exception("fileUnwritable", $file); throw new Exception("fileUnwritable", $file);
} }
} }
@ -64,12 +64,15 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
} }
public function __destruct() { public function __destruct() {
try{$this->db->close();} catch(\Exception $e) {} try {
$this->db->close();
} catch (\Exception $e) {
}
unset($this->db); unset($this->db);
} }
static public function driverName(): string { public static function driverName(): string {
return Arsse::$lang->msg("Driver.Db.SQLite3.Name"); return Arsse::$lang->msg("Driver.Db.SQLite3.Name");
} }
@ -79,37 +82,37 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
public function schemaUpdate(int $to, string $basePath = null): bool { public function schemaUpdate(int $to, string $basePath = null): bool {
$ver = $this->schemaVersion(); $ver = $this->schemaVersion();
if(!Arsse::$conf->dbAutoUpdate) { if (!Arsse::$conf->dbAutoUpdate) {
throw new Exception("updateManual", ['version' => $ver, 'driver_name' => $this->driverName()]); throw new Exception("updateManual", ['version' => $ver, 'driver_name' => $this->driverName()]);
} else if($ver >= $to) { } elseif ($ver >= $to) {
throw new Exception("updateTooNew", ['difference' => ($ver - $to), 'current' => $ver, 'target' => $to, 'driver_name' => $this->driverName()]); throw new Exception("updateTooNew", ['difference' => ($ver - $to), 'current' => $ver, 'target' => $to, 'driver_name' => $this->driverName()]);
} }
$sep = \DIRECTORY_SEPARATOR; $sep = \DIRECTORY_SEPARATOR;
$path = ($basePath ?? \JKingWeb\Arsse\BASE."sql").$sep."SQLite3".$sep; $path = ($basePath ?? \JKingWeb\Arsse\BASE."sql").$sep."SQLite3".$sep;
// lock the database // lock the database
$this->savepointCreate(true); $this->savepointCreate(true);
for($a = $this->schemaVersion(); $a < $to; $a++) { for ($a = $this->schemaVersion(); $a < $to; $a++) {
$this->savepointCreate(); $this->savepointCreate();
try { try {
$file = $path.$a.".sql"; $file = $path.$a.".sql";
if(!file_exists($file)) { if (!file_exists($file)) {
throw new Exception("updateFileMissing", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); throw new Exception("updateFileMissing", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]);
} else if(!is_readable($file)) { } elseif (!is_readable($file)) {
throw new Exception("updateFileUnreadable", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); throw new Exception("updateFileUnreadable", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]);
} }
$sql = @file_get_contents($file); $sql = @file_get_contents($file);
if($sql===false) { if ($sql===false) {
throw new Exception("updateFileUnusable", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); // @codeCoverageIgnore throw new Exception("updateFileUnusable", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); // @codeCoverageIgnore
} }
try { try {
$this->exec($sql); $this->exec($sql);
} catch(\Throwable $e) { } catch (\Throwable $e) {
throw new Exception("updateFileError", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a, 'message' => $this->getError()]); throw new Exception("updateFileError", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a, 'message' => $this->getError()]);
} }
if($this->schemaVersion() != $a+1) { if ($this->schemaVersion() != $a+1) {
throw new Exception("updateFileIncomplete", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]); throw new Exception("updateFileIncomplete", ['file' => $file, 'driver_name' => $this->driverName(), 'current' => $a]);
} }
} catch(\Throwable $e) { } catch (\Throwable $e) {
// undo any partial changes from the failed update // undo any partial changes from the failed update
$this->savepointUndo(); $this->savepointUndo();
// commit any successful updates if updating by more than one version // commit any successful updates if updating by more than one version
@ -130,7 +133,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
public function exec(string $query): bool { public function exec(string $query): bool {
try { try {
return (bool) $this->db->exec($query); return (bool) $this->db->exec($query);
} catch(\Exception $e) { } catch (\Exception $e) {
list($excClass, $excMsg, $excData) = $this->exceptionBuild(); list($excClass, $excMsg, $excData) = $this->exceptionBuild();
throw new $excClass($excMsg, $excData); throw new $excClass($excMsg, $excData);
} }
@ -139,7 +142,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
public function query(string $query): \JKingWeb\Arsse\Db\Result { public function query(string $query): \JKingWeb\Arsse\Db\Result {
try { try {
$r = $this->db->query($query); $r = $this->db->query($query);
} catch(\Exception $e) { } catch (\Exception $e) {
list($excClass, $excMsg, $excData) = $this->exceptionBuild(); list($excClass, $excMsg, $excData) = $this->exceptionBuild();
throw new $excClass($excMsg, $excData); throw new $excClass($excMsg, $excData);
} }
@ -151,7 +154,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement {
try { try {
$s = $this->db->prepare($query); $s = $this->db->prepare($query);
} catch(\Exception $e) { } catch (\Exception $e) {
list($excClass, $excMsg, $excData) = $this->exceptionBuild(); list($excClass, $excMsg, $excData) = $this->exceptionBuild();
throw new $excClass($excMsg, $excData); throw new $excClass($excMsg, $excData);
} }

5
lib/Db/SQLite3/ExceptionBuilder.php

@ -1,15 +1,14 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Db\SQLite3; namespace JKingWeb\Arsse\Db\SQLite3;
use JKingWeb\Arsse\Db\Exception; use JKingWeb\Arsse\Db\Exception;
use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\ExceptionTimeout; use JKingWeb\Arsse\Db\ExceptionTimeout;
trait ExceptionBuilder { trait ExceptionBuilder {
public function exceptionBuild() { public function exceptionBuild() {
switch($this->db->lastErrorCode()) { switch ($this->db->lastErrorCode()) {
case self::SQLITE_BUSY: case self::SQLITE_BUSY:
return [ExceptionTimeout::class, 'general', $this->db->lastErrorMsg()]; return [ExceptionTimeout::class, 'general', $this->db->lastErrorMsg()];
case self::SQLITE_CONSTRAINT: case self::SQLITE_CONSTRAINT:

9
lib/Db/SQLite3/Result.php

@ -14,7 +14,7 @@ class Result implements \JKingWeb\Arsse\Db\Result {
public function getValue() { public function getValue() {
$this->next(); $this->next();
if($this->valid()) { if ($this->valid()) {
$keys = array_keys($this->cur); $keys = array_keys($this->cur);
return $this->cur[array_shift($keys)]; return $this->cur[array_shift($keys)];
} }
@ -28,7 +28,7 @@ class Result implements \JKingWeb\Arsse\Db\Result {
public function getAll(): array { public function getAll(): array {
$out = []; $out = [];
foreach($this as $row) { foreach ($this as $row) {
$out [] = $row; $out [] = $row;
} }
return $out; return $out;
@ -52,7 +52,10 @@ class Result implements \JKingWeb\Arsse\Db\Result {
} }
public function __destruct() { public function __destruct() {
try{$this->set->finalize();} catch(\Throwable $e) {} try {
$this->set->finalize();
} catch (\Throwable $e) {
}
unset($this->set); unset($this->set);
} }

18
lib/Db/SQLite3/Statement.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Db\SQLite3; namespace JKingWeb\Arsse\Db\SQLite3;
use JKingWeb\Arsse\Db\Exception; use JKingWeb\Arsse\Db\Exception;
use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\ExceptionTimeout; use JKingWeb\Arsse\Db\ExceptionTimeout;
@ -33,7 +34,10 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
} }
public function __destruct() { public function __destruct() {
try {$this->st->close();} catch(\Throwable $e) {} try {
$this->st->close();
} catch (\Throwable $e) {
}
unset($this->st); unset($this->st);
} }
@ -42,7 +46,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
$this->bindValues($values); $this->bindValues($values);
try { try {
$r = $this->st->execute(); $r = $this->st->execute();
} catch(\Exception $e) { } catch (\Exception $e) {
list($excClass, $excMsg, $excData) = $this->exceptionBuild(); list($excClass, $excMsg, $excData) = $this->exceptionBuild();
throw new $excClass($excMsg, $excData); throw new $excClass($excMsg, $excData);
} }
@ -53,22 +57,22 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
protected function bindValues(array $values, int $offset = 0): int { protected function bindValues(array $values, int $offset = 0): int {
$a = $offset; $a = $offset;
foreach($values as $value) { foreach ($values as $value) {
if(is_array($value)) { if (is_array($value)) {
// recursively flatten any arrays, which may be provided for SET or IN() clauses // recursively flatten any arrays, which may be provided for SET or IN() clauses
$a += $this->bindValues($value, $a); $a += $this->bindValues($value, $a);
} else if(array_key_exists($a,$this->types)) { } elseif (array_key_exists($a, $this->types)) {
// if the parameter type is something other than the known values, this is an error // if the parameter type is something other than the known values, this is an error
assert(array_key_exists($this->types[$a], self::BINDINGS), new Exception("paramTypeUnknown", $this->types[$a])); assert(array_key_exists($this->types[$a], self::BINDINGS), new Exception("paramTypeUnknown", $this->types[$a]));
// if the parameter type is null or the value is null (and the type is nullable), just bind null // if the parameter type is null or the value is null (and the type is nullable), just bind null
if($this->types[$a]=="null" || ($this->isNullable[$a] && is_null($value))) { if ($this->types[$a]=="null" || ($this->isNullable[$a] && is_null($value))) {
$this->st->bindValue($a+1, null, \SQLITE3_NULL); $this->st->bindValue($a+1, null, \SQLITE3_NULL);
} else { } else {
// otherwise cast the value to the right type and bind the result // otherwise cast the value to the right type and bind the result
$type = self::BINDINGS[$this->types[$a]]; $type = self::BINDINGS[$this->types[$a]];
$value = $this->cast($value, $this->types[$a], $this->isNullable[$a]); $value = $this->cast($value, $this->types[$a], $this->isNullable[$a]);
// re-adjust for null casts // re-adjust for null casts
if($value===null) { if ($value===null) {
$type = \SQLITE3_NULL; $type = \SQLITE3_NULL;
} }
// perform binding // perform binding

8
lib/Db/Statement.php

@ -27,8 +27,8 @@ interface Statement {
"bit" => "boolean", "bit" => "boolean",
]; ];
function run(...$values): Result; public function run(...$values): Result;
function runArray(array $values = []): Result; public function runArray(array $values = []): Result;
function rebind(...$bindings): bool; public function rebind(...$bindings): bool;
function rebindArray(array $bindings): bool; public function rebindArray(array $bindings): bool;
} }

16
lib/Db/Transaction.php

@ -7,39 +7,39 @@ class Transaction {
protected $pending = false; protected $pending = false;
protected $drv; protected $drv;
function __construct(Driver $drv, bool $lock = false) { public function __construct(Driver $drv, bool $lock = false) {
$this->index = $drv->savepointCreate($lock); $this->index = $drv->savepointCreate($lock);
$this->drv = $drv; $this->drv = $drv;
$this->pending = true; $this->pending = true;
} }
function __destruct() { public function __destruct() {
if($this->pending) { if ($this->pending) {
try { try {
$this->drv->savepointUndo($this->index); $this->drv->savepointUndo($this->index);
} catch(\Throwable $e) { } catch (\Throwable $e) {
// do nothing // do nothing
} }
} }
} }
function commit(): bool { public function commit(): bool {
$out = $this->drv->savepointRelease($this->index); $out = $this->drv->savepointRelease($this->index);
$this->pending = false; $this->pending = false;
return $out; return $out;
} }
function rollback(): bool { public function rollback(): bool {
$out = $this->drv->savepointUndo($this->index); $out = $this->drv->savepointUndo($this->index);
$this->pending = false; $this->pending = false;
return $out; return $out;
} }
function getIndex(): int { public function getIndex(): int {
return $this->index; return $this->index;
} }
function isPending(): bool { public function isPending(): bool {
return $this->pending; return $this->pending;
} }
} }

111
lib/Feed.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
use PicoFeed\PicoFeedException; use PicoFeed\PicoFeedException;
use PicoFeed\Config\Config; use PicoFeed\Config\Config;
@ -38,25 +39,25 @@ class Feed {
$this->download($url, $lastModified, $etag, $username, $password); $this->download($url, $lastModified, $etag, $username, $password);
// format the HTTP Last-Modified date returned // format the HTTP Last-Modified date returned
$lastMod = $this->resource->getLastModified(); $lastMod = $this->resource->getLastModified();
if(strlen($lastMod)) { if (strlen($lastMod)) {
$this->lastModified = Date::normalize($lastMod, "http"); $this->lastModified = Date::normalize($lastMod, "http");
} }
$this->modified = $this->resource->isModified(); $this->modified = $this->resource->isModified();
//parse the feed, if it has been modified //parse the feed, if it has been modified
if($this->modified) { if ($this->modified) {
$this->parse(); $this->parse();
// ascertain whether there are any articles not in the database // ascertain whether there are any articles not in the database
$this->matchToDatabase($feedID); $this->matchToDatabase($feedID);
// if caching header fields are not sent by the server, try to ascertain a last-modified date from the feed contents // if caching header fields are not sent by the server, try to ascertain a last-modified date from the feed contents
if(!$this->lastModified) { if (!$this->lastModified) {
$this->lastModified = $this->computeLastModified(); $this->lastModified = $this->computeLastModified();
} }
// we only really care if articles have been modified; if there are no new articles, act as if the feed is unchanged // we only really care if articles have been modified; if there are no new articles, act as if the feed is unchanged
if(!sizeof($this->newItems) && !sizeof($this->changedItems)) { if (!sizeof($this->newItems) && !sizeof($this->changedItems)) {
$this->modified = false; $this->modified = false;
} }
// if requested, scrape full content for any new and changed items // if requested, scrape full content for any new and changed items
if($scrape) { if ($scrape) {
$this->scrape(); $this->scrape();
} }
} }
@ -107,19 +108,19 @@ class Feed {
// id doesn't exist. // id doesn't exist.
$content = $f->content.$f->enclosureUrl.$f->enclosureType; $content = $f->content.$f->enclosureUrl.$f->enclosureType;
// if the item link URL and item title are both equal to the feed link URL, then the item has neither a link URL nor a title // if the item link URL and item title are both equal to the feed link URL, then the item has neither a link URL nor a title
if($f->url==$feed->siteUrl && $f->title==$feed->siteUrl) { if ($f->url==$feed->siteUrl && $f->title==$feed->siteUrl) {
$f->urlTitleHash = ""; $f->urlTitleHash = "";
} else { } else {
$f->urlTitleHash = hash('sha256', $f->url.$f->title); $f->urlTitleHash = hash('sha256', $f->url.$f->title);
} }
// if the item link URL is equal to the feed link URL, it has no link URL; if there is additionally no content, these should not be hashed // if the item link URL is equal to the feed link URL, it has no link URL; if there is additionally no content, these should not be hashed
if(!strlen($content) && $f->url==$feed->siteUrl) { if (!strlen($content) && $f->url==$feed->siteUrl) {
$f->urlContentHash = ""; $f->urlContentHash = "";
} else { } else {
$f->urlContentHash = hash('sha256', $f->url.$content); $f->urlContentHash = hash('sha256', $f->url.$content);
} }
// if the item's title is the same as its link URL, it has no title; if there is additionally no content, these should not be hashed // if the item's title is the same as its link URL, it has no title; if there is additionally no content, these should not be hashed
if(!strlen($content) && $f->title==$f->url) { if (!strlen($content) && $f->title==$f->url) {
$f->titleContentHash = ""; $f->titleContentHash = "";
} else { } else {
$f->titleContentHash = hash('sha256', $f->title.$content); $f->titleContentHash = hash('sha256', $f->title.$content);
@ -128,44 +129,44 @@ class Feed {
// prefer an Atom ID as the item's ID // prefer an Atom ID as the item's ID
$id = (string) $f->xml->children('http://www.w3.org/2005/Atom')->id; $id = (string) $f->xml->children('http://www.w3.org/2005/Atom')->id;
// otherwise use the RSS2 guid element // otherwise use the RSS2 guid element
if(!strlen($id)) { if (!strlen($id)) {
$id = (string) $f->xml->guid; $id = (string) $f->xml->guid;
} }
// otherwise use the Dublin Core identifier element // otherwise use the Dublin Core identifier element
if(!strlen($id)) { if (!strlen($id)) {
$id = (string) $f->xml->children('http://purl.org/dc/elements/1.1/')->identifier; $id = (string) $f->xml->children('http://purl.org/dc/elements/1.1/')->identifier;
} }
// otherwise there is no ID; if there is one, hash it // otherwise there is no ID; if there is one, hash it
if(strlen($id)) { if (strlen($id)) {
$f->id = hash('sha256', $id); $f->id = hash('sha256', $id);
} }
// PicoFeed also doesn't gather up categories, so we do this as well // PicoFeed also doesn't gather up categories, so we do this as well
$f->categories = []; $f->categories = [];
// first add Atom categories // first add Atom categories
foreach($f->xml->children('http://www.w3.org/2005/Atom')->category as $c) { foreach ($f->xml->children('http://www.w3.org/2005/Atom')->category as $c) {
// if the category has a label, use that // if the category has a label, use that
$name = (string) $c->attributes()->label; $name = (string) $c->attributes()->label;
// otherwise use the term // otherwise use the term
if(!strlen($name)) { if (!strlen($name)) {
$name = (string) $c->attributes()->term; $name = (string) $c->attributes()->term;
} }
// ... assuming it has that much // ... assuming it has that much
if(strlen($name)) { if (strlen($name)) {
$f->categories[] = $name; $f->categories[] = $name;
} }
} }
// next add RSS2 categories // next add RSS2 categories
foreach($f->xml->children()->category as $c) { foreach ($f->xml->children()->category as $c) {
$name = (string) $c; $name = (string) $c;
if(strlen($name)) { if (strlen($name)) {
$f->categories[] = $name; $f->categories[] = $name;
} }
} }
// and finally try Dublin Core subjects // and finally try Dublin Core subjects
foreach($f->xml->children('http://purl.org/dc/elements/1.1/')->subject as $c) { foreach ($f->xml->children('http://purl.org/dc/elements/1.1/')->subject as $c) {
$name = (string) $c; $name = (string) $c;
if(strlen($name)) { if (strlen($name)) {
$f->categories[] = $name; $f->categories[] = $name;
} }
} }
@ -184,20 +185,20 @@ class Feed {
present within the feed. present within the feed.
*/ */
$out = []; $out = [];
foreach($items as $item) { foreach ($items as $item) {
foreach($out as $index => $check) { foreach ($out as $index => $check) {
// if the two items both have IDs and they differ, they do not match, regardless of hashes // if the two items both have IDs and they differ, they do not match, regardless of hashes
if($item->id && $check->id && $item->id != $check->id) { if ($item->id && $check->id && $item->id != $check->id) {
continue; continue;
} }
// if the two items have the same ID or any one hash matches, they are two versions of the same item // if the two items have the same ID or any one hash matches, they are two versions of the same item
if( if (
($item->id && $check->id && $item->id == $check->id) || ($item->id && $check->id && $item->id == $check->id) ||
($item->urlTitleHash && $item->urlTitleHash == $check->urlTitleHash) || ($item->urlTitleHash && $item->urlTitleHash == $check->urlTitleHash) ||
($item->urlContentHash && $item->urlContentHash == $check->urlContentHash) || ($item->urlContentHash && $item->urlContentHash == $check->urlContentHash) ||
($item->titleContentHash && $item->titleContentHash == $check->titleContentHash) ($item->titleContentHash && $item->titleContentHash == $check->titleContentHash)
) { ) {
if(// because newsfeeds are usually order newest-first, the later item should only be used if... if (// because newsfeeds are usually order newest-first, the later item should only be used if...
// the later item has an update date and the existing item does not // the later item has an update date and the existing item does not
($item->updatedDate && !$check->updatedDate) || ($item->updatedDate && !$check->updatedDate) ||
// the later item has an update date newer than the existing item's // the later item has an update date newer than the existing item's
@ -224,7 +225,7 @@ class Feed {
// first perform deduplication on items // first perform deduplication on items
$items = $this->deduplicateItems($this->data->items); $items = $this->deduplicateItems($this->data->items);
// if we haven't been given a database feed ID to check against, all items are new // if we haven't been given a database feed ID to check against, all items are new
if(is_null($feedID)) { if (is_null($feedID)) {
$this->newItems = $items; $this->newItems = $items;
return true; return true;
} }
@ -232,20 +233,20 @@ class Feed {
$articles = Arsse::$db->feedMatchLatest($feedID, sizeof($items))->getAll(); $articles = Arsse::$db->feedMatchLatest($feedID, sizeof($items))->getAll();
// perform a first pass matching the latest articles against items in the feed // perform a first pass matching the latest articles against items in the feed
list($this->newItems, $this->changedItems) = $this->matchItems($items, $articles); list($this->newItems, $this->changedItems) = $this->matchItems($items, $articles);
if(sizeof($this->newItems) && sizeof($items) <= sizeof($articles)) { if (sizeof($this->newItems) && sizeof($items) <= sizeof($articles)) {
// if we need to, perform a second pass on the database looking specifically for IDs and hashes of the new items // if we need to, perform a second pass on the database looking specifically for IDs and hashes of the new items
$ids = $hashesUT = $hashesUC = $hashesTC = []; $ids = $hashesUT = $hashesUC = $hashesTC = [];
foreach($this->newItems as $i) { foreach ($this->newItems as $i) {
if($i->id) { if ($i->id) {
$ids[] = $i->id; $ids[] = $i->id;
} }
if($i->urlTitleHash) { if ($i->urlTitleHash) {
$hashesUT[] = $i->urlTitleHash; $hashesUT[] = $i->urlTitleHash;
} }
if($i->urlContentHash) { if ($i->urlContentHash) {
$hashesUC[] = $i->urlContentHash; $hashesUC[] = $i->urlContentHash;
} }
if($i->titleContentHash) { if ($i->titleContentHash) {
$hashesTC[] = $i->titleContentHash; $hashesTC[] = $i->titleContentHash;
} }
} }
@ -260,14 +261,14 @@ class Feed {
protected function matchItems(array $items, array $articles): array { protected function matchItems(array $items, array $articles): array {
$new = $edited = []; $new = $edited = [];
// iterate through the articles and for each determine whether it is existing, edited, or entirely new // iterate through the articles and for each determine whether it is existing, edited, or entirely new
foreach($items as $i) { foreach ($items as $i) {
$found = false; $found = false;
foreach($articles as $a) { foreach ($articles as $a) {
// if the item has an ID and it doesn't match the article ID, the two don't match, regardless of hashes // if the item has an ID and it doesn't match the article ID, the two don't match, regardless of hashes
if($i->id && $i->id !== $a['guid']) { if ($i->id && $i->id !== $a['guid']) {
continue; continue;
} }
if( if (
// the item matches if the GUID matches... // the item matches if the GUID matches...
($i->id && $i->id === $a['guid']) || ($i->id && $i->id === $a['guid']) ||
// ... or if any one of the hashes match // ... or if any one of the hashes match
@ -275,13 +276,13 @@ class Feed {
($i->urlContentHash && $i->urlContentHash === $a['url_content_hash']) || ($i->urlContentHash && $i->urlContentHash === $a['url_content_hash']) ||
($i->titleContentHash && $i->titleContentHash === $a['title_content_hash']) ($i->titleContentHash && $i->titleContentHash === $a['title_content_hash'])
) { ) {
if($i->updatedDate && Date::transform($i->updatedDate, "sql") !== $a['edited']) { if ($i->updatedDate && Date::transform($i->updatedDate, "sql") !== $a['edited']) {
// if the item has an edit timestamp and it doesn't match that of the article in the database, the the article has been edited // if the item has an edit timestamp and it doesn't match that of the article in the database, the the article has been edited
// we store the item index and database record ID as a key/value pair // we store the item index and database record ID as a key/value pair
$found = true; $found = true;
$edited[$a['id']] = $i; $edited[$a['id']] = $i;
break; break;
} else if($i->urlTitleHash !== $a['url_title_hash'] || $i->urlContentHash !== $a['url_content_hash'] || $i->titleContentHash !== $a['title_content_hash']) { } elseif ($i->urlTitleHash !== $a['url_title_hash'] || $i->urlContentHash !== $a['url_content_hash'] || $i->titleContentHash !== $a['title_content_hash']) {
// if any of the hashes do not match, then the article has been edited // if any of the hashes do not match, then the article has been edited
$found = true; $found = true;
$edited[$a['id']] = $i; $edited[$a['id']] = $i;
@ -293,7 +294,7 @@ class Feed {
} }
} }
} }
if(!$found) { if (!$found) {
$new[] = $i; $new[] = $i;
} }
} }
@ -302,7 +303,7 @@ class Feed {
protected function computeNextFetch(): \DateTime { protected function computeNextFetch(): \DateTime {
$now = Date::normalize(time()); $now = Date::normalize(time());
if(!$this->modified) { if (!$this->modified) {
$diff = $now->getTimestamp() - $this->lastModified->getTimestamp(); $diff = $now->getTimestamp() - $this->lastModified->getTimestamp();
$offset = $this->normalizeDateDiff($diff); $offset = $this->normalizeDateDiff($diff);
$now->modify("+".$offset); $now->modify("+".$offset);
@ -313,14 +314,14 @@ class Feed {
// interval is "less than 30m"). If there is no commonality, the feed is checked in 1 hour. // interval is "less than 30m"). If there is no commonality, the feed is checked in 1 hour.
$offsets = []; $offsets = [];
$dates = $this->gatherDates(); $dates = $this->gatherDates();
if(sizeof($dates) > 3) { if (sizeof($dates) > 3) {
for($a = 0; $a < 3; $a++) { for ($a = 0; $a < 3; $a++) {
$diff = $dates[$a] - $dates[$a+1]; $diff = $dates[$a] - $dates[$a+1];
$offsets[] = $this->normalizeDateDiff($diff); $offsets[] = $this->normalizeDateDiff($diff);
} }
if($offsets[0]==$offsets[1] || $offsets[0]==$offsets[2]) { if ($offsets[0]==$offsets[1] || $offsets[0]==$offsets[2]) {
$now->modify("+".$offsets[0]); $now->modify("+".$offsets[0]);
} else if($offsets[1]==$offsets[2]) { } elseif ($offsets[1]==$offsets[2]) {
$now->modify("+".$offsets[1]); $now->modify("+".$offsets[1]);
} else { } else {
$now->modify("+ 1 hour"); $now->modify("+ 1 hour");
@ -333,9 +334,9 @@ class Feed {
} }
public static function nextFetchOnError($errCount): \DateTime { public static function nextFetchOnError($errCount): \DateTime {
if($errCount < 3) { if ($errCount < 3) {
$offset = "5 minutes"; $offset = "5 minutes";
} else if($errCount < 15) { } elseif ($errCount < 15) {
$offset = "3 hours"; $offset = "3 hours";
} else { } else {
$offset = "1 day"; $offset = "1 day";
@ -344,13 +345,13 @@ class Feed {
} }
protected function normalizeDateDiff(int $diff): string { protected function normalizeDateDiff(int $diff): string {
if($diff < (30 * 60)) { // less than 30 minutes if ($diff < (30 * 60)) { // less than 30 minutes
$offset = "15 minutes"; $offset = "15 minutes";
} else if($diff < (60 * 60)) { // less than an hour } elseif ($diff < (60 * 60)) { // less than an hour
$offset = "30 minutes"; $offset = "30 minutes";
} else if($diff < (3 * 60 * 60)) { // less than three hours } elseif ($diff < (3 * 60 * 60)) { // less than three hours
$offset = "1 hour"; $offset = "1 hour";
} else if($diff >= (36 * 60 * 60)) { // more than 36 hours } elseif ($diff >= (36 * 60 * 60)) { // more than 36 hours
$offset = "1 day"; $offset = "1 day";
} else { } else {
$offset = "3 hours"; $offset = "3 hours";
@ -359,11 +360,11 @@ class Feed {
} }
protected function computeLastModified() { protected function computeLastModified() {
if(!$this->modified) { if (!$this->modified) {
return $this->lastModified; return $this->lastModified;
} }
$dates = $this->gatherDates(); $dates = $this->gatherDates();
if(sizeof($dates)) { if (sizeof($dates)) {
return Date::normalize($dates[0]); return Date::normalize($dates[0]);
} else { } else {
return null; return null;
@ -372,11 +373,11 @@ class Feed {
protected function gatherDates(): array { protected function gatherDates(): array {
$dates = []; $dates = [];
foreach($this->data->items as $item) { foreach ($this->data->items as $item) {
if($item->updatedDate) { if ($item->updatedDate) {
$dates[] = $item->updatedDate->getTimestamp(); $dates[] = $item->updatedDate->getTimestamp();
} }
if($item->publishedDate) { if ($item->publishedDate) {
$dates[] = $item->publishedDate->getTimestamp(); $dates[] = $item->publishedDate->getTimestamp();
} }
} }
@ -387,10 +388,10 @@ class Feed {
protected function scrape(): bool { protected function scrape(): bool {
$scraper = new Scraper($this->config); $scraper = new Scraper($this->config);
foreach(array_merge($this->newItems, $this->changedItems) as $item) { foreach (array_merge($this->newItems, $this->changedItems) as $item) {
$scraper->setUrl($item->url); $scraper->setUrl($item->url);
$scraper->execute(); $scraper->execute();
if($scraper->hasRelevantContent()) { if ($scraper->hasRelevantContent()) {
$item->content = $scraper->getFilteredContent(); $item->content = $scraper->getFilteredContent();
} }
} }

76
lib/Lang.php

@ -16,34 +16,34 @@ class Lang {
]; ];
public $path; // path to locale files; this is a public property to facilitate unit testing public $path; // path to locale files; this is a public property to facilitate unit testing
static protected $requirementsMet = false; // whether the Intl extension is loaded protected static $requirementsMet = false; // whether the Intl extension is loaded
protected $synched = false; // whether the wanted locale is actually loaded (lazy loading is used by default) protected $synched = false; // whether the wanted locale is actually loaded (lazy loading is used by default)
protected $wanted = self::DEFAULT; // the currently requested locale protected $wanted = self::DEFAULT; // the currently requested locale
protected $locale = ""; // the currently loaded locale protected $locale = ""; // the currently loaded locale
protected $loaded = []; // the cascade of loaded locale file names protected $loaded = []; // the cascade of loaded locale file names
protected $strings = self::REQUIRED; // the loaded locale strings, merged protected $strings = self::REQUIRED; // the loaded locale strings, merged
function __construct(string $path = BASE."locale".DIRECTORY_SEPARATOR) { public function __construct(string $path = BASE."locale".DIRECTORY_SEPARATOR) {
$this->path = $path; $this->path = $path;
} }
public function set(string $locale, bool $immediate = false): string { public function set(string $locale, bool $immediate = false): string {
// make sure the Intl extension is loaded // make sure the Intl extension is loaded
if(!static::$requirementsMet) { if (!static::$requirementsMet) {
static::checkRequirements(); static::checkRequirements();
} }
// if requesting the same locale as already wanted, just return (but load first if we've requested an immediate load) // if requesting the same locale as already wanted, just return (but load first if we've requested an immediate load)
if($locale==$this->wanted) { if ($locale==$this->wanted) {
if($immediate && !$this->synched) { if ($immediate && !$this->synched) {
$this->load(); $this->load();
} }
return $locale; return $locale;
} }
// if we've requested a locale other than the null locale, fetch the list of available files and find the closest match e.g. en_ca_somedialect -> en_ca // if we've requested a locale other than the null locale, fetch the list of available files and find the closest match e.g. en_ca_somedialect -> en_ca
if($locale != "") { if ($locale != "") {
$list = $this->listFiles(); $list = $this->listFiles();
// if the default locale is unavailable, this is (for now) an error // if the default locale is unavailable, this is (for now) an error
if(!in_array(self::DEFAULT, $list)) { if (!in_array(self::DEFAULT, $list)) {
throw new Lang\Exception("defaultFileMissing", self::DEFAULT); throw new Lang\Exception("defaultFileMissing", self::DEFAULT);
} }
$this->wanted = $this->match($locale, $list); $this->wanted = $this->match($locale, $list);
@ -52,7 +52,7 @@ class Lang {
} }
$this->synched = false; $this->synched = false;
// load right now if asked to, otherwise load later when actually required // load right now if asked to, otherwise load later when actually required
if($immediate) { if ($immediate) {
$this->load(); $this->load();
} }
return $this->wanted; return $this->wanted;
@ -73,29 +73,33 @@ class Lang {
public function __invoke(string $msgID, $vars = null): string { public function __invoke(string $msgID, $vars = null): string {
// if we're trying to load the system default language and it fails, we have a chicken and egg problem, so we catch the exception and load no language file instead // if we're trying to load the system default language and it fails, we have a chicken and egg problem, so we catch the exception and load no language file instead
if(!$this->synched) try {$this->load();} catch(Lang\Exception $e) { if (!$this->synched) {
if($this->wanted==self::DEFAULT) { try {
$this->load();
} catch (Lang\Exception $e) {
if ($this->wanted==self::DEFAULT) {
$this->set("", true); $this->set("", true);
} else { } else {
throw $e; throw $e;
} }
} }
}
// if the requested message is not present in any of the currently loaded language files, throw an exception // if the requested message is not present in any of the currently loaded language files, throw an exception
// note that this is indicative of a programming error since the default locale should have all strings // note that this is indicative of a programming error since the default locale should have all strings
if(!array_key_exists($msgID, $this->strings)) { if (!array_key_exists($msgID, $this->strings)) {
throw new Lang\Exception("stringMissing", ['msgID' => $msgID, 'fileList' => implode(", ",$this->loaded)]); throw new Lang\Exception("stringMissing", ['msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]);
} }
$msg = $this->strings[$msgID]; $msg = $this->strings[$msgID];
// variables fed to MessageFormatter must be contained in an array // variables fed to MessageFormatter must be contained in an array
if($vars===null) { if ($vars===null) {
// even though strings not given parameters will not get formatted, we do not optimize this case away: we still want to catch invalid strings // even though strings not given parameters will not get formatted, we do not optimize this case away: we still want to catch invalid strings
$vars = []; $vars = [];
} else if(!is_array($vars)) { } elseif (!is_array($vars)) {
$vars = [$vars]; $vars = [$vars];
} }
$msg = \MessageFormatter::formatMessage($this->locale, $msg, $vars); $msg = \MessageFormatter::formatMessage($this->locale, $msg, $vars);
if($msg===false) { if ($msg===false) {
throw new Lang\Exception("stringInvalid", ['msgID' => $msgID, 'fileList' => implode(", ",$this->loaded)]); throw new Lang\Exception("stringInvalid", ['msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]);
} }
return $msg; return $msg;
} }
@ -103,22 +107,22 @@ class Lang {
public function list(string $locale = ""): array { public function list(string $locale = ""): array {
$out = []; $out = [];
$files = $this->listFiles(); $files = $this->listFiles();
foreach($files as $tag) { foreach ($files as $tag) {
$out[$tag] = \Locale::getDisplayName($tag, ($locale=="") ? $tag : $locale); $out[$tag] = \Locale::getDisplayName($tag, ($locale=="") ? $tag : $locale);
} }
return $out; return $out;
} }
public function match(string $locale, array $list = null): string { public function match(string $locale, array $list = null): string {
if($list===null) { if ($list===null) {
$list = $this->listFiles(); $list = $this->listFiles();
} }
$default = ($this->locale=="") ? self::DEFAULT : $this->locale; $default = ($this->locale=="") ? self::DEFAULT : $this->locale;
return \Locale::lookup($list,$locale, true, $default); return \Locale::lookup($list, $locale, true, $default);
} }
static protected function checkRequirements(): bool { protected static function checkRequirements(): bool {
if(!extension_loaded("intl")) { if (!extension_loaded("intl")) {
throw new ExceptionFatal("The \"Intl\" extension is required, but not loaded"); throw new ExceptionFatal("The \"Intl\" extension is required, but not loaded");
} }
static::$requirementsMet = true; static::$requirementsMet = true;
@ -133,22 +137,22 @@ class Lang {
protected function listFiles(): array { protected function listFiles(): array {
$out = $this->globFiles($this->path."*.php"); $out = $this->globFiles($this->path."*.php");
// trim the returned file paths to return just the language tag // trim the returned file paths to return just the language tag
$out = array_map(function($file) { $out = array_map(function ($file) {
$file = str_replace(DIRECTORY_SEPARATOR, "/", $file); // we replace the directory separator because we don't use native paths in testing $file = str_replace(DIRECTORY_SEPARATOR, "/", $file); // we replace the directory separator because we don't use native paths in testing
$file = substr($file, strrpos($file, "/")+1); $file = substr($file, strrpos($file, "/")+1);
return strtolower(substr($file,0,strrpos($file,"."))); return strtolower(substr($file, 0, strrpos($file, ".")));
},$out); }, $out);
// sort the results // sort the results
natsort($out); natsort($out);
return $out; return $out;
} }
protected function load(): bool { protected function load(): bool {
if(!self::$requirementsMet) { if (!self::$requirementsMet) {
self::checkRequirements(); self::checkRequirements();
} }
// if we've requested no locale (""), just load the fallback strings and return // if we've requested no locale (""), just load the fallback strings and return
if($this->wanted=="") { if ($this->wanted=="") {
$this->strings = self::REQUIRED; $this->strings = self::REQUIRED;
$this->locale = $this->wanted; $this->locale = $this->wanted;
$this->synched = true; $this->synched = true;
@ -157,27 +161,27 @@ class Lang {
// decompose the requested locale from specific to general, building a list of files to load // decompose the requested locale from specific to general, building a list of files to load
$tags = \Locale::parseLocale($this->wanted); $tags = \Locale::parseLocale($this->wanted);
$files = []; $files = [];
while(sizeof($tags) > 0) { while (sizeof($tags) > 0) {
$files[] = strtolower(\Locale::composeLocale($tags)); $files[] = strtolower(\Locale::composeLocale($tags));
$tag = array_pop($tags); $tag = array_pop($tags);
} }
// include the default locale as the base if the most general locale requested is not the default // include the default locale as the base if the most general locale requested is not the default
if($tag != self::DEFAULT) { if ($tag != self::DEFAULT) {
$files[] = self::DEFAULT; $files[] = self::DEFAULT;
} }
// save the list of files to be loaded for later reference // save the list of files to be loaded for later reference
$loaded = $files; $loaded = $files;
// reduce the list of files to be loaded to the minimum necessary (e.g. if we go from "fr" to "fr_ca", we don't need to load "fr" or "en") // reduce the list of files to be loaded to the minimum necessary (e.g. if we go from "fr" to "fr_ca", we don't need to load "fr" or "en")
$files = []; $files = [];
foreach($loaded as $file) { foreach ($loaded as $file) {
if($file==$this->locale) { if ($file==$this->locale) {
break; break;
} }
$files[] = $file; $files[] = $file;
} }
// if we need to load all files, start with the fallback strings // if we need to load all files, start with the fallback strings
$strings = []; $strings = [];
if($files==$loaded) { if ($files==$loaded) {
$strings[] = self::REQUIRED; $strings[] = self::REQUIRED;
} else { } else {
// otherwise start with the strings we already have if we're going from e.g. "fr" to "fr_ca" // otherwise start with the strings we already have if we're going from e.g. "fr" to "fr_ca"
@ -185,22 +189,22 @@ class Lang {
} }
// read files in reverse order // read files in reverse order
$files = array_reverse($files); $files = array_reverse($files);
foreach($files as $file) { foreach ($files as $file) {
if(!file_exists($this->path."$file.php")) { if (!file_exists($this->path."$file.php")) {
throw new Lang\Exception("fileMissing", $file); throw new Lang\Exception("fileMissing", $file);
} else if(!is_readable($this->path."$file.php")) { } elseif (!is_readable($this->path."$file.php")) {
throw new Lang\Exception("fileUnreadable", $file); throw new Lang\Exception("fileUnreadable", $file);
} }
try { try {
// we use output buffering in case the language file is corrupted // we use output buffering in case the language file is corrupted
ob_start(); ob_start();
$arr = (include $this->path."$file.php"); $arr = (include $this->path."$file.php");
} catch(\Throwable $e) { } catch (\Throwable $e) {
$arr = null; $arr = null;
} finally { } finally {
ob_end_clean(); ob_end_clean();
} }
if(!is_array($arr)) { if (!is_array($arr)) {
throw new Lang\Exception("fileCorrupt", $file); throw new Lang\Exception("fileCorrupt", $file);
} }
$strings[] = $arr; $strings[] = $arr;

47
lib/Misc/Context.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Misc; namespace JKingWeb\Arsse\Misc;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
class Context { class Context {
@ -23,7 +24,7 @@ class Context {
protected $props = []; protected $props = [];
protected function act(string $prop, int $set, $value) { protected function act(string $prop, int $set, $value) {
if($set) { if ($set) {
$this->props[$prop] = true; $this->props[$prop] = true;
$this->$prop = $value; $this->$prop = $value;
return $this; return $this;
@ -34,17 +35,17 @@ class Context {
protected function cleanArray(array $spec): array { protected function cleanArray(array $spec): array {
$spec = array_values($spec); $spec = array_values($spec);
for($a = 0; $a < sizeof($spec); $a++) { for ($a = 0; $a < sizeof($spec); $a++) {
$id = $spec[$a]; $id = $spec[$a];
if(is_int($id) && $id > -1) { if (is_int($id) && $id > -1) {
continue; continue;
} else if(is_float($id) && !fmod($id, 1) && $id >= 0) { } elseif (is_float($id) && !fmod($id, 1) && $id >= 0) {
$spec[$a] = (int) $id; $spec[$a] = (int) $id;
continue; continue;
} else if(is_string($id)) { } elseif (is_string($id)) {
$ch1 = strval(@intval($id)); $ch1 = strval(@intval($id));
$ch2 = strval($id); $ch2 = strval($id);
if($ch1 !== $ch2 || $id < 1) { if ($ch1 !== $ch2 || $id < 1) {
$id = 0; $id = 0;
} }
} else { } else {
@ -55,69 +56,69 @@ class Context {
return array_values(array_filter($spec)); return array_values(array_filter($spec));
} }
function reverse(bool $spec = null) { public function reverse(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function limit(int $spec = null) { public function limit(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function offset(int $spec = null) { public function offset(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function folder(int $spec = null) { public function folder(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function subscription(int $spec = null) { public function subscription(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function latestEdition(int $spec = null) { public function latestEdition(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function oldestEdition(int $spec = null) { public function oldestEdition(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function unread(bool $spec = null) { public function unread(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function starred(bool $spec = null) { public function starred(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function modifiedSince($spec = null) { public function modifiedSince($spec = null) {
$spec = Date::normalize($spec); $spec = Date::normalize($spec);
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function notModifiedSince($spec = null) { public function notModifiedSince($spec = null) {
$spec = Date::normalize($spec); $spec = Date::normalize($spec);
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function edition(int $spec = null) { public function edition(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function article(int $spec = null) { public function article(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function editions(array $spec = null) { public function editions(array $spec = null) {
if($spec) { if ($spec) {
$spec = $this->cleanArray($spec); $spec = $this->cleanArray($spec);
} }
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);
} }
function articles(array $spec = null) { public function articles(array $spec = null) {
if($spec) { if ($spec) {
$spec = $this->cleanArray($spec); $spec = $this->cleanArray($spec);
} }
return $this->act(__FUNCTION__, func_num_args(), $spec); return $this->act(__FUNCTION__, func_num_args(), $spec);

33
lib/Misc/Date.php

@ -3,14 +3,13 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\Misc; namespace JKingWeb\Arsse\Misc;
class Date { class Date {
public static function transform($date, string $outFormat = null, string $inFormat = null, bool $inLocal = false) {
static function transform($date, string $outFormat = null, string $inFormat = null, bool $inLocal = false) {
$date = self::normalize($date, $inFormat, $inLocal); $date = self::normalize($date, $inFormat, $inLocal);
if(is_null($date) || is_null($outFormat)) { if (is_null($date) || is_null($outFormat)) {
return $date; return $date;
} }
$outFormat = strtolower($outFormat); $outFormat = strtolower($outFormat);
if($outFormat=="unix") { if ($outFormat=="unix") {
return $date->getTimestamp(); return $date->getTimestamp();
} }
switch ($outFormat) { switch ($outFormat) {
@ -24,18 +23,18 @@ class Date {
return $date->format($f); return $date->format($f);
} }
static function normalize($date, string $inFormat = null, bool $inLocal = false) { public static function normalize($date, string $inFormat = null, bool $inLocal = false) {
if($date instanceof \DateTimeInterface) { if ($date instanceof \DateTimeInterface) {
return $date; return $date;
} else if(is_numeric($date)) { } elseif (is_numeric($date)) {
$time = (int) $date; $time = (int) $date;
} else if($date===null) { } elseif ($date===null) {
return null; return null;
} else if(is_string($date)) { } elseif (is_string($date)) {
try { try {
$tz = (!$inLocal) ? new \DateTimeZone("UTC") : null; $tz = (!$inLocal) ? new \DateTimeZone("UTC") : null;
if(!is_null($inFormat)) { if (!is_null($inFormat)) {
switch($inFormat) { switch ($inFormat) {
case 'http': $f = "D, d M Y H:i:s \G\M\T"; break; case 'http': $f = "D, d M Y H:i:s \G\M\T"; break;
case 'iso8601': $f = "Y-m-d\TH:i:sP"; break; case 'iso8601': $f = "Y-m-d\TH:i:sP"; break;
case 'sql': $f = "Y-m-d H:i:s"; break; case 'sql': $f = "Y-m-d H:i:s"; break;
@ -47,10 +46,10 @@ class Date {
} else { } else {
return new \DateTime($date, $tz); return new \DateTime($date, $tz);
} }
} catch(\Throwable $e) { } catch (\Throwable $e) {
return null; return null;
} }
} else if (is_bool($date)) { } elseif (is_bool($date)) {
return null; return null;
} else { } else {
$time = (int) $date; $time = (int) $date;
@ -61,17 +60,17 @@ class Date {
return $d; return $d;
} }
static function add(string $interval, $date = null): \DateTimeInterface { public static function add(string $interval, $date = null): \DateTimeInterface {
return self::modify("add", $interval, $date); return self::modify("add", $interval, $date);
} }
static function sub(string $interval, $date = null): \DateTimeInterface { public static function sub(string $interval, $date = null): \DateTimeInterface {
return self::modify("sub", $interval, $date); return self::modify("sub", $interval, $date);
} }
static protected function modify(string $func, string $interval, $date = null): \DateTimeInterface { protected static function modify(string $func, string $interval, $date = null): \DateTimeInterface {
$date = self::normalize($date ?? time()); $date = self::normalize($date ?? time());
if($date instanceof \DateTimeImmutable) { if ($date instanceof \DateTimeImmutable) {
return $date->$func(new \DateInterval($interval)); return $date->$func(new \DateInterval($interval));
} else { } else {
$date->$func(new \DateInterval($interval)); $date->$func(new \DateInterval($interval));

56
lib/Misc/Query.php

@ -18,42 +18,42 @@ class Query {
protected $offset = 0; protected $offset = 0;
function __construct(string $body = "", $types = null, $values = null) { public function __construct(string $body = "", $types = null, $values = null) {
$this->setBody($body, $types, $values); $this->setBody($body, $types, $values);
} }
function setBody(string $body = "", $types = null, $values = null): bool { public function setBody(string $body = "", $types = null, $values = null): bool {
$this->qBody = $body; $this->qBody = $body;
if(!is_null($types)) { if (!is_null($types)) {
$this->tBody[] = $types; $this->tBody[] = $types;
$this->vBody[] = $values; $this->vBody[] = $values;
} }
return true; return true;
} }
function setCTE(string $tableSpec, string $body, $types = null, $values = null, string $join = ''): bool { public function setCTE(string $tableSpec, string $body, $types = null, $values = null, string $join = ''): bool {
$this->qCTE[] = "$tableSpec as ($body)"; $this->qCTE[] = "$tableSpec as ($body)";
if(!is_null($types)) { if (!is_null($types)) {
$this->tCTE[] = $types; $this->tCTE[] = $types;
$this->vCTE[] = $values; $this->vCTE[] = $values;
} }
if(strlen($join)) { // the CTE might only participate in subqueries rather than a join on the main query if (strlen($join)) { // the CTE might only participate in subqueries rather than a join on the main query
$this->jCTE[] = $join; $this->jCTE[] = $join;
} }
return true; return true;
} }
function setWhere(string $where, $types = null, $values = null): bool { public function setWhere(string $where, $types = null, $values = null): bool {
$this->qWhere[] = $where; $this->qWhere[] = $where;
if(!is_null($types)) { if (!is_null($types)) {
$this->tWhere[] = $types; $this->tWhere[] = $types;
$this->vWhere[] = $values; $this->vWhere[] = $values;
} }
return true; return true;
} }
function setOrder(string $order, bool $prepend = false): bool { public function setOrder(string $order, bool $prepend = false): bool {
if($prepend) { if ($prepend) {
array_unshift($this->order, $order); array_unshift($this->order, $order);
} else { } else {
$this->order[] = $order; $this->order[] = $order;
@ -61,13 +61,13 @@ class Query {
return true; return true;
} }
function setLimit(int $limit, int $offset = 0): bool { public function setLimit(int $limit, int $offset = 0): bool {
$this->limit = $limit; $this->limit = $limit;
$this->offset = $offset; $this->offset = $offset;
return true; return true;
} }
function pushCTE(string $tableSpec, string $join = ''): bool { public function pushCTE(string $tableSpec, string $join = ''): bool {
// this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack // this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack
// all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query // all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query
$this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere], [$this->vBody, $this->vWhere]); $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere], [$this->vBody, $this->vWhere]);
@ -78,16 +78,16 @@ class Query {
$this->tWhere = []; $this->tWhere = [];
$this->vWhere = []; $this->vWhere = [];
$this->order = []; $this->order = [];
$this->setLimit(0,0); $this->setLimit(0, 0);
if(strlen($join)) { if (strlen($join)) {
$this->jCTE[] = $join; $this->jCTE[] = $join;
} }
return true; return true;
} }
function __toString(): string { public function __toString(): string {
$out = ""; $out = "";
if(sizeof($this->qCTE)) { if (sizeof($this->qCTE)) {
// start with common table expressions // start with common table expressions
$out .= "WITH RECURSIVE ".implode(", ", $this->qCTE)." "; $out .= "WITH RECURSIVE ".implode(", ", $this->qCTE)." ";
} }
@ -96,31 +96,31 @@ class Query {
return $out; return $out;
} }
function getQuery(): string { public function getQuery(): string {
return $this->__toString(); return $this->__toString();
} }
function getTypes(): array { public function getTypes(): array {
return [$this->tCTE, $this->tBody, $this->tWhere]; return [$this->tCTE, $this->tBody, $this->tWhere];
} }
function getValues(): array { public function getValues(): array {
return [$this->vCTE, $this->vBody, $this->vWhere]; return [$this->vCTE, $this->vBody, $this->vWhere];
} }
function getWhereTypes(): array { public function getWhereTypes(): array {
return $this->tWhere; return $this->tWhere;
} }
function getWhereValues(): array { public function getWhereValues(): array {
return $this->vWhere; return $this->vWhere;
} }
function getCTETypes(): array { public function getCTETypes(): array {
return $this->tCTE; return $this->tCTE;
} }
function getCTEValues(): array { public function getCTEValues(): array {
return $this->vCTE; return $this->vCTE;
} }
@ -128,22 +128,22 @@ class Query {
$out = ""; $out = "";
// add the body // add the body
$out .= $this->qBody; $out .= $this->qBody;
if(sizeof($this->qCTE)) { if (sizeof($this->qCTE)) {
// add any joins against CTEs // add any joins against CTEs
$out .= " ".implode(" ", $this->jCTE); $out .= " ".implode(" ", $this->jCTE);
} }
// add any WHERE terms // add any WHERE terms
if(sizeof($this->qWhere)) { if (sizeof($this->qWhere)) {
$out .= " WHERE ".implode(" AND ", $this->qWhere); $out .= " WHERE ".implode(" AND ", $this->qWhere);
} }
// add any ORDER BY terms // add any ORDER BY terms
if(sizeof($this->order)) { if (sizeof($this->order)) {
$out .= " ORDER BY ".implode(", ", $this->order); $out .= " ORDER BY ".implode(", ", $this->order);
} }
// add LIMIT and OFFSET if the former is specified // add LIMIT and OFFSET if the former is specified
if($this->limit > 0) { if ($this->limit > 0) {
$out .= " LIMIT ".$this->limit; $out .= " LIMIT ".$this->limit;
if($this->offset > 0) { if ($this->offset > 0) {
$out .= " OFFSET ".$this->offset; $out .= " OFFSET ".$this->offset;
} }
} }

18
lib/REST.php

@ -27,27 +27,29 @@ class REST {
// CommaFeed https://www.commafeed.com/api/ // CommaFeed https://www.commafeed.com/api/
]; ];
function __construct() { public function __construct() {
} }
function dispatch(REST\Request $req = null): REST\Response { public function dispatch(REST\Request $req = null): REST\Response {
if($req===null) { if ($req===null) {
$req = new REST\Request(); $req = new REST\Request();
} }
$api = $this->apiMatch($req->url, $this->apis); $api = $this->apiMatch($req->url, $this->apis);
$req->url = substr($req->url,strlen($this->apis[$api]['strip'])); $req->url = substr($req->url, strlen($this->apis[$api]['strip']));
$req->refreshURL(); $req->refreshURL();
$class = $this->apis[$api]['class']; $class = $this->apis[$api]['class'];
$drv = new $class(); $drv = new $class();
return $drv->dispatch($req); return $drv->dispatch($req);
} }
function apiMatch(string $url, array $map): string { public function apiMatch(string $url, array $map): string {
// sort the API list so the longest URL prefixes come first // sort the API list so the longest URL prefixes come first
uasort($map, function($a, $b) {return (strlen($a['match']) <=> strlen($b['match'])) * -1;}); uasort($map, function ($a, $b) {
return (strlen($a['match']) <=> strlen($b['match'])) * -1;
});
// find a match // find a match
foreach($map as $id => $api) { foreach ($map as $id => $api) {
if(strpos($url, $api['match'])===0) { if (strpos($url, $api['match'])===0) {
return $id; return $id;
} }
} }

42
lib/REST/AbstractHandler.php

@ -1,17 +1,17 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\REST; namespace JKingWeb\Arsse\REST;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
abstract class AbstractHandler implements Handler { abstract class AbstractHandler implements Handler {
abstract public function __construct();
abstract function __construct(); abstract public function dispatch(Request $req): Response;
abstract function dispatch(Request $req): Response;
protected function fieldMapNames(array $data, array $map): array { protected function fieldMapNames(array $data, array $map): array {
$out = []; $out = [];
foreach($map as $to => $from) { foreach ($map as $to => $from) {
if(array_key_exists($from, $data)) { if (array_key_exists($from, $data)) {
$out[$to] = $data[$from]; $out[$to] = $data[$from];
} }
} }
@ -19,9 +19,9 @@ abstract class AbstractHandler implements Handler {
} }
protected function fieldMapTypes(array $data, array $map, string $dateFormat = "sql"): array { protected function fieldMapTypes(array $data, array $map, string $dateFormat = "sql"): array {
foreach($map as $key => $type) { foreach ($map as $key => $type) {
if(array_key_exists($key, $data)) { if (array_key_exists($key, $data)) {
if($type=="datetime" && $dateFormat != "sql") { if ($type=="datetime" && $dateFormat != "sql") {
$data[$key] = Date::transform($data[$key], $dateFormat, "sql"); $data[$key] = Date::transform($data[$key], $dateFormat, "sql");
} else { } else {
settype($data[$key], $type); settype($data[$key], $type);
@ -39,18 +39,18 @@ abstract class AbstractHandler implements Handler {
protected function NormalizeInput(array $data, array $types, string $dateFormat = null): array { protected function NormalizeInput(array $data, array $types, string $dateFormat = null): array {
$out = []; $out = [];
foreach($data as $key => $value) { foreach ($data as $key => $value) {
if(!isset($types[$key])) { if (!isset($types[$key])) {
$out[$key] = $value; $out[$key] = $value;
continue; continue;
} }
if(is_null($value)) { if (is_null($value)) {
$out[$key] = null; $out[$key] = null;
continue; continue;
} }
switch($types[$key]) { switch ($types[$key]) {
case "int": case "int":
if($this->validateInt($value)) { if ($this->validateInt($value)) {
$out[$key] = (int) $value; $out[$key] = (int) $value;
} }
break; break;
@ -58,31 +58,31 @@ abstract class AbstractHandler implements Handler {
$out[$key] = (string) $value; $out[$key] = (string) $value;
break; break;
case "bool": case "bool":
if(is_bool($value)) { if (is_bool($value)) {
$out[$key] = $value; $out[$key] = $value;
} else if($this->validateInt($value)) { } elseif ($this->validateInt($value)) {
$value = (int) $value; $value = (int) $value;
if($value > -1 && $value < 2) { if ($value > -1 && $value < 2) {
$out[$key] = $value; $out[$key] = $value;
} }
} else if(is_string($value)) { } elseif (is_string($value)) {
$value = trim(strtolower($value)); $value = trim(strtolower($value));
if($value=="false") { if ($value=="false") {
$out[$key] = false; $out[$key] = false;
} }
if($value=="true") { if ($value=="true") {
$out[$key] = true; $out[$key] = true;
} }
} }
break; break;
case "float": case "float":
if(is_numeric($value)) { if (is_numeric($value)) {
$out[$key] = (float) $value; $out[$key] = (float) $value;
} }
break; break;
case "datetime": case "datetime":
$t = Date::normalize($value, $dateFormat); $t = Date::normalize($value, $dateFormat);
if($t) { if ($t) {
$out[$key] = $t; $out[$key] = $t;
} }
break; break;

4
lib/REST/Handler.php

@ -3,6 +3,6 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\REST; namespace JKingWeb\Arsse\REST;
interface Handler { interface Handler {
function __construct(); public function __construct();
function dispatch(Request $req): Response; public function dispatch(Request $req): Response;
} }

146
lib/REST/NextCloudNews/V1_2.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\REST\NextCloudNews; namespace JKingWeb\Arsse\REST\NextCloudNews;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User; use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Service;
@ -36,22 +37,22 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// 'items' => "array int", // just pass these through // 'items' => "array int", // just pass these through
]; ];
function __construct() { public function __construct() {
} }
function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response {
// try to authenticate // try to authenticate
if(!Arsse::$user->authHTTP()) { if (!Arsse::$user->authHTTP()) {
return new Response(401, "", "", ['WWW-Authenticate: Basic realm="'.self::REALM.'"']); return new Response(401, "", "", ['WWW-Authenticate: Basic realm="'.self::REALM.'"']);
} }
// normalize the input // normalize the input
if($req->body) { if ($req->body) {
// if the entity body is not JSON according to content type, return "415 Unsupported Media Type" // if the entity body is not JSON according to content type, return "415 Unsupported Media Type"
if(!preg_match("<^application/json\b|^$>", $req->type)) { if (!preg_match("<^application/json\b|^$>", $req->type)) {
return new Response(415, "", "", ['Accept: application/json']); return new Response(415, "", "", ['Accept: application/json']);
} }
$data = @json_decode($req->body, true); $data = @json_decode($req->body, true);
if(json_last_error() != \JSON_ERROR_NONE) { if (json_last_error() != \JSON_ERROR_NONE) {
// if the body could not be parsed as JSON, return "400 Bad Request" // if the body could not be parsed as JSON, return "400 Bad Request"
return new Response(400); return new Response(400);
} }
@ -66,21 +67,21 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// check to make sure the requested function is implemented // check to make sure the requested function is implemented
try { try {
$func = $this->chooseCall($req->paths, $req->method); $func = $this->chooseCall($req->paths, $req->method);
} catch(Exception501 $e) { } catch (Exception501 $e) {
return new Response(501); return new Response(501);
} catch(Exception405 $e) { } catch (Exception405 $e) {
return new Response(405, "", "", ["Allow: ".$e->getMessage()]); return new Response(405, "", "", ["Allow: ".$e->getMessage()]);
} }
if(!method_exists($this, $func)) { if (!method_exists($this, $func)) {
return new Response(501); return new Response(501);
} }
// dispatch // dispatch
try { try {
return $this->$func($req->paths, $data); return $this->$func($req->paths, $data);
} catch(Exception $e) { } catch (Exception $e) {
// if there was a REST exception return 400 // if there was a REST exception return 400
return new Response(400); return new Response(400);
} catch(AbstractException $e) { } catch (AbstractException $e) {
// if there was any other Arsse exception return 500 // if there was any other Arsse exception return 500
return new Response(500); return new Response(500);
} }
@ -133,27 +134,27 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// the first path element is the overall scope of the request // the first path element is the overall scope of the request
$scope = $url[0]; $scope = $url[0];
// any URL components which are only digits should be replaced with "0", for easier comparison (integer segments are IDs, and we don't care about the specific ID) // any URL components which are only digits should be replaced with "0", for easier comparison (integer segments are IDs, and we don't care about the specific ID)
for($a = 0; $a < sizeof($url); $a++) { for ($a = 0; $a < sizeof($url); $a++) {
if($this->validateInt($url[$a])) { if ($this->validateInt($url[$a])) {
$url[$a] = "0"; $url[$a] = "0";
} }
} }
// normalize the HTTP method to uppercase // normalize the HTTP method to uppercase
$method = strtoupper($method); $method = strtoupper($method);
// if the scope is not supported, return 501 // if the scope is not supported, return 501
if(!array_key_exists($scope, $choices)) { if (!array_key_exists($scope, $choices)) {
throw new Exception501(); throw new Exception501();
} }
// we now evaluate the supplied URL against every supported path for the selected scope // we now evaluate the supplied URL against every supported path for the selected scope
// the URL is evaluated as an array so as to avoid decoded escapes turning invalid URLs into valid ones // the URL is evaluated as an array so as to avoid decoded escapes turning invalid URLs into valid ones
foreach($choices[$scope] as $path => $funcs) { foreach ($choices[$scope] as $path => $funcs) {
// add the scope to the path to match against and split it // add the scope to the path to match against and split it
$path = (string) $path; $path = (string) $path;
$path = (strlen($path)) ? "$scope/$path" : $scope; $path = (strlen($path)) ? "$scope/$path" : $scope;
$path = explode("/", $path); $path = explode("/", $path);
if($path===$url) { if ($path===$url) {
// if the path matches, make sure the method is allowed // if the path matches, make sure the method is allowed
if(array_key_exists($method,$funcs)) { if (array_key_exists($method, $funcs)) {
// if it is allowed, return the object method to run // if it is allowed, return the object method to run
return $funcs[$method]; return $funcs[$method];
} else { } else {
@ -230,8 +231,8 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function folderAdd(array $url, array $data): Response { protected function folderAdd(array $url, array $data): Response {
try { try {
$folder = Arsse::$db->folderAdd(Arsse::$user->id, $data); $folder = Arsse::$db->folderAdd(Arsse::$user->id, $data);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
switch($e->getCode()) { switch ($e->getCode()) {
// folder already exists // folder already exists
case 10236: return new Response(409); case 10236: return new Response(409);
// folder name not acceptable // folder name not acceptable
@ -250,7 +251,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// perform the deletion // perform the deletion
try { try {
Arsse::$db->folderRemove(Arsse::$user->id, (int) $url[1]); Arsse::$db->folderRemove(Arsse::$user->id, (int) $url[1]);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
// folder does not exist // folder does not exist
return new Response(404); return new Response(404);
} }
@ -260,14 +261,14 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// rename a folder (also supports moving nesting folders, but this is not a feature of the API) // 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): Response {
// there must be some change to be made // there must be some change to be made
if(!sizeof($data)) { if (!sizeof($data)) {
return new Response(422); return new Response(422);
} }
// perform the edit // perform the edit
try { try {
Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $url[1], $data); Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $url[1], $data);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
switch($e->getCode()) { switch ($e->getCode()) {
// folder does not exist // folder does not exist
case 10239: return new Response(404); case 10239: return new Response(404);
// folder already exists // folder already exists
@ -285,7 +286,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// mark all articles associated with a folder as read // mark all articles associated with a folder as read
protected function folderMarkRead(array $url, array $data): Response { protected function folderMarkRead(array $url, array $data): Response {
$c = new Context; $c = new Context;
if(isset($data['newestItemId'])) { if (isset($data['newestItemId'])) {
// if the item ID is valid (i.e. an integer), add it to the context // if the item ID is valid (i.e. an integer), add it to the context
$c->latestEdition($data['newestItemId']); $c->latestEdition($data['newestItemId']);
} else { } else {
@ -297,7 +298,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// perform the operation // perform the operation
try { try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
// folder does not exist // folder does not exist
return new Response(404); return new Response(404);
} }
@ -307,13 +308,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// return list of feeds which should be refreshed // return list of feeds which should be refreshed
protected function feedListStale(array $url, array $data): Response { protected function feedListStale(array $url, array $data): Response {
// function requires admin rights per spec // function requires admin rights per spec
if(Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) {
return new Response(403); return new Response(403);
} }
// list stale feeds which should be checked for updates // list stale feeds which should be checked for updates
$feeds = Arsse::$db->feedListStale(); $feeds = Arsse::$db->feedListStale();
$out = []; $out = [];
foreach($feeds as $feed) { foreach ($feeds as $feed) {
// since in our implementation feeds don't belong the users, the 'userId' field will always be an empty string // since in our implementation feeds don't belong the users, the 'userId' field will always be an empty string
$out[] = ['id' => $feed, 'userId' => ""]; $out[] = ['id' => $feed, 'userId' => ""];
} }
@ -323,16 +324,16 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// refresh a feed // refresh a feed
protected function feedUpdate(array $url, array $data): Response { protected function feedUpdate(array $url, array $data): Response {
// function requires admin rights per spec // function requires admin rights per spec
if(Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) {
return new Response(403); return new Response(403);
} }
// perform an update of a single feed // perform an update of a single feed
if(!isset($data['feedId'])) { if (!isset($data['feedId'])) {
return new Response(422); return new Response(422);
} }
try { try {
Arsse::$db->feedUpdate($data['feedId']); Arsse::$db->feedUpdate($data['feedId']);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
return new Response(404); return new Response(404);
} }
return new Response(204); return new Response(204);
@ -341,7 +342,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// add a new feed // add a new feed
protected function subscriptionAdd(array $url, array $data): Response { protected function subscriptionAdd(array $url, array $data): Response {
// normalize the feed URL // normalize the feed URL
if(!isset($data['url'])) { if (!isset($data['url'])) {
return new Response(422); return new Response(422);
} }
// normalize the folder ID, if specified; zero should be transformed to null // normalize the folder ID, if specified; zero should be transformed to null
@ -350,18 +351,19 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
$tr = Arsse::$db->begin(); $tr = Arsse::$db->begin();
try { try {
$id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $data['url']); $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $data['url']);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
// feed already exists // feed already exists
return new Response(409); return new Response(409);
} catch(FeedException $e) { } catch (FeedException $e) {
// feed could not be retrieved // feed could not be retrieved
return new Response(422); return new Response(422);
} }
// if a folder was specified, move the feed to the correct folder; silently ignore errors // if a folder was specified, move the feed to the correct folder; silently ignore errors
if($folder) { if ($folder) {
try { try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $folder]); Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $folder]);
} catch(ExceptionInput $e) {} } catch (ExceptionInput $e) {
}
} }
$tr->commit(); $tr->commit();
// fetch the feed's metadata and format it appropriately // fetch the feed's metadata and format it appropriately
@ -369,7 +371,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
$feed = $this->feedTranslate($feed); $feed = $this->feedTranslate($feed);
$out = ['feeds' => [$feed]]; $out = ['feeds' => [$feed]];
$newest = Arsse::$db->editionLatest(Arsse::$user->id, (new Context)->subscription($id)); $newest = Arsse::$db->editionLatest(Arsse::$user->id, (new Context)->subscription($id));
if($newest) { if ($newest) {
$out['newestItemId'] = $newest; $out['newestItemId'] = $newest;
} }
return new Response(200, $out); return new Response(200, $out);
@ -379,13 +381,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function subscriptionList(array $url, array $data): Response { protected function subscriptionList(array $url, array $data): Response {
$subs = Arsse::$db->subscriptionList(Arsse::$user->id); $subs = Arsse::$db->subscriptionList(Arsse::$user->id);
$out = []; $out = [];
foreach($subs as $sub) { foreach ($subs as $sub) {
$out[] = $this->feedTranslate($sub); $out[] = $this->feedTranslate($sub);
} }
$out = ['feeds' => $out]; $out = ['feeds' => $out];
$out['starredCount'] = Arsse::$db->articleStarredCount(Arsse::$user->id); $out['starredCount'] = Arsse::$db->articleStarredCount(Arsse::$user->id);
$newest = Arsse::$db->editionLatest(Arsse::$user->id); $newest = Arsse::$db->editionLatest(Arsse::$user->id);
if($newest) { if ($newest) {
$out['newestItemId'] = $newest; $out['newestItemId'] = $newest;
} }
return new Response(200, $out); return new Response(200, $out);
@ -395,7 +397,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function subscriptionRemove(array $url, array $data): Response { protected function subscriptionRemove(array $url, array $data): Response {
try { try {
Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $url[1]); Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $url[1]);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
// feed does not exist // feed does not exist
return new Response(404); return new Response(404);
} }
@ -406,7 +408,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function subscriptionRename(array $url, array $data): Response { protected function subscriptionRename(array $url, array $data): Response {
// normalize input // normalize input
$in = []; $in = [];
if(array_key_exists('feedTitle', $data)) { // we use array_key_exists because null is a valid input if (array_key_exists('feedTitle', $data)) { // we use array_key_exists because null is a valid input
$in['title'] = $data['feedTitle']; $in['title'] = $data['feedTitle'];
} else { } else {
return new Response(422); return new Response(422);
@ -414,8 +416,8 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// perform the renaming // perform the renaming
try { try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], $in); Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], $in);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
switch($e->getCode()) { switch ($e->getCode()) {
// subscription does not exist // subscription does not exist
case 10239: return new Response(404); case 10239: return new Response(404);
// name is invalid // name is invalid
@ -432,7 +434,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function subscriptionMove(array $url, array $data): Response { protected function subscriptionMove(array $url, array $data): Response {
// normalize input // normalize input
$in = []; $in = [];
if(isset($data['folderId'])) { if (isset($data['folderId'])) {
$in['folder'] = $data['folderId'] ? $data['folderId'] : null; $in['folder'] = $data['folderId'] ? $data['folderId'] : null;
} else { } else {
return new Response(422); return new Response(422);
@ -440,8 +442,8 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// perform the move // perform the move
try { try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], $in); Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $url[1], $in);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
switch($e->getCode()) { switch ($e->getCode()) {
// subscription does not exist // subscription does not exist
case 10239: return new Response(404); case 10239: return new Response(404);
// folder does not exist // folder does not exist
@ -456,7 +458,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// mark all articles associated with a subscription as read // mark all articles associated with a subscription as read
protected function subscriptionMarkRead(array $url, array $data): Response { protected function subscriptionMarkRead(array $url, array $data): Response {
$c = new Context; $c = new Context;
if(isset($data['newestItemId'])) { if (isset($data['newestItemId'])) {
$c->latestEdition($data['newestItemId']); $c->latestEdition($data['newestItemId']);
} else { } else {
// otherwise return an error // otherwise return an error
@ -467,7 +469,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// perform the operation // perform the operation
try { try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c); Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
// subscription does not exist // subscription does not exist
return new Response(404); return new Response(404);
} }
@ -479,39 +481,39 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// set the context options supplied by the client // set the context options supplied by the client
$c = new Context; $c = new Context;
// set the batch size // set the batch size
if(isset($data['batchSize']) && $data['batchSize'] > 0) { if (isset($data['batchSize']) && $data['batchSize'] > 0) {
$c->limit($data['batchSize']); $c->limit($data['batchSize']);
} }
// set the order of returned items // set the order of returned items
if(isset($data['oldestFirst']) && $data['oldestFirst']) { if (isset($data['oldestFirst']) && $data['oldestFirst']) {
$c->reverse(false); $c->reverse(false);
} else { } else {
$c->reverse(true); $c->reverse(true);
} }
// set the edition mark-off; the database uses an or-equal comparison for internal consistency, but the protocol does not, so we must adjust by one // set the edition mark-off; the database uses an or-equal comparison for internal consistency, but the protocol does not, so we must adjust by one
if(isset($data['offset']) && $data['offset'] > 0) { if (isset($data['offset']) && $data['offset'] > 0) {
if($c->reverse) { if ($c->reverse) {
$c->latestEdition($data['offset'] - 1); $c->latestEdition($data['offset'] - 1);
} else { } else {
$c->oldestEdition($data['offset'] + 1); $c->oldestEdition($data['offset'] + 1);
} }
} }
// set whether to only return unread // set whether to only return unread
if(isset($data['getRead']) && !$data['getRead']) { if (isset($data['getRead']) && !$data['getRead']) {
$c->unread(true); $c->unread(true);
} }
// if no type is specified assume 3 (All) // if no type is specified assume 3 (All)
if(!isset($data['type'])) { if (!isset($data['type'])) {
$data['type'] = 3; $data['type'] = 3;
} }
switch($data['type']) { switch ($data['type']) {
case 0: // feed case 0: // feed
if(isset($data['id'])) { if (isset($data['id'])) {
$c->subscription($data['id']); $c->subscription($data['id']);
} }
break; break;
case 1: // folder case 1: // folder
if(isset($data['id'])) { if (isset($data['id'])) {
$c->folder($data['id']); $c->folder($data['id']);
} }
break; break;
@ -522,18 +524,18 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// return all items // return all items
} }
// whether to return only updated items // whether to return only updated items
if(isset($data['lastModified'])) { if (isset($data['lastModified'])) {
$c->modifiedSince($data['lastModified']); $c->modifiedSince($data['lastModified']);
} }
// perform the fetch // perform the fetch
try { try {
$items = Arsse::$db->articleList(Arsse::$user->id, $c); $items = Arsse::$db->articleList(Arsse::$user->id, $c);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
// ID of subscription or folder is not valid // ID of subscription or folder is not valid
return new Response(422); return new Response(422);
} }
$out = []; $out = [];
foreach($items as $item) { foreach ($items as $item) {
$out[] = $this->articleTranslate($item); $out[] = $this->articleTranslate($item);
} }
$out = ['items' => $out]; $out = ['items' => $out];
@ -543,7 +545,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// mark all articles as read // mark all articles as read
protected function articleMarkReadAll(array $url, array $data): Response { protected function articleMarkReadAll(array $url, array $data): Response {
$c = new Context; $c = new Context;
if(isset($data['newestItemId'])) { if (isset($data['newestItemId'])) {
// set the newest item ID as specified // set the newest item ID as specified
$c->latestEdition($data['newestItemId']); $c->latestEdition($data['newestItemId']);
} else { } else {
@ -564,7 +566,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
$set = ($url[2]=="read"); $set = ($url[2]=="read");
try { try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c); Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
// ID is not valid // ID is not valid
return new Response(404); return new Response(404);
} }
@ -580,7 +582,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
$set = ($url[3]=="star"); $set = ($url[3]=="star");
try { try {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c); Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c);
} catch(ExceptionInput $e) { } catch (ExceptionInput $e) {
// ID is not valid // ID is not valid
return new Response(404); return new Response(404);
} }
@ -592,19 +594,20 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// determine whether to mark read or unread // determine whether to mark read or unread
$set = ($url[1]=="read"); $set = ($url[1]=="read");
// if the input data is not at all valid, return an error // if the input data is not at all valid, return an error
if(!isset($data['items']) || !is_array($data['items'])) { if (!isset($data['items']) || !is_array($data['items'])) {
return new Response(422); return new Response(422);
} }
// start a transaction and loop through the items // start a transaction and loop through the items
$t = Arsse::$db->begin(); $t = Arsse::$db->begin();
$in = array_chunk($data['items'], 50); $in = array_chunk($data['items'], 50);
for($a = 0; $a < sizeof($in); $a++) { for ($a = 0; $a < sizeof($in); $a++) {
// initialize the matching context // initialize the matching context
$c = new Context; $c = new Context;
$c->editions($in[$a]); $c->editions($in[$a]);
try { try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c); Arsse::$db->articleMark(Arsse::$user->id, ['read' => $set], $c);
} catch(ExceptionInput $e) {} } catch (ExceptionInput $e) {
}
} }
$t->commit(); $t->commit();
return new Response(204); return new Response(204);
@ -615,19 +618,20 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// determine whether to mark starred or unstarred // determine whether to mark starred or unstarred
$set = ($url[1]=="star"); $set = ($url[1]=="star");
// if the input data is not at all valid, return an error // if the input data is not at all valid, return an error
if(!isset($data['items']) || !is_array($data['items'])) { if (!isset($data['items']) || !is_array($data['items'])) {
return new Response(422); return new Response(422);
} }
// start a transaction and loop through the items // start a transaction and loop through the items
$t = Arsse::$db->begin(); $t = Arsse::$db->begin();
$in = array_chunk(array_column($data['items'], "guidHash"), 50); $in = array_chunk(array_column($data['items'], "guidHash"), 50);
for($a = 0; $a < sizeof($in); $a++) { for ($a = 0; $a < sizeof($in); $a++) {
// initialize the matching context // initialize the matching context
$c = new Context; $c = new Context;
$c->articles($in[$a]); $c->articles($in[$a]);
try { try {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c); Arsse::$db->articleMark(Arsse::$user->id, ['starred' => $set], $c);
} catch(ExceptionInput $e) {} } catch (ExceptionInput $e) {
}
} }
$t->commit(); $t->commit();
return new Response(204); return new Response(204);
@ -636,7 +640,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function userStatus(array $url, array $data): Response { protected function userStatus(array $url, array $data): Response {
$data = Arsse::$user->propertiesGet(Arsse::$user->id, true); $data = Arsse::$user->propertiesGet(Arsse::$user->id, true);
// construct the avatar structure, if an image is available // construct the avatar structure, if an image is available
if(isset($data['avatar'])) { if (isset($data['avatar'])) {
$avatar = [ $avatar = [
'data' => base64_encode($data['avatar']['data']), 'data' => base64_encode($data['avatar']['data']),
'mime' => $data['avatar']['type'], 'mime' => $data['avatar']['type'],
@ -656,7 +660,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function cleanupBefore(array $url, array $data): Response { protected function cleanupBefore(array $url, array $data): Response {
// function requires admin rights per spec // function requires admin rights per spec
if(Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) {
return new Response(403); return new Response(403);
} }
Service::cleanupPre(); Service::cleanupPre();
@ -665,7 +669,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function cleanupAfter(array $url, array $data): Response { protected function cleanupAfter(array $url, array $data): Response {
// function requires admin rights per spec // function requires admin rights per spec
if(Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) { if (Arsse::$user->rightsGet(Arsse::$user->id)==User::RIGHTS_NONE) {
return new Response(403); return new Response(403);
} }
Service::cleanupPost(); Service::cleanupPost();

9
lib/REST/NextCloudNews/Versions.php

@ -1,18 +1,19 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\REST\NextCloudNews; namespace JKingWeb\Arsse\REST\NextCloudNews;
use JKingWeb\Arsse\REST\Response; use JKingWeb\Arsse\REST\Response;
class Versions implements \JKingWeb\Arsse\REST\Handler { class Versions implements \JKingWeb\Arsse\REST\Handler {
function __construct() { public function __construct() {
} }
function dispatch(\JKingWeb\Arsse\REST\Request $req): Response { public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response {
// if a method other than GET was used, this is an error // if a method other than GET was used, this is an error
if($req->method != "GET") { if ($req->method != "GET") {
return new Response(405); return new Response(405);
} }
if(preg_match("<^/?$>",$req->path)) { if (preg_match("<^/?$>", $req->path)) {
// if the request path is an empty string or just a slash, return the supported versions // if the request path is an empty string or just a slash, return the supported versions
$out = [ $out = [
'apiLevels' => [ 'apiLevels' => [

28
lib/REST/Request.php

@ -11,18 +11,18 @@ class Request {
public $type =""; public $type ="";
public $body = ""; public $body = "";
function __construct(string $method = null, string $url = null, string $body = null, string $contentType = null) { public function __construct(string $method = null, string $url = null, string $body = null, string $contentType = null) {
if(is_null($method)) { if (is_null($method)) {
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
} }
if(is_null($url)) { if (is_null($url)) {
$url = $_SERVER['REQUEST_URI']; $url = $_SERVER['REQUEST_URI'];
} }
if(is_null($body)) { if (is_null($body)) {
$body = file_get_contents("php://input"); $body = file_get_contents("php://input");
} }
if(is_null($contentType)) { if (is_null($contentType)) {
if(isset($_SERVER['HTTP_CONTENT_TYPE'])) { if (isset($_SERVER['HTTP_CONTENT_TYPE'])) {
$contentType = $_SERVER['HTTP_CONTENT_TYPE']; $contentType = $_SERVER['HTTP_CONTENT_TYPE'];
} else { } else {
$contentType = ""; $contentType = "";
@ -47,17 +47,17 @@ class Request {
$parts = explode("?", $url); $parts = explode("?", $url);
$out = ['path' => $parts[0], 'paths' => [''], 'query' => []]; $out = ['path' => $parts[0], 'paths' => [''], 'query' => []];
// if there is a query string, parse it // if there is a query string, parse it
if(isset($parts[1])) { if (isset($parts[1])) {
// split along & to get key-value pairs // split along & to get key-value pairs
$query = explode("&", $parts[1]); $query = explode("&", $parts[1]);
for($a = 0; $a < sizeof($query); $a++) { for ($a = 0; $a < sizeof($query); $a++) {
// split each pair, into no more than two parts // split each pair, into no more than two parts
$data = explode("=", $query[$a], 2); $data = explode("=", $query[$a], 2);
// decode the key // decode the key
$key = rawurldecode($data[0]); $key = rawurldecode($data[0]);
// decode the value if there is one // decode the value if there is one
$value = ""; $value = "";
if(isset($data[1])) { if (isset($data[1])) {
$value = rawurldecode($data[1]); $value = rawurldecode($data[1]);
} }
// add the pair to the query output, overwriting earlier values for the same key, is present // add the pair to the query output, overwriting earlier values for the same key, is present
@ -66,17 +66,19 @@ class Request {
} }
// also include the path as a set of decoded elements // also include the path as a set of decoded elements
// if the path is an empty string or just / nothing needs be done // if the path is an empty string or just / nothing needs be done
if(!in_array($out['path'],["/",""])) { if (!in_array($out['path'], ["/",""])) {
$paths = explode("/", $out['path']); $paths = explode("/", $out['path']);
// remove the first and last empty elements, if present (they are artefacts of the splitting; others should remain) // remove the first and last empty elements, if present (they are artefacts of the splitting; others should remain)
if(!strlen($paths[0])) { if (!strlen($paths[0])) {
array_shift($paths); array_shift($paths);
} }
if(!strlen($paths[sizeof($paths)-1])) { if (!strlen($paths[sizeof($paths)-1])) {
array_pop($paths); array_pop($paths);
} }
// %-decode each path element // %-decode each path element
$paths = array_map(function($v){return rawurldecode($v);}, $paths); $paths = array_map(function ($v) {
return rawurldecode($v);
}, $paths);
$out['paths'] = $paths; $out['paths'] = $paths;
} }
return $out; return $out;

17
lib/REST/Response.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\REST; namespace JKingWeb\Arsse\REST;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
class Response { class Response {
@ -14,34 +15,34 @@ class Response {
public $fields; public $fields;
function __construct(int $code, $payload = null, string $type = self::T_JSON, array $extraFields = []) { public function __construct(int $code, $payload = null, string $type = self::T_JSON, array $extraFields = []) {
$this->code = $code; $this->code = $code;
$this->payload = $payload; $this->payload = $payload;
$this->type = $type; $this->type = $type;
$this->fields = $extraFields; $this->fields = $extraFields;
} }
function output() { public function output() {
if(!headers_sent()) { if (!headers_sent()) {
try { try {
$statusText = Arsse::$lang->msg("HTTP.Status.".$this->code); $statusText = Arsse::$lang->msg("HTTP.Status.".$this->code);
} catch(\JKingWeb\Arsse\Lang\Exception $e) { } catch (\JKingWeb\Arsse\Lang\Exception $e) {
$statusText = ""; $statusText = "";
} }
header("Status: ".$this->code." ".$statusText); header("Status: ".$this->code." ".$statusText);
$body = ""; $body = "";
if(!is_null($this->payload)) { if (!is_null($this->payload)) {
header("Content-Type: ".$this->type); header("Content-Type: ".$this->type);
switch($this->type) { switch ($this->type) {
case self::T_JSON: case self::T_JSON:
$body = (string) json_encode($this->payload,\JSON_PRETTY_PRINT); $body = (string) json_encode($this->payload, \JSON_PRETTY_PRINT);
break; break;
default: default:
$body = (string) $this->payload; $body = (string) $this->payload;
break; break;
} }
} }
foreach($this->fields as $field) { foreach ($this->fields as $field) {
header($field); header($field);
} }
echo $body; echo $body;

31
lib/Service.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
class Service { class Service {
@ -10,11 +11,11 @@ class Service {
/** @var \DateInterval */ /** @var \DateInterval */
protected $interval; protected $interval;
static public function driverList(): array { public static function driverList(): array {
$sep = \DIRECTORY_SEPARATOR; $sep = \DIRECTORY_SEPARATOR;
$path = __DIR__.$sep."Service".$sep; $path = __DIR__.$sep."Service".$sep;
$classes = []; $classes = [];
foreach(glob($path."*".$sep."Driver.php") as $file) { foreach (glob($path."*".$sep."Driver.php") as $file) {
$name = basename(dirname($file)); $name = basename(dirname($file));
$class = NS_BASE."User\\$name\\Driver"; $class = NS_BASE."User\\$name\\Driver";
$classes[$class] = $class::driverName(); $classes[$class] = $class::driverName();
@ -23,26 +24,26 @@ class Service {
} }
public static function interval(): \DateInterval { public static function interval(): \DateInterval {
try{ try {
return new \DateInterval(Arsse::$conf->serviceFrequency); return new \DateInterval(Arsse::$conf->serviceFrequency);
} catch(\Exception $e) { } catch (\Exception $e) {
return new \DateInterval("PT2M"); return new \DateInterval("PT2M");
} }
} }
function __construct() { public function __construct() {
$driver = Arsse::$conf->serviceDriver; $driver = Arsse::$conf->serviceDriver;
$this->drv = new $driver(); $this->drv = new $driver();
$this->interval = static::interval(); $this->interval = static::interval();
} }
function watch(bool $loop = true): \DateTimeInterface { public function watch(bool $loop = true): \DateTimeInterface {
$t = new \DateTime(); $t = new \DateTime();
do { do {
$this->checkIn(); $this->checkIn();
static::cleanupPre(); static::cleanupPre();
$list = Arsse::$db->feedListStale(); $list = Arsse::$db->feedListStale();
if($list) { if ($list) {
$this->drv->queue(...$list); $this->drv->queue(...$list);
$this->drv->exec(); $this->drv->exec();
$this->drv->clean(); $this->drv->clean();
@ -50,23 +51,23 @@ class Service {
} }
static::cleanupPost(); static::cleanupPost();
$t->add($this->interval); $t->add($this->interval);
if($loop) { if ($loop) {
do { do {
@time_sleep_until($t->getTimestamp()); @time_sleep_until($t->getTimestamp());
} while($t->getTimestamp() > time()); } while ($t->getTimestamp() > time());
} }
} while($loop); } while ($loop);
return $t; return $t;
} }
function checkIn(): bool { public function checkIn(): bool {
return Arsse::$db->metaSet("service_last_checkin", time(), "datetime"); return Arsse::$db->metaSet("service_last_checkin", time(), "datetime");
} }
static function hasCheckedIn(): bool { public static function hasCheckedIn(): bool {
$checkin = Arsse::$db->metaGet("service_last_checkin"); $checkin = Arsse::$db->metaGet("service_last_checkin");
// if the service has never checked in, return false // if the service has never checked in, return false
if(!$checkin) { if (!$checkin) {
return false; return false;
} }
// convert the check-in timestamp to a DateTime instance // convert the check-in timestamp to a DateTime instance
@ -81,12 +82,12 @@ class Service {
return ($checkin >= $limit); return ($checkin >= $limit);
} }
static function cleanupPre(): bool { public static function cleanupPre(): bool {
// mark unsubscribed feeds as orphaned and delete orphaned feeds that are beyond their retention period // mark unsubscribed feeds as orphaned and delete orphaned feeds that are beyond their retention period
return Arsse::$db->feedCleanup(); return Arsse::$db->feedCleanup();
} }
static function cleanupPost(): bool { public static function cleanupPost(): bool {
// delete old articles, according to configured threasholds // delete old articles, according to configured threasholds
return Arsse::$db->articleCleanup(); return Arsse::$db->articleCleanup();
} }

17
lib/Service/Curl/Driver.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Service\Curl; namespace JKingWeb\Arsse\Service\Curl;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
class Driver implements \JKingWeb\Arsse\Service\Driver { class Driver implements \JKingWeb\Arsse\Service\Driver {
@ -8,15 +9,15 @@ class Driver implements \JKingWeb\Arsse\Service\Driver {
protected $queue; protected $queue;
protected $handles = []; protected $handles = [];
static function driverName(): string { public static function driverName(): string {
return Arsse::$lang->msg("Driver.Service.Curl.Name"); return Arsse::$lang->msg("Driver.Service.Curl.Name");
} }
static function requirementsMet(): bool { public static function requirementsMet(): bool {
return extension_loaded("curl"); return extension_loaded("curl");
} }
function __construct() { public function __construct() {
//default curl options for individual requests //default curl options for individual requests
$this->options = [ $this->options = [
\CURLOPT_URL => Arsse::$serviceCurlBase."index.php/apps/news/api/v1-2/feeds/update", \CURLOPT_URL => Arsse::$serviceCurlBase."index.php/apps/news/api/v1-2/feeds/update",
@ -42,8 +43,8 @@ class Driver implements \JKingWeb\Arsse\Service\Driver {
curl_multi_setopt($this->queue, \CURLMOPT_PIPELINING, 1); curl_multi_setopt($this->queue, \CURLMOPT_PIPELINING, 1);
} }
function queue(int ...$feeds): int { public function queue(int ...$feeds): int {
foreach($feeds as $id) { foreach ($feeds as $id) {
$h = curl_init(); $h = curl_init();
curl_setopt($h, \CURLOPT_POSTFIELDS, json_encode(['userId' => "", 'feedId' => $id])); curl_setopt($h, \CURLOPT_POSTFIELDS, json_encode(['userId' => "", 'feedId' => $id]));
$this->handles[] = $h; $this->handles[] = $h;
@ -52,7 +53,7 @@ class Driver implements \JKingWeb\Arsse\Service\Driver {
return sizeof($this->handles); return sizeof($this->handles);
} }
function exec(): int { public function exec(): int {
$active = 0; $active = 0;
do { do {
curl_multi_exec($this->queue, $active); curl_multi_exec($this->queue, $active);
@ -61,8 +62,8 @@ class Driver implements \JKingWeb\Arsse\Service\Driver {
return Arsse::$conf->serviceQueueWidth - $active; return Arsse::$conf->serviceQueueWidth - $active;
} }
function clean(): bool { public function clean(): bool {
foreach($this->handles as $h) { foreach ($this->handles as $h) {
curl_multi_remove_handle($this->queue, $h); curl_multi_remove_handle($this->queue, $h);
curl_close($h); curl_close($h);
} }

10
lib/Service/Driver.php

@ -3,9 +3,9 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\Service; namespace JKingWeb\Arsse\Service;
interface Driver { interface Driver {
static function driverName(): string; public static function driverName(): string;
static function requirementsMet(): bool; public static function requirementsMet(): bool;
function queue(int ...$feeds): int; public function queue(int ...$feeds): int;
function exec(): int; public function exec(): int;
function clean(): bool; public function clean(): bool;
} }

17
lib/Service/Forking/Driver.php

@ -1,36 +1,37 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Service\Forking; namespace JKingWeb\Arsse\Service\Forking;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
class Driver implements \JKingWeb\Arsse\Service\Driver { class Driver implements \JKingWeb\Arsse\Service\Driver {
protected $queue = []; protected $queue = [];
static function driverName(): string { public static function driverName(): string {
return Arsse::$lang->msg("Driver.Service.Forking.Name"); return Arsse::$lang->msg("Driver.Service.Forking.Name");
} }
static function requirementsMet(): bool { public static function requirementsMet(): bool {
return function_exists("popen"); return function_exists("popen");
} }
function __construct() { public function __construct() {
} }
function queue(int ...$feeds): int { public function queue(int ...$feeds): int {
$this->queue = array_merge($this->queue, $feeds); $this->queue = array_merge($this->queue, $feeds);
return sizeof($this->queue); return sizeof($this->queue);
} }
function exec(): int { public function exec(): int {
$pp = []; $pp = [];
while($this->queue) { while ($this->queue) {
$id = (int) array_shift($this->queue); $id = (int) array_shift($this->queue);
$php = '"'.\PHP_BINARY.'"'; $php = '"'.\PHP_BINARY.'"';
$arsse = '"'.$_SERVER['argv'][0].'"'; $arsse = '"'.$_SERVER['argv'][0].'"';
array_push($pp, popen("$php $arsse feed refresh $id", "r")); array_push($pp, popen("$php $arsse feed refresh $id", "r"));
} }
while($pp) { while ($pp) {
$p = array_pop($pp); $p = array_pop($pp);
fgets($p); // TODO: log output fgets($p); // TODO: log output
pclose($p); pclose($p);
@ -38,7 +39,7 @@ class Driver implements \JKingWeb\Arsse\Service\Driver {
return Arsse::$conf->serviceQueueWidth - sizeof($this->queue); return Arsse::$conf->serviceQueueWidth - sizeof($this->queue);
} }
function clean(): bool { public function clean(): bool {
$this->queue = []; $this->queue = [];
return true; return true;
} }

15
lib/Service/Internal/Driver.php

@ -1,37 +1,38 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Service\Internal; namespace JKingWeb\Arsse\Service\Internal;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
class Driver implements \JKingWeb\Arsse\Service\Driver { class Driver implements \JKingWeb\Arsse\Service\Driver {
protected $queue = []; protected $queue = [];
static function driverName(): string { public static function driverName(): string {
return Arsse::$lang->msg("Driver.Service.Internal.Name"); return Arsse::$lang->msg("Driver.Service.Internal.Name");
} }
static function requirementsMet(): bool { public static function requirementsMet(): bool {
// this driver has no requirements // this driver has no requirements
return true; return true;
} }
function __construct() { public function __construct() {
} }
function queue(int ...$feeds): int { public function queue(int ...$feeds): int {
$this->queue = array_merge($this->queue, $feeds); $this->queue = array_merge($this->queue, $feeds);
return sizeof($this->queue); return sizeof($this->queue);
} }
function exec(): int { public function exec(): int {
while(sizeof($this->queue)) { while (sizeof($this->queue)) {
$id = array_shift($this->queue); $id = array_shift($this->queue);
Arsse::$db->feedUpdate($id); Arsse::$db->feedUpdate($id);
} }
return Arsse::$conf->serviceQueueWidth - sizeof($this->queue); return Arsse::$conf->serviceQueueWidth - sizeof($this->queue);
} }
function clean(): bool { public function clean(): bool {
$this->queue = []; $this->queue = [];
return true; return true;
} }

143
lib/User.php

@ -19,11 +19,11 @@ class User {
protected $authzSupported = 0; protected $authzSupported = 0;
protected $actor = []; protected $actor = [];
static public function driverList(): array { public static function driverList(): array {
$sep = \DIRECTORY_SEPARATOR; $sep = \DIRECTORY_SEPARATOR;
$path = __DIR__.$sep."User".$sep; $path = __DIR__.$sep."User".$sep;
$classes = []; $classes = [];
foreach(glob($path."*".$sep."Driver.php") as $file) { foreach (glob($path."*".$sep."Driver.php") as $file) {
$name = basename(dirname($file)); $name = basename(dirname($file));
$class = NS_BASE."User\\$name\\Driver"; $class = NS_BASE."User\\$name\\Driver";
$classes[$class] = $class::driverName(); $classes[$class] = $class::driverName();
@ -37,72 +37,72 @@ class User {
} }
public function __toString() { public function __toString() {
if($this->id===null) { if ($this->id===null) {
$this->credentials(); $this->credentials();
} }
return (string) $this->id; return (string) $this->id;
} }
// checks whether the logged in user is authorized to act for the affected user (used especially when granting rights) // checks whether the logged in user is authorized to act for the affected user (used especially when granting rights)
function authorize(string $affectedUser, string $action, int $newRightsLevel = 0): bool { public function authorize(string $affectedUser, string $action, int $newRightsLevel = 0): bool {
// if authorization checks are disabled (either because we're running the installer or the background updater) just return true // if authorization checks are disabled (either because we're running the installer or the background updater) just return true
if(!$this->authorizationEnabled()) { if (!$this->authorizationEnabled()) {
return true; return true;
} }
// if we don't have a logged-in user, fetch credentials // if we don't have a logged-in user, fetch credentials
if($this->id===null) { if ($this->id===null) {
$this->credentials(); $this->credentials();
} }
// if the affected user is the actor and the actor is not trying to grant themselves rights, accept the request // if the affected user is the actor and the actor is not trying to grant themselves rights, accept the request
if($affectedUser==Arsse::$user->id && $action != "userRightsSet") { if ($affectedUser==Arsse::$user->id && $action != "userRightsSet") {
return true; return true;
} }
// if we're authorizing something other than a user function and the affected user is not the actor, make sure the affected user exists // if we're authorizing something other than a user function and the affected user is not the actor, make sure the affected user exists
$this->authorizationEnabled(false); $this->authorizationEnabled(false);
if(Arsse::$user->id != $affectedUser && strpos($action, "user")!==0 && !$this->exists($affectedUser)) { if (Arsse::$user->id != $affectedUser && strpos($action, "user")!==0 && !$this->exists($affectedUser)) {
throw new User\Exception("doesNotExist", ["action" => $action, "user" => $affectedUser]); throw new User\Exception("doesNotExist", ["action" => $action, "user" => $affectedUser]);
} }
$this->authorizationEnabled(true); $this->authorizationEnabled(true);
// get properties of actor if not already available // get properties of actor if not already available
if(!sizeof($this->actor)) { if (!sizeof($this->actor)) {
$this->actor = $this->propertiesGet(Arsse::$user->id); $this->actor = $this->propertiesGet(Arsse::$user->id);
} }
$rights = $this->actor["rights"]; $rights = $this->actor["rights"];
// if actor is a global admin, accept the request // if actor is a global admin, accept the request
if($rights==User\Driver::RIGHTS_GLOBAL_ADMIN) { if ($rights==User\Driver::RIGHTS_GLOBAL_ADMIN) {
return true; return true;
} }
// if actor is a common user, deny the request // if actor is a common user, deny the request
if($rights==User\Driver::RIGHTS_NONE) { if ($rights==User\Driver::RIGHTS_NONE) {
return false; return false;
} }
// if actor is not some other sort of admin, deny the request // if actor is not some other sort of admin, deny the request
if(!in_array($rights,[User\Driver::RIGHTS_GLOBAL_MANAGER,User\Driver::RIGHTS_DOMAIN_MANAGER,User\Driver::RIGHTS_DOMAIN_ADMIN],true)) { if (!in_array($rights, [User\Driver::RIGHTS_GLOBAL_MANAGER,User\Driver::RIGHTS_DOMAIN_MANAGER,User\Driver::RIGHTS_DOMAIN_ADMIN], true)) {
return false; return false;
} }
// if actor is a domain admin/manager and domains don't match, deny the request // if actor is a domain admin/manager and domains don't match, deny the request
if($this->actor["domain"] && $rights != User\Driver::RIGHTS_GLOBAL_MANAGER) { if ($this->actor["domain"] && $rights != User\Driver::RIGHTS_GLOBAL_MANAGER) {
$test = "@".$this->actor["domain"]; $test = "@".$this->actor["domain"];
if(substr($affectedUser,-1*strlen($test)) != $test) { if (substr($affectedUser, -1*strlen($test)) != $test) {
return false; return false;
} }
} }
// certain actions shouldn't check affected user's rights // certain actions shouldn't check affected user's rights
if(in_array($action, ["userRightsGet","userExists","userList"], true)) { if (in_array($action, ["userRightsGet","userExists","userList"], true)) {
return true; return true;
} }
if($action=="userRightsSet") { if ($action=="userRightsSet") {
// setting rights above your own is not allowed // setting rights above your own is not allowed
if($newRightsLevel > $rights) { if ($newRightsLevel > $rights) {
return false; return false;
} }
// setting yourself to rights you already have is harmless and can be allowed // setting yourself to rights you already have is harmless and can be allowed
if($this->id==$affectedUser && $newRightsLevel==$rights) { if ($this->id==$affectedUser && $newRightsLevel==$rights) {
return true; return true;
} }
// managers can only set their own rights, and only to normal user // managers can only set their own rights, and only to normal user
if(in_array($rights, [User\Driver::RIGHTS_DOMAIN_MANAGER, User\Driver::RIGHTS_GLOBAL_MANAGER])) { if (in_array($rights, [User\Driver::RIGHTS_DOMAIN_MANAGER, User\Driver::RIGHTS_GLOBAL_MANAGER])) {
if($this->id != $affectedUser || $newRightsLevel != User\Driver::RIGHTS_NONE) { if ($this->id != $affectedUser || $newRightsLevel != User\Driver::RIGHTS_NONE) {
return false; return false;
} }
return true; return true;
@ -110,20 +110,20 @@ class User {
} }
$affectedRights = $this->rightsGet($affectedUser); $affectedRights = $this->rightsGet($affectedUser);
// managers can only act on themselves (checked above) or regular users // managers can only act on themselves (checked above) or regular users
if(in_array($rights,[User\Driver::RIGHTS_GLOBAL_MANAGER,User\Driver::RIGHTS_DOMAIN_MANAGER]) && $affectedRights != User\Driver::RIGHTS_NONE) { if (in_array($rights, [User\Driver::RIGHTS_GLOBAL_MANAGER,User\Driver::RIGHTS_DOMAIN_MANAGER]) && $affectedRights != User\Driver::RIGHTS_NONE) {
return false; return false;
} }
// domain admins canot act above themselves // domain admins canot act above themselves
if(!in_array($affectedRights,[User\Driver::RIGHTS_NONE,User\Driver::RIGHTS_DOMAIN_MANAGER,User\Driver::RIGHTS_DOMAIN_ADMIN])) { if (!in_array($affectedRights, [User\Driver::RIGHTS_NONE,User\Driver::RIGHTS_DOMAIN_MANAGER,User\Driver::RIGHTS_DOMAIN_ADMIN])) {
return false; return false;
} }
return true; return true;
} }
public function credentials(): array { public function credentials(): array {
if($_SERVER['PHP_AUTH_USER']) { if ($_SERVER['PHP_AUTH_USER']) {
$out = ["user" => $_SERVER['PHP_AUTH_USER'], "password" => $_SERVER['PHP_AUTH_PW']]; $out = ["user" => $_SERVER['PHP_AUTH_USER'], "password" => $_SERVER['PHP_AUTH_PW']];
} else if($_SERVER['REMOTE_USER']) { } elseif ($_SERVER['REMOTE_USER']) {
$out = ["user" => $_SERVER['REMOTE_USER'], "password" => ""]; $out = ["user" => $_SERVER['REMOTE_USER'], "password" => ""];
} else { } else {
$out = ["user" => "", "password" => ""]; $out = ["user" => "", "password" => ""];
@ -133,25 +133,25 @@ class User {
} }
public function auth(string $user = null, string $password = null): bool { public function auth(string $user = null, string $password = null): bool {
if($user===null) { if ($user===null) {
return $this->authHTTP(); return $this->authHTTP();
} else { } else {
$this->id = $user; $this->id = $user;
$this->actor = []; $this->actor = [];
switch($this->u->driverFunctions("auth")) { switch ($this->u->driverFunctions("auth")) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
if(Arsse::$conf->userPreAuth) { if (Arsse::$conf->userPreAuth) {
$out = true; $out = true;
} else { } else {
$out = $this->u->auth($user, $password); $out = $this->u->auth($user, $password);
} }
if($out && !Arsse::$db->userExists($user)) { if ($out && !Arsse::$db->userExists($user)) {
$this->autoProvision($user, $password); $this->autoProvision($user, $password);
} }
return $out; return $out;
case User\Driver::FUNC_INTERNAL: case User\Driver::FUNC_INTERNAL:
if(Arsse::$conf->userPreAuth) { if (Arsse::$conf->userPreAuth) {
if(!Arsse::$db->userExists($user)) { if (!Arsse::$db->userExists($user)) {
$this->autoProvision($user, $password); $this->autoProvision($user, $password);
} }
return true; return true;
@ -166,7 +166,7 @@ class User {
public function authHTTP(): bool { public function authHTTP(): bool {
$cred = $this->credentials(); $cred = $this->credentials();
if(!$cred["user"]) { if (!$cred["user"]) {
return false; return false;
} }
return $this->auth($cred["user"], $cred["password"]); return $this->auth($cred["user"], $cred["password"]);
@ -178,15 +178,15 @@ class User {
public function list(string $domain = null): array { public function list(string $domain = null): array {
$func = "userList"; $func = "userList";
switch($this->u->driverFunctions($func)) { switch ($this->u->driverFunctions($func)) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
// we handle authorization checks for external drivers // we handle authorization checks for external drivers
if($domain===null) { if ($domain===null) {
if(!$this->authorize("@".$domain, $func)) { if (!$this->authorize("@".$domain, $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $domain]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $domain]);
} }
} else { } else {
if(!$this->authorize("", $func)) { if (!$this->authorize("", $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => "all users"]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => "all users"]);
} }
} }
@ -199,11 +199,11 @@ class User {
} }
public function authorizationEnabled(bool $setting = null): bool { public function authorizationEnabled(bool $setting = null): bool {
if(is_null($setting)) { if (is_null($setting)) {
return !$this->authz; return !$this->authz;
} }
$this->authz += ($setting ? -1 : 1); $this->authz += ($setting ? -1 : 1);
if($this->authz < 0) { if ($this->authz < 0) {
$this->authz = 0; $this->authz = 0;
} }
return !$this->authz; return !$this->authz;
@ -211,14 +211,14 @@ class User {
public function exists(string $user): bool { public function exists(string $user): bool {
$func = "userExists"; $func = "userExists";
switch($this->u->driverFunctions($func)) { switch ($this->u->driverFunctions($func)) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
// we handle authorization checks for external drivers // we handle authorization checks for external drivers
if(!$this->authorize($user, $func)) { if (!$this->authorize($user, $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
} }
$out = $this->u->userExists($user); $out = $this->u->userExists($user);
if($out && !Arsse::$db->userExists($user)) { if ($out && !Arsse::$db->userExists($user)) {
$this->autoProvision($user, ""); $this->autoProvision($user, "");
} }
return $out; return $out;
@ -233,15 +233,15 @@ class User {
public function add($user, $password = null): string { public function add($user, $password = null): string {
$func = "userAdd"; $func = "userAdd";
switch($this->u->driverFunctions($func)) { switch ($this->u->driverFunctions($func)) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
// we handle authorization checks for external drivers // we handle authorization checks for external drivers
if(!$this->authorize($user, $func)) { if (!$this->authorize($user, $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
} }
$newPassword = $this->u->userAdd($user, $password); $newPassword = $this->u->userAdd($user, $password);
// if there was no exception and we don't have the user in the internal database, add it // if there was no exception and we don't have the user in the internal database, add it
if(!Arsse::$db->userExists($user)) { if (!Arsse::$db->userExists($user)) {
$this->autoProvision($user, $newPassword); $this->autoProvision($user, $newPassword);
} }
return $newPassword; return $newPassword;
@ -255,16 +255,16 @@ class User {
public function remove(string $user): bool { public function remove(string $user): bool {
$func = "userRemove"; $func = "userRemove";
switch($this->u->driverFunctions($func)) { switch ($this->u->driverFunctions($func)) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
// we handle authorization checks for external drivers // we handle authorization checks for external drivers
if(!$this->authorize($user, $func)) { if (!$this->authorize($user, $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
} }
$out = $this->u->userRemove($user); $out = $this->u->userRemove($user);
if($out && Arsse::$db->userExists($user)) { if ($out && Arsse::$db->userExists($user)) {
// if the user was removed and we have it in our data, remove it there // if the user was removed and we have it in our data, remove it there
if(!Arsse::$db->userExists($user)) { if (!Arsse::$db->userExists($user)) {
Arsse::$db->userRemove($user); Arsse::$db->userRemove($user);
} }
} }
@ -279,14 +279,14 @@ class User {
public function passwordSet(string $user, string $newPassword = null, $oldPassword = null): string { public function passwordSet(string $user, string $newPassword = null, $oldPassword = null): string {
$func = "userPasswordSet"; $func = "userPasswordSet";
switch($this->u->driverFunctions($func)) { switch ($this->u->driverFunctions($func)) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
// we handle authorization checks for external drivers // we handle authorization checks for external drivers
if(!$this->authorize($user, $func)) { if (!$this->authorize($user, $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
} }
$out = $this->u->userPasswordSet($user, $newPassword, $oldPassword); $out = $this->u->userPasswordSet($user, $newPassword, $oldPassword);
if(Arsse::$db->userExists($user)) { if (Arsse::$db->userExists($user)) {
// if the password change was successful and the user exists, set the internal password to the same value // if the password change was successful and the user exists, set the internal password to the same value
Arsse::$db->userPasswordSet($user, $out); Arsse::$db->userPasswordSet($user, $out);
} else { } else {
@ -305,8 +305,8 @@ class User {
public function propertiesGet(string $user, bool $withAvatar = false): array { public function propertiesGet(string $user, bool $withAvatar = false): array {
// prepare default values // prepare default values
$domain = null; $domain = null;
if(strrpos($user,"@")!==false) { if (strrpos($user, "@")!==false) {
$domain = substr($user,strrpos($user,"@")+1); $domain = substr($user, strrpos($user, "@")+1);
} }
$init = [ $init = [
"id" => $user, "id" => $user,
@ -315,19 +315,19 @@ class User {
"domain" => $domain "domain" => $domain
]; ];
$func = "userPropertiesGet"; $func = "userPropertiesGet";
switch($this->u->driverFunctions($func)) { switch ($this->u->driverFunctions($func)) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
// we handle authorization checks for external drivers // we handle authorization checks for external drivers
if(!$this->authorize($user, $func)) { if (!$this->authorize($user, $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
} }
$out = array_merge($init, $this->u->userPropertiesGet($user)); $out = array_merge($init, $this->u->userPropertiesGet($user));
// remove password if it is return (not exhaustive, but...) // remove password if it is return (not exhaustive, but...)
if(array_key_exists('password', $out)) { if (array_key_exists('password', $out)) {
unset($out['password']); unset($out['password']);
} }
// if the user does not exist in the internal database, add it // if the user does not exist in the internal database, add it
if(!Arsse::$db->userExists($user)) { if (!Arsse::$db->userExists($user)) {
$this->autoProvision($user, "", $out); $this->autoProvision($user, "", $out);
} }
return $out; return $out;
@ -342,20 +342,20 @@ class User {
public function propertiesSet(string $user, array $properties): array { public function propertiesSet(string $user, array $properties): array {
// remove from the array any values which should be set specially // remove from the array any values which should be set specially
foreach(['id', 'domain', 'password', 'rights'] as $key) { foreach (['id', 'domain', 'password', 'rights'] as $key) {
if(array_key_exists($key, $properties)) { if (array_key_exists($key, $properties)) {
unset($properties[$key]); unset($properties[$key]);
} }
} }
$func = "userPropertiesSet"; $func = "userPropertiesSet";
switch($this->u->driverFunctions($func)) { switch ($this->u->driverFunctions($func)) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
// we handle authorization checks for external drivers // we handle authorization checks for external drivers
if(!$this->authorize($user, $func)) { if (!$this->authorize($user, $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
} }
$out = $this->u->userPropertiesSet($user, $properties); $out = $this->u->userPropertiesSet($user, $properties);
if(Arsse::$db->userExists($user)) { if (Arsse::$db->userExists($user)) {
// if the property change was successful and the user exists, set the internal properties to the same values // if the property change was successful and the user exists, set the internal properties to the same values
Arsse::$db->userPropertiesSet($user, $out); Arsse::$db->userPropertiesSet($user, $out);
} else { } else {
@ -373,15 +373,15 @@ class User {
public function rightsGet(string $user): int { public function rightsGet(string $user): int {
$func = "userRightsGet"; $func = "userRightsGet";
switch($this->u->driverFunctions($func)) { switch ($this->u->driverFunctions($func)) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
// we handle authorization checks for external drivers // we handle authorization checks for external drivers
if(!$this->authorize($user, $func)) { if (!$this->authorize($user, $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
} }
$out = $this->u->userRightsGet($user); $out = $this->u->userRightsGet($user);
// if the user does not exist in the internal database, add it // if the user does not exist in the internal database, add it
if(!Arsse::$db->userExists($user)) { if (!Arsse::$db->userExists($user)) {
$this->autoProvision($user, "", null, $out); $this->autoProvision($user, "", null, $out);
} }
return $out; return $out;
@ -396,20 +396,20 @@ class User {
public function rightsSet(string $user, int $level): bool { public function rightsSet(string $user, int $level): bool {
$func = "userRightsSet"; $func = "userRightsSet";
switch($this->u->driverFunctions($func)) { switch ($this->u->driverFunctions($func)) {
case User\Driver::FUNC_EXTERNAL: case User\Driver::FUNC_EXTERNAL:
// we handle authorization checks for external drivers // we handle authorization checks for external drivers
if(!$this->authorize($user, $func)) { if (!$this->authorize($user, $func)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]); throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
} }
$out = $this->u->userRightsSet($user, $level); $out = $this->u->userRightsSet($user, $level);
// if the user does not exist in the internal database, add it // if the user does not exist in the internal database, add it
if($out && Arsse::$db->userExists($user)) { if ($out && Arsse::$db->userExists($user)) {
$authz = $this->authorizationEnabled(); $authz = $this->authorizationEnabled();
$this->authorizationEnabled(false); $this->authorizationEnabled(false);
Arsse::$db->userRightsSet($user, $level); Arsse::$db->userRightsSet($user, $level);
$this->authorizationEnabled($authz); $this->authorizationEnabled($authz);
} else if($out) { } elseif ($out) {
$this->autoProvision($user, "", null, $level); $this->autoProvision($user, "", null, $level);
} }
return $out; return $out;
@ -429,13 +429,14 @@ class User {
// set the user rights // set the user rights
Arsse::$db->userRightsSet($user, $rights); Arsse::$db->userRightsSet($user, $rights);
// set the user properties... // set the user properties...
if($properties===null) { if ($properties===null) {
// if nothing is provided but the driver uses an external function, try to get the current values from the external source // if nothing is provided but the driver uses an external function, try to get the current values from the external source
try { try {
if($this->u->driverFunctions("userPropertiesGet")==User\Driver::FUNC_EXTERNAL) { if ($this->u->driverFunctions("userPropertiesGet")==User\Driver::FUNC_EXTERNAL) {
Arsse::$db->userPropertiesSet($user, $this->u->userPropertiesGet($user)); Arsse::$db->userPropertiesSet($user, $this->u->userPropertiesGet($user));
} }
} catch(\Throwable $e) {} } catch (\Throwable $e) {
}
} else { } else {
// otherwise if values are provided, use those // otherwise if values are provided, use those
Arsse::$db->userPropertiesSet($user, $properties); Arsse::$db->userPropertiesSet($user, $properties);

28
lib/User/Driver.php

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\User; namespace JKingWeb\Arsse\User;
Interface Driver { interface Driver {
const FUNC_NOT_IMPLEMENTED = 0; const FUNC_NOT_IMPLEMENTED = 0;
const FUNC_INTERNAL = 1; const FUNC_INTERNAL = 1;
const FUNC_EXTERNAL = 2; const FUNC_EXTERNAL = 2;
@ -14,29 +14,29 @@ Interface Driver {
const RIGHTS_GLOBAL_ADMIN = 100; // is completely unrestricted const RIGHTS_GLOBAL_ADMIN = 100; // is completely unrestricted
// returns an instance of a class implementing this interface. // returns an instance of a class implementing this interface.
function __construct(); public function __construct();
// returns a human-friendly name for the driver (for display in installer, for example) // returns a human-friendly name for the driver (for display in installer, for example)
static function driverName(): string; public static function driverName(): string;
// returns an array (or single queried member of same) of methods defined by this interface and whether the class implements the internal function or a custom version // returns an array (or single queried member of same) of methods defined by this interface and whether the class implements the internal function or a custom version
function driverFunctions(string $function = null); public function driverFunctions(string $function = null);
// authenticates a user against their name and password // authenticates a user against their name and password
function auth(string $user, string $password): bool; public function auth(string $user, string $password): bool;
// checks whether a user exists // checks whether a user exists
function userExists(string $user): bool; public function userExists(string $user): bool;
// adds a user // adds a user
function userAdd(string $user, string $password = null): string; public function userAdd(string $user, string $password = null): string;
// removes a user // removes a user
function userRemove(string $user): bool; public function userRemove(string $user): bool;
// lists all users // lists all users
function userList(string $domain = null): array; public function userList(string $domain = null): array;
// sets a user's password; if the driver does not require the old password, it may be ignored // sets a user's password; if the driver does not require the old password, it may be ignored
function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string; public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string;
// gets user metadata (currently not useful) // gets user metadata (currently not useful)
function userPropertiesGet(string $user): array; public function userPropertiesGet(string $user): array;
// sets user metadata (currently not useful) // sets user metadata (currently not useful)
function userPropertiesSet(string $user, array $properties): array; public function userPropertiesSet(string $user, array $properties): array;
// returns a user's access level according to RIGHTS_* constants (or some custom semantics, if using custom implementation of authorize()) // returns a user's access level according to RIGHTS_* constants (or some custom semantics, if using custom implementation of authorize())
function userRightsGet(string $user): int; public function userRightsGet(string $user): int;
// sets a user's access level // sets a user's access level
function userRightsSet(string $user, int $level): bool; public function userRightsSet(string $user, int $level): bool;
} }

6
lib/User/Internal/Driver.php

@ -19,15 +19,15 @@ final class Driver implements \JKingWeb\Arsse\User\Driver {
"userRightsSet" => self::FUNC_INTERNAL, "userRightsSet" => self::FUNC_INTERNAL,
]; ];
static public function driverName(): string { public static function driverName(): string {
return Arsse::$lang->msg("Driver.User.Internal.Name"); return Arsse::$lang->msg("Driver.User.Internal.Name");
} }
public function driverFunctions(string $function = null) { public function driverFunctions(string $function = null) {
if($function===null) { if ($function===null) {
return $this->functions; return $this->functions;
} }
if(array_key_exists($function, $this->functions)) { if (array_key_exists($function, $this->functions)) {
return $this->functions[$function]; return $this->functions[$function];
} else { } else {
return self::FUNC_NOT_IMPLEMENTED; return self::FUNC_NOT_IMPLEMENTED;

25
lib/User/Internal/InternalFunctions.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\User\Internal; namespace JKingWeb\Arsse\User\Internal;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User\Exception; use JKingWeb\Arsse\User\Exception;
@ -10,51 +11,51 @@ trait InternalFunctions {
public function __construct() { public function __construct() {
} }
function auth(string $user, string $password): bool { public function auth(string $user, string $password): bool {
try { try {
$hash = Arsse::$db->userPasswordGet($user); $hash = Arsse::$db->userPasswordGet($user);
} catch(Exception $e) { } catch (Exception $e) {
return false; return false;
} }
if($password==="" && $hash==="") { if ($password==="" && $hash==="") {
return true; return true;
} }
return password_verify($password, $hash); return password_verify($password, $hash);
} }
function userExists(string $user): bool { public function userExists(string $user): bool {
return Arsse::$db->userExists($user); return Arsse::$db->userExists($user);
} }
function userAdd(string $user, string $password = null): string { public function userAdd(string $user, string $password = null): string {
return Arsse::$db->userAdd($user, $password); return Arsse::$db->userAdd($user, $password);
} }
function userRemove(string $user): bool { public function userRemove(string $user): bool {
return Arsse::$db->userRemove($user); return Arsse::$db->userRemove($user);
} }
function userList(string $domain = null): array { public function userList(string $domain = null): array {
return Arsse::$db->userList($domain); return Arsse::$db->userList($domain);
} }
function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string { public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string {
return Arsse::$db->userPasswordSet($user, $newPassword); return Arsse::$db->userPasswordSet($user, $newPassword);
} }
function userPropertiesGet(string $user): array { public function userPropertiesGet(string $user): array {
return Arsse::$db->userPropertiesGet($user); return Arsse::$db->userPropertiesGet($user);
} }
function userPropertiesSet(string $user, array $properties): array { public function userPropertiesSet(string $user, array $properties): array {
return Arsse::$db->userPropertiesSet($user, $properties); return Arsse::$db->userPropertiesSet($user, $properties);
} }
function userRightsGet(string $user): int { public function userRightsGet(string $user): int {
return Arsse::$db->userRightsGet($user); return Arsse::$db->userRightsGet($user);
} }
function userRightsSet(string $user, int $level): bool { public function userRightsSet(string $user, int $level): bool {
return Arsse::$db->userRightsSet($user, $level); return Arsse::$db->userRightsSet($user, $level);
} }
} }

35
tests/Conf/TestConf.php

@ -1,14 +1,15 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStream;
/** @covers \JKingWeb\Arsse\Conf */ /** @covers \JKingWeb\Arsse\Conf */
class TestConf extends Test\AbstractTest { class TestConf extends Test\AbstractTest {
static $vfs; public static $vfs;
static $path; public static $path;
function setUp() { public function setUp() {
$this->clearData(); $this->clearData();
self::$vfs = vfsStream::setup("root", null, [ self::$vfs = vfsStream::setup("root", null, [
'confGood' => '<?php return Array("lang" => "xx");', 'confGood' => '<?php return Array("lang" => "xx");',
@ -26,18 +27,18 @@ class TestConf extends Test\AbstractTest {
chmod(self::$path."confForbidden", 0000); chmod(self::$path."confForbidden", 0000);
} }
function tearDown() { public function tearDown() {
self::$path = null; self::$path = null;
self::$vfs = null; self::$vfs = null;
$this->clearData(); $this->clearData();
} }
function testLoadDefaultValues() { public function testLoadDefaultValues() {
$this->assertInstanceOf(Conf::class, new Conf()); $this->assertInstanceOf(Conf::class, new Conf());
} }
/** @depends testLoadDefaultValues */ /** @depends testLoadDefaultValues */
function testImportFromArray() { public function testImportFromArray() {
$arr = ['lang' => "xx"]; $arr = ['lang' => "xx"];
$conf = new Conf(); $conf = new Conf();
$conf->import($arr); $conf->import($arr);
@ -45,7 +46,7 @@ class TestConf extends Test\AbstractTest {
} }
/** @depends testImportFromArray */ /** @depends testImportFromArray */
function testImportFromFile() { public function testImportFromFile() {
$conf = new Conf(); $conf = new Conf();
$conf->importFile(self::$path."confGood"); $conf->importFile(self::$path."confGood");
$this->assertEquals("xx", $conf->lang); $this->assertEquals("xx", $conf->lang);
@ -54,43 +55,43 @@ class TestConf extends Test\AbstractTest {
} }
/** @depends testImportFromFile */ /** @depends testImportFromFile */
function testImportFromMissingFile() { public function testImportFromMissingFile() {
$this->assertException("fileMissing", "Conf"); $this->assertException("fileMissing", "Conf");
$conf = new Conf(self::$path."confMissing"); $conf = new Conf(self::$path."confMissing");
} }
/** @depends testImportFromFile */ /** @depends testImportFromFile */
function testImportFromEmptyFile() { public function testImportFromEmptyFile() {
$this->assertException("fileCorrupt", "Conf"); $this->assertException("fileCorrupt", "Conf");
$conf = new Conf(self::$path."confEmpty"); $conf = new Conf(self::$path."confEmpty");
} }
/** @depends testImportFromFile */ /** @depends testImportFromFile */
function testImportFromFileWithoutReadPermission() { public function testImportFromFileWithoutReadPermission() {
$this->assertException("fileUnreadable", "Conf"); $this->assertException("fileUnreadable", "Conf");
$conf = new Conf(self::$path."confUnreadable"); $conf = new Conf(self::$path."confUnreadable");
} }
/** @depends testImportFromFile */ /** @depends testImportFromFile */
function testImportFromFileWhichIsNotAnArray() { public function testImportFromFileWhichIsNotAnArray() {
$this->assertException("fileCorrupt", "Conf"); $this->assertException("fileCorrupt", "Conf");
$conf = new Conf(self::$path."confNotArray"); $conf = new Conf(self::$path."confNotArray");
} }
/** @depends testImportFromFile */ /** @depends testImportFromFile */
function testImportFromFileWhichIsNotPhp() { public function testImportFromFileWhichIsNotPhp() {
$this->assertException("fileCorrupt", "Conf"); $this->assertException("fileCorrupt", "Conf");
// this should not print the output of the non-PHP file // this should not print the output of the non-PHP file
$conf = new Conf(self::$path."confNotPHP"); $conf = new Conf(self::$path."confNotPHP");
} }
/** @depends testImportFromFile */ /** @depends testImportFromFile */
function testImportFromCorruptFile() { public function testImportFromCorruptFile() {
$this->assertException("fileCorrupt", "Conf"); $this->assertException("fileCorrupt", "Conf");
$conf = new Conf(self::$path."confCorrupt"); $conf = new Conf(self::$path."confCorrupt");
} }
function testExportToArray() { public function testExportToArray() {
$conf = new Conf(); $conf = new Conf();
$conf->lang = ["en", "fr"]; // should not be exported: not scalar $conf->lang = ["en", "fr"]; // should not be exported: not scalar
$conf->dbSQLite3File = "test.db"; // should be exported: value changed $conf->dbSQLite3File = "test.db"; // should be exported: value changed
@ -107,7 +108,7 @@ class TestConf extends Test\AbstractTest {
/** @depends testExportToArray /** @depends testExportToArray
* @depends testImportFromFile */ * @depends testImportFromFile */
function testExportToFile() { public function testExportToFile() {
$conf = new Conf(); $conf = new Conf();
$conf->lang = ["en", "fr"]; // should not be exported: not scalar $conf->lang = ["en", "fr"]; // should not be exported: not scalar
$conf->dbSQLite3File = "test.db"; // should be exported: value changed $conf->dbSQLite3File = "test.db"; // should be exported: value changed
@ -125,12 +126,12 @@ class TestConf extends Test\AbstractTest {
$this->assertArraySubset($exp, $arr); $this->assertArraySubset($exp, $arr);
} }
function testExportToFileWithoutWritePermission() { public function testExportToFileWithoutWritePermission() {
$this->assertException("fileUnwritable", "Conf"); $this->assertException("fileUnwritable", "Conf");
(new Conf)->exportFile(self::$path."confUnreadable"); (new Conf)->exportFile(self::$path."confUnreadable");
} }
function testExportToFileWithoutCreatePermission() { public function testExportToFileWithoutCreatePermission() {
$this->assertException("fileUncreatable", "Conf"); $this->assertException("fileUncreatable", "Conf");
(new Conf)->exportFile(self::$path."confForbidden/conf"); (new Conf)->exportFile(self::$path."confForbidden/conf");
} }

33
tests/Db/SQLite3/TestDbDriverCreationSQLite3.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStream;
use Phake; use Phake;
@ -13,8 +14,8 @@ class TestDbDriverCreationSQLite3 extends Test\AbstractTest {
protected $drv; protected $drv;
protected $ch; protected $ch;
function setUp() { public function setUp() {
if(!extension_loaded("sqlite3")) { if (!extension_loaded("sqlite3")) {
$this->markTestSkipped("SQLite extension not loaded"); $this->markTestSkipped("SQLite extension not loaded");
} }
$this->clearData(); $this->clearData();
@ -105,83 +106,83 @@ class TestDbDriverCreationSQLite3 extends Test\AbstractTest {
// set up database shim // set up database shim
} }
function tearDown() { public function tearDown() {
$this->clearData(); $this->clearData();
} }
function testFailToCreateDatabase() { public function testFailToCreateDatabase() {
Arsse::$conf->dbSQLite3File = $this->path."Cmain/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Cmain/arsse.db";
$this->assertException("fileUncreatable", "Db"); $this->assertException("fileUncreatable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToCreateJournal() { public function testFailToCreateJournal() {
Arsse::$conf->dbSQLite3File = $this->path."Cwal/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Cwal/arsse.db";
$this->assertException("fileUncreatable", "Db"); $this->assertException("fileUncreatable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToCreateSharedMmeory() { public function testFailToCreateSharedMmeory() {
Arsse::$conf->dbSQLite3File = $this->path."Cshm/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Cshm/arsse.db";
$this->assertException("fileUncreatable", "Db"); $this->assertException("fileUncreatable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToReadDatabase() { public function testFailToReadDatabase() {
Arsse::$conf->dbSQLite3File = $this->path."Rmain/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Rmain/arsse.db";
$this->assertException("fileUnreadable", "Db"); $this->assertException("fileUnreadable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToReadJournal() { public function testFailToReadJournal() {
Arsse::$conf->dbSQLite3File = $this->path."Rwal/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Rwal/arsse.db";
$this->assertException("fileUnreadable", "Db"); $this->assertException("fileUnreadable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToReadSharedMmeory() { public function testFailToReadSharedMmeory() {
Arsse::$conf->dbSQLite3File = $this->path."Rshm/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Rshm/arsse.db";
$this->assertException("fileUnreadable", "Db"); $this->assertException("fileUnreadable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToWriteToDatabase() { public function testFailToWriteToDatabase() {
Arsse::$conf->dbSQLite3File = $this->path."Wmain/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Wmain/arsse.db";
$this->assertException("fileUnwritable", "Db"); $this->assertException("fileUnwritable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToWriteToJournal() { public function testFailToWriteToJournal() {
Arsse::$conf->dbSQLite3File = $this->path."Wwal/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Wwal/arsse.db";
$this->assertException("fileUnwritable", "Db"); $this->assertException("fileUnwritable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToWriteToSharedMmeory() { public function testFailToWriteToSharedMmeory() {
Arsse::$conf->dbSQLite3File = $this->path."Wshm/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Wshm/arsse.db";
$this->assertException("fileUnwritable", "Db"); $this->assertException("fileUnwritable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToAccessDatabase() { public function testFailToAccessDatabase() {
Arsse::$conf->dbSQLite3File = $this->path."Amain/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Amain/arsse.db";
$this->assertException("fileUnusable", "Db"); $this->assertException("fileUnusable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToAccessJournal() { public function testFailToAccessJournal() {
Arsse::$conf->dbSQLite3File = $this->path."Awal/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Awal/arsse.db";
$this->assertException("fileUnusable", "Db"); $this->assertException("fileUnusable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testFailToAccessSharedMmeory() { public function testFailToAccessSharedMmeory() {
Arsse::$conf->dbSQLite3File = $this->path."Ashm/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."Ashm/arsse.db";
$this->assertException("fileUnusable", "Db"); $this->assertException("fileUnusable", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;
} }
function testAssumeDatabaseCorruption() { public function testAssumeDatabaseCorruption() {
Arsse::$conf->dbSQLite3File = $this->path."corrupt/arsse.db"; Arsse::$conf->dbSQLite3File = $this->path."corrupt/arsse.db";
$this->assertException("fileCorrupt", "Db"); $this->assertException("fileCorrupt", "Db");
new Db\SQLite3\Driver; new Db\SQLite3\Driver;

70
tests/Db/SQLite3/TestDbDriverSQLite3.php

@ -10,8 +10,8 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
protected $drv; protected $drv;
protected $ch; protected $ch;
function setUp() { public function setUp() {
if(!extension_loaded("sqlite3")) { if (!extension_loaded("sqlite3")) {
$this->markTestSkipped("SQLite extension not loaded"); $this->markTestSkipped("SQLite extension not loaded");
} }
$this->clearData(); $this->clearData();
@ -24,110 +24,110 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->ch->enableExceptions(true); $this->ch->enableExceptions(true);
} }
function tearDown() { public function tearDown() {
unset($this->drv); unset($this->drv);
unset($this->ch); unset($this->ch);
if(isset(Arsse::$conf)) { if (isset(Arsse::$conf)) {
unlink(Arsse::$conf->dbSQLite3File); unlink(Arsse::$conf->dbSQLite3File);
} }
$this->clearData(); $this->clearData();
} }
function testFetchDriverName() { public function testFetchDriverName() {
$class = Arsse::$conf->dbDriver; $class = Arsse::$conf->dbDriver;
$this->assertTrue(strlen($class::driverName()) > 0); $this->assertTrue(strlen($class::driverName()) > 0);
} }
function testExecAValidStatement() { public function testExecAValidStatement() {
$this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key)")); $this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key)"));
} }
function testExecAnInvalidStatement() { public function testExecAnInvalidStatement() {
$this->assertException("engineErrorGeneral", "Db"); $this->assertException("engineErrorGeneral", "Db");
$this->drv->exec("And the meek shall inherit the earth..."); $this->drv->exec("And the meek shall inherit the earth...");
} }
function testExecMultipleStatements() { public function testExecMultipleStatements() {
$this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key); INSERT INTO test(id) values(2112)")); $this->assertTrue($this->drv->exec("CREATE TABLE test(id integer primary key); INSERT INTO test(id) values(2112)"));
$this->assertEquals(2112, $this->ch->querySingle("SELECT id from test")); $this->assertEquals(2112, $this->ch->querySingle("SELECT id from test"));
} }
function testExecTimeout() { public function testExecTimeout() {
$this->ch->exec("BEGIN EXCLUSIVE TRANSACTION"); $this->ch->exec("BEGIN EXCLUSIVE TRANSACTION");
$this->assertException("general", "Db", "ExceptionTimeout"); $this->assertException("general", "Db", "ExceptionTimeout");
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
} }
function testExecConstraintViolation() { public function testExecConstraintViolation() {
$this->drv->exec("CREATE TABLE test(id integer not null)"); $this->drv->exec("CREATE TABLE test(id integer not null)");
$this->assertException("constraintViolation", "Db", "ExceptionInput"); $this->assertException("constraintViolation", "Db", "ExceptionInput");
$this->drv->exec("INSERT INTO test(id) values(null)"); $this->drv->exec("INSERT INTO test(id) values(null)");
} }
function testExecTypeViolation() { public function testExecTypeViolation() {
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
$this->assertException("typeViolation", "Db", "ExceptionInput"); $this->assertException("typeViolation", "Db", "ExceptionInput");
$this->drv->exec("INSERT INTO test(id) values('ook')"); $this->drv->exec("INSERT INTO test(id) values('ook')");
} }
function testMakeAValidQuery() { public function testMakeAValidQuery() {
$this->assertInstanceOf(Db\Result::class, $this->drv->query("SELECT 1")); $this->assertInstanceOf(Db\Result::class, $this->drv->query("SELECT 1"));
} }
function testMakeAnInvalidQuery() { public function testMakeAnInvalidQuery() {
$this->assertException("engineErrorGeneral", "Db"); $this->assertException("engineErrorGeneral", "Db");
$this->drv->query("Apollo was astonished; Dionysus thought me mad"); $this->drv->query("Apollo was astonished; Dionysus thought me mad");
} }
function testQueryTimeout() { public function testQueryTimeout() {
$this->ch->exec("BEGIN EXCLUSIVE TRANSACTION"); $this->ch->exec("BEGIN EXCLUSIVE TRANSACTION");
$this->assertException("general", "Db", "ExceptionTimeout"); $this->assertException("general", "Db", "ExceptionTimeout");
$this->drv->query("CREATE TABLE test(id integer primary key)"); $this->drv->query("CREATE TABLE test(id integer primary key)");
} }
function testQueryConstraintViolation() { public function testQueryConstraintViolation() {
$this->drv->exec("CREATE TABLE test(id integer not null)"); $this->drv->exec("CREATE TABLE test(id integer not null)");
$this->assertException("constraintViolation", "Db", "ExceptionInput"); $this->assertException("constraintViolation", "Db", "ExceptionInput");
$this->drv->query("INSERT INTO test(id) values(null)"); $this->drv->query("INSERT INTO test(id) values(null)");
} }
function testQueryTypeViolation() { public function testQueryTypeViolation() {
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
$this->assertException("typeViolation", "Db", "ExceptionInput"); $this->assertException("typeViolation", "Db", "ExceptionInput");
$this->drv->query("INSERT INTO test(id) values('ook')"); $this->drv->query("INSERT INTO test(id) values('ook')");
} }
function testPrepareAValidQuery() { public function testPrepareAValidQuery() {
$s = $this->drv->prepare("SELECT ?, ?", "int", "int"); $s = $this->drv->prepare("SELECT ?, ?", "int", "int");
$this->assertInstanceOf(Db\Statement::class, $s); $this->assertInstanceOf(Db\Statement::class, $s);
} }
function testPrepareAnInvalidQuery() { public function testPrepareAnInvalidQuery() {
$this->assertException("engineErrorGeneral", "Db"); $this->assertException("engineErrorGeneral", "Db");
$s = $this->drv->prepare("This is an invalid query", "int", "int"); $s = $this->drv->prepare("This is an invalid query", "int", "int");
} }
function testCreateASavepoint() { public function testCreateASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate()); $this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate()); $this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate()); $this->assertEquals(3, $this->drv->savepointCreate());
} }
function testReleaseASavepoint() { public function testReleaseASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate()); $this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(true, $this->drv->savepointRelease()); $this->assertEquals(true, $this->drv->savepointRelease());
$this->assertException("invalid", "Db", "ExceptionSavepoint"); $this->assertException("invalid", "Db", "ExceptionSavepoint");
$this->drv->savepointRelease(); $this->drv->savepointRelease();
} }
function testUndoASavepoint() { public function testUndoASavepoint() {
$this->assertEquals(1, $this->drv->savepointCreate()); $this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(true, $this->drv->savepointUndo()); $this->assertEquals(true, $this->drv->savepointUndo());
$this->assertException("invalid", "Db", "ExceptionSavepoint"); $this->assertException("invalid", "Db", "ExceptionSavepoint");
$this->drv->savepointUndo(); $this->drv->savepointUndo();
} }
function testManipulateSavepoints() { public function testManipulateSavepoints() {
$this->assertEquals(1, $this->drv->savepointCreate()); $this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate()); $this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate()); $this->assertEquals(3, $this->drv->savepointCreate());
@ -144,7 +144,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->drv->savepointRelease(2); $this->drv->savepointRelease(2);
} }
function testManipulateSavepointsSomeMore() { public function testManipulateSavepointsSomeMore() {
$this->assertEquals(1, $this->drv->savepointCreate()); $this->assertEquals(1, $this->drv->savepointCreate());
$this->assertEquals(2, $this->drv->savepointCreate()); $this->assertEquals(2, $this->drv->savepointCreate());
$this->assertEquals(3, $this->drv->savepointCreate()); $this->assertEquals(3, $this->drv->savepointCreate());
@ -155,7 +155,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->drv->savepointUndo(2); $this->drv->savepointUndo(2);
} }
function testBeginATransaction() { public function testBeginATransaction() {
$select = "SELECT count(*) FROM test"; $select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)"; $insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
@ -168,7 +168,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->assertEquals(0, $this->ch->querySingle($select)); $this->assertEquals(0, $this->ch->querySingle($select));
} }
function testCommitATransaction() { public function testCommitATransaction() {
$select = "SELECT count(*) FROM test"; $select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)"; $insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
@ -181,7 +181,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->assertEquals(1, $this->ch->querySingle($select)); $this->assertEquals(1, $this->ch->querySingle($select));
} }
function testRollbackATransaction() { public function testRollbackATransaction() {
$select = "SELECT count(*) FROM test"; $select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)"; $insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
@ -194,7 +194,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->assertEquals(0, $this->ch->querySingle($select)); $this->assertEquals(0, $this->ch->querySingle($select));
} }
function testBeginChainedTransactions() { public function testBeginChainedTransactions() {
$select = "SELECT count(*) FROM test"; $select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)"; $insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
@ -208,7 +208,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->assertEquals(0, $this->ch->querySingle($select)); $this->assertEquals(0, $this->ch->querySingle($select));
} }
function testCommitChainedTransactions() { public function testCommitChainedTransactions() {
$select = "SELECT count(*) FROM test"; $select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)"; $insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
@ -226,7 +226,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->assertEquals(2, $this->ch->querySingle($select)); $this->assertEquals(2, $this->ch->querySingle($select));
} }
function testCommitChainedTransactionsOutOfOrder() { public function testCommitChainedTransactionsOutOfOrder() {
$select = "SELECT count(*) FROM test"; $select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)"; $insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
@ -243,7 +243,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$tr2->commit(); $tr2->commit();
} }
function testRollbackChainedTransactions() { public function testRollbackChainedTransactions() {
$select = "SELECT count(*) FROM test"; $select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)"; $insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
@ -263,7 +263,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->assertEquals(0, $this->ch->querySingle($select)); $this->assertEquals(0, $this->ch->querySingle($select));
} }
function testRollbackChainedTransactionsOutOfOrder() { public function testRollbackChainedTransactionsOutOfOrder() {
$select = "SELECT count(*) FROM test"; $select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)"; $insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
@ -283,7 +283,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->assertEquals(0, $this->ch->querySingle($select)); $this->assertEquals(0, $this->ch->querySingle($select));
} }
function testPartiallyRollbackChainedTransactions() { public function testPartiallyRollbackChainedTransactions() {
$select = "SELECT count(*) FROM test"; $select = "SELECT count(*) FROM test";
$insert = "INSERT INTO test(id) values(null)"; $insert = "INSERT INTO test(id) values(null)";
$this->drv->exec("CREATE TABLE test(id integer primary key)"); $this->drv->exec("CREATE TABLE test(id integer primary key)");
@ -303,7 +303,7 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->assertEquals(1, $this->ch->querySingle($select)); $this->assertEquals(1, $this->ch->querySingle($select));
} }
function testFetchSchemaVersion() { public function testFetchSchemaVersion() {
$this->assertSame(0, $this->drv->schemaVersion()); $this->assertSame(0, $this->drv->schemaVersion());
$this->drv->exec("PRAGMA user_version=1"); $this->drv->exec("PRAGMA user_version=1");
$this->assertSame(1, $this->drv->schemaVersion()); $this->assertSame(1, $this->drv->schemaVersion());
@ -311,13 +311,13 @@ class TestDbDriverSQLite3 extends Test\AbstractTest {
$this->assertSame(2, $this->drv->schemaVersion()); $this->assertSame(2, $this->drv->schemaVersion());
} }
function testLockTheDatabase() { public function testLockTheDatabase() {
$this->drv->savepointCreate(true); $this->drv->savepointCreate(true);
$this->assertException(); $this->assertException();
$this->ch->exec("CREATE TABLE test(id integer primary key)"); $this->ch->exec("CREATE TABLE test(id integer primary key)");
} }
function testUnlockTheDatabase() { public function testUnlockTheDatabase() {
$this->drv->savepointCreate(true); $this->drv->savepointCreate(true);
$this->drv->savepointRelease(); $this->drv->savepointRelease();
$this->drv->savepointCreate(true); $this->drv->savepointCreate(true);

29
tests/Db/SQLite3/TestDbResultSQLite3.php

@ -4,11 +4,10 @@ namespace JKingWeb\Arsse;
/** @covers \JKingWeb\Arsse\Db\SQLite3\Result<extended> */ /** @covers \JKingWeb\Arsse\Db\SQLite3\Result<extended> */
class TestDbResultSQLite3 extends Test\AbstractTest { class TestDbResultSQLite3 extends Test\AbstractTest {
protected $c; protected $c;
function setUp() { public function setUp() {
if(!extension_loaded("sqlite3")) { if (!extension_loaded("sqlite3")) {
$this->markTestSkipped("SQLite extension not loaded"); $this->markTestSkipped("SQLite extension not loaded");
} }
$c = new \SQLite3(":memory:"); $c = new \SQLite3(":memory:");
@ -16,49 +15,49 @@ class TestDbResultSQLite3 extends Test\AbstractTest {
$this->c = $c; $this->c = $c;
} }
function tearDown() { public function tearDown() {
$this->c->close(); $this->c->close();
unset($this->c); unset($this->c);
} }
function testConstructResult() { public function testConstructResult() {
$set = $this->c->query("SELECT 1"); $set = $this->c->query("SELECT 1");
$this->assertInstanceOf(Db\Result::class, new Db\SQLite3\Result($set)); $this->assertInstanceOf(Db\Result::class, new Db\SQLite3\Result($set));
} }
function testGetChangeCountAndLastInsertId() { public function testGetChangeCountAndLastInsertId() {
$this->c->query("CREATE TABLE test(col)"); $this->c->query("CREATE TABLE test(col)");
$set = $this->c->query("INSERT INTO test(col) values(1)"); $set = $this->c->query("INSERT INTO test(col) values(1)");
$rows = $this->c->changes(); $rows = $this->c->changes();
$id = $this->c->lastInsertRowID(); $id = $this->c->lastInsertRowID();
$r = new Db\SQLite3\Result($set,[$rows,$id]); $r = new Db\SQLite3\Result($set, [$rows,$id]);
$this->assertEquals($rows, $r->changes()); $this->assertEquals($rows, $r->changes());
$this->assertEquals($id, $r->lastId()); $this->assertEquals($id, $r->lastId());
} }
function testIterateOverResults() { public function testIterateOverResults() {
$set = $this->c->query("SELECT 1 as col union select 2 as col union select 3 as col"); $set = $this->c->query("SELECT 1 as col union select 2 as col union select 3 as col");
$rows = []; $rows = [];
foreach(new Db\SQLite3\Result($set) as $index => $row) { foreach (new Db\SQLite3\Result($set) as $index => $row) {
$rows[$index] = $row['col']; $rows[$index] = $row['col'];
} }
$this->assertEquals([0 => 1, 1 => 2, 2 => 3], $rows); $this->assertEquals([0 => 1, 1 => 2, 2 => 3], $rows);
} }
function testIterateOverResultsTwice() { public function testIterateOverResultsTwice() {
$set = $this->c->query("SELECT 1 as col union select 2 as col union select 3 as col"); $set = $this->c->query("SELECT 1 as col union select 2 as col union select 3 as col");
$rows = []; $rows = [];
$test = new Db\SQLite3\Result($set); $test = new Db\SQLite3\Result($set);
foreach($test as $row) { foreach ($test as $row) {
$rows[] = $row['col']; $rows[] = $row['col'];
} }
foreach($test as $row) { foreach ($test as $row) {
$rows[] = $row['col']; $rows[] = $row['col'];
} }
$this->assertEquals([1,2,3,1,2,3], $rows); $this->assertEquals([1,2,3,1,2,3], $rows);
} }
function testGetSingleValues() { public function testGetSingleValues() {
$set = $this->c->query("SELECT 1867 as year union select 1970 as year union select 2112 as year"); $set = $this->c->query("SELECT 1867 as year union select 1970 as year union select 2112 as year");
$test = new Db\SQLite3\Result($set); $test = new Db\SQLite3\Result($set);
$this->assertEquals(1867, $test->getValue()); $this->assertEquals(1867, $test->getValue());
@ -67,7 +66,7 @@ class TestDbResultSQLite3 extends Test\AbstractTest {
$this->assertSame(null, $test->getValue()); $this->assertSame(null, $test->getValue());
} }
function testGetFirstValuesOnly() { public function testGetFirstValuesOnly() {
$set = $this->c->query("SELECT 1867 as year, 19 as century union select 1970 as year, 20 as century union select 2112 as year, 22 as century"); $set = $this->c->query("SELECT 1867 as year, 19 as century union select 1970 as year, 20 as century union select 2112 as year, 22 as century");
$test = new Db\SQLite3\Result($set); $test = new Db\SQLite3\Result($set);
$this->assertEquals(1867, $test->getValue()); $this->assertEquals(1867, $test->getValue());
@ -76,7 +75,7 @@ class TestDbResultSQLite3 extends Test\AbstractTest {
$this->assertSame(null, $test->getValue()); $this->assertSame(null, $test->getValue());
} }
function testGetRows() { public function testGetRows() {
$set = $this->c->query("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track"); $set = $this->c->query("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track");
$rows = [ $rows = [
['album' => '2112', 'track' => '2112'], ['album' => '2112', 'track' => '2112'],

26
tests/Db/SQLite3/TestDbStatementSQLite3.php

@ -1,8 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Db\Statement;
use JKingWeb\Arsse\Db\Statement;
/** /**
* @covers \JKingWeb\Arsse\Db\SQLite3\Statement<extended> * @covers \JKingWeb\Arsse\Db\SQLite3\Statement<extended>
@ -11,11 +11,11 @@ class TestDbStatementSQLite3 extends Test\AbstractTest {
use Test\Db\BindingTests; use Test\Db\BindingTests;
protected $c; protected $c;
static protected $imp = Db\SQLite3\Statement::class; protected static $imp = Db\SQLite3\Statement::class;
function setUp() { public function setUp() {
$this->clearData(); $this->clearData();
if(!extension_loaded("sqlite3")) { if (!extension_loaded("sqlite3")) {
$this->markTestSkipped("SQLite extension not loaded"); $this->markTestSkipped("SQLite extension not loaded");
} }
$c = new \SQLite3(":memory:"); $c = new \SQLite3(":memory:");
@ -23,7 +23,7 @@ class TestDbStatementSQLite3 extends Test\AbstractTest {
$this->c = $c; $this->c = $c;
} }
function tearDown() { public function tearDown() {
$this->c->close(); $this->c->close();
unset($this->c); unset($this->c);
} }
@ -32,7 +32,7 @@ class TestDbStatementSQLite3 extends Test\AbstractTest {
$nativeStatement = $this->c->prepare("SELECT ? as value"); $nativeStatement = $this->c->prepare("SELECT ? as value");
$s = new self::$imp($this->c, $nativeStatement); $s = new self::$imp($this->c, $nativeStatement);
$types = array_unique(Statement::TYPES); $types = array_unique(Statement::TYPES);
foreach($types as $type) { foreach ($types as $type) {
$s->rebindArray([$strict ? "strict $type" : $type]); $s->rebindArray([$strict ? "strict $type" : $type]);
$val = $s->runArray([$input])->getRow()['value']; $val = $s->runArray([$input])->getRow()['value'];
$this->assertSame($expectations[$type], $val, "Binding from type $type failed comparison."); $this->assertSame($expectations[$type], $val, "Binding from type $type failed comparison.");
@ -42,19 +42,19 @@ class TestDbStatementSQLite3 extends Test\AbstractTest {
} }
} }
function testConstructStatement() { public function testConstructStatement() {
$nativeStatement = $this->c->prepare("SELECT ? as value"); $nativeStatement = $this->c->prepare("SELECT ? as value");
$this->assertInstanceOf(Statement::class, new Db\SQLite3\Statement($this->c, $nativeStatement)); $this->assertInstanceOf(Statement::class, new Db\SQLite3\Statement($this->c, $nativeStatement));
} }
function testBindMissingValue() { public function testBindMissingValue() {
$nativeStatement = $this->c->prepare("SELECT ? as value"); $nativeStatement = $this->c->prepare("SELECT ? as value");
$s = new self::$imp($this->c, $nativeStatement); $s = new self::$imp($this->c, $nativeStatement);
$val = $s->runArray()->getRow()['value']; $val = $s->runArray()->getRow()['value'];
$this->assertSame(null, $val); $this->assertSame(null, $val);
} }
function testBindMultipleValues() { public function testBindMultipleValues() {
$exp = [ $exp = [
'one' => 1, 'one' => 1,
'two' => 2, 'two' => 2,
@ -65,7 +65,7 @@ class TestDbStatementSQLite3 extends Test\AbstractTest {
$this->assertSame($exp, $val); $this->assertSame($exp, $val);
} }
function testBindRecursively() { public function testBindRecursively() {
$exp = [ $exp = [
'one' => 1, 'one' => 1,
'two' => 2, 'two' => 2,
@ -78,14 +78,14 @@ class TestDbStatementSQLite3 extends Test\AbstractTest {
$this->assertSame($exp, $val); $this->assertSame($exp, $val);
} }
function testBindWithoutType() { public function testBindWithoutType() {
$nativeStatement = $this->c->prepare("SELECT ? as value"); $nativeStatement = $this->c->prepare("SELECT ? as value");
$this->assertException("paramTypeMissing", "Db"); $this->assertException("paramTypeMissing", "Db");
$s = new self::$imp($this->c, $nativeStatement, []); $s = new self::$imp($this->c, $nativeStatement, []);
$s->runArray([1]); $s->runArray([1]);
} }
function testViolateConstraint() { public function testViolateConstraint() {
$this->c->exec("CREATE TABLE test(id integer not null)"); $this->c->exec("CREATE TABLE test(id integer not null)");
$nativeStatement = $this->c->prepare("INSERT INTO test(id) values(?)"); $nativeStatement = $this->c->prepare("INSERT INTO test(id) values(?)");
$s = new self::$imp($this->c, $nativeStatement, ["int"]); $s = new self::$imp($this->c, $nativeStatement, ["int"]);
@ -93,7 +93,7 @@ class TestDbStatementSQLite3 extends Test\AbstractTest {
$s->runArray([null]); $s->runArray([null]);
} }
function testMismatchTypes() { public function testMismatchTypes() {
$this->c->exec("CREATE TABLE test(id integer primary key)"); $this->c->exec("CREATE TABLE test(id integer primary key)");
$nativeStatement = $this->c->prepare("INSERT INTO test(id) values(?)"); $nativeStatement = $this->c->prepare("INSERT INTO test(id) values(?)");
$s = new self::$imp($this->c, $nativeStatement, ["str"]); $s = new self::$imp($this->c, $nativeStatement, ["str"]);

32
tests/Db/SQLite3/TestDbUpdateSQLite3.php

@ -1,8 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use org\bovigo\vfs\vfsStream;
use org\bovigo\vfs\vfsStream;
/** /**
* @covers \JKingWeb\Arsse\Db\SQLite3\Driver<extended> * @covers \JKingWeb\Arsse\Db\SQLite3\Driver<extended>
@ -16,13 +16,13 @@ class TestDbUpdateSQLite3 extends Test\AbstractTest {
const MINIMAL1 = "create table arsse_meta(key text primary key not null, value text); pragma user_version=1"; const MINIMAL1 = "create table arsse_meta(key text primary key not null, value text); pragma user_version=1";
const MINIMAL2 = "pragma user_version=2"; const MINIMAL2 = "pragma user_version=2";
function setUp(Conf $conf = null) { public function setUp(Conf $conf = null) {
if(!extension_loaded("sqlite3")) { if (!extension_loaded("sqlite3")) {
$this->markTestSkipped("SQLite extension not loaded"); $this->markTestSkipped("SQLite extension not loaded");
} }
$this->clearData(); $this->clearData();
$this->vfs = vfsStream::setup("schemata", null, ['SQLite3' => []]); $this->vfs = vfsStream::setup("schemata", null, ['SQLite3' => []]);
if(!$conf) { if (!$conf) {
$conf = new Conf(); $conf = new Conf();
} }
$conf->dbDriver = Db\SQLite3\Driver::class; $conf->dbDriver = Db\SQLite3\Driver::class;
@ -33,68 +33,68 @@ class TestDbUpdateSQLite3 extends Test\AbstractTest {
$this->drv = new Db\SQLite3\Driver(true); $this->drv = new Db\SQLite3\Driver(true);
} }
function tearDown() { public function tearDown() {
unset($this->drv); unset($this->drv);
unset($this->data); unset($this->data);
unset($this->vfs); unset($this->vfs);
$this->clearData(); $this->clearData();
} }
function testLoadMissingFile() { public function testLoadMissingFile() {
$this->assertException("updateFileMissing", "Db"); $this->assertException("updateFileMissing", "Db");
$this->drv->schemaUpdate(1, $this->base); $this->drv->schemaUpdate(1, $this->base);
} }
function testLoadUnreadableFile() { public function testLoadUnreadableFile() {
touch($this->path."0.sql"); touch($this->path."0.sql");
chmod($this->path."0.sql", 0000); chmod($this->path."0.sql", 0000);
$this->assertException("updateFileUnreadable", "Db"); $this->assertException("updateFileUnreadable", "Db");
$this->drv->schemaUpdate(1, $this->base); $this->drv->schemaUpdate(1, $this->base);
} }
function testLoadCorruptFile() { public function testLoadCorruptFile() {
file_put_contents($this->path."0.sql", "This is a corrupt file"); file_put_contents($this->path."0.sql", "This is a corrupt file");
$this->assertException("updateFileError", "Db"); $this->assertException("updateFileError", "Db");
$this->drv->schemaUpdate(1, $this->base); $this->drv->schemaUpdate(1, $this->base);
} }
function testLoadIncompleteFile() { public function testLoadIncompleteFile() {
file_put_contents($this->path."0.sql", "create table arsse_meta(key text primary key not null, value text);"); file_put_contents($this->path."0.sql", "create table arsse_meta(key text primary key not null, value text);");
$this->assertException("updateFileIncomplete", "Db"); $this->assertException("updateFileIncomplete", "Db");
$this->drv->schemaUpdate(1, $this->base); $this->drv->schemaUpdate(1, $this->base);
} }
function testLoadCorrectFile() { public function testLoadCorrectFile() {
file_put_contents($this->path."0.sql", self::MINIMAL1); file_put_contents($this->path."0.sql", self::MINIMAL1);
$this->drv->schemaUpdate(1, $this->base); $this->drv->schemaUpdate(1, $this->base);
$this->assertEquals(1, $this->drv->schemaVersion()); $this->assertEquals(1, $this->drv->schemaVersion());
} }
function testPerformPartialUpdate() { public function testPerformPartialUpdate() {
file_put_contents($this->path."0.sql", self::MINIMAL1); file_put_contents($this->path."0.sql", self::MINIMAL1);
file_put_contents($this->path."1.sql", ""); file_put_contents($this->path."1.sql", "");
$this->assertException("updateFileIncomplete", "Db"); $this->assertException("updateFileIncomplete", "Db");
try { try {
$this->drv->schemaUpdate(2, $this->base); $this->drv->schemaUpdate(2, $this->base);
} catch(Exception $e) { } catch (Exception $e) {
$this->assertEquals(1, $this->drv->schemaVersion()); $this->assertEquals(1, $this->drv->schemaVersion());
throw $e; throw $e;
} }
} }
function testPerformSequentialUpdate() { public function testPerformSequentialUpdate() {
file_put_contents($this->path."0.sql", self::MINIMAL1); file_put_contents($this->path."0.sql", self::MINIMAL1);
file_put_contents($this->path."1.sql", self::MINIMAL2); file_put_contents($this->path."1.sql", self::MINIMAL2);
$this->drv->schemaUpdate(2, $this->base); $this->drv->schemaUpdate(2, $this->base);
$this->assertEquals(2, $this->drv->schemaVersion()); $this->assertEquals(2, $this->drv->schemaVersion());
} }
function testPerformActualUpdate() { public function testPerformActualUpdate() {
$this->drv->schemaUpdate(Database::SCHEMA_VERSION); $this->drv->schemaUpdate(Database::SCHEMA_VERSION);
$this->assertEquals(Database::SCHEMA_VERSION, $this->drv->schemaVersion()); $this->assertEquals(Database::SCHEMA_VERSION, $this->drv->schemaVersion());
} }
function testDeclineManualUpdate() { public function testDeclineManualUpdate() {
// turn auto-updating off // turn auto-updating off
$conf = new Conf(); $conf = new Conf();
$conf->dbAutoUpdate = false; $conf->dbAutoUpdate = false;
@ -103,7 +103,7 @@ class TestDbUpdateSQLite3 extends Test\AbstractTest {
$this->drv->schemaUpdate(Database::SCHEMA_VERSION); $this->drv->schemaUpdate(Database::SCHEMA_VERSION);
} }
function testDeclineDowngrade() { public function testDeclineDowngrade() {
$this->assertException("updateTooNew", "Db"); $this->assertException("updateTooNew", "Db");
$this->drv->schemaUpdate(-1, $this->base); $this->drv->schemaUpdate(-1, $this->base);
} }

9
tests/Db/TestTransaction.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\Db\Transaction;
use Phake; use Phake;
@ -9,7 +10,7 @@ use Phake;
class TestTransaction extends Test\AbstractTest { class TestTransaction extends Test\AbstractTest {
protected $drv; protected $drv;
function setUp() { public function setUp() {
$this->clearData(); $this->clearData();
$drv = Phake::mock(Db\SQLite3\Driver::class); $drv = Phake::mock(Db\SQLite3\Driver::class);
Phake::when($drv)->savepointRelease->thenReturn(true); Phake::when($drv)->savepointRelease->thenReturn(true);
@ -18,7 +19,7 @@ class TestTransaction extends Test\AbstractTest {
$this->drv = $drv; $this->drv = $drv;
} }
function testManipulateTransactions() { public function testManipulateTransactions() {
$tr1 = new Transaction($this->drv); $tr1 = new Transaction($this->drv);
$tr2 = new Transaction($this->drv); $tr2 = new Transaction($this->drv);
Phake::verify($this->drv, Phake::times(2))->savepointCreate; Phake::verify($this->drv, Phake::times(2))->savepointCreate;
@ -30,7 +31,7 @@ class TestTransaction extends Test\AbstractTest {
Phake::verify($this->drv)->savepointUndo(2); Phake::verify($this->drv)->savepointUndo(2);
} }
function testCloseTransactions() { public function testCloseTransactions() {
$tr1 = new Transaction($this->drv); $tr1 = new Transaction($this->drv);
$tr2 = new Transaction($this->drv); $tr2 = new Transaction($this->drv);
$this->assertTrue($tr1->isPending()); $this->assertTrue($tr1->isPending());
@ -45,7 +46,7 @@ class TestTransaction extends Test\AbstractTest {
Phake::verify($this->drv)->savepointUndo(2); Phake::verify($this->drv)->savepointUndo(2);
} }
function testIgnoreRollbackErrors() { public function testIgnoreRollbackErrors() {
Phake::when($this->drv)->savepointUndo->thenThrow(new Db\ExceptionSavepoint("stale")); Phake::when($this->drv)->savepointUndo->thenThrow(new Db\ExceptionSavepoint("stale"));
$tr1 = new Transaction($this->drv); $tr1 = new Transaction($this->drv);
$tr2 = new Transaction($this->drv); $tr2 = new Transaction($this->drv);

18
tests/Exception/TestException.php

@ -1,19 +1,19 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
Use Phake;
use Phake;
/** @covers \JKingWeb\Arsse\AbstractException */ /** @covers \JKingWeb\Arsse\AbstractException */
class TestException extends Test\AbstractTest { class TestException extends Test\AbstractTest {
function setUp() { public function setUp() {
$this->clearData(false); $this->clearData(false);
// create a mock Lang object so as not to create a dependency loop // create a mock Lang object so as not to create a dependency loop
Arsse::$lang = Phake::mock(Lang::class); Arsse::$lang = Phake::mock(Lang::class);
Phake::when(Arsse::$lang)->msg->thenReturn(""); Phake::when(Arsse::$lang)->msg->thenReturn("");
} }
function tearDown() { public function tearDown() {
// verify calls to the mock Lang object // verify calls to the mock Lang object
Phake::verify(Arsse::$lang, Phake::atLeast(0))->msg($this->isType("string"), $this->anything()); Phake::verify(Arsse::$lang, Phake::atLeast(0))->msg($this->isType("string"), $this->anything());
Phake::verifyNoOtherInteractions(Arsse::$lang); Phake::verifyNoOtherInteractions(Arsse::$lang);
@ -21,7 +21,7 @@ class TestException extends Test\AbstractTest {
$this->clearData(true); $this->clearData(true);
} }
function testBaseClass() { public function testBaseClass() {
$this->assertException("unknown"); $this->assertException("unknown");
throw new Exception("unknown"); throw new Exception("unknown");
} }
@ -29,7 +29,7 @@ class TestException extends Test\AbstractTest {
/** /**
* @depends testBaseClass * @depends testBaseClass
*/ */
function testBaseClassWithoutMessage() { public function testBaseClassWithoutMessage() {
$this->assertException("unknown"); $this->assertException("unknown");
throw new Exception(); throw new Exception();
} }
@ -37,7 +37,7 @@ class TestException extends Test\AbstractTest {
/** /**
* @depends testBaseClass * @depends testBaseClass
*/ */
function testDerivedClass() { public function testDerivedClass() {
$this->assertException("fileMissing", "Lang"); $this->assertException("fileMissing", "Lang");
throw new Lang\Exception("fileMissing"); throw new Lang\Exception("fileMissing");
} }
@ -45,7 +45,7 @@ class TestException extends Test\AbstractTest {
/** /**
* @depends testDerivedClass * @depends testDerivedClass
*/ */
function testDerivedClassWithMessageParameters() { public function testDerivedClassWithMessageParameters() {
$this->assertException("fileMissing", "Lang"); $this->assertException("fileMissing", "Lang");
throw new Lang\Exception("fileMissing", "en"); throw new Lang\Exception("fileMissing", "en");
} }
@ -53,7 +53,7 @@ class TestException extends Test\AbstractTest {
/** /**
* @depends testBaseClass * @depends testBaseClass
*/ */
function testBaseClassWithUnknownCode() { public function testBaseClassWithUnknownCode() {
$this->assertException("uncoded"); $this->assertException("uncoded");
throw new Exception("testThisExceptionMessageDoesNotExist"); throw new Exception("testThisExceptionMessageDoesNotExist");
} }
@ -61,7 +61,7 @@ class TestException extends Test\AbstractTest {
/** /**
* @depends testBaseClassWithUnknownCode * @depends testBaseClassWithUnknownCode
*/ */
function testDerivedClassWithMissingMessage() { public function testDerivedClassWithMissingMessage() {
$this->assertException("uncoded"); $this->assertException("uncoded");
throw new Lang\Exception("testThisExceptionMessageDoesNotExist"); throw new Lang\Exception("testThisExceptionMessageDoesNotExist");
} }

40
tests/Feed/TestFeed.php

@ -1,10 +1,10 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
use Phake; use Phake;
/** /**
* @covers \JKingWeb\Arsse\Feed * @covers \JKingWeb\Arsse\Feed
* @covers \JKingWeb\Arsse\Feed\Exception */ * @covers \JKingWeb\Arsse\Feed\Exception */
@ -80,8 +80,8 @@ class TestFeed extends Test\AbstractTest {
], ],
]; ];
function setUp() { public function setUp() {
if(!@file_get_contents(self::$host."IsUp")) { if (!@file_get_contents(self::$host."IsUp")) {
$this->markTestSkipped("Test Web server is not accepting requests"); $this->markTestSkipped("Test Web server is not accepting requests");
} }
$this->base = self::$host."Feed/"; $this->base = self::$host."Feed/";
@ -90,7 +90,7 @@ class TestFeed extends Test\AbstractTest {
Arsse::$db = Phake::mock(Database::class); Arsse::$db = Phake::mock(Database::class);
} }
function testParseAFeed() { public function testParseAFeed() {
// test that various properties are set on the feed and on items // test that various properties are set on the feed and on items
$f = new Feed(null, $this->base."Parsing/Valid"); $f = new Feed(null, $this->base."Parsing/Valid");
$this->assertTrue(isset($f->lastModified)); $this->assertTrue(isset($f->lastModified));
@ -133,27 +133,27 @@ class TestFeed extends Test\AbstractTest {
$this->assertSame($categories, $f->data->items[5]->categories); $this->assertSame($categories, $f->data->items[5]->categories);
} }
function testParseEntityExpansionAttack() { public function testParseEntityExpansionAttack() {
$this->assertException("xmlEntity", "Feed"); $this->assertException("xmlEntity", "Feed");
new Feed(null, $this->base."Parsing/XEEAttack"); new Feed(null, $this->base."Parsing/XEEAttack");
} }
function testParseExternalEntityAttack() { public function testParseExternalEntityAttack() {
$this->assertException("xmlEntity", "Feed"); $this->assertException("xmlEntity", "Feed");
new Feed(null, $this->base."Parsing/XXEAttack"); new Feed(null, $this->base."Parsing/XXEAttack");
} }
function testParseAnUnsupportedFeed() { public function testParseAnUnsupportedFeed() {
$this->assertException("unsupportedFeedFormat", "Feed"); $this->assertException("unsupportedFeedFormat", "Feed");
new Feed(null, $this->base."Parsing/Unsupported"); new Feed(null, $this->base."Parsing/Unsupported");
} }
function testParseAMalformedFeed() { public function testParseAMalformedFeed() {
$this->assertException("malformedXml", "Feed"); $this->assertException("malformedXml", "Feed");
new Feed(null, $this->base."Parsing/Malformed"); new Feed(null, $this->base."Parsing/Malformed");
} }
function testDeduplicateFeedItems() { public function testDeduplicateFeedItems() {
// duplicates with dates lead to the newest match being kept // duplicates with dates lead to the newest match being kept
$t = strtotime("2002-05-19T15:21:36Z"); $t = strtotime("2002-05-19T15:21:36Z");
$f = new Feed(null, $this->base."Deduplication/Permalink-Dates"); $f = new Feed(null, $this->base."Deduplication/Permalink-Dates");
@ -180,7 +180,7 @@ class TestFeed extends Test\AbstractTest {
$this->assertSame("http://example.com/1", $f->newItems[0]->url); $this->assertSame("http://example.com/1", $f->newItems[0]->url);
} }
function testHandleCacheHeadersOn304() { public function testHandleCacheHeadersOn304() {
// upon 304, the client should re-use the caching header values it supplied the server // upon 304, the client should re-use the caching header values it supplied the server
$t = time(); $t = time();
$e = "78567a"; $e = "78567a";
@ -198,7 +198,7 @@ class TestFeed extends Test\AbstractTest {
$this->assertSame($e, $f->resource->getETag()); $this->assertSame($e, $f->resource->getETag());
} }
function testHandleCacheHeadersOn200() { public function testHandleCacheHeadersOn200() {
// these tests should trust the server-returned time, even in cases of obviously incorrect results // these tests should trust the server-returned time, even in cases of obviously incorrect results
$t = time() - 2000; $t = time() - 2000;
$f = new Feed(null, $this->base."Caching/200Past"); $f = new Feed(null, $this->base."Caching/200Past");
@ -226,11 +226,11 @@ class TestFeed extends Test\AbstractTest {
$this->assertTime($t, $f->lastModified); $this->assertTime($t, $f->lastModified);
} }
function testComputeNextFetchOnError() { public function testComputeNextFetchOnError() {
for($a = 0; $a < 100; $a++) { for ($a = 0; $a < 100; $a++) {
if($a < 3) { if ($a < 3) {
$this->assertTime("now + 5 minutes", Feed::nextFetchOnError($a)); $this->assertTime("now + 5 minutes", Feed::nextFetchOnError($a));
} else if($a < 15) { } elseif ($a < 15) {
$this->assertTime("now + 3 hours", Feed::nextFetchOnError($a)); $this->assertTime("now + 3 hours", Feed::nextFetchOnError($a));
} else { } else {
$this->assertTime("now + 1 day", Feed::nextFetchOnError($a)); $this->assertTime("now + 1 day", Feed::nextFetchOnError($a));
@ -238,7 +238,7 @@ class TestFeed extends Test\AbstractTest {
} }
} }
function testComputeNextFetchFrom304() { public function testComputeNextFetchFrom304() {
// if less than half an hour, check in 15 minutes // if less than half an hour, check in 15 minutes
$t = strtotime("now"); $t = strtotime("now");
$f = new Feed(null, $this->base."NextFetch/NotModified?t=$t", Date::transform($t, "http")); $f = new Feed(null, $this->base."NextFetch/NotModified?t=$t", Date::transform($t, "http"));
@ -286,7 +286,7 @@ class TestFeed extends Test\AbstractTest {
$this->assertTime($exp, $f->nextFetch); $this->assertTime($exp, $f->nextFetch);
} }
function testComputeNextFetchFrom200() { public function testComputeNextFetchFrom200() {
// if less than half an hour, check in 15 minutes // if less than half an hour, check in 15 minutes
$f = new Feed(null, $this->base."NextFetch/30m"); $f = new Feed(null, $this->base."NextFetch/30m");
$exp = strtotime("now + 15 minutes"); $exp = strtotime("now + 15 minutes");
@ -313,7 +313,7 @@ class TestFeed extends Test\AbstractTest {
$this->assertTime($exp, $f->nextFetch); $this->assertTime($exp, $f->nextFetch);
} }
function testMatchLatestArticles() { public function testMatchLatestArticles() {
Phake::when(Arsse::$db)->feedMatchLatest(1, $this->anything())->thenReturn(new Test\Result($this->latest)); Phake::when(Arsse::$db)->feedMatchLatest(1, $this->anything())->thenReturn(new Test\Result($this->latest));
$f = new Feed(1, $this->base."Matching/1"); $f = new Feed(1, $this->base."Matching/1");
$this->assertCount(0, $f->newItems); $this->assertCount(0, $f->newItems);
@ -329,7 +329,7 @@ class TestFeed extends Test\AbstractTest {
$this->assertCount(2, $f->changedItems); $this->assertCount(2, $f->changedItems);
} }
function testMatchHistoricalArticles() { public function testMatchHistoricalArticles() {
Phake::when(Arsse::$db)->feedMatchLatest(1, $this->anything())->thenReturn(new Test\Result($this->latest)); Phake::when(Arsse::$db)->feedMatchLatest(1, $this->anything())->thenReturn(new Test\Result($this->latest));
Phake::when(Arsse::$db)->feedMatchIds(1, $this->anything(), $this->anything(), $this->anything(), $this->anything())->thenReturn(new Test\Result($this->others)); Phake::when(Arsse::$db)->feedMatchIds(1, $this->anything(), $this->anything(), $this->anything(), $this->anything())->thenReturn(new Test\Result($this->others));
$f = new Feed(1, $this->base."Matching/5"); $f = new Feed(1, $this->base."Matching/5");
@ -337,7 +337,7 @@ class TestFeed extends Test\AbstractTest {
$this->assertCount(0, $f->changedItems); $this->assertCount(0, $f->changedItems);
} }
function testScrapeFullContent() { public function testScrapeFullContent() {
// first make sure that the absence of scraping works as expected // first make sure that the absence of scraping works as expected
$f = new Feed(null, $this->base."Scraping/Feed"); $f = new Feed(null, $this->base."Scraping/Feed");
$exp = "<p>Partial content</p>"; $exp = "<p>Partial content</p>";

26
tests/Feed/TestFeedFetching.php

@ -1,18 +1,18 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
Use Phake;
use Phake;
/** @covers \JKingWeb\Arsse\Feed */ /** @covers \JKingWeb\Arsse\Feed */
class TestFeedFetching extends Test\AbstractTest { class TestFeedFetching extends Test\AbstractTest {
protected static $host = "http://localhost:8000/"; protected static $host = "http://localhost:8000/";
protected $base = ""; protected $base = "";
function setUp() { public function setUp() {
if(!extension_loaded('curl')) { if (!extension_loaded('curl')) {
$this->markTestSkipped("Feed fetching tests are only accurate with curl enabled."); $this->markTestSkipped("Feed fetching tests are only accurate with curl enabled.");
} else if(!@file_get_contents(self::$host."IsUp")) { } elseif (!@file_get_contents(self::$host."IsUp")) {
$this->markTestSkipped("Test Web server is not accepting requests"); $this->markTestSkipped("Test Web server is not accepting requests");
} }
$this->base = self::$host."Feed/"; $this->base = self::$host."Feed/";
@ -20,49 +20,49 @@ class TestFeedFetching extends Test\AbstractTest {
Arsse::$conf = new Conf(); Arsse::$conf = new Conf();
} }
function testHandle400() { public function testHandle400() {
$this->assertException("unsupportedFeedFormat", "Feed"); $this->assertException("unsupportedFeedFormat", "Feed");
new Feed(null, $this->base."Fetching/Error?code=400"); new Feed(null, $this->base."Fetching/Error?code=400");
} }
function testHandle401() { public function testHandle401() {
$this->assertException("unauthorized", "Feed"); $this->assertException("unauthorized", "Feed");
new Feed(null, $this->base."Fetching/Error?code=401"); new Feed(null, $this->base."Fetching/Error?code=401");
} }
function testHandle403() { public function testHandle403() {
$this->assertException("forbidden", "Feed"); $this->assertException("forbidden", "Feed");
new Feed(null, $this->base."Fetching/Error?code=403"); new Feed(null, $this->base."Fetching/Error?code=403");
} }
function testHandle404() { public function testHandle404() {
$this->assertException("invalidUrl", "Feed"); $this->assertException("invalidUrl", "Feed");
new Feed(null, $this->base."Fetching/Error?code=404"); new Feed(null, $this->base."Fetching/Error?code=404");
} }
function testHandle500() { public function testHandle500() {
$this->assertException("unsupportedFeedFormat", "Feed"); $this->assertException("unsupportedFeedFormat", "Feed");
new Feed(null, $this->base."Fetching/Error?code=500"); new Feed(null, $this->base."Fetching/Error?code=500");
} }
function testHandleARedirectLoop() { public function testHandleARedirectLoop() {
$this->assertException("maxRedirect", "Feed"); $this->assertException("maxRedirect", "Feed");
new Feed(null, $this->base."Fetching/EndlessLoop?i=0"); new Feed(null, $this->base."Fetching/EndlessLoop?i=0");
} }
function testHandleATimeout() { public function testHandleATimeout() {
Arsse::$conf->fetchTimeout = 1; Arsse::$conf->fetchTimeout = 1;
$this->assertException("timeout", "Feed"); $this->assertException("timeout", "Feed");
new Feed(null, $this->base."Fetching/Timeout"); new Feed(null, $this->base."Fetching/Timeout");
} }
function testHandleAnOverlyLargeFeed() { public function testHandleAnOverlyLargeFeed() {
Arsse::$conf->fetchSizeLimit = 512; Arsse::$conf->fetchSizeLimit = 512;
$this->assertException("maxSize", "Feed"); $this->assertException("maxSize", "Feed");
new Feed(null, $this->base."Fetching/TooLarge"); new Feed(null, $this->base."Fetching/TooLarge");
} }
function testHandleACertificateError() { public function testHandleACertificateError() {
$this->assertException("invalidCertificate", "Feed"); $this->assertException("invalidCertificate", "Feed");
new Feed(null, "https://localhost:8000/"); new Feed(null, "https://localhost:8000/");
} }

12
tests/Lang/TestLang.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStream;
/** @covers \JKingWeb\Arsse\Lang */ /** @covers \JKingWeb\Arsse\Lang */
@ -11,14 +12,14 @@ class TestLang extends Test\AbstractTest {
public $path; public $path;
public $l; public $l;
function testListLanguages() { public function testListLanguages() {
$this->assertCount(sizeof($this->files), $this->l->list("en")); $this->assertCount(sizeof($this->files), $this->l->list("en"));
} }
/** /**
* @depends testListLanguages * @depends testListLanguages
*/ */
function testSetLanguage() { public function testSetLanguage() {
$this->assertEquals("en", $this->l->set("en")); $this->assertEquals("en", $this->l->set("en"));
$this->assertEquals("en_ca", $this->l->set("en_ca")); $this->assertEquals("en_ca", $this->l->set("en_ca"));
$this->assertEquals("de", $this->l->set("de_ch")); $this->assertEquals("de", $this->l->set("de_ch"));
@ -31,7 +32,7 @@ class TestLang extends Test\AbstractTest {
/** /**
* @depends testSetLanguage * @depends testSetLanguage
*/ */
function testLoadInternalStrings() { public function testLoadInternalStrings() {
$this->assertEquals("", $this->l->set("", true)); $this->assertEquals("", $this->l->set("", true));
$this->assertCount(sizeof(Lang::REQUIRED), $this->l->dump()); $this->assertCount(sizeof(Lang::REQUIRED), $this->l->dump());
} }
@ -39,7 +40,7 @@ class TestLang extends Test\AbstractTest {
/** /**
* @depends testLoadInternalStrings * @depends testLoadInternalStrings
*/ */
function testLoadDefaultLanguage() { public function testLoadDefaultLanguage() {
$this->assertEquals(Lang::DEFAULT, $this->l->set(Lang::DEFAULT, true)); $this->assertEquals(Lang::DEFAULT, $this->l->set(Lang::DEFAULT, true));
$str = $this->l->dump(); $str = $this->l->dump();
$this->assertArrayHasKey('Exception.JKingWeb/Arsse/Exception.uncoded', $str); $this->assertArrayHasKey('Exception.JKingWeb/Arsse/Exception.uncoded', $str);
@ -49,7 +50,7 @@ class TestLang extends Test\AbstractTest {
/** /**
* @depends testLoadDefaultLanguage * @depends testLoadDefaultLanguage
*/ */
function testLoadSupplementaryLanguage() { public function testLoadSupplementaryLanguage() {
$this->l->set(Lang::DEFAULT, true); $this->l->set(Lang::DEFAULT, true);
$this->assertEquals("ja", $this->l->set("ja", true)); $this->assertEquals("ja", $this->l->set("ja", true));
$str = $this->l->dump(); $str = $this->l->dump();
@ -57,5 +58,4 @@ class TestLang extends Test\AbstractTest {
$this->assertArrayHasKey('Test.presentText', $str); $this->assertArrayHasKey('Test.presentText', $str);
$this->assertArrayHasKey('Test.absentText', $str); $this->assertArrayHasKey('Test.absentText', $str);
} }
} }

21
tests/Lang/TestLangErrors.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStream;
/** @covers \JKingWeb\Arsse\Lang */ /** @covers \JKingWeb\Arsse\Lang */
@ -11,52 +12,52 @@ class TestLangErrors extends Test\AbstractTest {
public $path; public $path;
public $l; public $l;
function setUpSeries() { public function setUpSeries() {
$this->l->set("", true); $this->l->set("", true);
} }
function testLoadEmptyFile() { public function testLoadEmptyFile() {
$this->assertException("fileCorrupt", "Lang"); $this->assertException("fileCorrupt", "Lang");
$this->l->set("fr_ca", true); $this->l->set("fr_ca", true);
} }
function testLoadFileWhichDoesNotReturnAnArray() { public function testLoadFileWhichDoesNotReturnAnArray() {
$this->assertException("fileCorrupt", "Lang"); $this->assertException("fileCorrupt", "Lang");
$this->l->set("it", true); $this->l->set("it", true);
} }
function testLoadFileWhichIsNotPhp() { public function testLoadFileWhichIsNotPhp() {
$this->assertException("fileCorrupt", "Lang"); $this->assertException("fileCorrupt", "Lang");
$this->l->set("ko", true); $this->l->set("ko", true);
} }
function testLoadFileWhichIsCorrupt() { public function testLoadFileWhichIsCorrupt() {
$this->assertException("fileCorrupt", "Lang"); $this->assertException("fileCorrupt", "Lang");
$this->l->set("zh", true); $this->l->set("zh", true);
} }
function testLoadFileWithooutReadPermission() { public function testLoadFileWithooutReadPermission() {
$this->assertException("fileUnreadable", "Lang"); $this->assertException("fileUnreadable", "Lang");
$this->l->set("ru", true); $this->l->set("ru", true);
} }
function testLoadSubtagOfMissingLanguage() { public function testLoadSubtagOfMissingLanguage() {
$this->assertException("fileMissing", "Lang"); $this->assertException("fileMissing", "Lang");
$this->l->set("pt_br", true); $this->l->set("pt_br", true);
} }
function testFetchInvalidMessage() { public function testFetchInvalidMessage() {
$this->assertException("stringInvalid", "Lang"); $this->assertException("stringInvalid", "Lang");
$this->l->set("vi", true); $this->l->set("vi", true);
$txt = $this->l->msg('Test.presentText'); $txt = $this->l->msg('Test.presentText');
} }
function testFetchMissingMessage() { public function testFetchMissingMessage() {
$this->assertException("stringMissing", "Lang"); $this->assertException("stringMissing", "Lang");
$txt = $this->l->msg('Test.absentText'); $txt = $this->l->msg('Test.absentText');
} }
function testLoadMissingDefaultLanguage() { public function testLoadMissingDefaultLanguage() {
unlink($this->path.Lang::DEFAULT.".php"); unlink($this->path.Lang::DEFAULT.".php");
$this->assertException("defaultFileMissing", "Lang"); $this->assertException("defaultFileMissing", "Lang");
$this->l->set("fr", true); $this->l->set("fr", true);

25
tests/Lang/testLangComplex.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStream;
/** @covers \JKingWeb\Arsse\Lang */ /** @covers \JKingWeb\Arsse\Lang */
@ -11,11 +12,11 @@ class TestLangComplex extends Test\AbstractTest {
public $path; public $path;
public $l; public $l;
function setUpSeries() { public function setUpSeries() {
$this->l->set(Lang::DEFAULT, true); $this->l->set(Lang::DEFAULT, true);
} }
function testLazyLoad() { public function testLazyLoad() {
$this->l->set("ja"); $this->l->set("ja");
$this->assertArrayNotHasKey('Test.absentText', $this->l->dump()); $this->assertArrayNotHasKey('Test.absentText', $this->l->dump());
} }
@ -23,14 +24,14 @@ class TestLangComplex extends Test\AbstractTest {
/** /**
* @depends testLazyLoad * @depends testLazyLoad
*/ */
function testGetWantedAndLoadedLocale() { public function testGetWantedAndLoadedLocale() {
$this->l->set("en", true); $this->l->set("en", true);
$this->l->set("ja"); $this->l->set("ja");
$this->assertEquals("ja", $this->l->get()); $this->assertEquals("ja", $this->l->get());
$this->assertEquals("en", $this->l->get(true)); $this->assertEquals("en", $this->l->get(true));
} }
function testLoadCascadeOfFiles() { public function testLoadCascadeOfFiles() {
$this->l->set("ja", true); $this->l->set("ja", true);
$this->assertEquals("de", $this->l->set("de", true)); $this->assertEquals("de", $this->l->set("de", true));
$str = $this->l->dump(); $str = $this->l->dump();
@ -41,11 +42,11 @@ class TestLangComplex extends Test\AbstractTest {
/** /**
* @depends testLoadCascadeOfFiles * @depends testLoadCascadeOfFiles
*/ */
function testLoadSubtag() { public function testLoadSubtag() {
$this->assertEquals("en_ca", $this->l->set("en_ca", true)); $this->assertEquals("en_ca", $this->l->set("en_ca", true));
} }
function testFetchAMessage() { public function testFetchAMessage() {
$this->l->set("de", true); $this->l->set("de", true);
$this->assertEquals('und der Stein der Weisen', $this->l->msg('Test.presentText')); $this->assertEquals('und der Stein der Weisen', $this->l->msg('Test.presentText'));
} }
@ -53,7 +54,7 @@ class TestLangComplex extends Test\AbstractTest {
/** /**
* @depends testFetchAMessage * @depends testFetchAMessage
*/ */
function testFetchAMessageWithMissingParameters() { public function testFetchAMessageWithMissingParameters() {
$this->l->set("en_ca", true); $this->l->set("en_ca", true);
$this->assertEquals('{0} and {1}', $this->l->msg('Test.presentText')); $this->assertEquals('{0} and {1}', $this->l->msg('Test.presentText'));
} }
@ -61,7 +62,7 @@ class TestLangComplex extends Test\AbstractTest {
/** /**
* @depends testFetchAMessage * @depends testFetchAMessage
*/ */
function testFetchAMessageWithSingleNumericParameter() { public function testFetchAMessageWithSingleNumericParameter() {
$this->l->set("en_ca", true); $this->l->set("en_ca", true);
$this->assertEquals('Default language file "en" missing', $this->l->msg('Exception.JKingWeb/Arsse/Lang/Exception.defaultFileMissing', Lang::DEFAULT)); $this->assertEquals('Default language file "en" missing', $this->l->msg('Exception.JKingWeb/Arsse/Lang/Exception.defaultFileMissing', Lang::DEFAULT));
} }
@ -69,7 +70,7 @@ class TestLangComplex extends Test\AbstractTest {
/** /**
* @depends testFetchAMessage * @depends testFetchAMessage
*/ */
function testFetchAMessageWithMultipleNumericParameters() { public function testFetchAMessageWithMultipleNumericParameters() {
$this->l->set("en_ca", true); $this->l->set("en_ca", true);
$this->assertEquals('Happy Rotter and the Philosopher\'s Stone', $this->l->msg('Test.presentText', ['Happy Rotter', 'the Philosopher\'s Stone'])); $this->assertEquals('Happy Rotter and the Philosopher\'s Stone', $this->l->msg('Test.presentText', ['Happy Rotter', 'the Philosopher\'s Stone']));
} }
@ -77,14 +78,14 @@ class TestLangComplex extends Test\AbstractTest {
/** /**
* @depends testFetchAMessage * @depends testFetchAMessage
*/ */
function testFetchAMessageWithNamedParameters() { public function testFetchAMessageWithNamedParameters() {
$this->assertEquals('Message string "Test.absentText" missing from all loaded language files (en)', $this->l->msg('Exception.JKingWeb/Arsse/Lang/Exception.stringMissing', ['msgID' => 'Test.absentText', 'fileList' => 'en'])); $this->assertEquals('Message string "Test.absentText" missing from all loaded language files (en)', $this->l->msg('Exception.JKingWeb/Arsse/Lang/Exception.stringMissing', ['msgID' => 'Test.absentText', 'fileList' => 'en']));
} }
/** /**
* @depends testFetchAMessage * @depends testFetchAMessage
*/ */
function testReloadDefaultStrings() { public function testReloadDefaultStrings() {
$this->l->set("de", true); $this->l->set("de", true);
$this->l->set("en", true); $this->l->set("en", true);
$this->assertEquals('and the Philosopher\'s Stone', $this->l->msg('Test.presentText')); $this->assertEquals('and the Philosopher\'s Stone', $this->l->msg('Test.presentText'));
@ -93,7 +94,7 @@ class TestLangComplex extends Test\AbstractTest {
/** /**
* @depends testFetchAMessage * @depends testFetchAMessage
*/ */
function testReloadGeneralTagAfterSubtag() { public function testReloadGeneralTagAfterSubtag() {
$this->l->set("en", true); $this->l->set("en", true);
$this->l->set("en_us", true); $this->l->set("en_us", true);
$this->assertEquals('and the Sorcerer\'s Stone', $this->l->msg('Test.presentText')); $this->assertEquals('and the Sorcerer\'s Stone', $this->l->msg('Test.presentText'));

20
tests/Misc/TestContext.php

@ -1,15 +1,15 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Misc\Context;
/** @covers \JKingWeb\Arsse\Misc\Context */ /** @covers \JKingWeb\Arsse\Misc\Context */
class TestContext extends Test\AbstractTest { class TestContext extends Test\AbstractTest {
function testVerifyInitialState() { public function testVerifyInitialState() {
$c = new Context; $c = new Context;
foreach((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) {
if($m->isConstructor() || $m->isStatic()) { if ($m->isConstructor() || $m->isStatic()) {
continue; continue;
} }
$method = $m->name; $method = $m->name;
@ -18,7 +18,7 @@ class TestContext extends Test\AbstractTest {
} }
} }
function testSetContextOptions() { public function testSetContextOptions() {
$v = [ $v = [
'reverse' => true, 'reverse' => true,
'limit' => 10, 'limit' => 10,
@ -38,15 +38,15 @@ class TestContext extends Test\AbstractTest {
]; ];
$times = ['modifiedSince','notModifiedSince']; $times = ['modifiedSince','notModifiedSince'];
$c = new Context; $c = new Context;
foreach((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) {
if($m->isConstructor() || $m->isStatic()) { if ($m->isConstructor() || $m->isStatic()) {
continue; continue;
} }
$method = $m->name; $method = $m->name;
$this->assertArrayHasKey($method, $v, "Context method $method not included in test"); $this->assertArrayHasKey($method, $v, "Context method $method not included in test");
$this->assertInstanceOf(Context::class, $c->$method($v[$method])); $this->assertInstanceOf(Context::class, $c->$method($v[$method]));
$this->assertTrue($c->$method()); $this->assertTrue($c->$method());
if(in_array($method, $times)) { if (in_array($method, $times)) {
$this->assertTime($c->$method, $v[$method]); $this->assertTime($c->$method, $v[$method]);
} else { } else {
$this->assertSame($c->$method, $v[$method]); $this->assertSame($c->$method, $v[$method]);
@ -54,12 +54,12 @@ class TestContext extends Test\AbstractTest {
} }
} }
function testCleanArrayValues() { public function testCleanArrayValues() {
$methods = ["articles", "editions"]; $methods = ["articles", "editions"];
$in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; $in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0];
$out = [1,2, 3]; $out = [1,2, 3];
$c = new Context; $c = new Context;
foreach($methods as $method) { foreach ($methods as $method) {
$this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results");
} }
} }

79
tests/REST/NextCloudNews/TestNCNV1_2.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\REST\Request;
use JKingWeb\Arsse\REST\Response; use JKingWeb\Arsse\REST\Response;
use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Test\Result;
@ -259,7 +260,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
], ],
]; ];
function setUp() { public function setUp() {
$this->clearData(); $this->clearData();
Arsse::$conf = new Conf(); Arsse::$conf = new Conf();
// create a mock user manager // create a mock user manager
@ -268,16 +269,16 @@ class TestNCNV1_2 extends Test\AbstractTest {
Phake::when(Arsse::$user)->rightsGet->thenReturn(100); Phake::when(Arsse::$user)->rightsGet->thenReturn(100);
Arsse::$user->id = "john.doe@example.com"; Arsse::$user->id = "john.doe@example.com";
// create a mock database interface // create a mock database interface
Arsse::$db = Phake::mock(Database::Class); Arsse::$db = Phake::mock(Database::class);
Phake::when(Arsse::$db)->begin->thenReturn(Phake::mock(Transaction::class)); Phake::when(Arsse::$db)->begin->thenReturn(Phake::mock(Transaction::class));
$this->h = new REST\NextCloudNews\V1_2(); $this->h = new REST\NextCloudNews\V1_2();
} }
function tearDown() { public function tearDown() {
$this->clearData(); $this->clearData();
} }
function testRespondToInvalidPaths() { public function testRespondToInvalidPaths() {
$errs = [ $errs = [
501 => [ 501 => [
['GET', "/"], ['GET', "/"],
@ -309,34 +310,34 @@ class TestNCNV1_2 extends Test\AbstractTest {
], ],
], ],
]; ];
foreach($errs[501] as $req) { foreach ($errs[501] as $req) {
$exp = new Response(501); $exp = new Response(501);
list($method, $path) = $req; list($method, $path) = $req;
$this->assertEquals($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 501."); $this->assertEquals($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 501.");
} }
foreach($errs[405] as $allow => $cases) { foreach ($errs[405] as $allow => $cases) {
$exp = new Response(405, "", "", ['Allow: '.$allow]); $exp = new Response(405, "", "", ['Allow: '.$allow]);
foreach($cases as $req) { foreach ($cases as $req) {
list($method, $path) = $req; list($method, $path) = $req;
$this->assertEquals($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 405."); $this->assertEquals($exp, $this->h->dispatch(new Request($method, $path)), "$method call to $path did not return 405.");
} }
} }
} }
function testRespondToInvalidInputTypes() { public function testRespondToInvalidInputTypes() {
$exp = new Response(415, "", "", ['Accept: application/json']); $exp = new Response(415, "", "", ['Accept: application/json']);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '<data/>', 'application/xml'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '<data/>', 'application/xml')));
$exp = new Response(400); $exp = new Response(400);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '<data/>', 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/1", '<data/>', 'application/json')));
} }
function testReceiveAuthenticationChallenge() { public function testReceiveAuthenticationChallenge() {
Phake::when(Arsse::$user)->authHTTP->thenReturn(false); Phake::when(Arsse::$user)->authHTTP->thenReturn(false);
$exp = new Response(401, "", "", ['WWW-Authenticate: Basic realm="'.REST\NextCloudNews\V1_2::REALM.'"']); $exp = new Response(401, "", "", ['WWW-Authenticate: Basic realm="'.REST\NextCloudNews\V1_2::REALM.'"']);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/")));
} }
function testListFolders() { public function testListFolders() {
$list = [ $list = [
['id' => 1, 'name' => "Software", 'parent' => null], ['id' => 1, 'name' => "Software", 'parent' => null],
['id' => 12, 'name' => "Hardware", 'parent' => null], ['id' => 12, 'name' => "Hardware", 'parent' => null],
@ -348,7 +349,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/folders"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/folders")));
} }
function testAddAFolder() { public function testAddAFolder() {
$in = [ $in = [
["name" => "Software"], ["name" => "Software"],
["name" => "Hardware"], ["name" => "Hardware"],
@ -387,7 +388,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[1]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", json_encode($in[1]), 'application/json')));
} }
function testRemoveAFolder() { public function testRemoveAFolder() {
Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
$exp = new Response(204); $exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("DELETE", "/folders/1"))); $this->assertEquals($exp, $this->h->dispatch(new Request("DELETE", "/folders/1")));
@ -397,7 +398,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
Phake::verify(Arsse::$db, Phake::times(2))->folderRemove(Arsse::$user->id, 1); Phake::verify(Arsse::$db, Phake::times(2))->folderRemove(Arsse::$user->id, 1);
} }
function testRenameAFolder() { public function testRenameAFolder() {
$in = [ $in = [
["name" => "Software"], ["name" => "Software"],
["name" => "Software"], ["name" => "Software"],
@ -425,7 +426,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/3", json_encode($in[0]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/3", json_encode($in[0]), 'application/json')));
} }
function testRetrieveServerVersion() { public function testRetrieveServerVersion() {
$exp = new Response(200, [ $exp = new Response(200, [
'arsse_version' => \JKingWeb\Arsse\VERSION, 'arsse_version' => \JKingWeb\Arsse\VERSION,
'version' => REST\NextCloudNews\V1_2::VERSION, 'version' => REST\NextCloudNews\V1_2::VERSION,
@ -433,7 +434,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/version"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/version")));
} }
function testListSubscriptions() { public function testListSubscriptions() {
$exp1 = [ $exp1 = [
'feeds' => [], 'feeds' => [],
'starredCount' => 0, 'starredCount' => 0,
@ -452,7 +453,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds")));
} }
function testAddASubscription() { public function testAddASubscription() {
$in = [ $in = [
['url' => "http://example.com/news.atom", 'folderId' => 3], ['url' => "http://example.com/news.atom", 'folderId' => 3],
['url' => "http://example.org/news.atom", 'folderId' => 8], ['url' => "http://example.org/news.atom", 'folderId' => 8],
@ -467,11 +468,11 @@ class TestNCNV1_2 extends Test\AbstractTest {
]; ];
// set up the necessary mocks // set up the necessary mocks
Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.com/news.atom")->thenReturn(2112)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.com/news.atom")->thenReturn(2112)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call
Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.org/news.atom")->thenReturn( 42 )->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.org/news.atom")->thenReturn(42)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call
Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2112)->thenReturn($this->feeds['db'][0]); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2112)->thenReturn($this->feeds['db'][0]);
Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 42)->thenReturn($this->feeds['db'][1]); Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 42)->thenReturn($this->feeds['db'][1]);
Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription(2112))->thenReturn(0); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription(2112))->thenReturn(0);
Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription( 42))->thenReturn(4758915); Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription(42))->thenReturn(4758915);
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 2112, ['folder' => 3])->thenThrow(new ExceptionInput("idMissing")); // folder ID 3 does not exist Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 2112, ['folder' => 3])->thenThrow(new ExceptionInput("idMissing")); // folder ID 3 does not exist
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, ['folder' => 8])->thenReturn(true); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, ['folder' => 8])->thenReturn(true);
// set up a mock for a bad feed // set up a mock for a bad feed
@ -494,7 +495,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[3]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[3]), 'application/json')));
} }
function testRemoveASubscription() { public function testRemoveASubscription() {
Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
$exp = new Response(204); $exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1"))); $this->assertEquals($exp, $this->h->dispatch(new Request("DELETE", "/feeds/1")));
@ -504,7 +505,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
Phake::verify(Arsse::$db, Phake::times(2))->subscriptionRemove(Arsse::$user->id, 1); Phake::verify(Arsse::$db, Phake::times(2))->subscriptionRemove(Arsse::$user->id, 1);
} }
function testMoveASubscription() { public function testMoveASubscription() {
$in = [ $in = [
['folderId' => 0], ['folderId' => 0],
['folderId' => 42], ['folderId' => 42],
@ -528,7 +529,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[4]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[4]), 'application/json')));
} }
function testRenameASubscription() { public function testRenameASubscription() {
$in = [ $in = [
['feedTitle' => null], ['feedTitle' => null],
['feedTitle' => "Ook"], ['feedTitle' => "Ook"],
@ -558,7 +559,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[6]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/rename", json_encode($in[6]), 'application/json')));
} }
function testListStaleFeeds() { public function testListStaleFeeds() {
$out = [ $out = [
[ [
'id' => 42, 'id' => 42,
@ -569,7 +570,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
'userId' => "", 'userId' => "",
], ],
]; ];
Phake::when(Arsse::$db)->feedListStale->thenReturn(array_column($out,"id")); Phake::when(Arsse::$db)->feedListStale->thenReturn(array_column($out, "id"));
$exp = new Response(200, ['feeds' => $out]); $exp = new Response(200, ['feeds' => $out]);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/all")));
// retrieving the list when not an admin fails // retrieving the list when not an admin fails
@ -578,14 +579,14 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/all"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/all")));
} }
function testUpdateAFeed() { public function testUpdateAFeed() {
$in = [ $in = [
['feedId' => 42], // valid ['feedId' => 42], // valid
['feedId' => 2112], // feed does not exist ['feedId' => 2112], // feed does not exist
['feedId' => "ook"], // invalid ID ['feedId' => "ook"], // invalid ID
['feed' => 42], // invalid input ['feed' => 42], // invalid input
]; ];
Phake::when(Arsse::$db)->feedUpdate( 42)->thenReturn(true); Phake::when(Arsse::$db)->feedUpdate(42)->thenReturn(true);
Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing"));
$exp = new Response(204); $exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json')));
@ -601,7 +602,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[0]), 'application/json')));
} }
function testListArticles() { public function testListArticles() {
$res = new Result($this->articles['db']); $res = new Result($this->articles['db']);
$t = new \DateTime; $t = new \DateTime;
$in = [ $in = [
@ -648,7 +649,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5));
} }
function testMarkAFolderRead() { public function testMarkAFolderRead() {
$read = ['read' => true]; $read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]); $in = json_encode(['newestItemId' => 2112]);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(true); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(true);
@ -663,7 +664,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/42/read", $in, 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/folders/42/read", $in, 'application/json')));
} }
function testMarkASubscriptionRead() { public function testMarkASubscriptionRead() {
$read = ['read' => true]; $read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]); $in = json_encode(['newestItemId' => 2112]);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(true); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(true);
@ -678,7 +679,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/read", $in, 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/read", $in, 'application/json')));
} }
function testMarkAllItemsRead() { public function testMarkAllItemsRead() {
$read = ['read' => true]; $read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]); $in = json_encode(['newestItemId' => 2112]);
Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(true); Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(true);
@ -690,7 +691,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=ook"))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/items/read?newestItemId=ook")));
} }
function testChangeMarksOfASingleArticle() { public function testChangeMarksOfASingleArticle() {
$read = ['read' => true]; $read = ['read' => true];
$unread = ['read' => false]; $unread = ['read' => false];
$star = ['starred' => true]; $star = ['starred' => true];
@ -716,20 +717,20 @@ class TestNCNV1_2 extends Test\AbstractTest {
Phake::verify(Arsse::$db, Phake::times(8))->articleMark(Arsse::$user->id, $this->anything(), $this->anything()); Phake::verify(Arsse::$db, Phake::times(8))->articleMark(Arsse::$user->id, $this->anything(), $this->anything());
} }
function testChangeMarksOfMultipleArticles() { public function testChangeMarksOfMultipleArticles() {
$read = ['read' => true]; $read = ['read' => true];
$unread = ['read' => false]; $unread = ['read' => false];
$star = ['starred' => true]; $star = ['starred' => true];
$unstar = ['starred' => false]; $unstar = ['starred' => false];
$in = [ $in = [
["ook","eek","ack"], ["ook","eek","ack"],
range(100,199), range(100, 199),
range(100,149), range(100, 149),
range(150,199), range(150, 199),
]; ];
$inStar = $in; $inStar = $in;
for($a = 0; $a < sizeof($inStar); $a++) { for ($a = 0; $a < sizeof($inStar); $a++) {
for($b = 0; $b < sizeof($inStar[$a]); $b++) { for ($b = 0; $b < sizeof($inStar[$a]); $b++) {
$inStar[$a][$b] = ['feedId' => 2112, 'guidHash' => $inStar[$a][$b]]; $inStar[$a][$b] = ['feedId' => 2112, 'guidHash' => $inStar[$a][$b]];
} }
} }
@ -783,7 +784,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[3])); Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $unstar, (new Context)->articles($in[3]));
} }
function testQueryTheServerStatus() { public function testQueryTheServerStatus() {
$interval = Service::interval(); $interval = Service::interval();
$valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval); $valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval);
$invalid = $valid->sub($interval)->sub($interval); $invalid = $valid->sub($interval)->sub($interval);
@ -800,7 +801,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/status"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/status")));
} }
function testCleanUpBeforeUpdate() { public function testCleanUpBeforeUpdate() {
Phake::when(Arsse::$db)->feedCleanup()->thenReturn(true); Phake::when(Arsse::$db)->feedCleanup()->thenReturn(true);
$exp = new Response(204); $exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update")));
@ -811,7 +812,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/cleanup/before-update")));
} }
function testCleanUpAfterUpdate() { public function testCleanUpAfterUpdate() {
Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true); Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true);
$exp = new Response(204); $exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update"))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/cleanup/after-update")));

9
tests/REST/NextCloudNews/TestNCNVersionDiscovery.php

@ -1,16 +1,17 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\REST\Request;
use JKingWeb\Arsse\REST\Response; use JKingWeb\Arsse\REST\Response;
/** @covers \JKingWeb\Arsse\REST\NextCloudNews\Versions */ /** @covers \JKingWeb\Arsse\REST\NextCloudNews\Versions */
class TestNCNVersionDiscovery extends Test\AbstractTest { class TestNCNVersionDiscovery extends Test\AbstractTest {
function setUp() { public function setUp() {
$this->clearData(); $this->clearData();
} }
function testFetchVersionList() { public function testFetchVersionList() {
$exp = new Response(200, ['apiLevels' => ['v1-2']]); $exp = new Response(200, ['apiLevels' => ['v1-2']]);
$h = new REST\NextCloudNews\Versions(); $h = new REST\NextCloudNews\Versions();
$req = new Request("GET", "/"); $req = new Request("GET", "/");
@ -24,7 +25,7 @@ class TestNCNVersionDiscovery extends Test\AbstractTest {
$this->assertEquals($exp, $res); $this->assertEquals($exp, $res);
} }
function testUseIncorrectMethod() { public function testUseIncorrectMethod() {
$exp = new Response(405); $exp = new Response(405);
$h = new REST\NextCloudNews\Versions(); $h = new REST\NextCloudNews\Versions();
$req = new Request("POST", "/"); $req = new Request("POST", "/");
@ -32,7 +33,7 @@ class TestNCNVersionDiscovery extends Test\AbstractTest {
$this->assertEquals($exp, $res); $this->assertEquals($exp, $res);
} }
function testUseIncorrectPath() { public function testUseIncorrectPath() {
$exp = new Response(501); $exp = new Response(501);
$h = new REST\NextCloudNews\Versions(); $h = new REST\NextCloudNews\Versions();
$req = new Request("GET", "/ook"); $req = new Request("GET", "/ook");

17
tests/Service/TestService.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
use Phake; use Phake;
@ -8,14 +9,14 @@ use Phake;
class TestService extends Test\AbstractTest { class TestService extends Test\AbstractTest {
protected $srv; protected $srv;
function setUp() { public function setUp() {
$this->clearData(); $this->clearData();
Arsse::$conf = new Conf(); Arsse::$conf = new Conf();
Arsse::$db = Phake::mock(Database::class); Arsse::$db = Phake::mock(Database::class);
$this->srv = new Service(); $this->srv = new Service();
} }
function testComputeInterval() { public function testComputeInterval() {
$in = [ $in = [
Arsse::$conf->serviceFrequency, Arsse::$conf->serviceFrequency,
"PT2M", "PT2M",
@ -24,21 +25,25 @@ class TestService extends Test\AbstractTest {
"5M", "5M",
"interval", "interval",
]; ];
foreach($in as $index => $spec) { foreach ($in as $index => $spec) {
try{$exp = new \DateInterval($spec);} catch(\Exception $e) {$exp = new \DateInterval("PT2M");} try {
$exp = new \DateInterval($spec);
} catch (\Exception $e) {
$exp = new \DateInterval("PT2M");
}
Arsse::$conf->serviceFrequency = $spec; Arsse::$conf->serviceFrequency = $spec;
$this->assertEquals($exp, Service::interval(), "Interval #$index '$spec' was not correctly calculated"); $this->assertEquals($exp, Service::interval(), "Interval #$index '$spec' was not correctly calculated");
} }
} }
function testCheckIn() { public function testCheckIn() {
$now = time(); $now = time();
$this->srv->checkIn(); $this->srv->checkIn();
Phake::verify(Arsse::$db)->metaSet("service_last_checkin", Phake::capture($then), "datetime"); Phake::verify(Arsse::$db)->metaSet("service_last_checkin", Phake::capture($then), "datetime");
$this->assertTime($now, $then); $this->assertTime($now, $then);
} }
function testReportHavingCheckedIn() { public function testReportHavingCheckedIn() {
// the mock's metaGet() returns null by default // the mock's metaGet() returns null by default
$this->assertFalse(Service::hasCheckedIn()); $this->assertFalse(Service::hasCheckedIn());
$interval = Service::interval(); $interval = Service::interval();

135
tests/User/TestAuthorization.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
use Phake; use Phake;
/** @covers \JKingWeb\Arsse\User */ /** @covers \JKingWeb\Arsse\User */
@ -44,29 +45,29 @@ class TestAuthorization extends Test\AbstractTest {
protected $data; protected $data;
function setUp(string $drv = Test\User\DriverInternalMock::class, string $db = null) { public function setUp(string $drv = Test\User\DriverInternalMock::class, string $db = null) {
$this->clearData(); $this->clearData();
$conf = new Conf(); $conf = new Conf();
$conf->userDriver = $drv; $conf->userDriver = $drv;
$conf->userPreAuth = false; $conf->userPreAuth = false;
Arsse::$conf = $conf; Arsse::$conf = $conf;
if($db !== null) { if ($db !== null) {
Arsse::$db = new $db(); Arsse::$db = new $db();
} }
Arsse::$user = Phake::partialMock(User::class); Arsse::$user = Phake::partialMock(User::class);
Phake::when(Arsse::$user)->authorize->thenReturn(true); Phake::when(Arsse::$user)->authorize->thenReturn(true);
foreach(self::USERS as $user => $level) { foreach (self::USERS as $user => $level) {
Arsse::$user->add($user, ""); Arsse::$user->add($user, "");
Arsse::$user->rightsSet($user, $level); Arsse::$user->rightsSet($user, $level);
} }
Phake::reset(Arsse::$user); Phake::reset(Arsse::$user);
} }
function tearDown() { public function tearDown() {
$this->clearData(); $this->clearData();
} }
function testToggleLogic() { public function testToggleLogic() {
$this->assertTrue(Arsse::$user->authorizationEnabled()); $this->assertTrue(Arsse::$user->authorizationEnabled());
$this->assertTrue(Arsse::$user->authorizationEnabled(true)); $this->assertTrue(Arsse::$user->authorizationEnabled(true));
$this->assertFalse(Arsse::$user->authorizationEnabled(false)); $this->assertFalse(Arsse::$user->authorizationEnabled(false));
@ -75,8 +76,8 @@ class TestAuthorization extends Test\AbstractTest {
$this->assertTrue(Arsse::$user->authorizationEnabled(true)); $this->assertTrue(Arsse::$user->authorizationEnabled(true));
} }
function testSelfActionLogic() { public function testSelfActionLogic() {
foreach(array_keys(self::USERS) as $user) { foreach (array_keys(self::USERS) as $user) {
Arsse::$user->auth($user, ""); Arsse::$user->auth($user, "");
// users should be able to do basic actions for themselves // users should be able to do basic actions for themselves
$this->assertTrue(Arsse::$user->authorize($user, "userExists"), "User $user could not act for themselves."); $this->assertTrue(Arsse::$user->authorize($user, "userExists"), "User $user could not act for themselves.");
@ -84,15 +85,15 @@ class TestAuthorization extends Test\AbstractTest {
} }
} }
function testRegularUserLogic() { public function testRegularUserLogic() {
foreach(self::USERS as $actor => $rights) { foreach (self::USERS as $actor => $rights) {
if($rights != User\Driver::RIGHTS_NONE) { if ($rights != User\Driver::RIGHTS_NONE) {
continue; continue;
} }
Arsse::$user->auth($actor, ""); Arsse::$user->auth($actor, "");
foreach(array_keys(self::USERS) as $affected) { foreach (array_keys(self::USERS) as $affected) {
// regular users should only be able to act for themselves // regular users should only be able to act for themselves
if($actor==$affected) { if ($actor==$affected) {
$this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied.");
$this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied.");
} else { } else {
@ -100,41 +101,41 @@ class TestAuthorization extends Test\AbstractTest {
$this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed.");
} }
// they should never be able to set rights // they should never be able to set rights
foreach(self::LEVELS as $level) { foreach (self::LEVELS as $level) {
$this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed.");
} }
} }
// they should not be able to list users // they should not be able to list users
foreach(self::DOMAINS as $domain) { foreach (self::DOMAINS as $domain) {
$this->assertFalse(Arsse::$user->authorize($domain, "userList"), "User $actor improperly checked user list for domain '$domain', but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($domain, "userList"), "User $actor improperly checked user list for domain '$domain', but the action was allowed.");
} }
} }
} }
function testDomainManagerLogic() { public function testDomainManagerLogic() {
foreach(self::USERS as $actor => $actorRights) { foreach (self::USERS as $actor => $actorRights) {
if($actorRights != User\Driver::RIGHTS_DOMAIN_MANAGER) { if ($actorRights != User\Driver::RIGHTS_DOMAIN_MANAGER) {
continue; continue;
} }
$actorDomain = substr($actor,strrpos($actor,"@")+1); $actorDomain = substr($actor, strrpos($actor, "@")+1);
Arsse::$user->auth($actor, ""); Arsse::$user->auth($actor, "");
foreach(self::USERS as $affected => $affectedRights) { foreach (self::USERS as $affected => $affectedRights) {
$affectedDomain = substr($affected,strrpos($affected,"@")+1); $affectedDomain = substr($affected, strrpos($affected, "@")+1);
// domain managers should be able to check any user on the same domain // domain managers should be able to check any user on the same domain
if($actorDomain==$affectedDomain) { if ($actorDomain==$affectedDomain) {
$this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($affected, "userExists"), "User $actor acted improperly for $affected, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userExists"), "User $actor acted improperly for $affected, but the action was allowed.");
} }
// they should only be able to act for regular users on the same domain // they should only be able to act for regular users on the same domain
if($actor==$affected || ($actorDomain==$affectedDomain && $affectedRights==User\Driver::RIGHTS_NONE)) { if ($actor==$affected || ($actorDomain==$affectedDomain && $affectedRights==User\Driver::RIGHTS_NONE)) {
$this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed.");
} }
// and they should only be able to set their own rights to regular user // and they should only be able to set their own rights to regular user
foreach(self::LEVELS as $level) { foreach (self::LEVELS as $level) {
if($actor==$affected && in_array($level, [User\Driver::RIGHTS_NONE, User\Driver::RIGHTS_DOMAIN_MANAGER])) { if ($actor==$affected && in_array($level, [User\Driver::RIGHTS_NONE, User\Driver::RIGHTS_DOMAIN_MANAGER])) {
$this->assertTrue(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted properly for $affected settings rights level $level, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted properly for $affected settings rights level $level, but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed.");
@ -142,8 +143,8 @@ class TestAuthorization extends Test\AbstractTest {
} }
} }
// they should also be able to list all users on their own domain // they should also be able to list all users on their own domain
foreach(self::DOMAINS as $domain) { foreach (self::DOMAINS as $domain) {
if($domain=="@".$actorDomain) { if ($domain=="@".$actorDomain) {
$this->assertTrue(Arsse::$user->authorize($domain, "userList"), "User $actor properly checked user list for domain '$domain', but the action was denied."); $this->assertTrue(Arsse::$user->authorize($domain, "userList"), "User $actor properly checked user list for domain '$domain', but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($domain, "userList"), "User $actor improperly checked user list for domain '$domain', but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($domain, "userList"), "User $actor improperly checked user list for domain '$domain', but the action was allowed.");
@ -152,31 +153,31 @@ class TestAuthorization extends Test\AbstractTest {
} }
} }
function testDomainAdministratorLogic() { public function testDomainAdministratorLogic() {
foreach(self::USERS as $actor => $actorRights) { foreach (self::USERS as $actor => $actorRights) {
if($actorRights != User\Driver::RIGHTS_DOMAIN_ADMIN) { if ($actorRights != User\Driver::RIGHTS_DOMAIN_ADMIN) {
continue; continue;
} }
$actorDomain = substr($actor,strrpos($actor,"@")+1); $actorDomain = substr($actor, strrpos($actor, "@")+1);
Arsse::$user->auth($actor, ""); Arsse::$user->auth($actor, "");
$allowed = [User\Driver::RIGHTS_NONE,User\Driver::RIGHTS_DOMAIN_MANAGER,User\Driver::RIGHTS_DOMAIN_ADMIN]; $allowed = [User\Driver::RIGHTS_NONE,User\Driver::RIGHTS_DOMAIN_MANAGER,User\Driver::RIGHTS_DOMAIN_ADMIN];
foreach(self::USERS as $affected => $affectedRights) { foreach (self::USERS as $affected => $affectedRights) {
$affectedDomain = substr($affected,strrpos($affected,"@")+1); $affectedDomain = substr($affected, strrpos($affected, "@")+1);
// domain admins should be able to check any user on the same domain // domain admins should be able to check any user on the same domain
if($actorDomain==$affectedDomain) { if ($actorDomain==$affectedDomain) {
$this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($affected, "userExists"), "User $actor acted improperly for $affected, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userExists"), "User $actor acted improperly for $affected, but the action was allowed.");
} }
// they should be able to act for any user on the same domain who is not a global manager or admin // they should be able to act for any user on the same domain who is not a global manager or admin
if($actorDomain==$affectedDomain && in_array($affectedRights, $allowed)) { if ($actorDomain==$affectedDomain && in_array($affectedRights, $allowed)) {
$this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed.");
} }
// they should be able to set rights for any user on their domain who is not a global manager or admin, up to domain admin level // they should be able to set rights for any user on their domain who is not a global manager or admin, up to domain admin level
foreach(self::LEVELS as $level) { foreach (self::LEVELS as $level) {
if($actorDomain==$affectedDomain && in_array($affectedRights, $allowed) && in_array($level, $allowed)) { if ($actorDomain==$affectedDomain && in_array($affectedRights, $allowed) && in_array($level, $allowed)) {
$this->assertTrue(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted properly for $affected settings rights level $level, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted properly for $affected settings rights level $level, but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed.");
@ -184,8 +185,8 @@ class TestAuthorization extends Test\AbstractTest {
} }
} }
// they should also be able to list all users on their own domain // they should also be able to list all users on their own domain
foreach(self::DOMAINS as $domain) { foreach (self::DOMAINS as $domain) {
if($domain=="@".$actorDomain) { if ($domain=="@".$actorDomain) {
$this->assertTrue(Arsse::$user->authorize($domain, "userList"), "User $actor properly checked user list for domain '$domain', but the action was denied."); $this->assertTrue(Arsse::$user->authorize($domain, "userList"), "User $actor properly checked user list for domain '$domain', but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($domain, "userList"), "User $actor improperly checked user list for domain '$domain', but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($domain, "userList"), "User $actor improperly checked user list for domain '$domain', but the action was allowed.");
@ -194,26 +195,26 @@ class TestAuthorization extends Test\AbstractTest {
} }
} }
function testGlobalManagerLogic() { public function testGlobalManagerLogic() {
foreach(self::USERS as $actor => $actorRights) { foreach (self::USERS as $actor => $actorRights) {
if($actorRights != User\Driver::RIGHTS_GLOBAL_MANAGER) { if ($actorRights != User\Driver::RIGHTS_GLOBAL_MANAGER) {
continue; continue;
} }
$actorDomain = substr($actor,strrpos($actor,"@")+1); $actorDomain = substr($actor, strrpos($actor, "@")+1);
Arsse::$user->auth($actor, ""); Arsse::$user->auth($actor, "");
foreach(self::USERS as $affected => $affectedRights) { foreach (self::USERS as $affected => $affectedRights) {
$affectedDomain = substr($affected,strrpos($affected,"@")+1); $affectedDomain = substr($affected, strrpos($affected, "@")+1);
// global managers should be able to check any user // global managers should be able to check any user
$this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied.");
// they should only be able to act for regular users // they should only be able to act for regular users
if($actor==$affected || $affectedRights==User\Driver::RIGHTS_NONE) { if ($actor==$affected || $affectedRights==User\Driver::RIGHTS_NONE) {
$this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed.");
} }
// and they should only be able to set their own rights to regular user // and they should only be able to set their own rights to regular user
foreach(self::LEVELS as $level) { foreach (self::LEVELS as $level) {
if($actor==$affected && in_array($level, [User\Driver::RIGHTS_NONE, User\Driver::RIGHTS_GLOBAL_MANAGER])) { if ($actor==$affected && in_array($level, [User\Driver::RIGHTS_NONE, User\Driver::RIGHTS_GLOBAL_MANAGER])) {
$this->assertTrue(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted properly for $affected settings rights level $level, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted properly for $affected settings rights level $level, but the action was denied.");
} else { } else {
$this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed.");
@ -221,41 +222,41 @@ class TestAuthorization extends Test\AbstractTest {
} }
} }
// they should also be able to list all users // they should also be able to list all users
foreach(self::DOMAINS as $domain) { foreach (self::DOMAINS as $domain) {
$this->assertTrue(Arsse::$user->authorize($domain, "userList"), "User $actor properly checked user list for domain '$domain', but the action was denied."); $this->assertTrue(Arsse::$user->authorize($domain, "userList"), "User $actor properly checked user list for domain '$domain', but the action was denied.");
} }
} }
} }
function testGlobalAdministratorLogic() { public function testGlobalAdministratorLogic() {
foreach(self::USERS as $actor => $actorRights) { foreach (self::USERS as $actor => $actorRights) {
if($actorRights != User\Driver::RIGHTS_GLOBAL_ADMIN) { if ($actorRights != User\Driver::RIGHTS_GLOBAL_ADMIN) {
continue; continue;
} }
Arsse::$user->auth($actor, ""); Arsse::$user->auth($actor, "");
// global admins can do anything // global admins can do anything
foreach(self::USERS as $affected => $affectedRights) { foreach (self::USERS as $affected => $affectedRights) {
$this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied.");
$this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied.");
foreach(self::LEVELS as $level) { foreach (self::LEVELS as $level) {
$this->assertTrue(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted properly for $affected settings rights level $level, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted properly for $affected settings rights level $level, but the action was denied.");
} }
} }
foreach(self::DOMAINS as $domain) { foreach (self::DOMAINS as $domain) {
$this->assertTrue(Arsse::$user->authorize($domain, "userList"), "User $actor properly checked user list for domain '$domain', but the action was denied."); $this->assertTrue(Arsse::$user->authorize($domain, "userList"), "User $actor properly checked user list for domain '$domain', but the action was denied.");
} }
} }
} }
function testInvalidLevelLogic() { public function testInvalidLevelLogic() {
foreach(self::USERS as $actor => $rights) { foreach (self::USERS as $actor => $rights) {
if(in_array($rights, self::LEVELS)) { if (in_array($rights, self::LEVELS)) {
continue; continue;
} }
Arsse::$user->auth($actor, ""); Arsse::$user->auth($actor, "");
foreach(array_keys(self::USERS) as $affected) { foreach (array_keys(self::USERS) as $affected) {
// users with unknown/invalid rights should be treated just like regular users and only be able to act for themselves // users with unknown/invalid rights should be treated just like regular users and only be able to act for themselves
if($actor==$affected) { if ($actor==$affected) {
$this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userExists"), "User $actor acted properly for $affected, but the action was denied.");
$this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied."); $this->assertTrue(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted properly for $affected, but the action was denied.");
} else { } else {
@ -263,18 +264,18 @@ class TestAuthorization extends Test\AbstractTest {
$this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRemove"), "User $actor acted improperly for $affected, but the action was allowed.");
} }
// they should never be able to set rights // they should never be able to set rights
foreach(self::LEVELS as $level) { foreach (self::LEVELS as $level) {
$this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($affected, "userRightsSet", $level), "User $actor acted improperly for $affected settings rights level $level, but the action was allowed.");
} }
} }
// they should not be able to list users // they should not be able to list users
foreach(self::DOMAINS as $domain) { foreach (self::DOMAINS as $domain) {
$this->assertFalse(Arsse::$user->authorize($domain, "userList"), "User $actor improperly checked user list for domain '$domain', but the action was allowed."); $this->assertFalse(Arsse::$user->authorize($domain, "userList"), "User $actor improperly checked user list for domain '$domain', but the action was allowed.");
} }
} }
} }
function testInternalExceptionLogic() { public function testInternalExceptionLogic() {
$tests = [ $tests = [
// methods of User class to test, with parameters besides affected user // methods of User class to test, with parameters besides affected user
'exists' => [], 'exists' => [],
@ -295,7 +296,7 @@ class TestAuthorization extends Test\AbstractTest {
$this->assertCount(sizeof($tests), $this->checkExceptions("user@example.org", $tests)); $this->assertCount(sizeof($tests), $this->checkExceptions("user@example.org", $tests));
} }
function testExternalExceptionLogic() { public function testExternalExceptionLogic() {
// set up the test for an external driver // set up the test for an external driver
$this->setUp(Test\User\DriverExternalMock::class, Test\User\Database::class); $this->setUp(Test\User\DriverExternalMock::class, Test\User\Database::class);
// run the previous test with the external driver set up // run the previous test with the external driver set up
@ -306,21 +307,21 @@ class TestAuthorization extends Test\AbstractTest {
// calls each requested function with supplied arguments, catches authorization exceptions, and returns an array of caught failed calls // calls each requested function with supplied arguments, catches authorization exceptions, and returns an array of caught failed calls
protected function checkExceptions(string $user, $tests): array { protected function checkExceptions(string $user, $tests): array {
$err = []; $err = [];
foreach($tests as $func => $args) { foreach ($tests as $func => $args) {
// list method does not take an affected user, so do not unshift for that one // list method does not take an affected user, so do not unshift for that one
if($func != "list") { if ($func != "list") {
array_unshift($args, $user); array_unshift($args, $user);
} }
try { try {
call_user_func_array(array(Arsse::$user, $func), $args); call_user_func_array(array(Arsse::$user, $func), $args);
} catch(User\ExceptionAuthz $e) { } catch (User\ExceptionAuthz $e) {
$err[] = $func; $err[] = $func;
} }
} }
return $err; return $err;
} }
function testMissingUserLogic() { public function testMissingUserLogic() {
Arsse::$user->auth("gadm@example.com", ""); Arsse::$user->auth("gadm@example.com", "");
$this->assertTrue(Arsse::$user->authorize("user@example.com", "someFunction")); $this->assertTrue(Arsse::$user->authorize("user@example.com", "someFunction"));
$this->assertException("doesNotExist", "User"); $this->assertException("doesNotExist", "User");

2
tests/User/TestUserMockInternal.php

@ -11,7 +11,7 @@ class TestUserMockInternal extends Test\AbstractTest {
public $drv = Test\User\DriverInternalMock::class; public $drv = Test\User\DriverInternalMock::class;
function setUpSeries() { public function setUpSeries() {
Arsse::$db = null; Arsse::$db = null;
} }
} }

2
tests/docroot/Feed/Caching/304Random.php

@ -1,6 +1,6 @@
<?php return [ <?php return [
'code' => 304, 'code' => 304,
'lastMod' => random_int(0,2^31), 'lastMod' => random_int(0, 2^31),
'fields' => [ 'fields' => [
"ETag: ".bin2hex(random_bytes(8)), "ETag: ".bin2hex(random_bytes(8)),
], ],

2
tests/docroot/Feed/NextFetch/NotModified.php

@ -1,5 +1,5 @@
<?php <?php
if(array_key_exists("t", $_GET)) { if (array_key_exists("t", $_GET)) {
return [ return [
'code' => 304, 'code' => 304,
'lastMod' => (int) $_GET['t'], 'lastMod' => (int) $_GET['t'],

16
tests/lib/AbstractTest.php

@ -1,18 +1,18 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test; namespace JKingWeb\Arsse\Test;
use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Exception;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
/** @coversNothing */ /** @coversNothing */
abstract class AbstractTest extends \PHPUnit\Framework\TestCase { abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") {
function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { if (func_num_args()) {
if(func_num_args()) {
$class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type;
$msgID = ($prefix !== "" ? $prefix . "/" : "") . $type. ".$msg"; $msgID = ($prefix !== "" ? $prefix . "/" : "") . $type. ".$msg";
if(array_key_exists($msgID, Exception::CODES)) { if (array_key_exists($msgID, Exception::CODES)) {
$code = Exception::CODES[$msgID]; $code = Exception::CODES[$msgID];
} else { } else {
$code = 0; $code = 0;
@ -25,19 +25,19 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
} }
} }
function assertTime($exp, $test) { public function assertTime($exp, $test) {
$exp = Date::transform($exp, "iso8601"); $exp = Date::transform($exp, "iso8601");
$test = Date::transform($test, "iso8601"); $test = Date::transform($test, "iso8601");
$this->assertSame($exp, $test); $this->assertSame($exp, $test);
} }
function clearData(bool $loadLang = true): bool { public function clearData(bool $loadLang = true): bool {
$r = new \ReflectionClass(\JKingWeb\Arsse\Arsse::class); $r = new \ReflectionClass(\JKingWeb\Arsse\Arsse::class);
$props = array_keys($r->getStaticProperties()); $props = array_keys($r->getStaticProperties());
foreach($props as $prop) { foreach ($props as $prop) {
Arsse::$$prop = null; Arsse::$$prop = null;
} }
if($loadLang) { if ($loadLang) {
Arsse::$lang = new \JKingWeb\Arsse\Lang(); Arsse::$lang = new \JKingWeb\Arsse\Lang();
} }
return true; return true;

7
tests/lib/Database/DriverSQLite3.php

@ -1,19 +1,20 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Db\SQLite3\Driver; use JKingWeb\Arsse\Db\SQLite3\Driver;
trait DriverSQLite3 { trait DriverSQLite3 {
function setUpDriver() { public function setUpDriver() {
if(!extension_loaded("sqlite3")) { if (!extension_loaded("sqlite3")) {
$this->markTestSkipped("SQLite extension not loaded"); $this->markTestSkipped("SQLite extension not loaded");
} }
Arsse::$conf->dbSQLite3File = ":memory:"; Arsse::$conf->dbSQLite3File = ":memory:";
$this->drv = new Driver(true); $this->drv = new Driver(true);
} }
function nextID(string $table): int { public function nextID(string $table): int {
return $this->drv->query("SELECT (case when max(id) then max(id) else 0 end)+1 from $table")->getValue(); return $this->drv->query("SELECT (case when max(id) then max(id) else 0 end)+1 from $table")->getValue();
} }
} }

99
tests/lib/Database/SeriesArticle.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Date;
@ -299,7 +300,7 @@ trait SeriesArticle {
], ],
]; ];
function setUpSeries() { public function setUpSeries() {
$this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified"],]; $this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified"],];
$this->user = "john.doe@example.net"; $this->user = "john.doe@example.net";
} }
@ -311,7 +312,7 @@ trait SeriesArticle {
$this->assertEquals($exp, $ids); $this->assertEquals($exp, $ids);
} }
function testListArticlesCheckingContext() { public function testListArticlesCheckingContext() {
$this->user = "john.doe@example.com"; $this->user = "john.doe@example.com";
// get all items for user // get all items for user
$exp = [1,2,3,4,5,6,7,8,19,20]; $exp = [1,2,3,4,5,6,7,8,19,20];
@ -356,28 +357,28 @@ trait SeriesArticle {
$this->compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1)); $this->compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1));
} }
function testListArticlesOfAMissingFolder() { public function testListArticlesOfAMissingFolder() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->articleList($this->user, (new Context)->folder(1)); Arsse::$db->articleList($this->user, (new Context)->folder(1));
} }
function testListArticlesOfAMissingSubscription() { public function testListArticlesOfAMissingSubscription() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->articleList($this->user, (new Context)->subscription(1)); Arsse::$db->articleList($this->user, (new Context)->subscription(1));
} }
function testListArticlesCheckingProperties() { public function testListArticlesCheckingProperties() {
$this->user = "john.doe@example.org"; $this->user = "john.doe@example.org";
$this->assertResult($this->matches, Arsse::$db->articleList($this->user)); $this->assertResult($this->matches, Arsse::$db->articleList($this->user));
} }
function testListArticlesWithoutAuthority() { public function testListArticlesWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->articleList($this->user); Arsse::$db->articleList($this->user);
} }
function testMarkAllArticlesUnread() { public function testMarkAllArticlesUnread() {
Arsse::$db->articleMark($this->user, ['read'=>false]); Arsse::$db->articleMark($this->user, ['read'=>false]);
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -388,7 +389,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAllArticlesRead() { public function testMarkAllArticlesRead() {
Arsse::$db->articleMark($this->user, ['read'=>true]); Arsse::$db->articleMark($this->user, ['read'=>true]);
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -403,7 +404,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAllArticlesUnstarred() { public function testMarkAllArticlesUnstarred() {
Arsse::$db->articleMark($this->user, ['starred'=>false]); Arsse::$db->articleMark($this->user, ['starred'=>false]);
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -414,7 +415,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAllArticlesStarred() { public function testMarkAllArticlesStarred() {
Arsse::$db->articleMark($this->user, ['starred'=>true]); Arsse::$db->articleMark($this->user, ['starred'=>true]);
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -429,7 +430,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAllArticlesUnreadAndUnstarred() { public function testMarkAllArticlesUnreadAndUnstarred() {
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>false]); Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>false]);
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -443,7 +444,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAllArticlesReadAndStarred() { public function testMarkAllArticlesReadAndStarred() {
Arsse::$db->articleMark($this->user, ['read'=>true,'starred'=>true]); Arsse::$db->articleMark($this->user, ['read'=>true,'starred'=>true]);
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -461,7 +462,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAllArticlesUnreadAndStarred() { public function testMarkAllArticlesUnreadAndStarred() {
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true]); Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true]);
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -479,7 +480,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAllArticlesReadAndUnstarred() { public function testMarkAllArticlesReadAndUnstarred() {
Arsse::$db->articleMark($this->user, ['read'=>true,'starred'=>false]); Arsse::$db->articleMark($this->user, ['read'=>true,'starred'=>false]);
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -497,7 +498,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkATreeFolder() { public function testMarkATreeFolder() {
Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(7)); Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(7));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -508,7 +509,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkALeafFolder() { public function testMarkALeafFolder() {
Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(8)); Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(8));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -517,12 +518,12 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAMissingFolder() { public function testMarkAMissingFolder() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(42)); Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(42));
} }
function testMarkASubscription() { public function testMarkASubscription() {
Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->subscription(13)); Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->subscription(13));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -531,12 +532,12 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAMissingSubscription() { public function testMarkAMissingSubscription() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(2112)); Arsse::$db->articleMark($this->user, ['read'=>true], (new Context)->folder(2112));
} }
function testMarkAnArticle() { public function testMarkAnArticle() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->article(20)); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->article(20));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -545,7 +546,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkMultipleArticles() { public function testMarkMultipleArticles() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->articles([2,4,7,20])); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->articles([2,4,7,20]));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -555,7 +556,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkMultipleArticlessUnreadAndStarred() { public function testMarkMultipleArticlessUnreadAndStarred() {
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([2,4,7,20])); Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([2,4,7,20]));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -568,22 +569,22 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkTooFewMultipleArticles() { public function testMarkTooFewMultipleArticles() {
$this->assertException("tooShort", "Db", "ExceptionInput"); $this->assertException("tooShort", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([])); Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([]));
} }
function testMarkTooManyMultipleArticles() { public function testMarkTooManyMultipleArticles() {
$this->assertException("tooLong", "Db", "ExceptionInput"); $this->assertException("tooLong", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1,51))); Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, 51)));
} }
function testMarkAMissingArticle() { public function testMarkAMissingArticle() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->article(1)); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->article(1));
} }
function testMarkAnEdition() { public function testMarkAnEdition() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->edition(1001)); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->edition(1001));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -592,7 +593,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkMultipleEditions() { public function testMarkMultipleEditions() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->editions([2,4,7,20])); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->editions([2,4,7,20]));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -602,7 +603,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkMultipleEditionsUnread() { public function testMarkMultipleEditionsUnread() {
Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->editions([2,4,7,1001])); Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->editions([2,4,7,1001]));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -613,7 +614,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkMultipleEditionsUnreadWithStale() { public function testMarkMultipleEditionsUnreadWithStale() {
Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->editions([2,4,7,20])); Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->editions([2,4,7,20]));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -622,7 +623,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkMultipleEditionsUnreadAndStarredWithStale() { public function testMarkMultipleEditionsUnreadAndStarredWithStale() {
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([2,4,7,20])); Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([2,4,7,20]));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -634,23 +635,23 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkTooFewMultipleEditions() { public function testMarkTooFewMultipleEditions() {
$this->assertException("tooShort", "Db", "ExceptionInput"); $this->assertException("tooShort", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([])); Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([]));
} }
function testMarkTooManyMultipleEditions() { public function testMarkTooManyMultipleEditions() {
$this->assertException("tooLong", "Db", "ExceptionInput"); $this->assertException("tooLong", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1,51))); Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1, 51)));
} }
function testMarkAStaleEditionUnread() { public function testMarkAStaleEditionUnread() {
Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->edition(20)); // no changes occur Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->edition(20)); // no changes occur
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAStaleEditionStarred() { public function testMarkAStaleEditionStarred() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->edition(20)); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->edition(20));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -659,7 +660,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAStaleEditionUnreadAndStarred() { public function testMarkAStaleEditionUnreadAndStarred() {
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->edition(20)); // only starred is changed Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->edition(20)); // only starred is changed
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -668,18 +669,18 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAStaleEditionUnreadAndUnstarred() { public function testMarkAStaleEditionUnreadAndUnstarred() {
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>false], (new Context)->edition(20)); // no changes occur Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>false], (new Context)->edition(20)); // no changes occur
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkAMissingEdition() { public function testMarkAMissingEdition() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->edition(2)); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->edition(2));
} }
function testMarkByOldestEdition() { public function testMarkByOldestEdition() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->oldestEdition(19)); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->oldestEdition(19));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -690,7 +691,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkByLatestEdition() { public function testMarkByLatestEdition() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->latestEdition(20)); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->latestEdition(20));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -703,7 +704,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkByLastModified() { public function testMarkByLastModified() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->modifiedSince('2017-01-01T00:00:00Z')); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->modifiedSince('2017-01-01T00:00:00Z'));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -714,7 +715,7 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkByNotLastModified() { public function testMarkByNotLastModified() {
Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->notModifiedSince('2000-01-01T00:00:00Z')); Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->notModifiedSince('2000-01-01T00:00:00Z'));
$now = Date::transform(time(), "sql"); $now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables); $state = $this->primeExpectations($this->data, $this->checkTables);
@ -723,36 +724,36 @@ trait SeriesArticle {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMarkArticlesWithoutAuthority() { public function testMarkArticlesWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->articleMark($this->user, ['read'=>false]); Arsse::$db->articleMark($this->user, ['read'=>false]);
} }
function testCountStarredArticles() { public function testCountStarredArticles() {
$this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.com")); $this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.com"));
$this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.org")); $this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.org"));
$this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.net")); $this->assertSame(2, Arsse::$db->articleStarredCount("john.doe@example.net"));
$this->assertSame(0, Arsse::$db->articleStarredCount("jane.doe@example.com")); $this->assertSame(0, Arsse::$db->articleStarredCount("jane.doe@example.com"));
} }
function testCountStarredArticlesWithoutAuthority() { public function testCountStarredArticlesWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->articleStarredCount($this->user); Arsse::$db->articleStarredCount($this->user);
} }
function testFetchLatestEdition() { public function testFetchLatestEdition() {
$this->assertSame(1001, Arsse::$db->editionLatest($this->user)); $this->assertSame(1001, Arsse::$db->editionLatest($this->user));
$this->assertSame(4, Arsse::$db->editionLatest($this->user, (new Context)->subscription(12))); $this->assertSame(4, Arsse::$db->editionLatest($this->user, (new Context)->subscription(12)));
} }
function testFetchLatestEditionOfMissingSubscription() { public function testFetchLatestEditionOfMissingSubscription() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->editionLatest($this->user, (new Context)->subscription(1)); Arsse::$db->editionLatest($this->user, (new Context)->subscription(1));
} }
function testFetchLatestEditionWithoutAuthority() { public function testFetchLatestEditionWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->editionLatest($this->user); Arsse::$db->editionLatest($this->user);

30
tests/lib/Database/SeriesCleanup.php

@ -1,18 +1,18 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use Phake; use Phake;
trait SeriesCleanup { trait SeriesCleanup {
public function setUpSeries() {
function setUpSeries() {
// set up the test data // set up the test data
$nowish = gmdate("Y-m-d H:i:s",strtotime("now - 1 minute")); $nowish = gmdate("Y-m-d H:i:s", strtotime("now - 1 minute"));
$yesterday = gmdate("Y-m-d H:i:s",strtotime("now - 1 day")); $yesterday = gmdate("Y-m-d H:i:s", strtotime("now - 1 day"));
$daybefore = gmdate("Y-m-d H:i:s",strtotime("now - 2 days")); $daybefore = gmdate("Y-m-d H:i:s", strtotime("now - 2 days"));
$daysago = gmdate("Y-m-d H:i:s",strtotime("now - 7 days")); $daysago = gmdate("Y-m-d H:i:s", strtotime("now - 7 days"));
$weeksago = gmdate("Y-m-d H:i:s",strtotime("now - 21 days")); $weeksago = gmdate("Y-m-d H:i:s", strtotime("now - 21 days"));
$this->data = [ $this->data = [
'arsse_users' => [ 'arsse_users' => [
'columns' => [ 'columns' => [
@ -109,7 +109,7 @@ trait SeriesCleanup {
]; ];
} }
function testCleanUpOrphanedFeeds() { public function testCleanUpOrphanedFeeds() {
Arsse::$db->feedCleanup(); Arsse::$db->feedCleanup();
$now = gmdate("Y-m-d H:i:s"); $now = gmdate("Y-m-d H:i:s");
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
@ -121,42 +121,42 @@ trait SeriesCleanup {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testCleanUpOldArticlesWithStandardRetention() { public function testCleanUpOldArticlesWithStandardRetention() {
Arsse::$db->articleCleanup(); Arsse::$db->articleCleanup();
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
'arsse_articles' => ["id"] 'arsse_articles' => ["id"]
]); ]);
foreach([7,8,9] as $id) { foreach ([7,8,9] as $id) {
unset($state['arsse_articles']['rows'][$id - 1]); unset($state['arsse_articles']['rows'][$id - 1]);
} }
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testCleanUpOldArticlesWithUnlimitedReadRetention() { public function testCleanUpOldArticlesWithUnlimitedReadRetention() {
Arsse::$conf->purgeArticlesRead = ""; Arsse::$conf->purgeArticlesRead = "";
Arsse::$db->articleCleanup(); Arsse::$db->articleCleanup();
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
'arsse_articles' => ["id"] 'arsse_articles' => ["id"]
]); ]);
foreach([7,8] as $id) { foreach ([7,8] as $id) {
unset($state['arsse_articles']['rows'][$id - 1]); unset($state['arsse_articles']['rows'][$id - 1]);
} }
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testCleanUpOldArticlesWithUnlimitedUnreadRetention() { public function testCleanUpOldArticlesWithUnlimitedUnreadRetention() {
Arsse::$conf->purgeArticlesUnread = ""; Arsse::$conf->purgeArticlesUnread = "";
Arsse::$db->articleCleanup(); Arsse::$db->articleCleanup();
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
'arsse_articles' => ["id"] 'arsse_articles' => ["id"]
]); ]);
foreach([9] as $id) { foreach ([9] as $id) {
unset($state['arsse_articles']['rows'][$id - 1]); unset($state['arsse_articles']['rows'][$id - 1]);
} }
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testCleanUpOldArticlesWithUnlimitedRetention() { public function testCleanUpOldArticlesWithUnlimitedRetention() {
Arsse::$conf->purgeArticlesRead = ""; Arsse::$conf->purgeArticlesRead = "";
Arsse::$conf->purgeArticlesUnread = ""; Arsse::$conf->purgeArticlesUnread = "";
Arsse::$db->articleCleanup(); Arsse::$db->articleCleanup();

27
tests/lib/Database/SeriesFeed.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Feed; use JKingWeb\Arsse\Feed;
use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\Feed\Exception as FeedException;
@ -26,11 +27,11 @@ trait SeriesFeed {
], ],
]; ];
function setUpSeries() { public function setUpSeries() {
// set up the test data // set up the test data
$past = gmdate("Y-m-d H:i:s",strtotime("now - 1 minute")); $past = gmdate("Y-m-d H:i:s", strtotime("now - 1 minute"));
$future = gmdate("Y-m-d H:i:s",strtotime("now + 1 minute")); $future = gmdate("Y-m-d H:i:s", strtotime("now + 1 minute"));
$now = gmdate("Y-m-d H:i:s",strtotime("now")); $now = gmdate("Y-m-d H:i:s", strtotime("now"));
$this->data = [ $this->data = [
'arsse_users' => [ 'arsse_users' => [
'columns' => [ 'columns' => [
@ -160,13 +161,13 @@ trait SeriesFeed {
]; ];
} }
function testListLatestItems() { public function testListLatestItems() {
$this->assertResult($this->matches, Arsse::$db->feedMatchLatest(1,2)); $this->assertResult($this->matches, Arsse::$db->feedMatchLatest(1, 2));
} }
function testMatchItemsById() { public function testMatchItemsById() {
$this->assertResult($this->matches, Arsse::$db->feedMatchIds(1, ['804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41'])); $this->assertResult($this->matches, Arsse::$db->feedMatchIds(1, ['804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41']));
foreach($this->matches as $m) { foreach ($this->matches as $m) {
$exp = [$m]; $exp = [$m];
$this->assertResult($exp, Arsse::$db->feedMatchIds(1, [], [$m['url_title_hash']])); $this->assertResult($exp, Arsse::$db->feedMatchIds(1, [], [$m['url_title_hash']]));
$this->assertResult($exp, Arsse::$db->feedMatchIds(1, [], [], [$m['url_content_hash']])); $this->assertResult($exp, Arsse::$db->feedMatchIds(1, [], [], [$m['url_content_hash']]));
@ -175,7 +176,7 @@ trait SeriesFeed {
$this->assertResult([['id' => 1]], Arsse::$db->feedMatchIds(1, ['e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda'])); // this ID appears in both feed 1 and feed 2; only one result should be returned $this->assertResult([['id' => 1]], Arsse::$db->feedMatchIds(1, ['e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda'])); // this ID appears in both feed 1 and feed 2; only one result should be returned
} }
function testUpdateAFeed() { public function testUpdateAFeed() {
// update a valid feed with both new and changed items // update a valid feed with both new and changed items
Arsse::$db->feedUpdate(1); Arsse::$db->feedUpdate(1);
$now = gmdate("Y-m-d H:i:s"); $now = gmdate("Y-m-d H:i:s");
@ -215,17 +216,17 @@ trait SeriesFeed {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testUpdateAMissingFeed() { public function testUpdateAMissingFeed() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->feedUpdate(2112); Arsse::$db->feedUpdate(2112);
} }
function testUpdateAFeedThrowingExceptions() { public function testUpdateAFeedThrowingExceptions() {
$this->assertException("invalidUrl", "Feed"); $this->assertException("invalidUrl", "Feed");
Arsse::$db->feedUpdate(3, true); Arsse::$db->feedUpdate(3, true);
} }
function testUpdateAFeedWithEnclosuresAndCategories() { public function testUpdateAFeedWithEnclosuresAndCategories() {
Arsse::$db->feedUpdate(5); Arsse::$db->feedUpdate(5);
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
'arsse_enclosures' => ["url","type"], 'arsse_enclosures' => ["url","type"],
@ -245,7 +246,7 @@ trait SeriesFeed {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testListStaleFeeds() { public function testListStaleFeeds() {
$this->assertSame([1,3,4], Arsse::$db->feedListStale()); $this->assertSame([1,3,4], Arsse::$db->feedListStale());
Arsse::$db->feedUpdate(3); Arsse::$db->feedUpdate(3);
Arsse::$db->feedUpdate(4); Arsse::$db->feedUpdate(4);

71
tests/lib/Database/SeriesFolder.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use Phake; use Phake;
@ -45,7 +46,7 @@ trait SeriesFolder {
], ],
]; ];
function testAddARootFolder() { public function testAddARootFolder() {
$user = "john.doe@example.com"; $user = "john.doe@example.com";
$folderID = $this->nextID("arsse_folders"); $folderID = $this->nextID("arsse_folders");
$this->assertSame($folderID, Arsse::$db->folderAdd($user, ['name' => "Entertainment"])); $this->assertSame($folderID, Arsse::$db->folderAdd($user, ['name' => "Entertainment"]));
@ -55,12 +56,12 @@ trait SeriesFolder {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testAddADuplicateRootFolder() { public function testAddADuplicateRootFolder() {
$this->assertException("constraintViolation", "Db", "ExceptionInput"); $this->assertException("constraintViolation", "Db", "ExceptionInput");
Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Politics"]); Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Politics"]);
} }
function testAddANestedFolder() { public function testAddANestedFolder() {
$user = "john.doe@example.com"; $user = "john.doe@example.com";
$folderID = $this->nextID("arsse_folders"); $folderID = $this->nextID("arsse_folders");
$this->assertSame($folderID, Arsse::$db->folderAdd($user, ['name' => "GNOME", 'parent' => 2])); $this->assertSame($folderID, Arsse::$db->folderAdd($user, ['name' => "GNOME", 'parent' => 2]));
@ -70,38 +71,38 @@ trait SeriesFolder {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testAddANestedFolderToAMissingParent() { public function testAddANestedFolderToAMissingParent() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology", 'parent' => 2112]); Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology", 'parent' => 2112]);
} }
function testAddANestedFolderForTheWrongOwner() { public function testAddANestedFolderForTheWrongOwner() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology", 'parent' => 4]); // folder ID 4 belongs to Jane Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology", 'parent' => 4]); // folder ID 4 belongs to Jane
} }
function testAddAFolderWithAMissingName() { public function testAddAFolderWithAMissingName() {
$this->assertException("missing", "Db", "ExceptionInput"); $this->assertException("missing", "Db", "ExceptionInput");
Arsse::$db->folderAdd("john.doe@example.com", []); Arsse::$db->folderAdd("john.doe@example.com", []);
} }
function testAddAFolderWithABlankName() { public function testAddAFolderWithABlankName() {
$this->assertException("missing", "Db", "ExceptionInput"); $this->assertException("missing", "Db", "ExceptionInput");
Arsse::$db->folderAdd("john.doe@example.com", ['name' => ""]); Arsse::$db->folderAdd("john.doe@example.com", ['name' => ""]);
} }
function testAddAFolderWithAWhitespaceName() { public function testAddAFolderWithAWhitespaceName() {
$this->assertException("whitespace", "Db", "ExceptionInput"); $this->assertException("whitespace", "Db", "ExceptionInput");
Arsse::$db->folderAdd("john.doe@example.com", ['name' => " "]); Arsse::$db->folderAdd("john.doe@example.com", ['name' => " "]);
} }
function testAddAFolderWithoutAuthority() { public function testAddAFolderWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology"]); Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology"]);
} }
function testListRootFolders() { public function testListRootFolders() {
$exp = [ $exp = [
['id' => 5, 'name' => "Politics", 'parent' => null], ['id' => 5, 'name' => "Politics", 'parent' => null],
['id' => 1, 'name' => "Technology", 'parent' => null], ['id' => 1, 'name' => "Technology", 'parent' => null],
@ -118,7 +119,7 @@ trait SeriesFolder {
Phake::verify(Arsse::$user)->authorize("admin@example.net", "folderList"); Phake::verify(Arsse::$user)->authorize("admin@example.net", "folderList");
} }
function testListFoldersRecursively() { public function testListFoldersRecursively() {
$exp = [ $exp = [
['id' => 5, 'name' => "Politics", 'parent' => null], ['id' => 5, 'name' => "Politics", 'parent' => null],
['id' => 6, 'name' => "Politics", 'parent' => 2], ['id' => 6, 'name' => "Politics", 'parent' => 2],
@ -139,23 +140,23 @@ trait SeriesFolder {
Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "folderList"); Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "folderList");
} }
function testListFoldersOfAMissingParent() { public function testListFoldersOfAMissingParent() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->folderList("john.doe@example.com", 2112); Arsse::$db->folderList("john.doe@example.com", 2112);
} }
function testListFoldersOfTheWrongOwner() { public function testListFoldersOfTheWrongOwner() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->folderList("john.doe@example.com", 4); // folder ID 4 belongs to Jane Arsse::$db->folderList("john.doe@example.com", 4); // folder ID 4 belongs to Jane
} }
function testListFoldersWithoutAuthority() { public function testListFoldersWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->folderList("john.doe@example.com"); Arsse::$db->folderList("john.doe@example.com");
} }
function testRemoveAFolder() { public function testRemoveAFolder() {
$this->assertTrue(Arsse::$db->folderRemove("john.doe@example.com", 6)); $this->assertTrue(Arsse::$db->folderRemove("john.doe@example.com", 6));
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove"); Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
@ -163,33 +164,33 @@ trait SeriesFolder {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testRemoveAFolderTree() { public function testRemoveAFolderTree() {
$this->assertTrue(Arsse::$db->folderRemove("john.doe@example.com", 1)); $this->assertTrue(Arsse::$db->folderRemove("john.doe@example.com", 1));
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove"); Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
foreach([0,1,2,5] as $index) { foreach ([0,1,2,5] as $index) {
unset($state['arsse_folders']['rows'][$index]); unset($state['arsse_folders']['rows'][$index]);
} }
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testRemoveAMissingFolder() { public function testRemoveAMissingFolder() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->folderRemove("john.doe@example.com", 2112); Arsse::$db->folderRemove("john.doe@example.com", 2112);
} }
function testRemoveAFolderOfTheWrongOwner() { public function testRemoveAFolderOfTheWrongOwner() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->folderRemove("john.doe@example.com", 4); // folder ID 4 belongs to Jane Arsse::$db->folderRemove("john.doe@example.com", 4); // folder ID 4 belongs to Jane
} }
function testRemoveAFolderWithoutAuthority() { public function testRemoveAFolderWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->folderRemove("john.doe@example.com", 1); Arsse::$db->folderRemove("john.doe@example.com", 1);
} }
function testGetThePropertiesOfAFolder() { public function testGetThePropertiesOfAFolder() {
$exp = [ $exp = [
'id' => 6, 'id' => 6,
'name' => "Politics", 'name' => "Politics",
@ -199,23 +200,23 @@ trait SeriesFolder {
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesGet"); Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesGet");
} }
function testGetThePropertiesOfAMissingFolder() { public function testGetThePropertiesOfAMissingFolder() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesGet("john.doe@example.com", 2112); Arsse::$db->folderPropertiesGet("john.doe@example.com", 2112);
} }
function testGetThePropertiesOfAFolderOfTheWrongOwner() { public function testGetThePropertiesOfAFolderOfTheWrongOwner() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesGet("john.doe@example.com", 4); // folder ID 4 belongs to Jane Arsse::$db->folderPropertiesGet("john.doe@example.com", 4); // folder ID 4 belongs to Jane
} }
function testGetThePropertiesOfAFolderWithoutAuthority() { public function testGetThePropertiesOfAFolderWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->folderPropertiesGet("john.doe@example.com", 1); Arsse::$db->folderPropertiesGet("john.doe@example.com", 1);
} }
function testRenameAFolder() { public function testRenameAFolder() {
$this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['name' => "Opinion"])); $this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['name' => "Opinion"]));
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet"); Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
@ -223,17 +224,17 @@ trait SeriesFolder {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testRenameAFolderToTheEmptyString() { public function testRenameAFolderToTheEmptyString() {
$this->assertException("missing", "Db", "ExceptionInput"); $this->assertException("missing", "Db", "ExceptionInput");
$this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['name' => ""])); $this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['name' => ""]));
} }
function testRenameAFolderToWhitespaceOnly() { public function testRenameAFolderToWhitespaceOnly() {
$this->assertException("whitespace", "Db", "ExceptionInput"); $this->assertException("whitespace", "Db", "ExceptionInput");
$this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['name' => " "])); $this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['name' => " "]));
} }
function testMoveAFolder() { public function testMoveAFolder() {
$this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['parent' => 5])); $this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['parent' => 5]));
Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet"); Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]); $state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
@ -241,37 +242,37 @@ trait SeriesFolder {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMoveAFolderToItsDescendant() { public function testMoveAFolderToItsDescendant() {
$this->assertException("circularDependence", "Db", "ExceptionInput"); $this->assertException("circularDependence", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => 3]); Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => 3]);
} }
function testMoveAFolderToItself() { public function testMoveAFolderToItself() {
$this->assertException("circularDependence", "Db", "ExceptionInput"); $this->assertException("circularDependence", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => 1]); Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => 1]);
} }
function testMoveAFolderToAMissingParent() { public function testMoveAFolderToAMissingParent() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => 2112]); Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => 2112]);
} }
function testCauseAFolderCollision() { public function testCauseAFolderCollision() {
$this->assertException("constraintViolation", "Db", "ExceptionInput"); $this->assertException("constraintViolation", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['parent' => null]); Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['parent' => null]);
} }
function testSetThePropertiesOfAMissingFolder() { public function testSetThePropertiesOfAMissingFolder() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesSet("john.doe@example.com", 2112, ['parent' => null]); Arsse::$db->folderPropertiesSet("john.doe@example.com", 2112, ['parent' => null]);
} }
function testSetThePropertiesOfAFolderForTheWrongOwner() { public function testSetThePropertiesOfAFolderForTheWrongOwner() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesSet("john.doe@example.com", 4, ['parent' => null]); // folder ID 4 belongs to Jane Arsse::$db->folderPropertiesSet("john.doe@example.com", 4, ['parent' => null]); // folder ID 4 belongs to Jane
} }
function testSetThePropertiesOfAFolderWithoutAuthority() { public function testSetThePropertiesOfAFolderWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => null]); Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => null]);

13
tests/lib/Database/SeriesMeta.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Test\Database; use JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
@ -18,7 +19,7 @@ trait SeriesMeta {
], ],
]; ];
function setUpSeries() { public function setUpSeries() {
// the schema_version key is a special case, and to avoid jumping through hoops for every test we deal with it now // the schema_version key is a special case, and to avoid jumping through hoops for every test we deal with it now
$this->data = $this->dataBare; $this->data = $this->dataBare;
// as far as tests are concerned the schema version is part of the expectations primed into the database // as far as tests are concerned the schema version is part of the expectations primed into the database
@ -27,14 +28,14 @@ trait SeriesMeta {
$this->primeDatabase($this->dataBare); $this->primeDatabase($this->dataBare);
} }
function testAddANewValue() { public function testAddANewValue() {
$this->assertTrue(Arsse::$db->metaSet("favourite", "Cygnus X-1")); $this->assertTrue(Arsse::$db->metaSet("favourite", "Cygnus X-1"));
$state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]);
$state['arsse_meta']['rows'][] = ["favourite","Cygnus X-1"]; $state['arsse_meta']['rows'][] = ["favourite","Cygnus X-1"];
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testAddANewTypedValue() { public function testAddANewTypedValue() {
$this->assertTrue(Arsse::$db->metaSet("answer", 42, "int")); $this->assertTrue(Arsse::$db->metaSet("answer", 42, "int"));
$this->assertTrue(Arsse::$db->metaSet("true", true, "bool")); $this->assertTrue(Arsse::$db->metaSet("true", true, "bool"));
$this->assertTrue(Arsse::$db->metaSet("false", false, "bool")); $this->assertTrue(Arsse::$db->metaSet("false", false, "bool"));
@ -47,14 +48,14 @@ trait SeriesMeta {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testChangeAnExistingValue() { public function testChangeAnExistingValue() {
$this->assertTrue(Arsse::$db->metaSet("album", "Hemispheres")); $this->assertTrue(Arsse::$db->metaSet("album", "Hemispheres"));
$state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]);
$state['arsse_meta']['rows'][1][1] = "Hemispheres"; $state['arsse_meta']['rows'][1][1] = "Hemispheres";
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testRemoveAValue() { public function testRemoveAValue() {
$this->assertTrue(Arsse::$db->metaRemove("album")); $this->assertTrue(Arsse::$db->metaRemove("album"));
$this->assertFalse(Arsse::$db->metaRemove("album")); $this->assertFalse(Arsse::$db->metaRemove("album"));
$state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]);
@ -62,7 +63,7 @@ trait SeriesMeta {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testRetrieveAValue() { public function testRetrieveAValue() {
$this->assertSame("".Database::SCHEMA_VERSION, Arsse::$db->metaGet("schema_version")); $this->assertSame("".Database::SCHEMA_VERSION, Arsse::$db->metaGet("schema_version"));
$this->assertSame("A Farewell to Kings", Arsse::$db->metaGet("album")); $this->assertSame("A Farewell to Kings", Arsse::$db->metaGet("album"));
$this->assertSame(null, Arsse::$db->metaGet("this_key_does_not_exist")); $this->assertSame(null, Arsse::$db->metaGet("this_key_does_not_exist"));

8
tests/lib/Database/SeriesMiscellany.php

@ -1,24 +1,24 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Database;
trait SeriesMiscellany { trait SeriesMiscellany {
public function testListDrivers() {
function testListDrivers() {
$exp = [ $exp = [
'JKingWeb\\Arsse\\Db\\SQLite3\\Driver' => Arsse::$lang->msg("Driver.Db.SQLite3.Name"), 'JKingWeb\\Arsse\\Db\\SQLite3\\Driver' => Arsse::$lang->msg("Driver.Db.SQLite3.Name"),
]; ];
$this->assertArraySubset($exp, Database::driverList()); $this->assertArraySubset($exp, Database::driverList());
} }
function testInitializeDatabase() { public function testInitializeDatabase() {
$d = new Database(); $d = new Database();
$this->assertSame(Database::SCHEMA_VERSION, $d->driverSchemaVersion()); $this->assertSame(Database::SCHEMA_VERSION, $d->driverSchemaVersion());
} }
function testManuallyInitializeDatabase() { public function testManuallyInitializeDatabase() {
$d = new Database(false); $d = new Database(false);
$this->assertSame(0, $d->driverSchemaVersion()); $this->assertSame(0, $d->driverSchemaVersion());
$this->assertTrue($d->driverSchemaUpdate()); $this->assertTrue($d->driverSchemaUpdate());

61
tests/lib/Database/SeriesSubscription.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Test\Database; use JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\Feed\Exception as FeedException;
@ -101,7 +102,7 @@ trait SeriesSubscription {
], ],
]; ];
function setUpSeries() { public function setUpSeries() {
$this->data['arsse_feeds']['rows'] = [ $this->data['arsse_feeds']['rows'] = [
[1,"http://example.com/feed1", "Ook", "", "",strtotime("now")], [1,"http://example.com/feed1", "Ook", "", "",strtotime("now")],
[2,"http://example.com/feed2", "Eek", "", "",strtotime("now - 1 hour")], [2,"http://example.com/feed2", "Eek", "", "",strtotime("now - 1 hour")],
@ -112,11 +113,11 @@ trait SeriesSubscription {
$this->user = "john.doe@example.com"; $this->user = "john.doe@example.com";
} }
function testAddASubscriptionToAnExistingFeed() { public function testAddASubscriptionToAnExistingFeed() {
$url = "http://example.com/feed1"; $url = "http://example.com/feed1";
$subID = $this->nextID("arsse_subscriptions"); $subID = $this->nextID("arsse_subscriptions");
Phake::when(Arsse::$db)->feedUpdate->thenReturn(true); Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
$this->assertSame($subID,Arsse::$db->subscriptionAdd($this->user, $url)); $this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url));
Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd"); Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
Phake::verify(Arsse::$db, Phake::times(0))->feedUpdate(1, true); Phake::verify(Arsse::$db, Phake::times(0))->feedUpdate(1, true);
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
@ -127,12 +128,12 @@ trait SeriesSubscription {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testAddASubscriptionToANewFeed() { public function testAddASubscriptionToANewFeed() {
$url = "http://example.org/feed1"; $url = "http://example.org/feed1";
$feedID = $this->nextID("arsse_feeds"); $feedID = $this->nextID("arsse_feeds");
$subID = $this->nextID("arsse_subscriptions"); $subID = $this->nextID("arsse_subscriptions");
Phake::when(Arsse::$db)->feedUpdate->thenReturn(true); Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
$this->assertSame($subID,Arsse::$db->subscriptionAdd($this->user, $url)); $this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url));
Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd"); Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
Phake::verify(Arsse::$db)->feedUpdate($feedID, true); Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
@ -144,13 +145,13 @@ trait SeriesSubscription {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testAddASubscriptionToAnInvalidFeed() { public function testAddASubscriptionToAnInvalidFeed() {
$url = "http://example.org/feed1"; $url = "http://example.org/feed1";
$feedID = $this->nextID("arsse_feeds"); $feedID = $this->nextID("arsse_feeds");
Phake::when(Arsse::$db)->feedUpdate->thenThrow(new FeedException($url, new \PicoFeed\Client\InvalidUrlException())); Phake::when(Arsse::$db)->feedUpdate->thenThrow(new FeedException($url, new \PicoFeed\Client\InvalidUrlException()));
try { try {
Arsse::$db->subscriptionAdd($this->user, $url); Arsse::$db->subscriptionAdd($this->user, $url);
} catch(FeedException $e) { } catch (FeedException $e) {
Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd"); Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
Phake::verify(Arsse::$db)->feedUpdate($feedID, true); Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
@ -163,20 +164,20 @@ trait SeriesSubscription {
} }
} }
function testAddADuplicateSubscription() { public function testAddADuplicateSubscription() {
$url = "http://example.com/feed2"; $url = "http://example.com/feed2";
$this->assertException("constraintViolation", "Db", "ExceptionInput"); $this->assertException("constraintViolation", "Db", "ExceptionInput");
Arsse::$db->subscriptionAdd($this->user, $url); Arsse::$db->subscriptionAdd($this->user, $url);
} }
function testAddASubscriptionWithoutAuthority() { public function testAddASubscriptionWithoutAuthority() {
$url = "http://example.com/feed1"; $url = "http://example.com/feed1";
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->subscriptionAdd($this->user, $url); Arsse::$db->subscriptionAdd($this->user, $url);
} }
function testRemoveASubscription() { public function testRemoveASubscription() {
$this->assertTrue(Arsse::$db->subscriptionRemove($this->user, 1)); $this->assertTrue(Arsse::$db->subscriptionRemove($this->user, 1));
Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionRemove"); Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionRemove");
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
@ -187,24 +188,24 @@ trait SeriesSubscription {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testRemoveAMissingSubscription() { public function testRemoveAMissingSubscription() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionRemove($this->user, 2112); Arsse::$db->subscriptionRemove($this->user, 2112);
} }
function testRemoveASubscriptionForTheWrongOwner() { public function testRemoveASubscriptionForTheWrongOwner() {
$this->user = "jane.doe@example.com"; $this->user = "jane.doe@example.com";
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionRemove($this->user, 1); Arsse::$db->subscriptionRemove($this->user, 1);
} }
function testRemoveASubscriptionWithoutAuthority() { public function testRemoveASubscriptionWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->subscriptionRemove($this->user, 1); Arsse::$db->subscriptionRemove($this->user, 1);
} }
function testListSubscriptions() { public function testListSubscriptions() {
$exp = [ $exp = [
[ [
'url' => "http://example.com/feed2", 'url' => "http://example.com/feed2",
@ -232,7 +233,7 @@ trait SeriesSubscription {
$this->assertArraySubset($exp[1], Arsse::$db->subscriptionPropertiesGet($this->user, 3)); $this->assertArraySubset($exp[1], Arsse::$db->subscriptionPropertiesGet($this->user, 3));
} }
function testListSubscriptionsInAFolder() { public function testListSubscriptionsInAFolder() {
$exp = [ $exp = [
[ [
'url' => "http://example.com/feed3", 'url' => "http://example.com/feed3",
@ -247,30 +248,30 @@ trait SeriesSubscription {
$this->assertResult($exp, Arsse::$db->subscriptionList($this->user, 2)); $this->assertResult($exp, Arsse::$db->subscriptionList($this->user, 2));
} }
function testListSubscriptionsInAMissingFolder() { public function testListSubscriptionsInAMissingFolder() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionList($this->user, 4); Arsse::$db->subscriptionList($this->user, 4);
} }
function testListSubscriptionsWithoutAuthority() { public function testListSubscriptionsWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->subscriptionList($this->user); Arsse::$db->subscriptionList($this->user);
} }
function testGetThePropertiesOfAMissingSubscription() { public function testGetThePropertiesOfAMissingSubscription() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesGet($this->user, 2112); Arsse::$db->subscriptionPropertiesGet($this->user, 2112);
} }
function testGetThePropertiesOfASubscriptionWithoutAuthority() { public function testGetThePropertiesOfASubscriptionWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->subscriptionPropertiesGet($this->user, 1); Arsse::$db->subscriptionPropertiesGet($this->user, 1);
} }
function testSetThePropertiesOfASubscription() { public function testSetThePropertiesOfASubscription() {
Arsse::$db->subscriptionPropertiesSet($this->user, 1,[ Arsse::$db->subscriptionPropertiesSet($this->user, 1, [
'title' => "Ook Ook", 'title' => "Ook Ook",
'folder' => 3, 'folder' => 3,
'pinned' => false, 'pinned' => false,
@ -283,47 +284,47 @@ trait SeriesSubscription {
]); ]);
$state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0]; $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0];
$this->compareExpectations($state); $this->compareExpectations($state);
Arsse::$db->subscriptionPropertiesSet($this->user, 1,[ Arsse::$db->subscriptionPropertiesSet($this->user, 1, [
'title' => null, 'title' => null,
]); ]);
$state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0]; $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0];
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testMoveASubscriptionToAMissingFolder() { public function testMoveASubscriptionToAMissingFolder() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("idMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['folder' => 4]); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['folder' => 4]);
} }
function testMoveASubscriptionToTheRootFolder() { public function testMoveASubscriptionToTheRootFolder() {
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 3, ['folder' => null])); $this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 3, ['folder' => null]));
} }
function testRenameASubscriptionToABlankTitle() { public function testRenameASubscriptionToABlankTitle() {
$this->assertException("missing", "Db", "ExceptionInput"); $this->assertException("missing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => ""]); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => ""]);
} }
function testRenameASubscriptionToAWhitespaceTitle() { public function testRenameASubscriptionToAWhitespaceTitle() {
$this->assertException("whitespace", "Db", "ExceptionInput"); $this->assertException("whitespace", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => " "]); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => " "]);
} }
function testRenameASubscriptionToFalse() { public function testRenameASubscriptionToFalse() {
$this->assertException("missing", "Db", "ExceptionInput"); $this->assertException("missing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]);
} }
function testRenameASubscriptionToZero() { public function testRenameASubscriptionToZero() {
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => 0])); $this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => 0]));
} }
function testSetThePropertiesOfAMissingSubscription() { public function testSetThePropertiesOfAMissingSubscription() {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]); Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]);
} }
function testSetThePropertiesOfASubscriptionWithoutAuthority() { public function testSetThePropertiesOfASubscriptionWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['folder' => null]); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['folder' => null]);

65
tests/lib/Database/SeriesUser.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User\Driver as UserDriver; use JKingWeb\Arsse\User\Driver as UserDriver;
use Phake; use Phake;
@ -22,7 +23,7 @@ trait SeriesUser {
], ],
]; ];
function testCheckThatAUserExists() { public function testCheckThatAUserExists() {
$this->assertTrue(Arsse::$db->userExists("jane.doe@example.com")); $this->assertTrue(Arsse::$db->userExists("jane.doe@example.com"));
$this->assertFalse(Arsse::$db->userExists("jane.doe@example.org")); $this->assertFalse(Arsse::$db->userExists("jane.doe@example.org"));
Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "userExists"); Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "userExists");
@ -30,31 +31,31 @@ trait SeriesUser {
$this->compareExpectations($this->data); $this->compareExpectations($this->data);
} }
function testCheckThatAUserExistsWithoutAuthority() { public function testCheckThatAUserExistsWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userExists("jane.doe@example.com"); Arsse::$db->userExists("jane.doe@example.com");
} }
function testGetAPassword() { public function testGetAPassword() {
$hash = Arsse::$db->userPasswordGet("admin@example.net"); $hash = Arsse::$db->userPasswordGet("admin@example.net");
$this->assertSame('$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', $hash); $this->assertSame('$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', $hash);
Phake::verify(Arsse::$user)->authorize("admin@example.net", "userPasswordGet"); Phake::verify(Arsse::$user)->authorize("admin@example.net", "userPasswordGet");
$this->assertTrue(password_verify("secret", $hash)); $this->assertTrue(password_verify("secret", $hash));
} }
function testGetThePasswordOfAMissingUser() { public function testGetThePasswordOfAMissingUser() {
$this->assertException("doesNotExist", "User"); $this->assertException("doesNotExist", "User");
Arsse::$db->userPasswordGet("john.doe@example.org"); Arsse::$db->userPasswordGet("john.doe@example.org");
} }
function testGetAPasswordWithoutAuthority() { public function testGetAPasswordWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userPasswordGet("admin@example.net"); Arsse::$db->userPasswordGet("admin@example.net");
} }
function testAddANewUser() { public function testAddANewUser() {
$this->assertSame("", Arsse::$db->userAdd("john.doe@example.org", "")); $this->assertSame("", Arsse::$db->userAdd("john.doe@example.org", ""));
Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userAdd"); Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userAdd");
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id','name','rights']]); $state = $this->primeExpectations($this->data, ['arsse_users' => ['id','name','rights']]);
@ -66,7 +67,7 @@ trait SeriesUser {
* @depends testGetAPassword * @depends testGetAPassword
* @depends testAddANewUser * @depends testAddANewUser
*/ */
function testAddANewUserWithARandomPassword() { public function testAddANewUserWithARandomPassword() {
$user1 = "john.doe@example.org"; $user1 = "john.doe@example.org";
$user2 = "john.doe@example.net"; $user2 = "john.doe@example.net";
$pass1 = Arsse::$db->userAdd($user1); $pass1 = Arsse::$db->userAdd($user1);
@ -84,18 +85,18 @@ trait SeriesUser {
$this->assertTrue(password_verify($pass2, $hash2), "Failed verifying password of $user2 '$pass2' against hash '$hash2'."); $this->assertTrue(password_verify($pass2, $hash2), "Failed verifying password of $user2 '$pass2' against hash '$hash2'.");
} }
function testAddAnExistingUser() { public function testAddAnExistingUser() {
$this->assertException("alreadyExists", "User"); $this->assertException("alreadyExists", "User");
Arsse::$db->userAdd("john.doe@example.com", ""); Arsse::$db->userAdd("john.doe@example.com", "");
} }
function testAddANewUserWithoutAuthority() { public function testAddANewUserWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userAdd("john.doe@example.org", ""); Arsse::$db->userAdd("john.doe@example.org", "");
} }
function testRemoveAUser() { public function testRemoveAUser() {
$this->assertTrue(Arsse::$db->userRemove("admin@example.net")); $this->assertTrue(Arsse::$db->userRemove("admin@example.net"));
Phake::verify(Arsse::$user)->authorize("admin@example.net", "userRemove"); Phake::verify(Arsse::$user)->authorize("admin@example.net", "userRemove");
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]); $state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]);
@ -103,36 +104,36 @@ trait SeriesUser {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testRemoveAMissingUser() { public function testRemoveAMissingUser() {
$this->assertException("doesNotExist", "User"); $this->assertException("doesNotExist", "User");
Arsse::$db->userRemove("john.doe@example.org"); Arsse::$db->userRemove("john.doe@example.org");
} }
function testRemoveAUserWithoutAuthority() { public function testRemoveAUserWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userRemove("admin@example.net"); Arsse::$db->userRemove("admin@example.net");
} }
function testListAllUsers() { public function testListAllUsers() {
$users = ["admin@example.net", "jane.doe@example.com", "john.doe@example.com"]; $users = ["admin@example.net", "jane.doe@example.com", "john.doe@example.com"];
$this->assertSame($users, Arsse::$db->userList()); $this->assertSame($users, Arsse::$db->userList());
Phake::verify(Arsse::$user)->authorize("", "userList"); Phake::verify(Arsse::$user)->authorize("", "userList");
} }
function testListUsersOnADomain() { public function testListUsersOnADomain() {
$users = ["jane.doe@example.com", "john.doe@example.com"]; $users = ["jane.doe@example.com", "john.doe@example.com"];
$this->assertSame($users, Arsse::$db->userList("example.com")); $this->assertSame($users, Arsse::$db->userList("example.com"));
Phake::verify(Arsse::$user)->authorize("@example.com", "userList"); Phake::verify(Arsse::$user)->authorize("@example.com", "userList");
} }
function testListAllUsersWithoutAuthority() { public function testListAllUsersWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userList(); Arsse::$db->userList();
} }
function testListUsersOnADomainWithoutAuthority() { public function testListUsersOnADomainWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userList("example.com"); Arsse::$db->userList("example.com");
@ -141,7 +142,7 @@ trait SeriesUser {
/** /**
* @depends testGetAPassword * @depends testGetAPassword
*/ */
function testSetAPassword() { public function testSetAPassword() {
$user = "john.doe@example.com"; $user = "john.doe@example.com";
$this->assertEquals("", Arsse::$db->userPasswordGet($user)); $this->assertEquals("", Arsse::$db->userPasswordGet($user));
$pass = Arsse::$db->userPasswordSet($user, "secret"); $pass = Arsse::$db->userPasswordSet($user, "secret");
@ -150,25 +151,25 @@ trait SeriesUser {
Phake::verify(Arsse::$user)->authorize($user, "userPasswordSet"); Phake::verify(Arsse::$user)->authorize($user, "userPasswordSet");
$this->assertTrue(password_verify($pass, $hash), "Failed verifying password of $user '$pass' against hash '$hash'."); $this->assertTrue(password_verify($pass, $hash), "Failed verifying password of $user '$pass' against hash '$hash'.");
} }
function testSetARandomPassword() { public function testSetARandomPassword() {
$user = "john.doe@example.com"; $user = "john.doe@example.com";
$this->assertEquals("", Arsse::$db->userPasswordGet($user)); $this->assertEquals("", Arsse::$db->userPasswordGet($user));
$pass = Arsse::$db->userPasswordSet($user); $pass = Arsse::$db->userPasswordSet($user);
$hash = Arsse::$db->userPasswordGet($user); $hash = Arsse::$db->userPasswordGet($user);
} }
function testSetThePasswordOfAMissingUser() { public function testSetThePasswordOfAMissingUser() {
$this->assertException("doesNotExist", "User"); $this->assertException("doesNotExist", "User");
Arsse::$db->userPasswordSet("john.doe@example.org", "secret"); Arsse::$db->userPasswordSet("john.doe@example.org", "secret");
} }
function testSetAPasswordWithoutAuthority() { public function testSetAPasswordWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userPasswordSet("john.doe@example.com", "secret"); Arsse::$db->userPasswordSet("john.doe@example.com", "secret");
} }
function testGetUserProperties() { public function testGetUserProperties() {
$exp = [ $exp = [
'name' => 'Hard Lip Herbert', 'name' => 'Hard Lip Herbert',
'rights' => UserDriver::RIGHTS_GLOBAL_ADMIN, 'rights' => UserDriver::RIGHTS_GLOBAL_ADMIN,
@ -179,18 +180,18 @@ trait SeriesUser {
$this->assertArrayNotHasKey("password", $props); $this->assertArrayNotHasKey("password", $props);
} }
function testGetThePropertiesOfAMissingUser() { public function testGetThePropertiesOfAMissingUser() {
$this->assertException("doesNotExist", "User"); $this->assertException("doesNotExist", "User");
Arsse::$db->userPropertiesGet("john.doe@example.org"); Arsse::$db->userPropertiesGet("john.doe@example.org");
} }
function testGetUserPropertiesWithoutAuthority() { public function testGetUserPropertiesWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userPropertiesGet("john.doe@example.com"); Arsse::$db->userPropertiesGet("john.doe@example.com");
} }
function testSetUserProperties() { public function testSetUserProperties() {
$try = [ $try = [
'name' => 'James Kirk', // only this should actually change 'name' => 'James Kirk', // only this should actually change
'password' => '000destruct0', 'password' => '000destruct0',
@ -210,20 +211,20 @@ trait SeriesUser {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testSetThePropertiesOfAMissingUser() { public function testSetThePropertiesOfAMissingUser() {
$try = ['name' => 'John Doe']; $try = ['name' => 'John Doe'];
$this->assertException("doesNotExist", "User"); $this->assertException("doesNotExist", "User");
Arsse::$db->userPropertiesSet("john.doe@example.org", $try); Arsse::$db->userPropertiesSet("john.doe@example.org", $try);
} }
function testSetUserPropertiesWithoutAuthority() { public function testSetUserPropertiesWithoutAuthority() {
$try = ['name' => 'John Doe']; $try = ['name' => 'John Doe'];
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userPropertiesSet("john.doe@example.com", $try); Arsse::$db->userPropertiesSet("john.doe@example.com", $try);
} }
function testGetUserRights() { public function testGetUserRights() {
$user1 = "john.doe@example.com"; $user1 = "john.doe@example.com";
$user2 = "admin@example.net"; $user2 = "admin@example.net";
$this->assertSame(UserDriver::RIGHTS_NONE, Arsse::$db->userRightsGet($user1)); $this->assertSame(UserDriver::RIGHTS_NONE, Arsse::$db->userRightsGet($user1));
@ -232,18 +233,18 @@ trait SeriesUser {
Phake::verify(Arsse::$user)->authorize($user2, "userRightsGet"); Phake::verify(Arsse::$user)->authorize($user2, "userRightsGet");
} }
function testGetTheRightsOfAMissingUser() { public function testGetTheRightsOfAMissingUser() {
$this->assertSame(UserDriver::RIGHTS_NONE, Arsse::$db->userRightsGet("john.doe@example.org")); $this->assertSame(UserDriver::RIGHTS_NONE, Arsse::$db->userRightsGet("john.doe@example.org"));
Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userRightsGet"); Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userRightsGet");
} }
function testGetUserRightsWithoutAuthority() { public function testGetUserRightsWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");
Arsse::$db->userRightsGet("john.doe@example.com"); Arsse::$db->userRightsGet("john.doe@example.com");
} }
function testSetUserRights() { public function testSetUserRights() {
$user = "john.doe@example.com"; $user = "john.doe@example.com";
$rights = UserDriver::RIGHTS_GLOBAL_ADMIN; $rights = UserDriver::RIGHTS_GLOBAL_ADMIN;
$this->assertTrue(Arsse::$db->userRightsSet($user, $rights)); $this->assertTrue(Arsse::$db->userRightsSet($user, $rights));
@ -253,13 +254,13 @@ trait SeriesUser {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
function testSetTheRightsOfAMissingUser() { public function testSetTheRightsOfAMissingUser() {
$rights = UserDriver::RIGHTS_GLOBAL_ADMIN; $rights = UserDriver::RIGHTS_GLOBAL_ADMIN;
$this->assertException("doesNotExist", "User"); $this->assertException("doesNotExist", "User");
Arsse::$db->userRightsSet("john.doe@example.org", $rights); Arsse::$db->userRightsSet("john.doe@example.org", $rights);
} }
function testSetUserRightsWithoutAuthority() { public function testSetUserRightsWithoutAuthority() {
$rights = UserDriver::RIGHTS_GLOBAL_ADMIN; $rights = UserDriver::RIGHTS_GLOBAL_ADMIN;
Phake::when(Arsse::$user)->authorize->thenReturn(false); Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz"); $this->assertException("notAuthorized", "User", "ExceptionAuthz");

51
tests/lib/Database/Setup.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Database; namespace JKingWeb\Arsse\Test\Database;
use JKingWeb\Arsse\User\Driver as UserDriver; use JKingWeb\Arsse\User\Driver as UserDriver;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\Conf;
@ -13,7 +14,7 @@ trait Setup {
protected $drv; protected $drv;
protected $primed = false; protected $primed = false;
function setUp() { public function setUp() {
// establish a clean baseline // establish a clean baseline
$this->clearData(); $this->clearData();
// create a default configuration // create a default configuration
@ -27,28 +28,34 @@ trait Setup {
Arsse::$user = Phake::mock(User::class); Arsse::$user = Phake::mock(User::class);
Phake::when(Arsse::$user)->authorize->thenReturn(true); Phake::when(Arsse::$user)->authorize->thenReturn(true);
// call the additional setup method if it exists // call the additional setup method if it exists
if(method_exists($this, "setUpSeries")) $this->setUpSeries(); if (method_exists($this, "setUpSeries")) {
$this->setUpSeries();
}
// prime the database with series data if it hasn't already been done // prime the database with series data if it hasn't already been done
if(!$this->primed && isset($this->data)) $this->primeDatabase($this->data); if (!$this->primed && isset($this->data)) {
$this->primeDatabase($this->data);
}
} }
function tearDown() { public function tearDown() {
// call the additional teardiwn method if it exists // call the additional teardiwn method if it exists
if(method_exists($this, "tearDownSeries")) $this->tearDownSeries(); if (method_exists($this, "tearDownSeries")) {
$this->tearDownSeries();
}
// clean up // clean up
$this->primed = false; $this->primed = false;
$this->drv = null; $this->drv = null;
$this->clearData(); $this->clearData();
} }
function primeDatabase(array $data): bool { public function primeDatabase(array $data): bool {
$tr = $this->drv->begin(); $tr = $this->drv->begin();
foreach($data as $table => $info) { foreach ($data as $table => $info) {
$cols = implode(",", array_keys($info['columns'])); $cols = implode(",", array_keys($info['columns']));
$bindings = array_values($info['columns']); $bindings = array_values($info['columns']);
$params = implode(",", array_fill(0, sizeof($info['columns']), "?")); $params = implode(",", array_fill(0, sizeof($info['columns']), "?"));
$s = $this->drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings); $s = $this->drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings);
foreach($info['rows'] as $row) { foreach ($info['rows'] as $row) {
$this->assertEquals(1, $s->runArray($row)->changes()); $this->assertEquals(1, $s->runArray($row)->changes());
} }
} }
@ -57,12 +64,12 @@ trait Setup {
return true; return true;
} }
function compareExpectations(array $expected): bool { public function compareExpectations(array $expected): bool {
foreach($expected as $table => $info) { foreach ($expected as $table => $info) {
$cols = implode(",", array_keys($info['columns'])); $cols = implode(",", array_keys($info['columns']));
$data = $this->drv->prepare("SELECT $cols from $table")->run()->getAll(); $data = $this->drv->prepare("SELECT $cols from $table")->run()->getAll();
$cols = array_keys($info['columns']); $cols = array_keys($info['columns']);
foreach($info['rows'] as $index => $row) { foreach ($info['rows'] as $index => $row) {
$this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields"); $this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields");
$row = array_combine($cols, $row); $row = array_combine($cols, $row);
$this->assertContains($row, $data, "Table $table does not contain record at array index $index."); $this->assertContains($row, $data, "Table $table does not contain record at array index $index.");
@ -74,14 +81,14 @@ trait Setup {
return true; return true;
} }
function primeExpectations(array $source, array $tableSpecs = null): array { public function primeExpectations(array $source, array $tableSpecs = null): array {
$out = []; $out = [];
foreach($tableSpecs as $table => $columns) { foreach ($tableSpecs as $table => $columns) {
// make sure the source has the table we want // make sure the source has the table we want
$this->assertArrayHasKey($table, $source, "Source for expectations does not contain requested table $table."); $this->assertArrayHasKey($table, $source, "Source for expectations does not contain requested table $table.");
$out[$table] = [ $out[$table] = [
'columns' => [], 'columns' => [],
'rows' => array_fill(0,sizeof($source[$table]['rows']), []), 'rows' => array_fill(0, sizeof($source[$table]['rows']), []),
]; ];
// make sure the source has all the columns we want for the table // make sure the source has all the columns we want for the table
$cols = array_flip($columns); $cols = array_flip($columns);
@ -89,10 +96,10 @@ trait Setup {
$this->assertSame(array_keys($cols), $columns, "Source for table $table does not contain all requested columns"); $this->assertSame(array_keys($cols), $columns, "Source for table $table does not contain all requested columns");
// get a map of source value offsets and keys // get a map of source value offsets and keys
$targets = array_flip(array_keys($source[$table]['columns'])); $targets = array_flip(array_keys($source[$table]['columns']));
foreach($cols as $key => $order) { foreach ($cols as $key => $order) {
// fill the column-spec // fill the column-spec
$out[$table]['columns'][$key] = $source[$table]['columns'][$key]; $out[$table]['columns'][$key] = $source[$table]['columns'][$key];
foreach($source[$table]['rows'] as $index => $row) { foreach ($source[$table]['rows'] as $index => $row) {
// fill each row column-wise with re-ordered values // fill each row column-wise with re-ordered values
$out[$table]['rows'][$index][$order] = $row[$targets[$key]]; $out[$table]['rows'][$index][$order] = $row[$targets[$key]];
} }
@ -101,13 +108,13 @@ trait Setup {
return $out; return $out;
} }
function assertResult(array $expected, Result $data) { public function assertResult(array $expected, Result $data) {
$data = $data->getAll(); $data = $data->getAll();
$this->assertCount(sizeof($expected), $data, "Number of result rows (".sizeof($data).") differs from number of expected rows (".sizeof($expected).")"); $this->assertCount(sizeof($expected), $data, "Number of result rows (".sizeof($data).") differs from number of expected rows (".sizeof($expected).")");
if(sizeof($expected)) { if (sizeof($expected)) {
// make sure the expectations are consistent // make sure the expectations are consistent
foreach($expected as $exp) { foreach ($expected as $exp) {
if(!isset($keys)) { if (!isset($keys)) {
$keys = $exp; $keys = $exp;
continue; continue;
} }
@ -115,11 +122,11 @@ trait Setup {
} }
// filter the result set to contain just the desired keys (we don't care if the result has extra keys) // filter the result set to contain just the desired keys (we don't care if the result has extra keys)
$rows = []; $rows = [];
foreach($data as $row) { foreach ($data as $row) {
$rows[] = array_intersect_key($row, $keys); $rows[] = array_intersect_key($row, $keys);
} }
// compare the result set to the expectations // compare the result set to the expectations
foreach($expected as $index => $exp) { foreach ($expected as $index => $exp) {
$this->assertContains($exp, $rows, "Result set does not contain record at array index $index."); $this->assertContains($exp, $rows, "Result set does not contain record at array index $index.");
$found = array_search($exp, $rows, true); $found = array_search($exp, $rows, true);
unset($rows[$found]); unset($rows[$found]);

29
tests/lib/Db/BindingTests.php

@ -1,10 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Db; namespace JKingWeb\Arsse\Test\Db;
use JKingWeb\Arsse\Db\Statement; use JKingWeb\Arsse\Db\Statement;
trait BindingTests { trait BindingTests {
function testBindNull() { public function testBindNull() {
$input = null; $input = null;
$exp = [ $exp = [
"null" => null, "null" => null,
@ -34,7 +35,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindTrue() { public function testBindTrue() {
$input = true; $input = true;
$exp = [ $exp = [
"null" => null, "null" => null,
@ -51,7 +52,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindFalse() { public function testBindFalse() {
$input = false; $input = false;
$exp = [ $exp = [
"null" => null, "null" => null,
@ -68,7 +69,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindInteger() { public function testBindInteger() {
$input = 2112; $input = 2112;
$exp = [ $exp = [
"null" => null, "null" => null,
@ -85,7 +86,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindIntegerZero() { public function testBindIntegerZero() {
$input = 0; $input = 0;
$exp = [ $exp = [
"null" => null, "null" => null,
@ -102,7 +103,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindFloat() { public function testBindFloat() {
$input = 2112.0; $input = 2112.0;
$exp = [ $exp = [
"null" => null, "null" => null,
@ -119,7 +120,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindFloatZero() { public function testBindFloatZero() {
$input = 0.0; $input = 0.0;
$exp = [ $exp = [
"null" => null, "null" => null,
@ -136,7 +137,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindAsciiString() { public function testBindAsciiString() {
$input = "Random string"; $input = "Random string";
$exp = [ $exp = [
"null" => null, "null" => null,
@ -153,7 +154,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindUtf8String() { public function testBindUtf8String() {
$input = "é"; $input = "é";
$exp = [ $exp = [
"null" => null, "null" => null,
@ -170,7 +171,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindBinaryString() { public function testBindBinaryString() {
// FIXME: This test may be unreliable; SQLite happily stores invalid UTF-8 text as bytes untouched, but other engines probably don't do this // FIXME: This test may be unreliable; SQLite happily stores invalid UTF-8 text as bytes untouched, but other engines probably don't do this
$input = chr(233); $input = chr(233);
$exp = [ $exp = [
@ -188,7 +189,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindIso8601DateString() { public function testBindIso8601DateString() {
$input = "2017-01-09T13:11:17"; $input = "2017-01-09T13:11:17";
$time = strtotime($input); $time = strtotime($input);
$exp = [ $exp = [
@ -206,7 +207,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindArbitraryDateString() { public function testBindArbitraryDateString() {
$input = "Today"; $input = "Today";
$time = strtotime($input); $time = strtotime($input);
$exp = [ $exp = [
@ -224,7 +225,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindMutableDateObject($class = '\DateTime') { public function testBindMutableDateObject($class = '\DateTime') {
$input = new $class("Noon Today"); $input = new $class("Noon Today");
$time = $input->getTimestamp(); $time = $input->getTimestamp();
$exp = [ $exp = [
@ -242,7 +243,7 @@ trait BindingTests {
$this->checkBinding($input, $exp, true); $this->checkBinding($input, $exp, true);
} }
function testBindImmutableDateObject() { public function testBindImmutableDateObject() {
$this->testBindMutableDateObject('\DateTimeImmutable'); $this->testBindMutableDateObject('\DateTimeImmutable');
} }
} }

15
tests/lib/Lang/Setup.php

@ -1,15 +1,14 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Lang; namespace JKingWeb\Arsse\Test\Lang;
use JKingWeb\Arsse\Lang; use JKingWeb\Arsse\Lang;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStream;
use Phake; use Phake;
trait Setup { trait Setup {
function setUp() { public function setUp() {
// test files // test files
$this->files = [ $this->files = [
'en.php' => '<?php return ["Test.presentText" => "and the Philosopher\'s Stone"];', 'en.php' => '<?php return ["Test.presentText" => "and the Philosopher\'s Stone"];',
@ -40,16 +39,20 @@ trait Setup {
Arsse::$lang = Phake::mock(Lang::class); Arsse::$lang = Phake::mock(Lang::class);
Phake::when(Arsse::$lang)->msg->thenReturn(""); Phake::when(Arsse::$lang)->msg->thenReturn("");
// call the additional setup method if it exists // call the additional setup method if it exists
if(method_exists($this, "setUpSeries")) $this->setUpSeries(); if (method_exists($this, "setUpSeries")) {
$this->setUpSeries();
}
} }
function tearDown() { public function tearDown() {
// verify calls to the mock Lang object // verify calls to the mock Lang object
Phake::verify(Arsse::$lang, Phake::atLeast(0))->msg($this->isType("string"), $this->anything()); Phake::verify(Arsse::$lang, Phake::atLeast(0))->msg($this->isType("string"), $this->anything());
Phake::verifyNoOtherInteractions(Arsse::$lang); Phake::verifyNoOtherInteractions(Arsse::$lang);
// clean up // clean up
$this->clearData(true); $this->clearData(true);
// call the additional teardiwn method if it exists // call the additional teardiwn method if it exists
if(method_exists($this, "tearDownSeries")) $this->tearDownSeries(); if (method_exists($this, "tearDownSeries")) {
$this->tearDownSeries();
}
} }
} }

2
tests/lib/Lang/TestLang.php

@ -1,10 +1,10 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\Lang; namespace JKingWeb\Arsse\Test\Lang;
use Webmozart\Glob\Glob; use Webmozart\Glob\Glob;
class TestLang extends \JKingWeb\Arsse\Lang { class TestLang extends \JKingWeb\Arsse\Lang {
protected function globFiles(string $path): array { protected function globFiles(string $path): array {
return Glob::glob($this->path."*.php"); return Glob::glob($this->path."*.php");
} }

2
tests/lib/Result.php

@ -14,7 +14,7 @@ class Result implements \JKingWeb\Arsse\Db\Result {
public function getValue() { public function getValue() {
$arr = $this->next(); $arr = $this->next();
if($this->valid()) { if ($this->valid()) {
$keys = array_keys($arr); $keys = array_keys($arr);
return $arr[array_shift($keys)]; return $arr[array_shift($keys)];
} }

54
tests/lib/User/CommonTests.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\User; namespace JKingWeb\Arsse\Test\User;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\User; use JKingWeb\Arsse\User;
@ -8,8 +9,7 @@ use JKingWeb\Arsse\User\Driver;
use Phake; use Phake;
trait CommonTests { trait CommonTests {
public function setUp() {
function setUp() {
$this->clearData(); $this->clearData();
$conf = new Conf(); $conf = new Conf();
$conf->userDriver = $this->drv; $conf->userDriver = $this->drv;
@ -21,58 +21,62 @@ trait CommonTests {
$_SERVER['PHP_AUTH_USER'] = self::USER1; $_SERVER['PHP_AUTH_USER'] = self::USER1;
$_SERVER['PHP_AUTH_PW'] = "secret"; $_SERVER['PHP_AUTH_PW'] = "secret";
// call the additional setup method if it exists // call the additional setup method if it exists
if(method_exists($this, "setUpSeries")) $this->setUpSeries(); if (method_exists($this, "setUpSeries")) {
$this->setUpSeries();
}
} }
function tearDown() { public function tearDown() {
$this->clearData(); $this->clearData();
// call the additional teardiwn method if it exists // call the additional teardiwn method if it exists
if(method_exists($this, "tearDownSeries")) $this->tearDownSeries(); if (method_exists($this, "tearDownSeries")) {
$this->tearDownSeries();
}
} }
function testListUsers() { public function testListUsers() {
$this->assertCount(0,Arsse::$user->list()); $this->assertCount(0, Arsse::$user->list());
} }
function testCheckIfAUserDoesNotExist() { public function testCheckIfAUserDoesNotExist() {
$this->assertFalse(Arsse::$user->exists(self::USER1)); $this->assertFalse(Arsse::$user->exists(self::USER1));
} }
function testAddAUser() { public function testAddAUser() {
Arsse::$user->add(self::USER1, ""); Arsse::$user->add(self::USER1, "");
$this->assertCount(1,Arsse::$user->list()); $this->assertCount(1, Arsse::$user->list());
} }
function testCheckIfAUserDoesExist() { public function testCheckIfAUserDoesExist() {
Arsse::$user->add(self::USER1, ""); Arsse::$user->add(self::USER1, "");
$this->assertTrue(Arsse::$user->exists(self::USER1)); $this->assertTrue(Arsse::$user->exists(self::USER1));
} }
function testAddADuplicateUser() { public function testAddADuplicateUser() {
Arsse::$user->add(self::USER1, ""); Arsse::$user->add(self::USER1, "");
$this->assertException("alreadyExists", "User"); $this->assertException("alreadyExists", "User");
Arsse::$user->add(self::USER1, ""); Arsse::$user->add(self::USER1, "");
} }
function testAddMultipleUsers() { public function testAddMultipleUsers() {
Arsse::$user->add(self::USER1, ""); Arsse::$user->add(self::USER1, "");
Arsse::$user->add(self::USER2, ""); Arsse::$user->add(self::USER2, "");
$this->assertCount(2,Arsse::$user->list()); $this->assertCount(2, Arsse::$user->list());
} }
function testRemoveAUser() { public function testRemoveAUser() {
Arsse::$user->add(self::USER1, ""); Arsse::$user->add(self::USER1, "");
$this->assertCount(1,Arsse::$user->list()); $this->assertCount(1, Arsse::$user->list());
Arsse::$user->remove(self::USER1); Arsse::$user->remove(self::USER1);
$this->assertCount(0,Arsse::$user->list()); $this->assertCount(0, Arsse::$user->list());
} }
function testRemoveAMissingUser() { public function testRemoveAMissingUser() {
$this->assertException("doesNotExist", "User"); $this->assertException("doesNotExist", "User");
Arsse::$user->remove(self::USER1); Arsse::$user->remove(self::USER1);
} }
function testAuthenticateAUser() { public function testAuthenticateAUser() {
$_SERVER['PHP_AUTH_USER'] = self::USER1; $_SERVER['PHP_AUTH_USER'] = self::USER1;
$_SERVER['PHP_AUTH_PW'] = "secret"; $_SERVER['PHP_AUTH_PW'] = "secret";
Arsse::$user->add(self::USER1, "secret"); Arsse::$user->add(self::USER1, "secret");
@ -83,7 +87,7 @@ trait CommonTests {
$this->assertTrue(Arsse::$user->auth(self::USER2, "")); $this->assertTrue(Arsse::$user->auth(self::USER2, ""));
} }
function testChangeAPassword() { public function testChangeAPassword() {
Arsse::$user->add(self::USER1, "secret"); Arsse::$user->add(self::USER1, "secret");
$this->assertEquals("superman", Arsse::$user->passwordSet(self::USER1, "superman")); $this->assertEquals("superman", Arsse::$user->passwordSet(self::USER1, "superman"));
$this->assertTrue(Arsse::$user->auth(self::USER1, "superman")); $this->assertTrue(Arsse::$user->auth(self::USER1, "superman"));
@ -93,12 +97,12 @@ trait CommonTests {
$this->assertEquals(Arsse::$conf->userTempPasswordLength, strlen(Arsse::$user->passwordSet(self::USER1))); $this->assertEquals(Arsse::$conf->userTempPasswordLength, strlen(Arsse::$user->passwordSet(self::USER1)));
} }
function testChangeAPasswordForAMissingUser() { public function testChangeAPasswordForAMissingUser() {
$this->assertException("doesNotExist", "User"); $this->assertException("doesNotExist", "User");
Arsse::$user->passwordSet(self::USER1, "superman"); Arsse::$user->passwordSet(self::USER1, "superman");
} }
function testGetThePropertiesOfAUser() { public function testGetThePropertiesOfAUser() {
Arsse::$user->add(self::USER1, "secret"); Arsse::$user->add(self::USER1, "secret");
$p = Arsse::$user->propertiesGet(self::USER1); $p = Arsse::$user->propertiesGet(self::USER1);
$this->assertArrayHasKey('id', $p); $this->assertArrayHasKey('id', $p);
@ -109,7 +113,7 @@ trait CommonTests {
$this->assertEquals(self::USER1, $p['name']); $this->assertEquals(self::USER1, $p['name']);
} }
function testSetThePropertiesOfAUser() { public function testSetThePropertiesOfAUser() {
$pSet = [ $pSet = [
'name' => 'John Doe', 'name' => 'John Doe',
'id' => 'invalid', 'id' => 'invalid',
@ -131,12 +135,12 @@ trait CommonTests {
$this->assertFalse(Arsse::$user->auth(self::USER1, "superman")); $this->assertFalse(Arsse::$user->auth(self::USER1, "superman"));
} }
function testGetTheRightsOfAUser() { public function testGetTheRightsOfAUser() {
Arsse::$user->add(self::USER1, ""); Arsse::$user->add(self::USER1, "");
$this->assertEquals(Driver::RIGHTS_NONE, Arsse::$user->rightsGet(self::USER1)); $this->assertEquals(Driver::RIGHTS_NONE, Arsse::$user->rightsGet(self::USER1));
} }
function testSetTheRightsOfAUser() { public function testSetTheRightsOfAUser() {
Arsse::$user->add(self::USER1, ""); Arsse::$user->add(self::USER1, "");
Arsse::$user->rightsSet(self::USER1, Driver::RIGHTS_GLOBAL_ADMIN); Arsse::$user->rightsSet(self::USER1, Driver::RIGHTS_GLOBAL_ADMIN);
$this->assertEquals(Driver::RIGHTS_GLOBAL_ADMIN, Arsse::$user->rightsGet(self::USER1)); $this->assertEquals(Driver::RIGHTS_GLOBAL_ADMIN, Arsse::$user->rightsGet(self::USER1));

108
tests/lib/User/Database.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\User; namespace JKingWeb\Arsse\Test\User;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User\Driver; use JKingWeb\Arsse\User\Driver;
use JKingWeb\Arsse\User\Exception; use JKingWeb\Arsse\User\Exception;
@ -8,80 +9,121 @@ use JKingWeb\Arsse\User\ExceptionAuthz;
use PasswordGenerator\Generator as PassGen; use PasswordGenerator\Generator as PassGen;
class Database extends DriverSkeleton { class Database extends DriverSkeleton {
public $db = []; public $db = [];
public function __construct() { public function __construct() {
} }
function userExists(string $user): bool { public function userExists(string $user): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
return parent::userExists($user); return parent::userExists($user);
} }
function userAdd(string $user, string $password = null): string { public function userAdd(string $user, string $password = null): string {
if(!Arsse::$user->authorize($user, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); if (!Arsse::$user->authorize($user, __FUNCTION__)) {
if($this->userExists($user)) throw new Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]); throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
if($password===null) $password = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); }
if ($this->userExists($user)) {
throw new Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
}
if ($password===null) {
$password = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
}
return parent::userAdd($user, $password); return parent::userAdd($user, $password);
} }
function userRemove(string $user): bool { public function userRemove(string $user): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); if (!Arsse::$user->authorize($user, __FUNCTION__)) {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return parent::userRemove($user); return parent::userRemove($user);
} }
function userList(string $domain = null): array { public function userList(string $domain = null): array {
if($domain===null) { if ($domain===null) {
if(!Arsse::$user->authorize("", __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => "global"]); if (!Arsse::$user->authorize("", __FUNCTION__)) {
throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => "global"]);
}
return parent::userList(); return parent::userList();
} else { } else {
$suffix = '@'.$domain; $suffix = '@'.$domain;
if(!Arsse::$user->authorize($suffix, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $domain]); if (!Arsse::$user->authorize($suffix, __FUNCTION__)) {
throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $domain]);
}
return parent::userList($domain); return parent::userList($domain);
} }
} }
function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string { public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string {
if(!Arsse::$user->authorize($user, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); if (!Arsse::$user->authorize($user, __FUNCTION__)) {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
if($newPassword===null) $newPassword = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); }
if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
if ($newPassword===null) {
$newPassword = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
}
return parent::userPasswordSet($user, $newPassword); return parent::userPasswordSet($user, $newPassword);
} }
function userPropertiesGet(string $user): array { public function userPropertiesGet(string $user): array {
if(!Arsse::$user->authorize($user, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); if (!Arsse::$user->authorize($user, __FUNCTION__)) {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$out = parent::userPropertiesGet($user); $out = parent::userPropertiesGet($user);
unset($out['password']); unset($out['password']);
return $out; return $out;
} }
function userPropertiesSet(string $user, array $properties): array { public function userPropertiesSet(string $user, array $properties): array {
if(!Arsse::$user->authorize($user, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); if (!Arsse::$user->authorize($user, __FUNCTION__)) {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
parent::userPropertiesSet($user, $properties); parent::userPropertiesSet($user, $properties);
return $this->userPropertiesGet($user); return $this->userPropertiesGet($user);
} }
function userRightsGet(string $user): int { public function userRightsGet(string $user): int {
if(!Arsse::$user->authorize($user, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); if (!Arsse::$user->authorize($user, __FUNCTION__)) {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return parent::userRightsGet($user); return parent::userRightsGet($user);
} }
function userRightsSet(string $user, int $level): bool { public function userRightsSet(string $user, int $level): bool {
if(!Arsse::$user->authorize($user, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); if (!Arsse::$user->authorize($user, __FUNCTION__)) {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return parent::userRightsSet($user, $level); return parent::userRightsSet($user, $level);
} }
// specific to mock database // specific to mock database
function userPasswordGet(string $user): string { public function userPasswordGet(string $user): string {
if(!Arsse::$user->authorize($user, __FUNCTION__)) throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); if (!Arsse::$user->authorize($user, __FUNCTION__)) {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); throw new ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return $this->db[$user]['password']; return $this->db[$user]['password'];
} }
} }

80
tests/lib/User/DriverExternalMock.php

@ -1,13 +1,13 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\User; namespace JKingWeb\Arsse\Test\User;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User\Driver; use JKingWeb\Arsse\User\Driver;
use JKingWeb\Arsse\User\Exception; use JKingWeb\Arsse\User\Exception;
use PasswordGenerator\Generator as PassGen; use PasswordGenerator\Generator as PassGen;
class DriverExternalMock extends DriverSkeleton implements Driver { class DriverExternalMock extends DriverSkeleton implements Driver {
public $db = []; public $db = [];
protected $functions = [ protected $functions = [
"auth" => Driver::FUNC_EXTERNAL, "auth" => Driver::FUNC_EXTERNAL,
@ -22,13 +22,15 @@ class DriverExternalMock extends DriverSkeleton implements Driver {
"userRightsSet" => Driver::FUNC_EXTERNAL, "userRightsSet" => Driver::FUNC_EXTERNAL,
]; ];
static public function driverName(): string { public static function driverName(): string {
return "Mock External Driver"; return "Mock External Driver";
} }
public function driverFunctions(string $function = null) { public function driverFunctions(string $function = null) {
if($function===null) return $this->functions; if ($function===null) {
if(array_key_exists($function, $this->functions)) { return $this->functions;
}
if (array_key_exists($function, $this->functions)) {
return $this->functions[$function]; return $this->functions[$function];
} else { } else {
return Driver::FUNC_NOT_IMPLEMENTED; return Driver::FUNC_NOT_IMPLEMENTED;
@ -38,60 +40,84 @@ class DriverExternalMock extends DriverSkeleton implements Driver {
public function __construct() { public function __construct() {
} }
function auth(string $user, string $password): bool { public function auth(string $user, string $password): bool {
if(!$this->userExists($user)) return false; if (!$this->userExists($user)) {
if($password==="" && $this->db[$user]['password']==="") return true; return false;
if(password_verify($password, $this->db[$user]['password'])) return true; }
if ($password==="" && $this->db[$user]['password']==="") {
return true;
}
if (password_verify($password, $this->db[$user]['password'])) {
return true;
}
return false; return false;
} }
function userExists(string $user): bool { public function userExists(string $user): bool {
return parent::userExists($user); return parent::userExists($user);
} }
function userAdd(string $user, string $password = null): string { public function userAdd(string $user, string $password = null): string {
if($this->userExists($user)) throw new Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]); if ($this->userExists($user)) {
if($password===null) $password = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); throw new Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
}
if ($password===null) {
$password = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
}
return parent::userAdd($user, $password); return parent::userAdd($user, $password);
} }
function userRemove(string $user): bool { public function userRemove(string $user): bool {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return parent::userRemove($user); return parent::userRemove($user);
} }
function userList(string $domain = null): array { public function userList(string $domain = null): array {
if($domain===null) { if ($domain===null) {
return parent::userList(); return parent::userList();
} else { } else {
return parent::userList($domain); return parent::userList($domain);
} }
} }
function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string { public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); if (!$this->userExists($user)) {
if($newPassword===null) $newPassword = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
if ($newPassword===null) {
$newPassword = (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
}
return parent::userPasswordSet($user, $newPassword); return parent::userPasswordSet($user, $newPassword);
} }
function userPropertiesGet(string $user): array { public function userPropertiesGet(string $user): array {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return parent::userPropertiesGet($user); return parent::userPropertiesGet($user);
} }
function userPropertiesSet(string $user, array $properties): array { public function userPropertiesSet(string $user, array $properties): array {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
parent::userPropertiesSet($user, $properties); parent::userPropertiesSet($user, $properties);
return $this->userPropertiesGet($user); return $this->userPropertiesGet($user);
} }
function userRightsGet(string $user): int { public function userRightsGet(string $user): int {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return parent::userRightsGet($user); return parent::userRightsGet($user);
} }
function userRightsSet(string $user, int $level): bool { public function userRightsSet(string $user, int $level): bool {
if(!$this->userExists($user)) throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); if (!$this->userExists($user)) {
throw new Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return parent::userRightsSet($user, $level); return parent::userRightsSet($user, $level);
} }
} }

24
tests/lib/User/DriverInternalMock.php

@ -1,10 +1,10 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\User; namespace JKingWeb\Arsse\Test\User;
use JKingWeb\Arsse\User\Driver; use JKingWeb\Arsse\User\Driver;
class DriverInternalMock extends Database implements Driver { class DriverInternalMock extends Database implements Driver {
public $db = []; public $db = [];
protected $functions = [ protected $functions = [
"auth" => Driver::FUNC_INTERNAL, "auth" => Driver::FUNC_INTERNAL,
@ -19,13 +19,15 @@ class DriverInternalMock extends Database implements Driver {
"userRightsSet" => Driver::FUNC_INTERNAL, "userRightsSet" => Driver::FUNC_INTERNAL,
]; ];
static public function driverName(): string { public static function driverName(): string {
return "Mock Internal Driver"; return "Mock Internal Driver";
} }
public function driverFunctions(string $function = null) { public function driverFunctions(string $function = null) {
if($function===null) return $this->functions; if ($function===null) {
if(array_key_exists($function, $this->functions)) { return $this->functions;
}
if (array_key_exists($function, $this->functions)) {
return $this->functions[$function]; return $this->functions[$function];
} else { } else {
return Driver::FUNC_NOT_IMPLEMENTED; return Driver::FUNC_NOT_IMPLEMENTED;
@ -35,10 +37,16 @@ class DriverInternalMock extends Database implements Driver {
public function __construct() { public function __construct() {
} }
function auth(string $user, string $password): bool { public function auth(string $user, string $password): bool {
if(!$this->userExists($user)) return false; if (!$this->userExists($user)) {
if($password==="" && $this->db[$user]['password']==="") return true; return false;
if(password_verify($password, $this->db[$user]['password'])) return true; }
if ($password==="" && $this->db[$user]['password']==="") {
return true;
}
if (password_verify($password, $this->db[$user]['password'])) {
return true;
}
return false; return false;
} }
} }

24
tests/lib/User/DriverSkeleton.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse\Test\User; namespace JKingWeb\Arsse\Test\User;
use JKingWeb\Arsse\Lang; use JKingWeb\Arsse\Lang;
use JKingWeb\Arsse\User\Driver; use JKingWeb\Arsse\User\Driver;
use JKingWeb\Arsse\User\Exception; use JKingWeb\Arsse\User\Exception;
@ -8,14 +9,13 @@ use JKingWeb\Arsse\User\ExceptionAuthz;
use PasswordGenerator\Generator as PassGen; use PasswordGenerator\Generator as PassGen;
abstract class DriverSkeleton { abstract class DriverSkeleton {
protected $db = []; protected $db = [];
function userExists(string $user): bool { public function userExists(string $user): bool {
return array_key_exists($user, $this->db); return array_key_exists($user, $this->db);
} }
function userAdd(string $user, string $password = null): string { public function userAdd(string $user, string $password = null): string {
$u = [ $u = [
'password' => $password ? password_hash($password, \PASSWORD_DEFAULT) : "", 'password' => $password ? password_hash($password, \PASSWORD_DEFAULT) : "",
'rights' => Driver::RIGHTS_NONE, 'rights' => Driver::RIGHTS_NONE,
@ -24,44 +24,44 @@ abstract class DriverSkeleton {
return $password; return $password;
} }
function userRemove(string $user): bool { public function userRemove(string $user): bool {
unset($this->db[$user]); unset($this->db[$user]);
return true; return true;
} }
function userList(string $domain = null): array { public function userList(string $domain = null): array {
$list = array_keys($this->db); $list = array_keys($this->db);
if($domain===null) { if ($domain===null) {
return $list; return $list;
} else { } else {
$suffix = '@'.$domain; $suffix = '@'.$domain;
$len = -1 * strlen($suffix); $len = -1 * strlen($suffix);
return array_filter($list, function($user) use($suffix, $len) { return array_filter($list, function ($user) use ($suffix, $len) {
return substr_compare($user, $suffix, $len); return substr_compare($user, $suffix, $len);
}); });
} }
} }
function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string { public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): string {
$this->db[$user]['password'] = password_hash($newPassword, \PASSWORD_DEFAULT); $this->db[$user]['password'] = password_hash($newPassword, \PASSWORD_DEFAULT);
return $newPassword; return $newPassword;
} }
function userPropertiesGet(string $user): array { public function userPropertiesGet(string $user): array {
$out = $this->db[$user]; $out = $this->db[$user];
return $out; return $out;
} }
function userPropertiesSet(string $user, array $properties): array { public function userPropertiesSet(string $user, array $properties): array {
$this->db[$user] = array_merge($this->db[$user], $properties); $this->db[$user] = array_merge($this->db[$user], $properties);
return $this->userPropertiesGet($user); return $this->userPropertiesGet($user);
} }
function userRightsGet(string $user): int { public function userRightsGet(string $user): int {
return $this->db[$user]['rights']; return $this->db[$user]['rights'];
} }
function userRightsSet(string $user, int $level): bool { public function userRightsSet(string $user, int $level): bool {
$this->db[$user]['rights'] = $level; $this->db[$user]['rights'] = $level;
return true; return true;
} }

19
tests/server.php

@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace JKingWeb\Arsse; namespace JKingWeb\Arsse;
require_once __DIR__."/../bootstrap.php"; require_once __DIR__."/../bootstrap.php";
/* /*
@ -35,10 +36,10 @@ $defaults = [ // default values for response
'fields' => [], 'fields' => [],
]; ];
$url = explode("?",$_SERVER['REQUEST_URI'])[0]; $url = explode("?", $_SERVER['REQUEST_URI'])[0];
$base = BASE."tests".\DIRECTORY_SEPARATOR."docroot"; $base = BASE."tests".\DIRECTORY_SEPARATOR."docroot";
$test = $base.str_replace("/",\DIRECTORY_SEPARATOR,$url).".php"; $test = $base.str_replace("/", \DIRECTORY_SEPARATOR, $url).".php";
if(!file_exists($test)) { if (!file_exists($test)) {
$response = [ $response = [
'code' => 499, 'code' => 499,
'content' => "Test '$test' missing.", 'content' => "Test '$test' missing.",
@ -53,14 +54,18 @@ if(!file_exists($test)) {
// set the response code // set the response code
http_response_code((int) $response['code']); http_response_code((int) $response['code']);
// if the response has a body, set the content type and (possibly) the ETag. // if the response has a body, set the content type and (possibly) the ETag.
if(strlen($response['content'])) { if (strlen($response['content'])) {
header("Content-Type: ".$response['mime']); header("Content-Type: ".$response['mime']);
if($response['cache']) header('ETag: "'.md5($response['content']).'"'); if ($response['cache']) {
header('ETag: "'.md5($response['content']).'"');
}
} }
// if caching is enabled, set the last-modified date // if caching is enabled, set the last-modified date
if($response['cache']) header("Last-Modified: ".gmdate("D, d M Y H:i:s \G\M\T", $response['lastMod'])); if ($response['cache']) {
header("Last-Modified: ".gmdate("D, d M Y H:i:s \G\M\T", $response['lastMod']));
}
// set any other specified fields verbatim // set any other specified fields verbatim
foreach($response['fields'] as $h) { foreach ($response['fields'] as $h) {
header($h); header($h);
} }
// send the content // send the content

Loading…
Cancel
Save