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:
parent
423ab02846
commit
18e6796726
18 changed files with 983 additions and 16 deletions
tests/api
184
tests/api/ApiMiddlewareTest.php
Normal file
184
tests/api/ApiMiddlewareTest.php
Normal 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
206
tests/api/ApiUtilsTest.php
Normal 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');
|
||||
}
|
||||
}
|
113
tests/api/controllers/InfoTest.php
Normal file
113
tests/api/controllers/InfoTest.php
Normal 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']);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue