Browse Source

Multiple fixes to input sanitization

- Database functions now accept any input, but throw typeViolation exceptions where appropriate instead of idMissing or subjectMissing
- Added unit tests for the new Misc\ValueInfo static class
- Added ValueInfo::id() method to centrally validate database IDs, and made use of it consistently
- Made use of PHP's filter_var() function where appropriate when validating or sanitizing input
- Made the NCN protocol handler reject most invalid IDs before handing off to method handlers
- Made NCN's feedUpdate and subscriptionMove methods return 422 on invalid input
- Adjusted several tests to handler type violations
microsub
J. King 7 years ago
parent
commit
d365529493
  1. 95
      lib/Database.php
  2. 2
      lib/Misc/Context.php
  3. 53
      lib/Misc/ValueInfo.php
  4. 37
      lib/REST/AbstractHandler.php
  5. 53
      lib/REST/NextCloudNews/V1_2.php
  6. 4
      tests/Misc/TestContext.php
  7. 185
      tests/Misc/TestValueInfo.php
  8. 99
      tests/REST/NextCloudNews/TestNCNV1_2.php
  9. 4
      tests/lib/AbstractTest.php
  10. 5
      tests/lib/Database/SeriesFeed.php
  11. 21
      tests/lib/Database/SeriesFolder.php
  12. 17
      tests/lib/Database/SeriesSubscription.php
  13. 7
      tests/phpunit.xml

95
lib/Database.php

@ -238,17 +238,13 @@ class Database {
return $this->db->prepare("INSERT INTO arsse_folders(owner,parent,name) values(?,?,?)", "str", "int", "str")->run($user, $parent, $name)->lastId(); return $this->db->prepare("INSERT INTO arsse_folders(owner,parent,name) values(?,?,?)", "str", "int", "str")->run($user, $parent, $name)->lastId();
} }
public function folderList(string $user, int $parent = null, bool $recursive = true): Db\Result { public function folderList(string $user, $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)) { $parent = $this->folderValidateId($user, $parent)['id'];
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]);
}
}
// 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);
@ -260,10 +256,13 @@ class Database {
} }
} }
public function folderRemove(string $user, int $id): bool { public function folderRemove(string $user, $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]);
} }
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id, 'type' => "int > 0"]);
}
$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]);
@ -271,10 +270,13 @@ class Database {
return true; return true;
} }
public function folderPropertiesGet(string $user, int $id): array { public function folderPropertiesGet(string $user, $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]);
} }
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'id' => $id, 'type' => "int > 0"]);
}
$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]);
@ -282,7 +284,7 @@ class Database {
return $props; return $props;
} }
public function folderPropertiesSet(string $user, int $id, array $data): bool { public function folderPropertiesSet(string $user, $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]);
} }
@ -294,14 +296,18 @@ class Database {
// if a new name and parent are specified, validate both together // if a new name and parent are specified, validate both together
$this->folderValidateName($data['name']); $this->folderValidateName($data['name']);
$in['name'] = $data['name']; $in['name'] = $data['name'];
$in['parent'] = $this->folderValidateMove($user, $id, $data['parent'], $data['name']); $in['parent'] = $this->folderValidateMove($user, (int) $id, $data['parent'], $data['name']);
} elseif ($name) { } elseif ($name) {
// if we're trying to rename the root folder, this simply fails
if (!$id) {
return false;
}
// if a new name is specified, validate it // if a new name is specified, validate it
$this->folderValidateName($data['name'], true, $in['parent']); $this->folderValidateName($data['name'], true, $in['parent']);
$in['name'] = $data['name']; $in['name'] = $data['name'];
} elseif ($parent) { } elseif ($parent) {
// if a new parent is specified, validate it // if a new parent is specified, validate it
$in['parent'] = $this->folderValidateMove($user, $id, $data['parent']); $in['parent'] = $this->folderValidateMove($user, (int) $id, $data['parent']);
} else { } else {
// if neither was specified, do nothing // if neither was specified, do nothing
return false; return false;
@ -315,14 +321,13 @@ class Database {
} }
protected function folderValidateId(string $user, $id = null, bool $subject = false): array { protected function folderValidateId(string $user, $id = null, bool $subject = false): array {
$idInfo = ValueInfo::int($id); // if the specified ID is not a non-negative integer (or null), this will always fail
if ($idInfo & (ValueInfo::NULL | ValueInfo::ZERO)) { if(!ValueInfo::id($id, true)) {
// if a null or zero ID is specified this is a no-op throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "folder", 'type' => "int >= 0"]);
return ['id' => null, 'name' => null, 'parent' => null];
} }
// if a negative integer or non-integer is specified this will always fail // if a null or zero ID is specified this is a no-op
if (!($idInfo & ValueInfo::VALID) || (($idInfo & ValueInfo::NEG))) { if (!$id) {
throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "folder", 'id' => $id]); return ['id' => null, '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 id,name,parent from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->getRow(); $f = $this->db->prepare("SELECT id,name,parent from arsse_folders where owner is ? and id is ?", "str", "int")->run($user, $id)->getRow();
@ -417,10 +422,12 @@ class Database {
return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId(); return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId();
} }
public function subscriptionList(string $user, int $folder = null, int $id = null): Db\Result { public function subscriptionList(string $user, $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]);
} }
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// create a complex query // create a complex query
$q = new Query( $q = new Query(
"SELECT "SELECT
@ -439,13 +446,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 ($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);
} elseif ($folder) { } elseif ($folder) {
// if a folder is specified, make sure it exists
$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
$q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $folder); $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent is folder", "int", $folder);
// add a suitable WHERE condition // add a suitable WHERE condition
@ -454,24 +459,30 @@ class Database {
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
} }
public function subscriptionRemove(string $user, int $id): bool { public function subscriptionRemove(string $user, $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]);
} }
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id, 'type' => "int > 0"]);
}
$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" => "feed", 'id' => $id]);
} }
return true; return true;
} }
public function subscriptionPropertiesGet(string $user, int $id): array { public function subscriptionPropertiesGet(string $user, $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]);
} }
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id, 'type' => "int > 0"]);
}
// 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, (int) $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]);
@ -479,15 +490,13 @@ class Database {
return $sub; return $sub;
} }
public function subscriptionPropertiesSet(string $user, int $id, array $data): bool { public function subscriptionPropertiesSet(string $user, $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()) { // validate the ID
// if the ID doesn't exist or doesn't belong to the user, throw an exception $id = $this->subscriptionValidateId($user, $id, true)['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
$data['folder'] = $this->folderValidateId($user, $data['folder'])['id']; $data['folder'] = $this->folderValidateId($user, $data['folder'])['id'];
@ -517,10 +526,13 @@ class Database {
return $out; return $out;
} }
protected function subscriptionValidateId(string $user, int $id): array { protected function subscriptionValidateId(string $user, $id, bool $subject = false): array {
$out = $this->db->prepare("SELECT feed from arsse_subscriptions where id is ? and owner is ?", "int", "str")->run($id, $user)->getRow(); if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'id' => $id, 'type' => "int > 0"]);
}
$out = $this->db->prepare("SELECT id,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($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "subscription", 'id' => $id]);
} }
return $out; return $out;
} }
@ -530,9 +542,12 @@ class Database {
return array_column($feeds, 'id'); return array_column($feeds, 'id');
} }
public function feedUpdate(int $feedID, bool $throwError = false): bool { public function feedUpdate($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
if (!ValueInfo::id($feedID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID, 'type' => "int > 0"]);
}
$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]);
@ -543,7 +558,7 @@ class Database {
// here. When an exception is thrown it should update the database with the // here. When an exception is thrown it should update the database with the
// 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((int) $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);
@ -951,7 +966,10 @@ class Database {
return true; return true;
} }
protected function articleValidateId(string $user, int $id): array { protected function articleValidateId(string $user, $id): array {
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'id' => $id, 'type' => "int > 0"]); // @codeCoverageIgnore
}
$out = $this->db->prepare( $out = $this->db->prepare(
"SELECT "SELECT
arsse_articles.id as article, arsse_articles.id as article,
@ -970,6 +988,9 @@ class Database {
} }
protected function articleValidateEdition(string $user, int $id): array { protected function articleValidateEdition(string $user, int $id): array {
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'id' => $id, 'type' => "int > 0"]); // @codeCoverageIgnore
}
$out = $this->db->prepare( $out = $this->db->prepare(
"SELECT "SELECT
arsse_editions.id as edition, arsse_editions.id as edition,

2
lib/Misc/Context.php

@ -37,7 +37,7 @@ 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++) {
if(ValueInfo::int($spec[$a])===ValueInfo::VALID) { if(ValueInfo::id($spec[$a])) {
$spec[$a] = (int) $spec[$a]; $spec[$a] = (int) $spec[$a];
} else { } else {
$spec[$a] = 0; $spec[$a] = 0;

53
lib/Misc/ValueInfo.php

@ -15,31 +15,31 @@ class ValueInfo {
static public function int($value): int { static public function int($value): int {
$out = 0; $out = 0;
// check if the input is null
if (is_null($value)) { if (is_null($value)) {
$out += self::NULL; // check if the input is null
} return self::NULL;
// normalize the value to an integer or float if possible } elseif (is_string($value)) {
if (is_string($value)) { // normalize a string an integer or float if possible
if (strval(@intval($value))===$value) {
$value = (int) $value;
} elseif (strval(@floatval($value))===$value) {
$value = (float) $value;
}
// the empty string is equivalent to null when evaluating an integer
if (!strlen((string) $value)) { if (!strlen((string) $value)) {
$out += self::NULL; // the empty string is equivalent to null when evaluating an integer
return self::NULL;
} elseif (filter_var($value, \FILTER_VALIDATE_FLOAT) !== false && !fmod((float) $value, 1)) {
// an integral float is acceptable
$value = (int) $value;
} else {
return $out;
} }
} } elseif (is_float($value) && !fmod($value, 1)) {
// if the value is not an integer or integral float, stop // an integral float is acceptable
if (!is_int($value) && (!is_float($value) || fmod($value, 1))) { $value = (int) $value;
} elseif (!is_int($value)) {
// if the value is not an integer or integral float, stop
return $out; return $out;
} }
// mark validity // mark validity
$value = (int) $value;
$out += self::VALID; $out += self::VALID;
// mark zeroness // mark zeroness
if(!$value) { if($value==0) {
$out += self::ZERO; $out += self::ZERO;
} }
// mark negativeness // mark negativeness
@ -55,8 +55,8 @@ class ValueInfo {
if (is_null($value)) { if (is_null($value)) {
$out += self::NULL; $out += self::NULL;
} }
// if the value is not scalar, it cannot be valid // if the value is not scalar, is a boolean, or is infinity or NaN, it cannot be valid
if (!is_scalar($value)) { if (!is_scalar($value) || is_bool($value) || (is_float($value) && !is_finite($value))) {
return $out; return $out;
} }
// mark validity // mark validity
@ -70,4 +70,19 @@ class ValueInfo {
} }
return $out; return $out;
} }
static public function id($value, bool $allowNull = false): bool {
$info = self::int($value);
if ($allowNull && ($info & self::NULL)) { // null (and allowed)
return true;
} elseif (!($info & self::VALID)) { // not an integer
return false;
} elseif ($info & self::NEG) { // negative integer
return false;
} elseif (!$allowNull && ($info & self::ZERO)) { // zero (and not allowed)
return false;
} else { // non-negative integer
return true;
}
}
} }

37
lib/REST/AbstractHandler.php

@ -32,10 +32,6 @@ abstract class AbstractHandler implements Handler {
return $data; return $data;
} }
protected function validateInt($id): bool {
return (bool) (ValueInfo::int($id) & ValueInfo::VALID);
}
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) {
@ -49,34 +45,29 @@ abstract class AbstractHandler implements Handler {
} }
switch ($types[$key]) { switch ($types[$key]) {
case "int": case "int":
if ($this->validateInt($value)) { if (valueInfo::int($value) & ValueInfo::VALID) {
$out[$key] = (int) $value; $out[$key] = (int) $value;
} }
break; break;
case "string": case "string":
$out[$key] = (string) $value; if(is_bool($value)) {
$out[$key] = var_export($value, true);
} elseif (!is_scalar($value)) {
break;
} else {
$out[$key] = (string) $value;
}
break; break;
case "bool": case "bool":
if (is_bool($value)) { $test = filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE);
$out[$key] = $value; if (!is_null($test)) {
} elseif ($this->validateInt($value)) { $out[$key] = $test;
$value = (int) $value;
if ($value > -1 && $value < 2) {
$out[$key] = $value;
}
} elseif (is_string($value)) {
$value = trim(strtolower($value));
if ($value=="false") {
$out[$key] = false;
}
if ($value=="true") {
$out[$key] = true;
}
} }
break; break;
case "float": case "float":
if (is_numeric($value)) { $test = filter_var($value, \FILTER_VALIDATE_FLOAT);
$out[$key] = (float) $value; if ($test !== false) {
$out[$key] = $test;
} }
break; break;
case "datetime": case "datetime":

53
lib/REST/NextCloudNews/V1_2.php

@ -6,6 +6,7 @@ use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User; use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\Feed\Exception as FeedException;
@ -94,15 +95,15 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
'items' => [], 'items' => [],
'folders' => [ 'folders' => [
'' => ['GET' => "folderList", 'POST' => "folderAdd"], '' => ['GET' => "folderList", 'POST' => "folderAdd"],
'0' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"], '1' => ['PUT' => "folderRename", 'DELETE' => "folderRemove"],
'0/read' => ['PUT' => "folderMarkRead"], '1/read' => ['PUT' => "folderMarkRead"],
], ],
'feeds' => [ 'feeds' => [
'' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"], '' => ['GET' => "subscriptionList", 'POST' => "subscriptionAdd"],
'0' => ['DELETE' => "subscriptionRemove"], '1' => ['DELETE' => "subscriptionRemove"],
'0/move' => ['PUT' => "subscriptionMove"], '1/move' => ['PUT' => "subscriptionMove"],
'0/rename' => ['PUT' => "subscriptionRename"], '1/rename' => ['PUT' => "subscriptionRename"],
'0/read' => ['PUT' => "subscriptionMarkRead"], '1/read' => ['PUT' => "subscriptionMarkRead"],
'all' => ['GET' => "feedListStale"], 'all' => ['GET' => "feedListStale"],
'update' => ['GET' => "feedUpdate"], 'update' => ['GET' => "feedUpdate"],
], ],
@ -110,12 +111,12 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
'' => ['GET' => "articleList"], '' => ['GET' => "articleList"],
'updated' => ['GET' => "articleList"], 'updated' => ['GET' => "articleList"],
'read' => ['PUT' => "articleMarkReadAll"], 'read' => ['PUT' => "articleMarkReadAll"],
'0/read' => ['PUT' => "articleMarkRead"], '1/read' => ['PUT' => "articleMarkRead"],
'0/unread' => ['PUT' => "articleMarkRead"], '1/unread' => ['PUT' => "articleMarkRead"],
'read/multiple' => ['PUT' => "articleMarkReadMulti"], 'read/multiple' => ['PUT' => "articleMarkReadMulti"],
'unread/multiple' => ['PUT' => "articleMarkReadMulti"], 'unread/multiple' => ['PUT' => "articleMarkReadMulti"],
'0/0/star' => ['PUT' => "articleMarkStarred"], '1/1/star' => ['PUT' => "articleMarkStarred"],
'0/0/unstar' => ['PUT' => "articleMarkStarred"], '1/1/unstar' => ['PUT' => "articleMarkStarred"],
'star/multiple' => ['PUT' => "articleMarkStarredMulti"], 'star/multiple' => ['PUT' => "articleMarkStarredMulti"],
'unstar/multiple' => ['PUT' => "articleMarkStarredMulti"], 'unstar/multiple' => ['PUT' => "articleMarkStarredMulti"],
], ],
@ -135,10 +136,10 @@ 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 database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID)
for ($a = 0; $a < sizeof($url); $a++) { for ($a = 0; $a < sizeof($url); $a++) {
if ($this->validateInt($url[$a])) { if (ValueInfo::id($url[$a])) {
$url[$a] = "0"; $url[$a] = "1";
} }
} }
// normalize the HTTP method to uppercase // normalize the HTTP method to uppercase
@ -336,7 +337,14 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
try { try {
Arsse::$db->feedUpdate($data['feedId']); Arsse::$db->feedUpdate($data['feedId']);
} catch (ExceptionInput $e) { } catch (ExceptionInput $e) {
return new Response(404); switch ($e->getCode()) {
case 10239: // feed does not exist
return new Response(404);
case 10237: // feed ID invalid
return new Response(422);
default: // other errors related to input
return new Response(400); // @codeCoverageIgnore
}
} }
return new Response(204); return new Response(204);
} }
@ -347,8 +355,8 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
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
$folder = (isset($data['folderId']) && $data['folderId']) ? $data['folderId'] : null; $folder = isset($data['folderId']) ? $data['folderId'] : null;
// try to add the feed // try to add the feed
$tr = Arsse::$db->begin(); $tr = Arsse::$db->begin();
try { try {
@ -446,12 +454,13 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
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 case 10239: // subscription does not exist
case 10239: return new Response(404); return new Response(404);
// folder does not exist case 10235: // folder does not exist
case 10235: return new Response(422); case 10237: // folder ID is invalid
// other errors related to input return new Response(422);
default: return new Response(400); // @codeCoverageIgnore default: // other errors related to input
return new Response(400); // @codeCoverageIgnore
} }
} }
return new Response(204); return new Response(204);

4
tests/Misc/TestContext.php

@ -47,9 +47,9 @@ class TestContext extends Test\AbstractTest {
$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], "Context method $method did not return the expected results");
} else { } else {
$this->assertSame($c->$method, $v[$method]); $this->assertSame($c->$method, $v[$method], "Context method $method did not return the expected results");
} }
} }
} }

185
tests/Misc/TestValueInfo.php

@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\ValueInfo as I;
/** @covers \JKingWeb\Arsse\Misc\ValueInfo */
class TestValueInfo extends Test\AbstractTest {
public function testGetIntegerInfo() {
$tests = [
[null, I::NULL],
["", I::NULL],
[1, I::VALID],
[PHP_INT_MAX, I::VALID],
[1.0, I::VALID],
["1.0", I::VALID],
["001.0", I::VALID],
["1.0e2", I::VALID],
["1", I::VALID],
["001", I::VALID],
["1e2", I::VALID],
["+1.0", I::VALID],
["+001.0", I::VALID],
["+1.0e2", I::VALID],
["+1", I::VALID],
["+001", I::VALID],
["+1e2", I::VALID],
[0, I::VALID | I::ZERO],
["0", I::VALID | I::ZERO],
["000", I::VALID | I::ZERO],
[0.0, I::VALID | I::ZERO],
["0.0", I::VALID | I::ZERO],
["000.000", I::VALID | I::ZERO],
["+0", I::VALID | I::ZERO],
["+000", I::VALID | I::ZERO],
["+0.0", I::VALID | I::ZERO],
["+000.000", I::VALID | I::ZERO],
[-1, I::VALID | I::NEG],
[-1.0, I::VALID | I::NEG],
["-1.0", I::VALID | I::NEG],
["-001.0", I::VALID | I::NEG],
["-1.0e2", I::VALID | I::NEG],
["-1", I::VALID | I::NEG],
["-001", I::VALID | I::NEG],
["-1e2", I::VALID | I::NEG],
[-0, I::VALID | I::ZERO],
["-0", I::VALID | I::ZERO],
["-000", I::VALID | I::ZERO],
[-0.0, I::VALID | I::ZERO],
["-0.0", I::VALID | I::ZERO],
["-000.000", I::VALID | I::ZERO],
[false, 0],
[true, 0],
[INF, 0],
[-INF, 0],
[NAN, 0],
[[], 0],
["some string", 0],
[" ", 0],
[new \StdClass, 0],
];
foreach ($tests as $test) {
list($value, $exp) = $test;
$this->assertSame($exp, I::int($value), "Test returned ".decbin(I::int($value))." for value: ".var_export($value, true));
}
}
public function testGetStringInfo() {
$tests = [
[null, I::NULL],
["", I::VALID | I::EMPTY],
[1, I::VALID],
[PHP_INT_MAX, I::VALID],
[1.0, I::VALID],
["1.0", I::VALID],
["001.0", I::VALID],
["1.0e2", I::VALID],
["1", I::VALID],
["001", I::VALID],
["1e2", I::VALID],
["+1.0", I::VALID],
["+001.0", I::VALID],
["+1.0e2", I::VALID],
["+1", I::VALID],
["+001", I::VALID],
["+1e2", I::VALID],
[0, I::VALID],
["0", I::VALID],
["000", I::VALID],
[0.0, I::VALID],
["0.0", I::VALID],
["000.000", I::VALID],
["+0", I::VALID],
["+000", I::VALID],
["+0.0", I::VALID],
["+000.000", I::VALID],
[-1, I::VALID],
[-1.0, I::VALID],
["-1.0", I::VALID],
["-001.0", I::VALID],
["-1.0e2", I::VALID],
["-1", I::VALID],
["-001", I::VALID],
["-1e2", I::VALID],
[-0, I::VALID],
["-0", I::VALID],
["-000", I::VALID],
[-0.0, I::VALID],
["-0.0", I::VALID],
["-000.000", I::VALID],
[false, 0],
[true, 0],
[INF, 0],
[-INF, 0],
[NAN, 0],
[[], 0],
["some string", I::VALID],
[" ", I::VALID | I::WHITE],
[new \StdClass, 0],
];
foreach ($tests as $test) {
list($value, $exp) = $test;
$this->assertSame($exp, I::str($value), "Test returned ".decbin(I::str($value))." for value: ".var_export($value, true));
}
}
public function testValidateDatabaseIdentifier() {
$tests = [
[null, false, true],
["", false, true],
[1, true, true],
[PHP_INT_MAX, true, true],
[1.0, true, true],
["1.0", true, true],
["001.0", true, true],
["1.0e2", true, true],
["1", true, true],
["001", true, true],
["1e2", true, true],
["+1.0", true, true],
["+001.0", true, true],
["+1.0e2", true, true],
["+1", true, true],
["+001", true, true],
["+1e2", true, true],
[0, false, true],
["0", false, true],
["000", false, true],
[0.0, false, true],
["0.0", false, true],
["000.000", false, true],
["+0", false, true],
["+000", false, true],
["+0.0", false, true],
["+000.000", false, true],
[-1, false, false],
[-1.0, false, false],
["-1.0", false, false],
["-001.0", false, false],
["-1.0e2", false, false],
["-1", false, false],
["-001", false, false],
["-1e2", false, false],
[-0, false, true],
["-0", false, true],
["-000", false, true],
[-0.0, false, true],
["-0.0", false, true],
["-000.000", false, true],
[false, false, false],
[true, false, false],
[INF, false, false],
[-INF, false, false],
[NAN, false, false],
[[], false, false],
["some string", false, false],
[" ", false, false],
[new \StdClass, false, false],
];
foreach ($tests as $test) {
list($value, $exp, $expNull) = $test;
$this->assertSame($exp, I::id($value), "Non-null test failed for value: ".var_export($value, true));
$this->assertSame($expNull, I::id($value, true), "Null test failed for value: ".var_export($value, true));
}
}
}

