REST API structure using Slim framework

* REST API routes are handle by Slim.
  * Every API controller go through ApiMiddleware which handles security.
  * First service implemented `/info`, for tests purpose.
This commit is contained in:
ArthurHoaro 2016-12-15 10:13:00 +01:00
parent 423ab02846
commit 18e6796726
18 changed files with 983 additions and 16 deletions

4
.htaccess Normal file
View File

@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

View File

@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- REST API: see [Shaarli API documentation](http://shaarli.github.io/api-documentation/)
### Changed
### Fixed

View File

@ -0,0 +1,132 @@
<?php
namespace Shaarli\Api;
use Shaarli\Api\Exceptions\ApiException;
use Shaarli\Api\Exceptions\ApiAuthorizationException;
use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ApiMiddleware
*
* This will be called before accessing any API Controller.
* Its role is to make sure that the API is enabled, configured, and to validate the JWT token.
*
* If the request is validated, the controller is called, otherwise a JSON error response is returned.
*
* @package Api
*/
class ApiMiddleware
{
/**
* @var int JWT token validity in seconds (9 min).
*/
public static $TOKEN_DURATION = 540;
/**
* @var Container: contains conf, plugins, etc.
*/
protected $container;
/**
* @var \ConfigManager instance.
*/
protected $conf;
/**
* ApiMiddleware constructor.
*
* @param Container $container instance.
*/
public function __construct($container)
{
$this->container = $container;
$this->conf = $this->container->get('conf');
$this->setLinkDb($this->conf);
}
/**
* Middleware execution:
* - check the API request
* - execute the controller
* - return the response
*
* @param Request $request Slim request
* @param Response $response Slim response
* @param callable $next Next action
*
* @return Response response.
*/
public function __invoke($request, $response, $next)
{
try {
$this->checkRequest($request);
$response = $next($request, $response);
} catch(ApiException $e) {
$e->setResponse($response);
$e->setDebug($this->conf->get('dev.debug', false));
$response = $e->getApiResponse();
}
return $response;
}
/**
* Check the request validity (HTTP method, request value, etc.),
* that the API is enabled, and the JWT token validity.
*
* @param Request $request Slim request
*
* @throws ApiAuthorizationException The API is disabled or the token is invalid.
*/
protected function checkRequest($request)
{
if (! $this->conf->get('api.enabled', true)) {
throw new ApiAuthorizationException('API is disabled');
}
$this->checkToken($request);
}
/**
* Check that the JWT token is set and valid.
* The API secret setting must be set.
*
* @param Request $request Slim request
*
* @throws ApiAuthorizationException The token couldn't be validated.
*/
protected function checkToken($request) {
$jwt = $request->getHeaderLine('jwt');
if (empty($jwt)) {
throw new ApiAuthorizationException('JWT token not provided');
}
if (empty($this->conf->get('api.secret'))) {
throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
}
ApiUtils::validateJwtToken($jwt, $this->conf->get('api.secret'));
}
/**
* Instantiate a new LinkDB including private links,
* and load in the Slim container.
*
* FIXME! LinkDB could use a refactoring to avoid this trick.
*
* @param \ConfigManager $conf instance.
*/
protected function setLinkDb($conf)
{
$linkDb = new \LinkDB(
$conf->get('resource.datastore'),
true,
$conf->get('privacy.hide_public_links'),
$conf->get('redirector.url'),
$conf->get('redirector.encode_url')
);
$this->container['db'] = $linkDb;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Shaarli\Api;
use Shaarli\Api\Exceptions\ApiAuthorizationException;
/**
* Class ApiUtils
*
* Utility functions for the API.
*/
class ApiUtils
{
/**
* Validates a JWT token authenticity.
*
* @param string $token JWT token extracted from the headers.
* @param string $secret API secret set in the settings.
*
* @throws ApiAuthorizationException the token is not valid.
*/
public static function validateJwtToken($token, $secret)
{
$parts = explode('.', $token);
if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) {
throw new ApiAuthorizationException('Malformed JWT token');
}
$genSign = hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret);
if ($parts[2] != $genSign) {
throw new ApiAuthorizationException('Invalid JWT signature');
}
$header = json_decode(base64_decode($parts[0]));
if ($header === null) {
throw new ApiAuthorizationException('Invalid JWT header');
}
$payload = json_decode(base64_decode($parts[1]));
if ($payload === null) {
throw new ApiAuthorizationException('Invalid JWT payload');
}
if (empty($payload->iat)
|| $payload->iat > time()
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
) {
throw new ApiAuthorizationException('Invalid JWT issued time');
}
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Shaarli\Api\Controllers;
use \Slim\Container;
/**
* Abstract Class ApiController
*
* Defines REST API Controller dependencies injected from the container.
*
* @package Api\Controllers
*/
abstract class ApiController
{
/**
* @var Container
*/
protected $ci;
/**
* @var \ConfigManager
*/
protected $conf;
/**
* @var \LinkDB
*/
protected $linkDb;
/**
* @var int|null JSON style option.
*/
protected $jsonStyle;
/**
* ApiController constructor.
*
* Note: enabling debug mode displays JSON with readable formatting.
*
* @param Container $ci Slim container.
*/
public function __construct(Container $ci)
{
$this->ci = $ci;
$this->conf = $ci->get('conf');
$this->linkDb = $ci->get('db');
if ($this->conf->get('dev.debug', false)) {
$this->jsonStyle = JSON_PRETTY_PRINT;
} else {
$this->jsonStyle = null;
}
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Shaarli\Api\Controllers;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class Info
*
* REST API Controller: /info
*
* @package Api\Controllers
* @see http://shaarli.github.io/api-documentation/#links-instance-information-get
*/
class Info extends ApiController
{
/**
* Service providing various information about Shaarli instance.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
*
* @return Response response.
*/
public function getInfo($request, $response)
{
$info = [
'global_counter' => count($this->linkDb),
'private_counter' => count_private($this->linkDb),
'settings' => array(
'title' => $this->conf->get('general.title', 'Shaarli'),
'header_link' => $this->conf->get('general.header_link', '?'),
'timezone' => $this->conf->get('general.timezone', 'UTC'),
'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
'default_private_links' => $this->conf->get('privacy.default_private_links', false),
),
];
return $response->withJson($info, 200, $this->jsonStyle);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Shaarli\Api\Exceptions;
/**
* Class ApiAuthorizationException
*
* Request not authorized, return a 401 HTTP code.
*/
class ApiAuthorizationException extends ApiException
{
/**
* {@inheritdoc}
*/
public function getApiResponse()
{
$this->setMessage('Not authorized');
return $this->buildApiResponse(401);
}
/**
* Set the exception message.
*
* We only return a generic error message in production mode to avoid giving
* to much security information.
*
* @param $message string the exception message.
*/
public function setMessage($message)
{
$original = $this->debug === true ? ': '. $this->getMessage() : '';
$this->message = $message . $original;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Shaarli\Api\Exceptions;
/**
* Class ApiBadParametersException
*
* Invalid request exception, return a 400 HTTP code.
*/
class ApiBadParametersException extends ApiException
{
/**
* {@inheritdoc}
*/
public function getApiResponse()
{
return $this->buildApiResponse(400);
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace Shaarli\Api\Exceptions;
use Slim\Http\Response;
/**
* Abstract class ApiException
*
* Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
* Also can include various information in debug mode.
*/
abstract class ApiException extends \Exception {
/**
* @var Response instance from Slim.
*/
protected $response;
/**
* @var bool Debug mode enabled/disabled.
*/
protected $debug;
/**
* Build the final response.
*
* @return Response Final response to give.
*/
public abstract function getApiResponse();
/**
* Creates ApiResponse body.
* In production mode, it will only return the exception message,
* but in dev mode, it includes additional information in an array.
*
* @return array|string response body
*/
protected function getApiResponseBody() {
if ($this->debug !== true) {
return $this->getMessage();
}
return [
'message' => $this->getMessage(),
'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
];
}
/**
* Build the Response object to return.
*
* @param int $code HTTP status.
*
* @return Response with status + body.
*/
protected function buildApiResponse($code)
{
$style = $this->debug ? JSON_PRETTY_PRINT : null;
return $this->response->withJson($this->getApiResponseBody(), $code, $style);
}
/**
* @param Response $response
*/
public function setResponse($response)
{
$this->response = $response;
}
/**
* @param bool $debug
*/
public function setDebug($debug)
{
$this->debug = $debug;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Shaarli\Api\Exceptions;
/**
* Class ApiInternalException
*
* Generic exception, return a 500 HTTP code.
*/
class ApiInternalException extends ApiException
{
/**
* @inheritdoc
*/
public function getApiResponse()
{
return $this->buildApiResponse(500);
}
}

View File

@ -20,6 +20,8 @@ class ConfigManager
*/
protected static $NOT_FOUND = 'NOT_FOUND';
public static $DEFAULT_PLUGINS = array('qrcode');
/**
* @var string Config folder.
*/
@ -308,7 +310,7 @@ class ConfigManager
$this->setEmpty('general.header_link', '?');
$this->setEmpty('general.links_per_page', 20);
$this->setEmpty('general.enabled_plugins', array('qrcode'));
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
$this->setEmpty('updates.check_updates', false);
$this->setEmpty('updates.check_updates_branch', 'stable');

View File

@ -12,12 +12,20 @@
"require": {
"php": ">=5.5",
"shaarli/netscape-bookmark-parser": "1.*",
"erusev/parsedown": "1.6"
"erusev/parsedown": "1.6",
"slim/slim": "^3.0"
},
"require-dev": {
"phpmd/phpmd" : "@stable",
"phpunit/phpunit": "4.8.*",
"sebastian/phpcpd": "*",
"squizlabs/php_codesniffer": "2.*"
},
"autoload": {
"psr-4": {
"Shaarli\\Api\\": "application/api/",
"Shaarli\\Api\\Controllers\\": "application/api/controllers",
"Shaarli\\Api\\Exceptions\\": "application/api/exceptions"
}
}
}

View File

@ -175,7 +175,6 @@ define('STAY_SIGNED_IN_TOKEN', sha1($conf->get('credentials.hash') . $_SERVER['R
if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
autoLocale($_SERVER['HTTP_ACCEPT_LANGUAGE']);
}
header('Content-Type: text/html; charset=utf-8'); // We use UTF-8 for proper international characters handling.
/**
* Checking session state (i.e. is the user still logged in)
@ -731,17 +730,10 @@ function showLinkList($PAGE, $LINKSDB, $conf, $pluginManager) {
*
* @param ConfigManager $conf Configuration Manager instance.
* @param PluginManager $pluginManager Plugin Manager instance,
* @param LinkDB $LINKSDB
*/
function renderPage($conf, $pluginManager)
function renderPage($conf, $pluginManager, $LINKSDB)
{
$LINKSDB = new LinkDB(
$conf->get('resource.datastore'),
isLoggedIn(),
$conf->get('privacy.hide_public_links'),
$conf->get('redirector.url'),
$conf->get('redirector.encode_url')
);
$updater = new Updater(
read_updates_file($conf->get('resource.updates')),
$LINKSDB,
@ -938,7 +930,7 @@ function renderPage($conf, $pluginManager)
exit;
}
// Display openseach plugin (XML)
// Display opensearch plugin (XML)
if ($targetPage == Router::$PAGE_OPENSEARCH) {
header('Content-Type: application/xml; charset=utf-8');
$PAGE->assign('serverurl', index_url($_SERVER));
@ -2226,4 +2218,32 @@ if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=
if (!isset($_SESSION['LINKS_PER_PAGE'])) {
$_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20);
}
renderPage($conf, $pluginManager);
$linkDb = new LinkDB(
$conf->get('resource.datastore'),
isLoggedIn(),
$conf->get('privacy.hide_public_links'),
$conf->get('redirector.url'),
$conf->get('redirector.encode_url')
);
$container = new \Slim\Container();
$container['conf'] = $conf;
$container['plugins'] = $pluginManager;
$app = new \Slim\App($container);
// REST API routes
$app->group('/api/v1', function() {
$this->get('/info', '\Api\Controllers\Info:getInfo');
})->add('\Api\ApiMiddleware');
$response = $app->run(true);
// Hack to make Slim and Shaarli router work together:
// If a Slim route isn't found, we call renderPage().
if ($response->getStatusCode() == 404) {
// We use UTF-8 for proper international characters handling.
header('Content-Type: text/html; charset=utf-8');
renderPage($conf, $pluginManager, $linkDb);
} else {
$app->respond($response);
}

View File

@ -0,0 +1,184 @@
<?php
namespace Shaarli\Api;
use Slim\Container;
use Slim\Http\Environment;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ApiMiddlewareTest
*
* Test the REST API Slim Middleware.
*
* Note that we can't test a valid use case here, because the middleware
* needs to call a valid controller/action during its execution.
*
* @package Api
*/
class ApiMiddlewareTest extends \PHPUnit_Framework_TestCase
{
/**
* @var string datastore to test write operations
*/
protected static $testDatastore = 'sandbox/datastore.php';
/**
* @var \ConfigManager instance
*/
protected $conf;
/**
* @var \ReferenceLinkDB instance.
*/
protected $refDB = null;
/**
* @var Container instance.
*/
protected $container;
/**
* Before every test, instantiate a new Api with its config, plugins and links.
*/
public function setUp()
{
$this->conf = new \ConfigManager('tests/utils/config/configJson.json.php');
$this->conf->set('api.secret', 'NapoleonWasALizard');
$this->refDB = new \ReferenceLinkDB();
$this->refDB->write(self::$testDatastore);
$this->container = new Container();
$this->container['conf'] = $this->conf;
}
/**
* After every test, remove the test datastore.
*/
public function tearDown()
{
@unlink(self::$testDatastore);
}
/**
* Invoke the middleware with the API disabled:
* should return a 401 error Unauthorized.
*/
public function testInvokeMiddlewareApiDisabled()
{
$this->conf->set('api.enabled', false);
$mw = new ApiMiddleware($this->container);
$env = Environment::mock([
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/echo',
]);
$request = Request::createFromEnvironment($env);
$response = new Response();
/** @var Response $response */
$response = $mw($request, $response, null);
$this->assertEquals(401, $response->getStatusCode());
$body = json_decode((string) $response->getBody());
$this->assertEquals('Not authorized', $body);
}
/**
* Invoke the middleware with the API disabled in debug mode:
* should return a 401 error Unauthorized - with a specific message and a stacktrace.
*/
public function testInvokeMiddlewareApiDisabledDebug()
{
$this->conf->set('api.enabled', false);
$this->conf->set('dev.debug', true);
$mw = new ApiMiddleware($this->container);
$env = Environment::mock([
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/echo',
]);
$request = Request::createFromEnvironment($env);
$response = new Response();
/** @var Response $response */
$response = $mw($request, $response, null);
$this->assertEquals(401, $response->getStatusCode());
$body = json_decode((string) $response->getBody());
$this->assertEquals('Not authorized: API is disabled', $body->message);
$this->assertContains('ApiAuthorizationException', $body->stacktrace);
}
/**
* Invoke the middleware without a token (debug):
* should return a 401 error Unauthorized - with a specific message and a stacktrace.
*/
public function testInvokeMiddlewareNoTokenProvidedDebug()
{
$this->conf->set('dev.debug', true);
$mw = new ApiMiddleware($this->container);
$env = Environment::mock([
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/echo',
]);
$request = Request::createFromEnvironment($env);
$response = new Response();
/** @var Response $response */
$response = $mw($request, $response, null);
$this->assertEquals(401, $response->getStatusCode());
$body = json_decode((string) $response->getBody());
$this->assertEquals('Not authorized: JWT token not provided', $body->message);
$this->assertContains('ApiAuthorizationException', $body->stacktrace);
}
/**
* Invoke the middleware without a secret set in settings (debug):
* should return a 401 error Unauthorized - with a specific message and a stacktrace.
*/
public function testInvokeMiddlewareNoSecretSetDebug()
{
$this->conf->set('dev.debug', true);
$this->conf->set('api.secret', '');
$mw = new ApiMiddleware($this->container);
$env = Environment::mock([
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/echo',
'HTTP_JWT'=> 'jwt',
]);
$request = Request::createFromEnvironment($env);
$response = new Response();
/** @var Response $response */
$response = $mw($request, $response, null);
$this->assertEquals(401, $response->getStatusCode());
$body = json_decode((string) $response->getBody());
$this->assertEquals('Not authorized: Token secret must be set in Shaarli\'s administration', $body->message);
$this->assertContains('ApiAuthorizationException', $body->stacktrace);
}
/**
* Invoke the middleware without an invalid JWT token (debug):
* should return a 401 error Unauthorized - with a specific message and a stacktrace.
*
* Note: specific JWT errors tests are handled in ApiUtilsTest.
*/
public function testInvokeMiddlewareInvalidJwtDebug()
{
$this->conf->set('dev.debug', true);
$mw = new ApiMiddleware($this->container);
$env = Environment::mock([
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/echo',
'HTTP_JWT'=> 'bad jwt',
]);
$request = Request::createFromEnvironment($env);
$response = new Response();
/** @var Response $response */
$response = $mw($request, $response, null);
$this->assertEquals(401, $response->getStatusCode());
$body = json_decode((string) $response->getBody());
$this->assertEquals('Not authorized: Malformed JWT token', $body->message);
$this->assertContains('ApiAuthorizationException', $body->stacktrace);
}
}

206
tests/api/ApiUtilsTest.php Normal file
View File

@ -0,0 +1,206 @@
<?php
namespace Shaarli\Api;
/**
* Class ApiUtilsTest
*/
class ApiUtilsTest extends \PHPUnit_Framework_TestCase
{
/**
* Force the timezone for ISO datetimes.
*/
public static function setUpBeforeClass()
{
date_default_timezone_set('UTC');
}
/**
* Generate a valid JWT token.
*
* @param string $secret API secret used to generate the signature.
*
* @return string Generated token.
*/
public static function generateValidJwtToken($secret)
{
$header = base64_encode('{
"typ": "JWT",
"alg": "HS512"
}');
$payload = base64_encode('{
"iat": '. time() .'
}');
$signature = hash_hmac('sha512', $header .'.'. $payload , $secret);
return $header .'.'. $payload .'.'. $signature;
}
/**
* Generate a JWT token from given header and payload.
*
* @param string $header Header in JSON format.
* @param string $payload Payload in JSON format.
* @param string $secret API secret used to hash the signature.
*
* @return string JWT token.
*/
public static function generateCustomJwtToken($header, $payload, $secret)
{
$header = base64_encode($header);
$payload = base64_encode($payload);
$signature = hash_hmac('sha512', $header . '.' . $payload, $secret);
return $header . '.' . $payload . '.' . $signature;
}
/**
* Test validateJwtToken() with a valid JWT token.
*/
public function testValidateJwtTokenValid()
{
$secret = 'WarIsPeace';
ApiUtils::validateJwtToken(self::generateValidJwtToken($secret), $secret);
}
/**
* Test validateJwtToken() with a malformed JWT token.
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Malformed JWT token
*/
public function testValidateJwtTokenMalformed()
{
$token = 'ABC.DEF';
ApiUtils::validateJwtToken($token, 'foo');
}
/**
* Test validateJwtToken() with an empty JWT token.
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Malformed JWT token
*/
public function testValidateJwtTokenMalformedEmpty()
{
$token = false;
ApiUtils::validateJwtToken($token, 'foo');
}
/**
* Test validateJwtToken() with a JWT token without header.
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Malformed JWT token
*/
public function testValidateJwtTokenMalformedEmptyHeader()
{
$token = '.payload.signature';
ApiUtils::validateJwtToken($token, 'foo');
}
/**
* Test validateJwtToken() with a JWT token without payload
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Malformed JWT token
*/
public function testValidateJwtTokenMalformedEmptyPayload()
{
$token = 'header..signature';
ApiUtils::validateJwtToken($token, 'foo');
}
/**
* Test validateJwtToken() with a JWT token with an empty signature.
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Invalid JWT signature
*/
public function testValidateJwtTokenInvalidSignatureEmpty()
{
$token = 'header.payload.';
ApiUtils::validateJwtToken($token, 'foo');
}
/**
* Test validateJwtToken() with a JWT token with an invalid signature.
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Invalid JWT signature
*/
public function testValidateJwtTokenInvalidSignature()
{
$token = 'header.payload.nope';
ApiUtils::validateJwtToken($token, 'foo');
}
/**
* Test validateJwtToken() with a JWT token with a signature generated with the wrong API secret.
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Invalid JWT signature
*/
public function testValidateJwtTokenInvalidSignatureSecret()
{
ApiUtils::validateJwtToken(self::generateValidJwtToken('foo'), 'bar');
}
/**
* Test validateJwtToken() with a JWT token with a an invalid header (not JSON).
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Invalid JWT header
*/
public function testValidateJwtTokenInvalidHeader()
{
$token = $this->generateCustomJwtToken('notJSON', '{"JSON":1}', 'secret');
ApiUtils::validateJwtToken($token, 'secret');
}
/**
* Test validateJwtToken() with a JWT token with a an invalid payload (not JSON).
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Invalid JWT payload
*/
public function testValidateJwtTokenInvalidPayload()
{
$token = $this->generateCustomJwtToken('{"JSON":1}', 'notJSON', 'secret');
ApiUtils::validateJwtToken($token, 'secret');
}
/**
* Test validateJwtToken() with a JWT token without issued time.
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Invalid JWT issued time
*/
public function testValidateJwtTokenInvalidTimeEmpty()
{
$token = $this->generateCustomJwtToken('{"JSON":1}', '{"JSON":1}', 'secret');
ApiUtils::validateJwtToken($token, 'secret');
}
/**
* Test validateJwtToken() with an expired JWT token.
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Invalid JWT issued time
*/
public function testValidateJwtTokenInvalidTimeExpired()
{
$token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() - 600) . '}', 'secret');
ApiUtils::validateJwtToken($token, 'secret');
}
/**
* Test validateJwtToken() with a JWT token issued in the future.
*
* @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
* @expectedExceptionMessage Invalid JWT issued time
*/
public function testValidateJwtTokenInvalidTimeFuture()
{
$token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() + 60) . '}', 'secret');
ApiUtils::validateJwtToken($token, 'secret');
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace Shaarli\Api\Controllers;
use Slim\Container;
use Slim\Http\Environment;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class InfoTest
*
* Test REST API controller Info.
*
* @package Api\Controllers
*/
class InfoTest extends \PHPUnit_Framework_TestCase
{
/**
* @var string datastore to test write operations
*/
protected static $testDatastore = 'sandbox/datastore.php';
/**
* @var \ConfigManager instance
*/
protected $conf;
/**
* @var \ReferenceLinkDB instance.
*/
protected $refDB = null;
/**
* @var Container instance.
*/
protected $container;
/**
* @var Info controller instance.
*/
protected $controller;
/**
* Before every test, instantiate a new Api with its config, plugins and links.
*/
public function setUp()
{
$this->conf = new \ConfigManager('tests/utils/config/configJson.json.php');
$this->refDB = new \ReferenceLinkDB();
$this->refDB->write(self::$testDatastore);
$this->container = new Container();
$this->container['conf'] = $this->conf;
$this->container['db'] = new \LinkDB(self::$testDatastore, true, false);
$this->controller = new Info($this->container);
}
/**
* After every test, remove the test datastore.
*/
public function tearDown()
{
@unlink(self::$testDatastore);
}
/**
* Test /info service.
*/
public function testGetInfo()
{
$env = Environment::mock([
'REQUEST_METHOD' => 'GET',
]);
$request = Request::createFromEnvironment($env);
$response = $this->controller->getInfo($request, new Response());
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode((string) $response->getBody(), true);
$this->assertEquals(8, $data['global_counter']);
$this->assertEquals(2, $data['private_counter']);
$this->assertEquals('Shaarli', $data['settings']['title']);
$this->assertEquals('?', $data['settings']['header_link']);
$this->assertEquals('UTC', $data['settings']['timezone']);
$this->assertEquals(\ConfigManager::$DEFAULT_PLUGINS, $data['settings']['enabled_plugins']);
$this->assertEquals(false, $data['settings']['default_private_links']);
$title = 'My links';
$headerLink = 'http://shaarli.tld';
$timezone = 'Europe/Paris';
$enabledPlugins = array('foo', 'bar');
$defaultPrivateLinks = true;
$this->conf->set('general.title', $title);
$this->conf->set('general.header_link', $headerLink);
$this->conf->set('general.timezone', $timezone);
$this->conf->set('general.enabled_plugins', $enabledPlugins);
$this->conf->set('privacy.default_private_links', $defaultPrivateLinks);
$response = $this->controller->getInfo($request, new Response());
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode((string) $response->getBody(), true);
$this->assertEquals(8, $data['global_counter']);
$this->assertEquals(2, $data['private_counter']);
$this->assertEquals($title, $data['settings']['title']);
$this->assertEquals($headerLink, $data['settings']['header_link']);
$this->assertEquals($timezone, $data['settings']['timezone']);
$this->assertEquals($enabledPlugins, $data['settings']['enabled_plugins']);
$this->assertEquals($defaultPrivateLinks, $data['settings']['default_private_links']);
}
}

View File

@ -81,7 +81,7 @@
</td>
</tr>
<tr>
<td valign="top"><b>Enable API</b></td>
<td valign="top"><b>Enable REST API</b></td>
<td>
<input type="checkbox" name="apiEnabled" id="apiEnabled"
{if="$api_enabled"}checked{/if}/>

View File

@ -21,7 +21,7 @@
<td>
<input type="checkbox" name="enableApi" id="enableApi" checked="checked">
<label for="enableApi">
&nbsp;Enable Shaarli's API.
&nbsp;Enable Shaarli's REST API.
Allow third party software to use Shaarli such as mobile application.
</label>
</td>