diff --git a/RoboFile.php b/RoboFile.php index e562668..65d0363 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -76,6 +76,10 @@ class RoboFile extends \Robo\Tasks { } } + protected function isWindows(): bool { + return defined("PHP_WINDOWS_VERSION_MAJOR"); + } + protected function runTests(string $executor, string $set, array $args) : Result { switch ($set) { case "typical": @@ -92,8 +96,9 @@ class RoboFile extends \Robo\Tasks { } $execpath = realpath(self::BASE."vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit"); $confpath = realpath(self::BASE_TEST."phpunit.xml"); + $blackhole = $this->isWindows() ? "nul" : "/dev/null"; $this->taskServer(8000)->host("localhost")->dir(self::BASE_TEST."docroot")->rawArg("-n")->arg(self::BASE_TEST."server.php")->background()->run(); - return $this->taskExec($executor)->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->run(); + return $this->taskExec($executor)->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->rawArg("2>$blackhole")->run(); } /** Packages a given commit of the software into a release tarball diff --git a/arsse.php b/arsse.php index 8214bb2..e33d618 100644 --- a/arsse.php +++ b/arsse.php @@ -18,7 +18,8 @@ if (\PHP_SAPI=="cli") { // initialize the CLI; this automatically handles --help and --version $cli = new CLI; // handle other CLI requests; some do not require configuration - $cli->dispatch(); + $exitStatus = $cli->dispatch(); + exit($exitStatus); } else { // load configuration $conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf; diff --git a/lib/Arsse.php b/lib/Arsse.php index 5300df6..0297f2d 100644 --- a/lib/Arsse.php +++ b/lib/Arsse.php @@ -19,10 +19,10 @@ class Arsse { public static $user; public static function load(Conf $conf) { - static::$lang = new Lang(); + static::$lang = static::$lang ?? new Lang; static::$conf = $conf; static::$lang->set($conf->lang); - static::$db = new Database(); - static::$user = new User(); + static::$db = static::$db ?? new Database; + static::$user = static::$user ?? new User; } } diff --git a/lib/CLI.php b/lib/CLI.php index 1605efe..f14b4c1 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -6,33 +6,42 @@ declare(strict_types=1); namespace JKingWeb\Arsse; -class CLI { - protected $args = []; +use Docopt\Response as Opts; - protected function usage(): string { - $prog = basename($_SERVER['argv'][0]); - return << - $prog conf save-defaults - $prog user add [] - $prog --version - $prog --help | -h + arsse.php daemon + arsse.php feed refresh + arsse.php conf save-defaults [] + arsse.php user [list] + arsse.php user add [] + arsse.php user remove + arsse.php user set-pass [--oldpass=] [] + arsse.php user auth + arsse.php --version + arsse.php --help | -h The Arsse command-line interface currently allows you to start the refresh -daemon, refresh a specific feed by numeric ID, add a user, or save default +daemon, refresh a specific feed by numeric ID, manage users, or save default configuration to a sample file. USAGE_TEXT; + + protected function usage($prog): string { + $prog = basename($prog); + return str_replace("arsse.php", $prog, self::USAGE); } - public function __construct(array $argv = null) { - $argv = $argv ?? array_slice($_SERVER['argv'], 1); - $this->args = \Docopt::handle($this->usage(), [ - 'argv' => $argv, - 'help' => true, - 'version' => Arsse::VERSION, - ]); + protected function command(array $options, $args): string { + foreach ($options as $cmd) { + foreach (explode(" ", $cmd) as $part) { + if (!$args[$part]) { + continue 2; + } + } + return $cmd; + } + return ""; } protected function loadConf(): bool { @@ -43,50 +52,91 @@ USAGE_TEXT; return true; } - public function dispatch(array $args = null): int { - // act on command line - $args = $args ?? $this->args; - if ($this->command("daemon", $args)) { - $this->loadConf(); - return $this->daemon(); - } elseif ($this->command("feed refresh", $args)) { - $this->loadConf(); - return $this->feedRefresh((int) $args['']); - } elseif ($this->command("conf save-defaults", $args)) { - return $this->confSaveDefaults($args['']); - } elseif ($this->command("user add", $args)) { - $this->loadConf(); - return $this->userAdd($args[''], $args['']); - } - } - - protected function command($cmd, $args): bool { - foreach (explode(" ", $cmd) as $part) { - if (!$args[$part]) { - return false; + public function dispatch(array $argv = null) { + $argv = $argv ?? $_SERVER['argv']; + $argv0 = array_shift($argv); + $args = \Docopt::handle($this->usage($argv0), [ + 'argv' => $argv, + 'help' => false, + ]); + try { + switch ($this->command(["--help", "--version", "daemon", "feed refresh", "conf save-defaults", "user"], $args)) { + case "--help": + echo $this->usage($argv0).\PHP_EOL; + return 0; + case "--version": + echo Arsse::VERSION.\PHP_EOL; + return 0; + case "daemon": + $this->loadConf(); + $this->getService()->watch(true); + return 0; + case "feed refresh": + $this->loadConf(); + return (int) !Arsse::$db->feedUpdate((int) $args[''], true); + case "conf save-defaults": + $file = $args['']; + $file = ($file=="-" ? null : $file) ?? "php://output"; + return (int) !($this->getConf())->exportFile($file, true); + case "user": + $this->loadConf(); + return $this->userManage($args); } + } catch (AbstractException $e) { + fwrite(STDERR, $e->getMessage().\PHP_EOL); + return $e->getCode(); } - return true; } - public function daemon(bool $loop = true): int { - (new Service)->watch($loop); - return 0; // FIXME: should return the exception code of thrown exceptions + /** @codeCoverageIgnore */ + protected function getService(): Service { + return new Service; } - public function feedRefresh(int $id): int { - return (int) !Arsse::$db->feedUpdate($id); // FIXME: exception error codes should be returned here + /** @codeCoverageIgnore */ + protected function getConf(): Conf { + return new Conf; } - public function confSaveDefaults(string $file): int { - return (int) !(new Conf)->exportFile($file, true); + protected function userManage($args): int { + switch ($this->command(["add", "remove", "set-pass", "list", "auth"], $args)) { + case "add": + return $this->userAddOrSetPassword("add", $args[""], $args[""]); + case "set-pass": + return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); + case "remove": + return (int) !Arsse::$user->remove($args[""]); + case "auth": + return $this->userAuthenticate($args[""], $args[""]); + case "list": + case "": + return $this->userList(); + } } - public function userAdd(string $user, string $password = null): int { - $passwd = Arsse::$user->add($user, $password); + protected function userAddOrSetPassword(string $method, string $user, string $password = null, string $oldpass = null): int { + $passwd = Arsse::$user->$method(...array_slice(func_get_args(), 1)); if (is_null($password)) { echo $passwd.\PHP_EOL; } return 0; } + + protected function userList(): int { + $list = Arsse::$user->list(); + if ($list) { + echo implode(\PHP_EOL, $list).\PHP_EOL; + } + return 0; + } + + protected function userAuthenticate(string $user, string $password): int { + if (Arsse::$user->auth($user, $password)) { + echo Arsse::$lang->msg("CLI.Auth.Success").\PHP_EOL; + return 0; + } else { + echo Arsse::$lang->msg("CLI.Auth.Failure").\PHP_EOL; + return 1; + } + } } diff --git a/locale/en.php b/locale/en.php index 477f04a..d721410 100644 --- a/locale/en.php +++ b/locale/en.php @@ -4,6 +4,9 @@ * See LICENSE and AUTHORS files for details */ return [ + 'CLI.Auth.Success' => 'Authentication successful', + 'CLI.Auth.Failure' => 'Authentication failed', + 'API.TTRSS.Category.Uncategorized' => 'Uncategorized', 'API.TTRSS.Category.Special' => 'Special', 'API.TTRSS.Category.Labels' => 'Labels', diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php new file mode 100644 index 0000000..108e328 --- /dev/null +++ b/tests/cases/CLI/TestCLI.php @@ -0,0 +1,223 @@ +clearData(false); + } + + public function assertConsole(CLI $cli, string $command, int $exitStatus, string $output = "", bool $pattern = false) { + $argv = \Clue\Arguments\split($command); + $output = strlen($output) ? $output.\PHP_EOL : ""; + if ($pattern) { + $this->expectOutputRegex($output); + } else { + $this->expectOutputString($output); + } + $this->assertSame($exitStatus, $cli->dispatch($argv)); + } + + public function assertLoaded(bool $loaded) { + $r = new \ReflectionClass(Arsse::class); + $props = array_keys($r->getStaticProperties()); + foreach ($props as $prop) { + if ($loaded) { + $this->assertNotNull(Arsse::$$prop, "Global $prop object should be loaded"); + } else { + $this->assertNull(Arsse::$$prop, "Global $prop object should not be loaded"); + } + } + } + + public function testPrintVersion() { + $this->assertConsole(new CLI, "arsse.php --version", 0, Arsse::VERSION); + $this->assertLoaded(false); + } + + /** @dataProvider provideHelpText */ + public function testPrintHelp(string $cmd, string $name) { + $this->assertConsole(new CLI, $cmd, 0, str_replace("arsse.php", $name, CLI::USAGE)); + $this->assertLoaded(false); + } + + public function provideHelpText() { + return [ + ["arsse.php --help", "arsse.php"], + ["arsse --help", "arsse"], + ["thearsse --help", "thearsse"], + ]; + } + + public function testStartTheDaemon() { + $srv = Phake::mock(Service::class); + $cli = Phake::partialMock(CLI::class); + Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); + Phake::when($cli)->getService->thenReturn($srv); + $this->assertConsole($cli, "arsse.php daemon", 0); + $this->assertLoaded(true); + Phake::verify($srv)->watch(true); + Phake::verify($cli)->getService; + } + + /** @dataProvider provideFeedUpdates */ + public function testRefreshAFeed(string $cmd, int $exitStatus, string $output) { + Arsse::$db = Phake::mock(Database::class); + Phake::when(Arsse::$db)->feedUpdate(1, true)->thenReturn(true); + Phake::when(Arsse::$db)->feedUpdate(2, true)->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/", new \PicoFeed\Client\InvalidUrlException)); + $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + $this->assertLoaded(true); + Phake::verify(Arsse::$db)->feedUpdate; + } + + public function provideFeedUpdates() { + return [ + ["arsse.php feed refresh 1", 0, ""], + ["arsse.php feed refresh 2", 10502, ""], + ]; + } + + /** @dataProvider provideDefaultConfigurationSaves */ + public function testSaveTheDefaultConfiguration(string $cmd, int $exitStatus, string $file) { + $conf = Phake::mock(Conf::class); + $cli = Phake::partialMock(CLI::class); + Phake::when($conf)->exportFile("php://output", true)->thenReturn(true); + Phake::when($conf)->exportFile("good.conf", true)->thenReturn(true); + Phake::when($conf)->exportFile("bad.conf", true)->thenThrow(new \JKingWeb\Arsse\Conf\Exception("fileUnwritable")); + Phake::when($cli)->getConf->thenReturn($conf); + $this->assertConsole($cli, $cmd, $exitStatus); + $this->assertLoaded(false); + Phake::verify($conf)->exportFile($file, true); + } + + public function provideDefaultConfigurationSaves() { + return [ + ["arsse.php conf save-defaults", 0, "php://output"], + ["arsse.php conf save-defaults -", 0, "php://output"], + ["arsse.php conf save-defaults good.conf", 0, "good.conf"], + ["arsse.php conf save-defaults bad.conf", 10304, "bad.conf"], + ]; + } + + /** @dataProvider provideUserList */ + public function testListUsers(string $cmd, array $list, int $exitStatus, string $output) { + // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + Arsse::$user = $this->createMock(User::class); + Arsse::$user->method("list")->willReturn($list); + $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + } + + public function provideUserList() { + $list = ["john.doe@example.com", "jane.doe@example.com"]; + $str = implode(PHP_EOL, $list); + return [ + ["arsse.php user list", $list, 0, $str], + ["arsse.php user", $list, 0, $str], + ["arsse.php user list", [], 0, ""], + ["arsse.php user", [], 0, ""], + ]; + } + + /** @dataProvider provideUserAdditions */ + public function testAddAUser(string $cmd, int $exitStatus, string $output) { + // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + Arsse::$user = $this->createMock(User::class); + Arsse::$user->method("add")->will($this->returnCallback(function($user, $pass = null) { + switch ($user) { + case "john.doe@example.com": + throw new \JKingWeb\Arsse\User\Exception("alreadyExists"); + case "jane.doe@example.com": + return is_null($pass) ? "random password" : $pass; + } + })); + $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + } + + public function provideUserAdditions() { + return [ + ["arsse.php user add john.doe@example.com", 10403, ""], + ["arsse.php user add jane.doe@example.com", 0, "random password"], + ["arsse.php user add jane.doe@example.com superman", 0, ""], + ]; + } + + /** @dataProvider provideUserAuthentication */ + public function testAuthenticateAUser(string $cmd, int $exitStatus, string $output) { + // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + Arsse::$user = $this->createMock(User::class); + Arsse::$user->method("auth")->will($this->returnCallback(function($user, $pass) { + return ( + ($user == "john.doe@example.com" && $pass == "secret") || + ($user == "jane.doe@example.com" && $pass == "superman") + ); + })); + $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + } + + public function provideUserAuthentication() { + $l = new \JKingWeb\Arsse\Lang; + return [ + ["arsse.php user auth john.doe@example.com secret", 0, $l("CLI.Auth.Success")], + ["arsse.php user auth john.doe@example.com superman", 1, $l("CLI.Auth.Failure")], + ["arsse.php user auth jane.doe@example.com secret", 1, $l("CLI.Auth.Failure")], + ["arsse.php user auth jane.doe@example.com superman", 0, $l("CLI.Auth.Success")], + ]; + } + + /** @dataProvider provideUserRemovals */ + public function testRemoveAUser(string $cmd, int $exitStatus, string $output) { + // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + Arsse::$user = $this->createMock(User::class); + Arsse::$user->method("remove")->will($this->returnCallback(function($user) { + if ($user == "john.doe@example.com") { + return true; + } + throw new \JKingWeb\Arsse\User\Exception("doesNotExist"); + })); + $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + } + + public function provideUserRemovals() { + return [ + ["arsse.php user remove john.doe@example.com", 0, ""], + ["arsse.php user remove jane.doe@example.com", 10402, ""], + ]; + } + + /** @dataProvider provideUserPasswordChanges */ + public function testChangeAUserPassword(string $cmd, int $exitStatus, string $output) { + // Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead + Arsse::$user = $this->createMock(User::class); + Arsse::$user->method("passwordSet")->will($this->returnCallback(function($user, $pass = null) { + switch ($user) { + case "jane.doe@example.com": + throw new \JKingWeb\Arsse\User\Exception("doesNotExist"); + case "john.doe@example.com": + return is_null($pass) ? "random password" : $pass; + } + })); + $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + } + + public function provideUserPasswordChanges() { + return [ + ["arsse.php user set-pass john.doe@example.com", 0, "random password"], + ["arsse.php user set-pass john.doe@example.com superman", 0, ""], + ["arsse.php user set-pass jane.doe@example.com", 10402, ""], + ]; + } +} diff --git a/tests/cases/Conf/TestConf.php b/tests/cases/Conf/TestConf.php index 73bec5f..5aa56d8 100644 --- a/tests/cases/Conf/TestConf.php +++ b/tests/cases/Conf/TestConf.php @@ -135,6 +135,14 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertArraySubset($exp, $arr); } + /** @depends testExportToFile */ + public function testExportToStdout() { + $conf = new Conf(self::$path."confGood"); + $conf->exportFile(self::$path."confGood"); + $this->expectOutputString(file_get_contents(self::$path."confGood")); + $conf->exportFile("php://output"); + } + public function testExportToFileWithoutWritePermission() { $this->assertException("fileUnwritable", "Conf"); (new Conf)->exportFile(self::$path."confUnreadable"); diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 7661381..effe34e 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; +use JKingWeb\Arsse\CLI; use JKingWeb\Arsse\Misc\Date; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; @@ -27,6 +28,18 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $this->clearData(); } + public function clearData(bool $loadLang = true) { + date_default_timezone_set("America/Toronto"); + $r = new \ReflectionClass(\JKingWeb\Arsse\Arsse::class); + $props = array_keys($r->getStaticProperties()); + foreach ($props as $prop) { + Arsse::$$prop = null; + } + if ($loadLang) { + Arsse::$lang = new \JKingWeb\Arsse\Lang(); + } + } + public function setConf(array $conf = []) { Arsse::$conf = (new Conf)->import($conf); } @@ -70,6 +83,13 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text); } + public function assertTime($exp, $test, string $msg = null) { + $test = $this->approximateTime($exp, $test); + $exp = Date::transform($exp, "iso8601"); + $test = Date::transform($test, "iso8601"); + $this->assertSame($exp, $test, $msg); + } + public function approximateTime($exp, $act) { if (is_null($act)) { return null; @@ -85,24 +105,4 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { return $act; } } - - public function assertTime($exp, $test, string $msg = null) { - $test = $this->approximateTime($exp, $test); - $exp = Date::transform($exp, "iso8601"); - $test = Date::transform($test, "iso8601"); - $this->assertSame($exp, $test, $msg); - } - - public function clearData(bool $loadLang = true): bool { - date_default_timezone_set("America/Toronto"); - $r = new \ReflectionClass(\JKingWeb\Arsse\Arsse::class); - $props = array_keys($r->getStaticProperties()); - foreach ($props as $prop) { - Arsse::$$prop = null; - } - if ($loadLang) { - Arsse::$lang = new \JKingWeb\Arsse\Lang(); - } - return true; - } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index b58b0cd..78406a0 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -96,8 +96,9 @@ cases/REST/TinyTinyRSS/TestIcon.php cases/REST/TinyTinyRSS/PDO/TestAPI.php - + cases/Service/TestService.php + cases/CLI/TestCLI.php - \ No newline at end of file + diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index 1d56423..2fe20f7 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -2,6 +2,7 @@ "require": { "phpunit/phpunit": "^6.5", "phake/phake": "^3.0", + "clue/arguments": "^2.0", "mikey179/vfsStream": "^1.6", "webmozart/glob": "^4.1" } diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index bf99b96..53d62ca 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -4,8 +4,58 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "2feb94beae7c769e2df081af57c89fed", + "content-hash": "4252b3d7817c9a4a5f60ac81f28202e2", "packages": [ + { + "name": "clue/arguments", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/clue/php-arguments.git", + "reference": "eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/php-arguments/zipball/eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2", + "reference": "eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Clue\\Arguments\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "The simple way to split your command line string into an array of command arguments in PHP.", + "homepage": "https://github.com/clue/php-arguments", + "keywords": [ + "args", + "arguments", + "argv", + "command", + "command line", + "explode", + "parse", + "split" + ], + "time": "2016-12-18T14:37:39+00:00" + }, { "name": "doctrine/instantiator", "version": "1.0.5",