99
tests/REST/NextCloudNews/TestNCNV1_2.php

@ -46,6 +46,21 @@ class TestNCNV1_2 extends Test\AbstractTest {
'title' => 'Second example feed', 'title' => 'Second example feed',
'unread' => 23, 'unread' => 23,
], ],
[
'id' => 47,
'url' => 'http://example.net/news.atom',
'favicon' => 'http://example.net/favicon.png',
'source' => 'http://example.net/',
'folder' => null,
'top_folder' => null,
'pinned' => 0,
'err_count' => 0,
'err_msg' => '',
'order_type' => 1,
'added' => '2017-05-20 13:35:54',
'title' => 'Third example feed',
'unread' => 0,
],
], ],
'rest' => [ 'rest' => [
[ [
@ -76,6 +91,20 @@ class TestNCNV1_2 extends Test\AbstractTest {
'title' => 'Second example feed', 'title' => 'Second example feed',
'unreadCount' => 23, 'unreadCount' => 23,
], ],
[
'id' => 47,
'url' => 'http://example.net/news.atom',
'faviconLink' => 'http://example.net/favicon.png',
'link' => 'http://example.net/',
'folderId' => 0,
'pinned' => false,
'updateErrorCount' => 0,
'lastUpdateError' => '',
'ordering' => 1,
'added' => 1495287354,
'title' => 'Third example feed',
'unreadCount' => 0,
],
], ],
]; ];
protected $articles = [ protected $articles = [
@ -331,7 +360,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$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')));
} }
public function testReceiveAuthenticationChallenge() { public function testSendAuthenticationChallenge() {
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", "/")));
@ -381,6 +410,7 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders"))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders")));
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":""}', 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":""}', 'application/json')));
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":" "}', 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":" "}', 'application/json')));
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders", '{"name":{}}', 'application/json')));
// try adding the same two folders again // try adding the same two folders again
$exp = new Response(409); $exp = new Response(409);
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders?name=Software"))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/folders?name=Software")));
@ -457,26 +487,29 @@ class TestNCNV1_2 extends Test\AbstractTest {
$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],
['url' => "http://example.net/news.atom", 'folderId' => 0], ['url' => "http://example.net/news.atom", 'folderId' => 8],
['url' => "http://example.net/news.atom", 'folderId' => -1],
[], [],
]; ];
$out = [ $out = [
['feeds' => [$this->feeds['rest'][0]]], ['feeds' => [$this->feeds['rest'][0]]],
['feeds' => [$this->feeds['rest'][1]], 'newestItemId' => 4758915], ['feeds' => [$this->feeds['rest'][1]], 'newestItemId' => 4758915],
[], ['feeds' => [$this->feeds['rest'][2]], 'newestItemId' => 2112],
[],
]; ];
// 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)->subscriptionPropertiesGet(Arsse::$user->id, 47)->thenReturn($this->feeds['db'][2]);
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)->editionLatest(Arsse::$user->id, (new Context)->subscription(47))->thenReturn(2112);
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, ['folder' => 8])->thenReturn(true); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 2112, ['folder' => 3])->thenThrow(new ExceptionInput("idMissing")); // folder ID 3 does not exist
// set up a mock for a bad feed Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, ['folder' => 8])->thenReturn(true);
Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.net/news.atom")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.net/news.atom", new \PicoFeed\Client\InvalidUrlException())); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 47, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder ID -1 is invalid
// set up a mock for a bad feed which succeeds the second time
Phake::when(Arsse::$db)->subscriptionAdd(Arsse::$user->id, "http://example.net/news.atom")->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.net/news.atom", new \PicoFeed\Client\InvalidUrlException()))->thenReturn(47);
// add the subscriptions // add the subscriptions
$exp = new Response(200, $out[0]); $exp = new Response(200, $out[0]);
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json')));
@ -485,14 +518,16 @@ class TestNCNV1_2 extends Test\AbstractTest {
// try to add them a second time // try to add them a second time
$exp = new Response(409); $exp = new Response(409);
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[0]), 'application/json')));
$exp = new Response(409);
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[1]), 'application/json')));
// try to add a bad feed // try to add a bad feed
$exp = new Response(422); $exp = new Response(422);
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[2]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[2]), 'application/json')));
// try again (this will succeed), with an invalid folder ID
$exp = new Response(200, $out[2]);
$this->assertEquals($exp, $this->h->dispatch(new Request("POST", "/feeds", json_encode($in[3]), 'application/json')));
// try to add no feed // try to add no feed
$exp = new Response(422); $exp = new Response(422);
$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[4]), 'application/json')));
} }
public function testRemoveASubscription() { public function testRemoveASubscription() {
@ -511,11 +546,13 @@ class TestNCNV1_2 extends Test\AbstractTest {
['folderId' => 42], ['folderId' => 42],
['folderId' => 2112], ['folderId' => 2112],
['folderId' => 42], ['folderId' => 42],
['folderId' => -1],
[], [],
]; ];
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 42])->thenReturn(true); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 42])->thenReturn(true);
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => null])->thenReturn(true); Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => null])->thenReturn(true);
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 2112])->thenThrow(new ExceptionInput("idMissing")); // folder does not exist Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 2112])->thenThrow(new ExceptionInput("idMissing")); // folder does not exist
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder is invalid
Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // subscription does not exist Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // subscription does not exist
$exp = new Response(204); $exp = new Response(204);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[0]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[0]), 'application/json')));
@ -527,6 +564,8 @@ class TestNCNV1_2 extends Test\AbstractTest {
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/move", json_encode($in[3]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/42/move", json_encode($in[3]), 'application/json')));
$exp = new Response(422); $exp = new Response(422);
$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')));
$exp = new Response(422);
$this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "/feeds/1/move", json_encode($in[5]), 'application/json')));
} }
public function testRenameASubscription() { public function testRenameASubscription() {
@ -584,18 +623,20 @@ class TestNCNV1_2 extends Test\AbstractTest {
['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
['feedId' => -1], // 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"));
Phake::when(Arsse::$db)->feedUpdate(-1)->thenThrow(new ExceptionInput("typeViolation"));
$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')));
$exp = new Response(404); $exp = new Response(404);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[1]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[1]), 'application/json')));
$exp = new Response(422); $exp = new Response(422);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[2]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[2]), 'application/json')));
$exp = new Response(422);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[3]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[3]), 'application/json')));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/feeds/update", json_encode($in[4]), 'application/json')));
// updating a feed when not an admin fails // updating a feed when not an admin fails
Phake::when(Arsse::$user)->rightsGet->thenReturn(0); Phake::when(Arsse::$user)->rightsGet->thenReturn(0);
$exp = new Response(403); $exp = new Response(403);
@ -606,13 +647,15 @@ class TestNCNV1_2 extends Test\AbstractTest {
$res = new Result($this->articles['db']); $res = new Result($this->articles['db']);
$t = new \DateTime; $t = new \DateTime;
$in = [ $in = [
['type' => 0, 'id' => 42], ['type' => 0, 'id' => 42], // type=0 => subscription/feed
['type' => 1, 'id' => 2112], ['type' => 1, 'id' => 2112], // type=1 => folder
['type' => 2, 'id' => 0], ['type' => 0, 'id' => -1], // type=0 => subscription/feed; invalid ID
['type' => 3, 'id' => 0], ['type' => 1, 'id' => -1], // type=1 => folder; invalid ID
['type' => 2, 'id' => 0], // type=2 => starred
['type' => 3, 'id' => 0], // type=3 => all (default); base context
['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5],
['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5],
['getRead' => true], ['getRead' => true], // base context
['getRead' => false], ['getRead' => false],
['lastModified' => $t->getTimestamp()], ['lastModified' => $t->getTimestamp()],
['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], // offset=0 should not set the latestEdition context ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], // offset=0 should not set the latestEdition context
@ -620,6 +663,8 @@ class TestNCNV1_2 extends Test\AbstractTest {
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything())->thenReturn($res); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything())->thenReturn($res);
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42))->thenThrow(new ExceptionInput("idMissing")); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42))->thenThrow(new ExceptionInput("idMissing"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112))->thenThrow(new ExceptionInput("idMissing")); Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112))->thenThrow(new ExceptionInput("idMissing"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1))->thenThrow(new ExceptionInput("typeViolation"));
Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1))->thenThrow(new ExceptionInput("typeViolation"));
$exp = new Response(200, ['items' => $this->articles['rest']]); $exp = new Response(200, ['items' => $this->articles['rest']]);
// check the contents of the response // check the contents of the response
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items"))); // first instance of base context $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items"))); // first instance of base context
@ -628,22 +673,26 @@ class TestNCNV1_2 extends Test\AbstractTest {
$exp = new Response(422); $exp = new Response(422);
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[0]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[0]), 'application/json')));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[1]), 'application/json'))); $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[1]), 'application/json')));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[2]), 'application/json')));
$this->assertEquals($exp, $this->h->dispatch(new Request("GET", "/items", json_encode($in[3]), 'application/json')));
// simply run through the remainder of the input for later method verification // simply run through the remainder of the input for later method verification
$this->h->dispatch(new Request("GET", "/items", json_encode($in[2]), 'application/json'));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[3]), 'application/json')); // third instance of base context
$this->h->dispatch(new Request("GET", "/items", json_encode($in[4]), 'application/json')); $this->h->dispatch(new Request("GET", "/items", json_encode($in[4]), 'application/json'));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[5]), 'application/json')); $this->h->dispatch(new Request("GET", "/items", json_encode($in[5]), 'application/json')); // third instance of base context
$this->h->dispatch(new Request("GET", "/items", json_encode($in[6]), 'application/json')); // fourth instance of base context $this->h->dispatch(new Request("GET", "/items", json_encode($in[6]), 'application/json'));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[7]), 'application/json')); $this->h->dispatch(new Request("GET", "/items", json_encode($in[7]), 'application/json'));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[8]), 'application/json')); $this->h->dispatch(new Request("GET", "/items", json_encode($in[8]), 'application/json')); // fourth instance of base context
$this->h->dispatch(new Request("GET", "/items", json_encode($in[9]), 'application/json')); $this->h->dispatch(new Request("GET", "/items", json_encode($in[9]), 'application/json'));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[10]), 'application/json'));
$this->h->dispatch(new Request("GET", "/items", json_encode($in[11]), 'application/json'));
// perform method verifications // perform method verifications
Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true)); Phake::verify(Arsse::$db, Phake::times(4))->articleList(Arsse::$user->id, (new Context)->reverse(true));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42)); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112)); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true)); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6)); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6)); // offset is one more than specified
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4)); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4)); // offset is one less than specified
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true)); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true));
Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->modifiedSince($t)); Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->modifiedSince($t));
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));

4
tests/lib/AbstractTest.php

@ -25,10 +25,10 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
} }
} }
public function assertTime($exp, $test) { public function assertTime($exp, $test, string $msg = null) {
$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, $msg);
} }
public function clearData(bool $loadLang = true): bool { public function clearData(bool $loadLang = true): bool {

5
tests/lib/Database/SeriesFeed.php

@ -221,6 +221,11 @@ trait SeriesFeed {
Arsse::$db->feedUpdate(2112); Arsse::$db->feedUpdate(2112);
} }
public function testUpdateAnInvalidFeed() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->feedUpdate(-1);
}
public function testUpdateAFeedThrowingExceptions() { public function testUpdateAFeedThrowingExceptions() {
$this->assertException("invalidUrl", "Feed"); $this->assertException("invalidUrl", "Feed");
Arsse::$db->feedUpdate(3, true); Arsse::$db->feedUpdate(3, true);

21
tests/lib/Database/SeriesFolder.php

@ -77,7 +77,7 @@ trait SeriesFolder {
} }
public function testAddANestedFolderToAnInvalidParent() { public function testAddANestedFolderToAnInvalidParent() {
$this->assertException("idMissing", "Db", "ExceptionInput"); $this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology", 'parent' => "stringFolderId"]); Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology", 'parent' => "stringFolderId"]);
} }
@ -184,6 +184,11 @@ trait SeriesFolder {
Arsse::$db->folderRemove("john.doe@example.com", 2112); Arsse::$db->folderRemove("john.doe@example.com", 2112);
} }
public function testRemoveAnInvalidFolder() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->folderRemove("john.doe@example.com", -1);
}
public 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
@ -210,6 +215,11 @@ trait SeriesFolder {
Arsse::$db->folderPropertiesGet("john.doe@example.com", 2112); Arsse::$db->folderPropertiesGet("john.doe@example.com", 2112);
} }
public function testGetThePropertiesOfAnInvalidFolder() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesGet("john.doe@example.com", -1);
}
public 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
@ -233,6 +243,10 @@ trait SeriesFolder {
$this->compareExpectations($state); $this->compareExpectations($state);
} }
public function testRenameTheRootFolder() {
$this->assertFalse(Arsse::$db->folderPropertiesSet("john.doe@example.com", null, ['name' => "Opinion"]));
}
public 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' => ""]));
@ -296,6 +310,11 @@ trait SeriesFolder {
Arsse::$db->folderPropertiesSet("john.doe@example.com", 2112, ['parent' => null]); Arsse::$db->folderPropertiesSet("john.doe@example.com", 2112, ['parent' => null]);
} }
public function testSetThePropertiesOfAnInvalidFolder() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesSet("john.doe@example.com", -1, ['parent' => null]);
}
public 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

17
tests/lib/Database/SeriesSubscription.php

@ -193,6 +193,11 @@ trait SeriesSubscription {
Arsse::$db->subscriptionRemove($this->user, 2112); Arsse::$db->subscriptionRemove($this->user, 2112);
} }
public function testRemoveAnInvalidSubscription() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->subscriptionRemove($this->user, -1);
}
public 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");
@ -264,6 +269,11 @@ trait SeriesSubscription {
Arsse::$db->subscriptionPropertiesGet($this->user, 2112); Arsse::$db->subscriptionPropertiesGet($this->user, 2112);
} }
public function testGetThePropertiesOfAnInvalidSubscription() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesGet($this->user, -1);
}
public 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");
@ -311,7 +321,7 @@ trait SeriesSubscription {
} }
public function testRenameASubscriptionToFalse() { public function testRenameASubscriptionToFalse() {
$this->assertException("missing", "Db", "ExceptionInput"); $this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]);
} }
@ -329,6 +339,11 @@ trait SeriesSubscription {
Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]); Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]);
} }
public function testSetThePropertiesOfAnInvalidSubscription() {
$this->assertException("typeViolation", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, -1, ['folder' => null]);
}
public 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");

7
tests/phpunit.xml

@ -31,6 +31,10 @@
<testsuite name="Configuration"> <testsuite name="Configuration">
<file>Conf/TestConf.php</file> <file>Conf/TestConf.php</file>
</testsuite> </testsuite>
<testsuite name="Sundry">
<file>Misc/TestValueInfo.php</file>
<file>Misc/TestContext.php</file>
</testsuite>
<testsuite name="User management"> <testsuite name="User management">
<file>User/TestUserMockInternal.php</file> <file>User/TestUserMockInternal.php</file>
<file>User/TestUserMockExternal.php</file> <file>User/TestUserMockExternal.php</file>
@ -41,9 +45,6 @@
<file>Feed/TestFeedFetching.php</file> <file>Feed/TestFeedFetching.php</file>
<file>Feed/TestFeed.php</file> <file>Feed/TestFeed.php</file>
</testsuite> </testsuite>
<testsuite name="Sundry">
<file>Misc/TestContext.php</file>
</testsuite>
<testsuite name="Database drivers"> <testsuite name="Database drivers">
<file>Db/TestTransaction.php</file> <file>Db/TestTransaction.php</file>
<file>Db/SQLite3/TestDbResultSQLite3.php</file> <file>Db/SQLite3/TestDbResultSQLite3.php</file>

Loading…
Cancel
Save