Api + unit tests

This commit is contained in:
Jérémy Munsch 2022-07-28 12:32:37 +02:00
parent aef61a6c8f
commit 5e14218ad4
No known key found for this signature in database
GPG key ID: 18361AF3BFEC0FEC
46 changed files with 1935 additions and 97 deletions

60
.env.testing Normal file
View file

@ -0,0 +1,60 @@
APP_NAME=Dam
APP_ENV=local
APP_KEY=base64:2fKdUeMKBwyKzJeOcIaRXC0yujSMKkQJ98XLhC2uqf0=
APP_DEBUG=true
APP_URL=https://dam.s-web.test
PROJECT_UUID=
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=dev-mariadb
DB_PORT=3306
DB_DATABASE=testing_dam
DB_USERNAME=root
DB_PASSWORD=secret
ELASTICSEARCH_HOSTS=dev-elasticsearch:9200
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=dev-mailhog
MAIL_PORT=1025
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=no-reply@dam.lesidc.com
MAIL_FROM_NAME="${APP_NAME}"
MAIL_ADMIN=equipe.web@ldc.fr
LDAP_ACCOUNT_PREFIX="cn="
LDAP_ACCOUNT_SUFFIX=",ou=users,dc=example,dc=org"
LDAP_HOSTS=dev-openldap
LDAP_PORT=1389
LDAP_BASE_DN="dc=example,dc=org"
LDAP_USERNAME="cn=admin,dc=example,dc=org"
LDAP_PASSWORD=adminpassword
LDAP_USE_SSL=false
PROJECT_UUID=
## MAIL ADDRESSES
MAIL_LIST_SERVICEWEB=equipe.web@ldc.fr
LDC_STATUS_CHECKER_API_KEY=CE8S52Qq7stf5k8frF4T
LDC_STATUS_CHECKER_UUID=4d8b2c20-107a-4682-b591-dc6c685126df

60
.env.testing.example Normal file
View file

@ -0,0 +1,60 @@
APP_NAME=Dam
APP_ENV=local
APP_KEY=base64:2fKdUeMKBwyKzJeOcIaRXC0yujSMKkQJ98XLhC2uqf0=
APP_DEBUG=true
APP_URL=https://dam.s-web.test
PROJECT_UUID=
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=dev-mariadb
DB_PORT=3306
DB_DATABASE=testing_dam
DB_USERNAME=root
DB_PASSWORD=
ELASTICSEARCH_HOSTS=dev-elasticsearch:9200
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=dev-mailhog
MAIL_PORT=1025
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=no-reply@dam.lesidc.com
MAIL_FROM_NAME="${APP_NAME}"
MAIL_ADMIN=equipe.web@ldc.fr
LDAP_ACCOUNT_PREFIX=
LDAP_ACCOUNT_SUFFIX=
LDAP_HOSTS=
LDAP_PORT=
LDAP_BASE_DN=
LDAP_USERNAME=
LDAP_PASSWORD=
LDAP_USE_SSL=
PROJECT_UUID=
## MAIL ADDRESSES
MAIL_LIST_SERVICEWEB=equipe.web@ldc.fr
LDC_STATUS_CHECKER_API_KEY=
LDC_STATUS_CHECKER_UUID=

View file

@ -3,6 +3,7 @@
# TODO:
- Remplace par VUE 3 + Runtime Only
- Typescript
- Faut-il exporter les stats Api avant de supprimer un ApiToken ?
## Installation
### - Default
@ -12,20 +13,10 @@ composer install
cp .env.example .env
php artisan key:generate
```
### - Stack en attendant le bump Laravel
### - Stack Laravel
```
npm install && npm run prod
composer install
```
Dans vendor/laravel/framework/src/Illuminate/Foundation/PackageManifest.php, ligne 116
```
//$packages = json_decode($this->files->get($path), true);
$installed = json_decode($this->files->get($path), true);
$packages = $installed['packages'] ?? $installed;
```
Puis
```
composer install
cp .env.example .env
php artisan key:generate
```
@ -36,6 +27,9 @@ php artisan key:generate
.env rajouter PROJECT_UUID=
.env rajouter MAIL_LIST_SERVICEWEB=equipe.web@ldc.fr
# SUPERVISOR
jobs Api stats sur queue `database`
## Dans les LABs
```
MAIL_VERIFY_PEER=false

View file

@ -0,0 +1,93 @@
<?php
namespace App\Enums\Legacy;
use Kwaadpepper\Enum\BaseEnumRoutable;
/**
* This describes an API error.
*
* @method static self modelNotFound()
* The request model was not found (typically the customer)
*
* @method static self validationFailed()
* This means some data could not be validated,
* you may have to look into message to get the details
*
* @method static self apiAuthFailed()
* The authentification to the api failed. You may have to
* check into config or .env file for your service credentials
*
* @method static self authFailed()
* The authentification for this customer failed because of wrong credentials
*
* @method static self methodNotAllowed()
* This http method is not allowed
*
* @method static self wrongTimeStamp()
* The timestamp given is wrong. This would happen in case if
* a replay attack scenario. Timestamps are provided in headers
* and in the http body
*
* @method static self missingParameter()
* The request is missing some parameters
*
* @method static self throttleRequestsException()
* Too many requests have been attempted. The is a throttle security.
* Normally you should not trigger this.
* If this happens fill a ticket on https://supportweb.ldc.fr
* with some technical details
*
* @method static self authorizationException()
* You are maybe trying to access data that is out of your range.
* This should not happen normally, it is here to allow you to handle this case.
* If this happens fill a ticket on https://supportweb.ldc.fr
* with some technical details
*
* @package DAMclient
* @subpackage enums
*
* TODO: pass this as php8 enum, when support of 7 will be dropped.
*/
class ApiError extends BaseEnumRoutable
{
/**
* Valeurs des possibilités de cet énumération (value)
*
* @return array
*/
public static function values(): array
{
return [
'modelNotFound' => 101,
'validationFailed' => 102,
'authFailed' => 117,
'apiAuthFailed' => 119,
'methodNotAllowed' => 150,
'wrongTimeStamp' => 151,
'missingParameter' => 400,
'authorizationException' => 403,
'throttleRequestsException' => 429
];
}
/**
* Libellés des possibilités de cet énumération (label)
*
* @return array
*/
public static function labels(): array
{
return [
'modelNotFound' => '%s introuvable.',
'validationFailed' => 'Impossible de valider les données',
'authFailed' => 'Echec de l\'authentification du compte',
'apiAuthFailed' => 'Echec de l\'authentification client à l\'Api DAM',
'methodNotAllowed' => 'This method is not allowed for this request',
'wrongTimeStamp' => 'Your request is malformed, please check timestamp header and parameter',
'missingParameter' => 'The request is missing some parameters',
'authorizationException' => 'You are not autorized to access this',
'throttleRequestsException' => 'Throttle: too many request have been attempted, please retry later'
];
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Enums\Legacy;
use Kwaadpepper\Enum\BaseEnumRoutable;
/**
* @method static self OK()
* @method static self KO()
*
* @package DAMclient
* @subpackage enums
*
* TODO: pass this as php8 enum, when support of 7 will be dropped.
*/
class ApiStatus extends BaseEnumRoutable
{
}

View file

@ -0,0 +1,102 @@
<?php
namespace App\Exceptions\Api;
use App\Enums\Legacy\ApiError;
use Illuminate\Support\Facades\Log;
/**
* This would be thrown if any of the case
* referenced in \App\Enums\ApiError would
* occur within the dam
*
* On the client an ApiErrorException would
* also be throw with the ApiError corresponding to
* the situation
*
* @package DAMclient
* @subpackage exceptions
*/
class ApiErrorException extends \RuntimeException
{
/**
* Api Error
* This value makes the error more explicit.
* Handling these errors are normal as inputs or
* request may be undesired or refused by the dam.
*
* @var \App\Enums\Legacy\ApiError
*/
protected $error;
/**
* This is an explicit error message,
* ou may print this to the front or not depending
* on $error value
*
* @var string
*/
protected $message;
/**
* This is the http code returned by the dam.
* This value could be used to handle errors.
* But please take in consideration $error first
*
* @var integer
*/
protected $httpCode;
/**
* Create a new exception base on ApiError enum.
*
* @param \App\Enums\Legacy\ApiError $error
* @param integer $httpCode
* @param string $message
* @param \Throwable|null $previous
*/
public function __construct(ApiError $error, int $httpCode = 400, string $message = '', \Throwable $previous = null)
{
$this->error = $error;
$this->message = $message;
$this->httpCode = $httpCode;
parent::__construct(
strlen($this->message) ? $this->message : $error->label,
$error->value,
$previous
);
}
/**
* Get the Api Error
*
* @return \App\Enums\Legacy\ApiError
*/
public function getApiError(): ApiError
{
return $this->error;
}
/**
* Get the http code
*
* @return integer
*/
public function getStatusCode(): int
{
return $this->httpCode;
}
/**
* This is used by laravel only.
* It uses Log class to print the error.
*
* @return void
*/
public function report()
{
// @codeCoverageIgnoreStart
Log::debug(sprintf('DAM API: %s', $this->getMessage()));
// @codeCoverageIgnoreEnd
}
}

View file

@ -2,7 +2,20 @@
namespace App\Exceptions;
use App\Enums\Legacy\ApiError;
use App\Enums\Legacy\ApiStatus;
use App\Exceptions\Api\ApiErrorException;
use App\Http\Resources\Api\ApiResponse as ApiResponseResource;
use App\Lib\Helper;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Kwaadpepper\ExceptionHandler\Exceptions\ExceptionHandler;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Throwable;
class Handler extends ExceptionHandler
@ -12,14 +25,18 @@ class Handler extends ExceptionHandler
*
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
*/
protected $levels = [];
protected $levels = [
ApiErrorException::class => \Psr\Log\LogLevel::WARNING
];
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<\Throwable>>
*/
protected $dontReport = [];
protected $dontReport = [
ApiErrorException::class,
];
/**
* A list of the inputs that are never flashed for validation exceptions.
@ -42,4 +59,126 @@ class Handler extends ExceptionHandler
$this->reportable(function (Throwable $e) {
});
}
/**
* Render an exception into an HTTP response.
*
* @param mixed $request
* @param \Throwable $e
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \Throwable If needed.
*/
public function render(mixed $request, Throwable $e)
{
if (Helper::isRequestForApi($request) and $request->isJson()) {
return $this->handleApiException($request, $e);
} //end if
// @codeCoverageIgnoreStart
return parent::render($request, $e);
// @codeCoverageIgnoreEnd
}
/**
* Generate a json response for any api error case
*
* @param \Illuminate\Http\Request $request
* @param \Throwable $e
* @return \Illuminate\Http\JsonResponse
*/
private function handleApiException(Request $request, \Throwable $e): JsonResponse
{
switch (get_class($e)) {
case AuthenticationException::class:
/** @var AuthenticationException $e */
return $this->getReponse(
$request,
401,
ApiError::apiAuthFailed()->value,
sprintf('%s, %s', ApiError::apiAuthFailed()->label, $e->getMessage())
);
case MethodNotAllowedHttpException::class:
/** @var MethodNotAllowedHttpException $e */
return $this->getReponse(
$request,
$e->getStatusCode(),
ApiError::methodNotAllowed()->value,
sprintf('%s, %s', ApiError::methodNotAllowed()->label, $e->getMessage())
);
case ValidationException::class:
/** @var ValidationException $e */
return $this->getReponse(
$request,
$e->status,
ApiError::validationFailed()->value,
sprintf(ApiError::validationFailed()->label, $e->getMessage()),
['errors' => $e->errors()]
);
case ModelNotFoundException::class:
/** @var ModelNotFoundException $e */
return $this->getReponse(
$request,
404,
ApiError::modelNotFound()->value,
sprintf(
ApiError::modelNotFound()->label,
\array_slice(explode('\\', (string)$e->getModel()), -1)[0]
)
);
case ApiErrorException::class:
/** @var ApiErrorException $e */
return $this->getReponse(
$request,
$e->getStatusCode(),
$e->getCode(),
$e->getMessage()
);
case ThrottleRequestsException::class:
/** @var ThrottleRequestsException $e */
return $this->getReponse(
$request,
429,
ApiError::throttleRequestsException()->value,
ApiError::throttleRequestsException()->label
);
case AuthorizationException::class:
/** @var AuthorizationException $e */
return $this->getReponse(
$request,
403,
ApiError::authorizationException()->value,
ApiError::authorizationException()->label
);
} //end switch
// @codeCoverageIgnoreStart
if (config('app.env') === 'testing') {
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Found
print_r((string)$e);
}
return $this->getReponse($request, 500, intval($e->getCode()), $e->getMessage());
// @codeCoverageIgnoreEnd
}
/**
* Generate an Api json response
*
* @param \Illuminate\Http\Request $request
* @param integer $httpCode
* @param integer $apiCode
* @param string $message
* @param array $data
* @return \Illuminate\Http\JsonResponse
*/
private static function getReponse(
Request $request,
int $httpCode,
int $apiCode,
string $message,
array $data = []
): JsonResponse {
return (new ApiResponseResource(
(object)array_merge($data, ['message' => $message, 'code' => $apiCode]),
ApiStatus::KO()
))->toResponse($request)->setStatusCode($httpCode);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ApiController extends Controller
{
/**
* Just returns HTTP 200 response
*
* @return JsonResponse
*/
public function ping(): JsonResponse
{
return response()->json();
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\MediaStatut;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\MediaListResource;
use App\Http\Resources\Api\MediaPictureResource;
use App\Http\Resources\Api\MediaResource;
use App\Models\Article;
use App\Models\Media;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection as JsonResourceCollection;
use Illuminate\Support\Facades\DB;
class MediaController extends Controller
{
/**
* Show specific media
*
* @param \App\Models\Media $media
* @return \Illuminate\Http\Resources\Json\JsonResource
*/
public function show(Media $media): JsonResource
{
return new MediaResource($media);
}
/**
* Get Media list of the latest version for each vue type and variant
* of media that is linked with an article.
*
* Working example
* SELECT `mediaList`.`id`,`mediaList`.`arcadia_id`,`mediaList`.`libelle`,`mediaList`.`uid`,
* `mediaList`.`version`,`mediaList`.`type_vue_id`,`mediaList`.`variante_id`,`mediaList`.`created_at`,`mediaList`.`updated_at`
* FROM (
* SELECT `media`.*
* from `media`
* JOIN `media_article` ON `media_article`.`media_id` =`media`.`id`
* JOIN `articles` ON `articles`.`id` =`media_article`.`article_id` AND `articles`.`noArt` = 2842
* WHERE
* `media`.type_media_id IN (1,5) AND
* `media`.isLocked = 0 AND
* `media`.statut != 0
* ORDER BY `version` DESC
* LIMIT 18446744073709551615
* ) AS mediaList
* GROUP BY `type_vue_id`,`variante_id`
*
* @param \App\Models\Article $article
* @return \Illuminate\Http\Resources\Json\ResourceCollection
*/
public function getMediaListByArticle(Article $article): JsonResourceCollection
{
// ! Hack to allow group by (may have side effects)
DB::statement("SET SQL_MODE=''");
/** @var \Illuminate\Database\Eloquent\Collection<\App\Models\Media> */
$mediaList = Media::withTrashed()->fromSub(
Media::query()->whereHas('articles', function ($q) use ($article) {
$q->where('noArt', $article->noArt);
})->whereHas('typeMedia', function ($q) {
$q->whereIn('libelle', ['Photo', 'jpeg']);
})->where('isLocked', false)
->where('statut', '!=', MediaStatut::brouillon)
->orderBy('version', 'DESC')
// ! Maria Hack: use limit to preserve order.
->limit(\PHP_INT_MAX),
'mediaList'
)->groupBy(['mediaList.type_vue_id', 'mediaList.variante_id'])
->paginate();
return new MediaListResource($mediaList);
}
/**
* Get Media picture resource
*
* @param \App\Models\Media $media
* @return \Illuminate\Http\Resources\Json\JsonResource
*/
public function getMediaPicture(Media $media): JsonResource
{
return new MediaPictureResource($media);
}
}

View file

@ -580,7 +580,7 @@ class MediaController extends Controller
// * On est sur un sujet arcadia (donc pas le sujet Groupe par exemple) .
// ! restreindre pour tout sauf si type de vue 19 (BAT) et sujet 1 (Articles)
if (\optional($media->typevue)->id === 19 and \optional($media->sujet)->id === 1) {
if (\optional($media->typeVue)->id === 19 and \optional($media->sujet)->id === 1) {
return \response()->json(ArcadiaLinkUtilities::getRefKey($media)->count() > 0);
}
@ -626,6 +626,7 @@ class MediaController extends Controller
*
* @param \App\Models\Media $media
* @return \Illuminate\Http\JsonResponse
* @throws \Illuminate\Validation\ValidationException If the media is already linked to Arcadia.
*/
public function createArcadiaLink(Media $media): JsonResponse
{

View file

@ -97,7 +97,7 @@ class UploadController extends Controller
// 400 instead of 422 is for resumablejs to handle properly.
return \response()->json($e->errors(), 400);
}
}
}//end if
// We are in chunk mode, lets send the current progress.
/** @var AbstractHandler $handler */
@ -156,7 +156,7 @@ class UploadController extends Controller
// 400 instead of 422 is for resumablejs to handle properly.
return \response()->json($e->errors(), 400);
}
}
}//end if
// We are in chunk mode, lets send the current progress.
/** @var AbstractHandler $handler */
@ -190,6 +190,7 @@ class UploadController extends Controller
*
* @param \Illuminate\Http\UploadedFile $file
* @return void
* @throws \Illuminate\Validation\ValidationException If file extension could not be confirmed.
*/
private function validateExtension(UploadedFile $file)
{
@ -203,16 +204,19 @@ class UploadController extends Controller
$mediaExtension = MediaExtension::from($file->getClientOriginalExtension());
if ($file->guessExtension()) {
$mt = new MimeTypes();
$mt = new MimeTypes();
$guessedExts = $mt->getExtensions($mt->guessMimeType($file->path()));
if (!\in_array($mediaExtension->value, $guessedExts)) {
$guessedExts = \implode(',', $guessedExts);
throw ValidationException::withMessages([
'file' => trans('File guessed extensions :guessedExts does not match file name extension :extension', [
'guessedExt' => $guessedExts,
'extension' => $mediaExtension,
])
'file' => trans(
'File guessed extensions :guessedExts does not match file name extension :extension',
[
'guessedExt' => $guessedExts,
'extension' => $mediaExtension,
]
)
]);
}
}

View file

@ -42,7 +42,10 @@ class Kernel extends HttpKernel
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\JsonOnly::class,
\App\Http\Middleware\ApiResponse::class,
\App\Http\Middleware\ApiCheckTimeStamps::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class
],
];

View file

@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Legacy\ApiError;
use App\Exceptions\Api\ApiErrorException;
use App\Lib\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
/**
* Check Http Header Timestamp with request timestamp
* to prevent replay attacks
* https://restfulapi.net/security-essentials/
*/
class ApiCheckTimeStamps
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
* @throws ApiErrorException With ApiError::wrongTimeStamp if timestamp has an incorrect format or is timed out.
*/
public function handle(Request $request, \Closure $next)
{
if (Helper::isRequestForApi($request)) {
if (!\is_string($request->timestamp) or !Carbon::hasFormat($request->timestamp, 'Y-m-d H:i:s')) {
throw new ApiErrorException(ApiError::wrongTimeStamp());
}
if (
Carbon::parse($request->timestamp, 'utc')
->isBefore(Carbon::now('UTC')->subSeconds(config('api.timestampTimeout', 10)))
) {
throw new ApiErrorException(ApiError::wrongTimeStamp());
}
}
return $next($request);
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Legacy\ApiStatus;
use App\Http\Resources\Api\ApiResponse as ApiResponseResource;
use App\Models\Api\ApiToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Make sur the response is an ApiResource resource.
*/
class ApiResponse
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
* @throws \ErrorException If the received response is not a JsonResponse.
*/
public function handle(Request $request, \Closure $next)
{
/** @var \Illuminate\Http\JsonResponse $response */
$response = $next($request);
if (\get_class($response) !== JsonResponse::class) {
// @codeCoverageIgnoreStart
throw new \ErrorException('All api responses should be json.');
// @codeCoverageIgnoreEnd
}
// Set no cache header.
$response->header('Cache-Control', 'No-Store');
/** @var \App\Models\Api\ApiToken|null */
if (\optional($apiToken = app(ApiToken::class))->id) {
$apiToken->incrementStat();
}
/** @var object $data */
$data = (object)$response->getData();
// If reponse is already a ApiResponse then just return it. This happens if error handler comes into action.
if (
isset($data->status) and
isset($data->version) and
in_array($data->status, [ApiStatus::OK()->value, ApiStatus::KO()->value])
) {
return $response;
}
/** @var \App\Enums\Legacy\ApiStatus $status */
$status = intval($response->getStatusCode() / 100) == 2 ? ApiStatus::OK() : ApiStatus::KO();
return $response->setData(new ApiResponseResource((object)\optional($data)->data ?? $data, $status));
}
}

View file

@ -15,6 +15,7 @@ class Authenticate extends Middleware
*/
protected function redirectTo($request)
{
// phpcs:enable
if (! $request->expectsJson()) {
return route('login');
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
/**
* Allow ony json requests
*/
class JsonOnly
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, \Closure $next)
{
if (
!$request->isJson() or
!$request->expectsJson() or
is_null(json_decode($request->getContent()))
) {
abort(400, 'Invalid JSON');
}
return $next($request);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Http\Resources\Api;
use App\Enums\Legacy\ApiStatus;
use Illuminate\Http\Resources\Json\JsonResource;
use stdClass;
class ApiResponse extends JsonResource
{
/** @var stdClass */
protected $data;
/** @var \App\Enums\ApiStatus */
protected $status;
/**
* @param stdClass $data
* @param ApiStatus $status
*/
public function __construct(stdClass $data = null, ApiStatus $status)
{
$this->status = $status;
$this->data = $data;
}
/**
* Transform the resource into an array.
*
* @param mixed $request
* @return array
*/
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.Missing
public function toArray($request): array
{
return [
'status' => $this->status->value,
'data' => (array)$this->data,
'version' => config('api.version')
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources\Api;
use App\Models\Media;
use Illuminate\Http\Resources\Json\ResourceCollection as JsonResourceCollection;
class MediaListResource extends JsonResourceCollection
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.Missing
public function toArray($request)
{
return $this->collection->map(function (Media $media) use ($request) {
return (new MediaResource($media))->toArray($request);
});
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Resources\Api;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
class MediaPictureResource extends JsonResource
{
/** @var array */
private static $skeleton = [
'base64_bin' => '',
'uid' => '',
'extension' => ''
];
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
* + base64_bin string
* + uid string
* + extension string
*/
//phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.Missing
public function toArray($request)
{
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$storage = Storage::disk('media');
return array_intersect_key(
array_merge([
'base64_bin' => \base64_encode((string)$storage->get($this->relative_path)),
'uid' => $this->uid,
'extension' => $this->extension->value
]),
$this->getSkeleton()
);
}
/**
* Returns the resource skeletton
*
* @return array
*/
public static function getSkeleton(): array
{
return self::$skeleton;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Http\Resources\Api;
use Illuminate\Http\Resources\Json\JsonResource;
class MediaResource extends JsonResource
{
/** @var array */
private static $skeleton = [
'id' => 0,
'arcadia_id' => 0,
'libelle' => '',
'uid' => '',
'version' => '',
'created_at' => '',
'updated_at' => '',
];
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
* + id integer
* + arcadia_id integer
* + libelle string
* + uid string
* + version integer
* + created_at datetime
* + updated_at datetime
*/
//phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.Missing
public function toArray($request)
{
$data = array_intersect_key(
array_merge([
'id' => $this->id,
'arcadia_id' => $this->arcadia_id,
'libelle' => $this->libelle,
'uid' => $this->uid,
'version' => $this->version,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
]),
$this->getSkeleton()
);
return $data;
}
/**
* Returns the resource skeletton
*
* @return array
*/
public static function getSkeleton(): array
{
return self::$skeleton;
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace App\Jobs;
use App\Models\Api\ApiToken;
use App\Models\Api\ApiTokenStat;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
/**
* @codeCoverageIgnore
*/
class SetApiTokenStats implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
private const KEY = '2202020';
/** @var \App\Models\Api\ApiToken */
protected $apiToken;
/**
* Create a new job instance.
*
* @param \App\Models\Api\ApiToken $apiToken
* @return void
*/
public function __construct(ApiToken $apiToken)
{
$this->apiToken = $apiToken;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$date = date('Y-m-d');
$apiTokenStat = ApiTokenStat::where('date', $date)
->whereHas('apiToken', function ($q) {
$q->where('id', $this->apiToken->id);
})->firstOrNew();
$apiTokenStat->date = $date;
$apiTokenStat->nb_calls += 1;
$apiTokenStat->apiToken()->associate($this->apiToken);
$apiTokenStat->saveOrFail();
}
/**
* Determine the time at which the job should timeout.
*
* @return \DateTime
*/
public function retryUntil()
{
return now()->addMinutes(rand(0, 360));
}
/**
* Get the middleware the job should pass through.
*
* @return array
*/
public function middleware()
{
return [
(new WithoutOverlapping(self::KEY))
->releaseAfter(rand(90, 180))
];
}
}

View file

@ -131,7 +131,7 @@ class Helper
'method' => $method
]);
$parameters = collect(Arr::flatten($parameters));
$parameters = collect(Arr::dot($parameters));
// * Remove signature from parameters.
$parameters->pull('signature');

View file

@ -20,7 +20,7 @@ class HistorizeMediaEvent
* @return void
* @throws \RuntimeException If saving historic to database failed.
*/
public function handle(MediaCreated | MediaLinkWithArcadia $event)
public function handle(MediaCreated | MediaLinkWithArcadia $event)
{
$historique = new Historique();
$type = EventHistoryType::created;

View file

@ -2,9 +2,11 @@
namespace App\Models\Api;
use App\Jobs\SetApiTokenStats;
use App\Lib\Helper;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
@ -26,6 +28,24 @@ class ApiToken extends Model
'token_date' => 'datetime'
];
/**
* The timeout cache for this model
*
* @var integer
*/
protected $cacheFor = 0;
/**
* Service
*
* @param array $attributes
*/
public function __construct(array $attributes = [])
{
$this->cacheFor = config('api.servicesDBCache') * 60;
parent::__construct($attributes);
}
/**
* Perform any actions required after the model boots.
*
@ -57,6 +77,19 @@ class ApiToken extends Model
// * METHODS
/**
* Increment API CRM Stat
*
* @return void
*/
public function incrementStat(): void
{
SetApiTokenStats::dispatch($this)
->afterResponse()
->onConnection('database')
->onQueue('stats');
}
/**
* Generates new public token
*
@ -84,6 +117,26 @@ class ApiToken extends Model
return $pwd;
}
/**
* Get Service from bearerToken with its brand
*
* @param string $bearerToken
* @return \App\Models\Api\ApiToken
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException If the token matches no known service.
*/
public static function getFromBearerToken(string $bearerToken): self
{
$apiTokens = static::all();
/** @var \App\Models\Api\ApiToken $apiToken */
foreach ($apiTokens as $apiToken) {
// * Keep compatibility with V3.
if ($bearerToken === $apiToken->public_token) {
return $apiToken;
}
}
throw new ModelNotFoundException('Could not find apiToken from bearer token');
}
/**
* Authenticate the request for this service
*

View file

@ -0,0 +1,48 @@
<?php
namespace App\Models\Api;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ApiTokenStat extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = ['date'];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'date' => 'date'
];
/**
* The model's attributes.
*
* @var array
*/
protected $attributes = [
'nb_calls' => 0
];
// * RELATIONSHIPS
/**
* BelongsTo apiToken relation
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function apiToken()
{
return $this->belongsTo(ApiToken::class);
}
}

View file

@ -17,6 +17,6 @@ class Article extends Model
*/
public function mediaList()
{
return $this->belongsToMany(Media::class);
return $this->belongsToMany(Media::class, 'media_article', 'media_id', 'article_id');
}
}

View file

@ -2,7 +2,12 @@
namespace App\Providers;
use App\Lib\Helper;
use App\Models\Api\ApiToken;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthServiceProvider extends ServiceProvider
{
@ -21,5 +26,46 @@ class AuthServiceProvider extends ServiceProvider
public function boot()
{
$this->registerPolicies();
Auth::viaRequest('api-hmac', function (Request $request) {
/**
* Un token devrais être fourni pour chaque requete
*/
if (is_null($request->bearer_token)) {
return null;
}
// * Si la route actuelle n'as pas de nom alors on refuse de la traiter.
if (!(Helper::getRoute($request))) {
// @codeCoverageIgnoreStart
return null;
// @codeCoverageIgnoreEnd
}
$apiToken = null;
try {
$apiToken = ApiToken::getFromBearerToken($request->bearer_token ?? '');
} catch (ModelNotFoundException $e) {
return null;
}
/**
** All Api routes must pass ApiToken model to controller.
** This hack is a custom binder instead of passing
** this to route as a parameter.
*/
app()->bind(ApiToken::class, function () use ($apiToken) {
return $apiToken;
});
// * On authenticate la request en vérifiant sa signature.
if ($apiToken->authenticateApi($request)) {
return $apiToken;
}
return null;
});
}
}

View file

@ -2,6 +2,8 @@
namespace App\Providers;
use App\Enums\MediaStatut;
use App\Models\Article;
use App\Models\Media;
use App\Models\Panier;
use Illuminate\Cache\RateLimiting\Limit;
@ -44,7 +46,29 @@ class RouteServiceProvider extends ServiceProvider
});
Route::bind('basket_uuid', function ($value) {
return Panier::where('uid', $value)
->with('owner')->firstOrFail();
->with('owner')->firstOrFail();
});
// * API
Route::bind('apiNoArticle', function ($value) {
return Article::where('noArt', $value)->firstOrFail();
});
Route::bind('apiMedia', function ($value) {
return Media::where(function ($q) use ($value) {
$q->where('id', $value);
})->whereIn('statut', [
MediaStatut::public,
MediaStatut::public_etendu
])->firstOrFail();
});
Route::bind('apiMediaArcadiaId', function ($value) {
return Media::where(function ($q) use ($value) {
$q->where('arcadia_id', $value)
->whereNotNull('arcadia_id');
})->whereIn('statut', [
MediaStatut::public,
MediaStatut::public_etendu
])->firstOrFail();
});
}
@ -55,8 +79,10 @@ class RouteServiceProvider extends ServiceProvider
*/
protected function configureRateLimiting()
{
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
return Limit::perMinute(config('api.apiRequestRatePerMinute', 100))
->by($request->user()?->id ?: app('clientIp'));
});
}
}

View file

@ -1,10 +1,11 @@
{
"name": "laravel/laravel",
"name": "ldc/dam",
"version": "1.0.0",
"type": "project",
"description": "The Laravel Framework.",
"description": "Big freaking media manager (because arcadia sucks)",
"keywords": [
"framework",
"laravel"
"application",
"media"
],
"license": "MIT",
"require": {
@ -16,6 +17,7 @@
"elasticquent/elasticquent": "dev-master",
"guzzlehttp/guzzle": "^7.2",
"james-heinrich/getid3": "^1.9",
"kwaadpepper/enum": "^2.0",
"kwaadpepper/exception-handler": "^0.0.8",
"laravel/framework": "^9.2",
"laravel/sanctum": "^2.14.1",
@ -50,6 +52,20 @@
}
},
"scripts": {
"test": [
"@configclear",
"@runtestTokenSeeder",
"@runtest"
],
"testCoverage": [
"@configclear",
"@runtestTokenSeeder",
"@runtestWithCoverage"
],
"configclear": "@php artisan config:clear",
"runtestTokenSeeder": "@php artisan db:seed --class=ApiTokenSeeder",
"runtest": "@php artisan test --no-coverage --testsuite API",
"runtestWithCoverage": "@php artisan test --testsuite API",
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"

58
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "77f343483e87b0d4403a8b8209b33bd9",
"content-hash": "d3caf1a99a48f4de736a4a4f8aa14124",
"packages": [
{
"name": "adldap2/adldap2",
@ -1758,6 +1758,62 @@
},
"time": "2021-09-22T16:34:51+00:00"
},
{
"name": "kwaadpepper/enum",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/Kwaadpepper/enum.git",
"reference": "a9f6e0722a68dd63eb9e989d7e65fc9b37e1f8e5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Kwaadpepper/enum/zipball/a9f6e0722a68dd63eb9e989d7e65fc9b37e1f8e5",
"reference": "a9f6e0722a68dd63eb9e989d7e65fc9b37e1f8e5",
"shasum": ""
},
"require-dev": {
"orchestra/testbench": "^6.13",
"phpunit/phpunit": "~9.0",
"slevomat/coding-standard": "^7.0",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Kwaadpepper\\Enum\\EnumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Kwaadpepper\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jérémy Munsch",
"email": "github@jeremydev.ovh",
"homepage": "https://jeremydev.ovh"
}
],
"description": "Enums to use for any project",
"homepage": "https://github.com/kwaadpepper/enum",
"keywords": [
"enum",
"laravel"
],
"support": {
"issues": "https://github.com/Kwaadpepper/enum/issues",
"source": "https://github.com/Kwaadpepper/enum/tree/2.0.1"
},
"time": "2021-09-12T00:23:39+00:00"
},
{
"name": "kwaadpepper/exception-handler",
"version": "0.0.8",

30
config/api.php Normal file
View file

@ -0,0 +1,30 @@
<?php
use App\Lib\Helper;
return [
/**
* Api versions
** this value is sent in response
*/
'version' => Helper::getPackageInfo()['version'],
/**
* Timestamp timeout in seconds
* Request with timestamp older than 30sec
* will be rejected
*/
'timestampTimeout' => 30,
/**
* The number of request
* per user or ip per minutes
*/
'apiRequestRatePerMinute' => 256,
/**
* Service requests cache
* time in minutes
*/
'servicesDBCache' => 10
];

View file

@ -40,6 +40,10 @@ return [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'api-hmac'
],
],
/*

View file

@ -1,41 +1,37 @@
<?php
return [
/*
* The storage config
*/
// * The storage config
'storage' => [
/*
* Returns the folder name of the chunks. The location is in storage/app/{folder_name}
*/
// * Returns the folder name of the chunks. The location is in storage/app/{folder_name}
'chunks' => 'chunks',
'disk' => 'chunk_upload',
],
'clear' => [
/*
* How old chunks we should delete
*/
// * How old chunks we should delete
'timestamp' => '-3 HOURS',
'schedule' => [
'enabled' => true,
'cron' => '25 * * * *', // run every hour on the 25th minute
// * run every hour on the 25th minute
'cron' => '25 * * * *',
],
],
'chunk' => [
// setup for the chunk naming setup to ensure same name upload at same time
// * setup for the chunk naming setup to ensure same name upload at same time
'name' => [
'use' => [
'session' => true, // should the chunk name use the session id? The uploader must send cookie!,
'browser' => false, // instead of session we can use the ip and browser?
// * should the chunk name use the session id? The uploader must send cookie!,
'session' => true,
// * instead of session we can use the ip and browser?
'browser' => false,
],
],
],
'handlers' => [
// A list of handlers/providers that will be appended to existing list of handlers
// * A list of handlers/providers that will be appended to existing list of handlers
'custom' => [],
// Overrides the list of handlers - use only what you really want
'override' => [
// \Pion\Laravel\ChunkUpload\Handler\DropZoneUploadHandler::class
],
// * Overrides the list of handlers - use only what you really want
'override' => [],
],
];

View file

@ -23,8 +23,14 @@ return new class extends Migration
File::link($newPath, $newLink);
(new ConsoleOutput())->writeln("Lien symbolique créé de $oldPath vers $newPath");
}
Media::withTrashed()->update(['folder' => DB::raw("REPLACE(folder, 'medias/', '')")]);
Media::withTrashed()->update(['folder' => DB::raw("REPLACE(folder, '/', '')")]);
Media::withTrashed()->update([
'folder' => DB::raw("REPLACE(folder, 'medias/', '')"),
'updated_at' => DB::raw('updated_at'),
]);
Media::withTrashed()->update([
'folder' => DB::raw("REPLACE(folder, '/', '')"),
'updated_at' => DB::raw('updated_at'),
]);
}
/**

View file

@ -50,6 +50,7 @@ return new class extends Migration
throw new \RuntimeException('Failed to convert size');
}
} //end switch
$media->timestamps = false;
$media->saveQuietly();
});

View file

@ -33,7 +33,8 @@ return new class extends Migration
]));
return $historique;
})->filter()->map(function (Historique $historique) {
$historique->saveOrFail();
$historique->timestamps = false;
$historique->saveQuietly();
});
Schema::table('historiques', function (Blueprint $table) {
$table->unsignedInteger('type')->default(EventHistoryType::created->value)

View file

@ -0,0 +1,35 @@
<?php
use App\Models\Api\ApiToken;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('api_token_stats', function (Blueprint $table) {
$table->id();
$table->date('date');
$table->unsignedBigInteger('nb_calls')->default(0);
$table->foreignIdFor(ApiToken::class)->constrained()->cascadeOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('api_token_stats');
}
};

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('jobs');
}
};

View file

@ -15,7 +15,7 @@ class ApiTokenSeeder extends Seeder
*/
public function run()
{
DB::table('api_tokens')->truncate();
DB::table((new ApiToken())->getTable())->delete();
ApiToken::factory(10)->create();
}
}

View file

@ -5,11 +5,8 @@
colors="true">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
<testsuite name="API">
<directory suffix="Test.php">./tests/Api</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">

View file

@ -1 +1,24 @@
<?php
use App\Http\Controllers\Api\ApiController;
use App\Http\Controllers\Api\MediaController;
use Illuminate\Support\Facades\Route;
Route::name('api.')->group(function () {
// * PUBLIC
Route::get('/ping', [ApiController::class, 'ping'])->name('ping');
// * PRIVATE
Route::middleware('auth:api')->group(function () {
// * OPTINS
Route::get('/media/{apiMedia}', [MediaController::class, 'show'])->name('media.show');
Route::get('/media/{apiMediaArcadiaId}/byArcadiaId', [MediaController::class, 'show'])
->name('media.showByArcadiaId');
Route::get('/article/{apiNoArticle}/media', [MediaController::class, 'getMediaListByArticle'])
->name('articles.media.index');
Route::get('/media/{apiMedia}/picture', [MediaController::class, 'getMediaPicture'])
->name('media.picture');
});
});

89
tests/Api/ApiBase.php Normal file
View file

@ -0,0 +1,89 @@
<?php
namespace Tests\Api;
use App\Enums\Legacy\ApiStatus;
use App\Lib\Helper;
use App\Models\Api\ApiToken;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Testing\AssertableJsonString;
use Illuminate\Testing\TestResponse;
use Tests\CreatesApplication;
class ApiBase extends BaseTestCase
{
use CreatesApplication;
use DatabaseTransactions;
/**
* apiToken used to make calls
*
* @var \App\Models\Api\ApiToken|null
*/
protected $apiToken = null;
/**
* Make assertion that json response has status.
*
* @param AssertableJsonString $json
* @param ApiStatus $status
* @return void
*/
protected function assertApiResponseStatusIs(AssertableJsonString $json, ApiStatus $status)
{
$json->assertFragment(['status' => $status->value]);
}
/**
* Raw Function to call api
*
* @param string $method
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Testing\TestResponse
*/
protected function callApi(string $method, string $uri, array $data = [], array $headers = []): TestResponse
{
if (!isset($data['timestamp'])) {
$data['timestamp'] = \time();
}
return $this->json($method, $uri, $data, $headers);
}
/**
* Raw Function to call api with Token
*
* @param string $method
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Testing\TestResponse
*/
protected function callApiWithToken(
string $method,
string $uri,
array $data = [],
array $headers = []
): TestResponse {
if (!$this->apiToken) {
$this->apiToken = ApiToken::query()->inRandomOrder()->firstOrFail();
$publicToken = $this->apiToken->generatePublicToken();
$this->apiToken->saveOrFail();
$this->apiToken->public_token = $publicToken;
}
$data['bearer_token'] = $this->apiToken->public_token;
$data['timestamp'] = \gmdate('Y-m-d H:i:s');
$data['signature'] = Helper::signRequest(
$this->apiToken->private_token,
$uri,
$method,
$data
);
return $this->callApi($method, $uri, $data, $headers);
}
}

186
tests/Api/ApiBaseTest.php Normal file
View file

@ -0,0 +1,186 @@
<?php
namespace Tests\Api;
use App\Enums\Legacy\ApiError;
use App\Enums\Legacy\ApiStatus;
use App\Models\Api\ApiToken;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Tests\CreatesApplication;
class ApiBaseTest extends ApiBase
{
use CreatesApplication;
use DatabaseTransactions;
/**
* Test Api only handle json calls
*
* @return void
*/
public function testOnlyJsonCallsAreHandled()
{
$response = $this->call('get', route('api.ping'), [
'timestamp' => \gmdate('Y-m-d H:i:s')
]);
$response->assertStatus(400);
}
/**
* Test timestamp middleware works as expected.
*
* @return void
*/
public function testTimestampMiddleware()
{
$response = $this->json('get', route('api.ping'), [
'timestamp' => \gmdate('Y-m-d H:i:s')
]);
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::OK());
}
/**
* Timestamp middleware should fail if timestamp parameter is missing.
*
* @return void
*/
public function testTimestampMiddlewareCheckFailMissing()
{
$response = $this->json('get', route('api.ping'), []);
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::KO());
$json->assertFragment(['data' => [
'code' => ApiError::wrongTimeStamp()->value,
'message' => ApiError::wrongTimeStamp()->label
]]);
}
/**
* Timestamp middleware should fail if timestamp parameter has wrong format.
*
* @return void
*/
public function testTimestampMiddlewareCheckFailWrongFormat()
{
$response = $this->json('get', route('api.ping'), ['timestamp' => \gmdate('Y/m/d')]);
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::KO());
$json->assertFragment(['data' => [
'code' => ApiError::wrongTimeStamp()->value,
'message' => ApiError::wrongTimeStamp()->label
]]);
}
/**
* Timestamp middleware should fail if timestamp is older than "config('api.timestampTimeout', 10)".
*
* @return void
*/
public function testTimestampMiddlewareCheckFailIfTooOld()
{
$response = $this->json('get', route('api.ping'), [
'timestamp' => \gmdate('Y-m-d H:i:s', \time() - config('api.timestampTimeout', 10))
]);
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::KO());
$json->assertFragment(['data' => [
'code' => ApiError::wrongTimeStamp()->value,
'message' => ApiError::wrongTimeStamp()->label
]]);
}
/**
* Test api route throttle.
*
* ! This has to be the last test of this class.
*
* @return void
*/
public function testApiThrottle()
{
for ($i = config('api.apiRequestRatePerMinute', 100); $i >= 0; $i--) {
$this->json('get', route('api.ping'), [
'timestamp' => \gmdate('Y-m-d H:i:s')
]);
}
$response = $this->json('get', route('api.ping'), [
'timestamp' => \gmdate('Y-m-d H:i:s')
]);
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::KO());
$response->assertStatus(429);
}
/**
* Test wrong http method call
*
* @return void
*/
public function testWrongMethodCall()
{
$response = $this->json('post', route('api.ping'), [
'timestamp' => \gmdate('Y-m-d H:i:s', \time())
]);
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::KO());
$this->assertEquals(ApiError::methodNotAllowed()->value, $json->json('data.code'));
}
/**
* Test Auth fail when no token
*
* @return void
*/
public function testAuthFailNoToken()
{
$response = $this->json('get', route('api.media.show', [
'apiMedia' => Str::uuid()
]), [
'timestamp' => \gmdate('Y-m-d H:i:s', \time())
]);
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::KO());
$this->assertEquals(ApiError::apiAuthFailed()->value, $json->json('data.code'));
}
/**
* Test Auth fail when unknown token
*
* @return void
*/
public function testAuthFailUnknownToken()
{
$response = $this->json('get', route('api.media.show', [
'apiMedia' => Str::uuid()
]), [
'bearer_token' => 'qsdqsdqsd',
'timestamp' => \gmdate('Y-m-d H:i:s', \time())
]);
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::KO());
$this->assertEquals(ApiError::apiAuthFailed()->value, $json->json('data.code'));
}
/**
* Test Auth fail when wrong signature
*
* @return void
*/
public function testAuthFailWrongSignature()
{
$apiToken = ApiToken::query()->firstOrFail();
$response = $this->json('get', route('api.media.show', [
'apiMedia' => Str::uuid()
]), [
'bearer_token' => $apiToken->public_token,
'timestamp' => \gmdate('Y-m-d H:i:s', \time()),
'signature' => 'sdqdqsdqd'
]);
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::KO());
$this->assertEquals(ApiError::apiAuthFailed()->value, $json->json('data.code'));
}
}

259
tests/Api/ApiMediaTest.php Normal file
View file

@ -0,0 +1,259 @@
<?php
namespace Tests\Api;
use App\Enums\Legacy\ApiError;
use App\Enums\Legacy\ApiStatus;
use App\Enums\MediaExtension;
use App\Enums\MediaStatut;
use App\Models\Article;
use App\Models\Media;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\TestResponse;
use Symfony\Component\Mime\MimeTypes;
use Tests\CreatesApplication;
class ApiMediaTest extends ApiBase
{
use CreatesApplication;
use DatabaseTransactions;
/**
* Test get media by Id
*
* @return void
*/
public function testMediaShowById()
{
\mt_srand();
$rand = \mt_rand(15, 60);
for ($i = 0; $i < $rand; $i++) {
\mt_srand();
$media = Media::query()->inRandomOrder(\mt_rand())
->where('statut', '!=', MediaStatut::brouillon)
->where('isDefault', false)
->first();
$response = $this->callApiWithToken('get', route('api.media.show', [
'apiMedia' => $media->id
]));
$this->assertResponseIsMediaResource($response);
}
}
/**
* Could throw api error exception not found
*
* @return void
*/
public function testMediaShowByArcadiaId()
{
\mt_srand();
$rand = \mt_rand(15, 60);
for ($i = 0; $i < $rand; $i++) {
\mt_srand();
$media = Media::query()->whereNotNull('arcadia_id')
->where('statut', '!=', MediaStatut::brouillon)
->where('isDefault', false)
->inRandomOrder(\mt_rand())->first();
$response = $this->callApiWithToken('get', route('api.media.showByArcadiaId', [
'apiMediaArcadiaId' => $media->arcadia_id
]));
$this->assertResponseIsMediaResource($response);
}
}
/**
* Test get media by Id not found
*
* @return void
*/
public function testMediaShowNotFound()
{
$response = $this->callApiWithToken('get', route('api.media.show', [
'apiMedia' => '94984919191616161'
]));
$this->assertResponseIsNotFound($response);
}
/**
* Test get media picture
*
* @return void
*/
public function testMediaPicture()
{
\mt_srand();
$rand = \mt_rand(15, 60);
for ($i = 0; $i < $rand; $i++) {
\mt_srand();
$media = Media::query()->whereNotNull('arcadia_id')
->where('statut', '!=', MediaStatut::brouillon)
->where('isDefault', false)
->inRandomOrder(\mt_rand())->first();
$response = $this->callApiWithToken('get', route('api.media.picture', [
'apiMedia' => $media->id
]));
$this->assertResponseIsMediaPictureResource($response);
}
}
/**
* Test get media picture
*
* @return void
*/
public function testMediaPictureNotFound()
{
\mt_srand();
$response = $this->callApiWithToken('get', route('api.media.picture', [
'apiMedia' => \mt_rand(9999999, 99999999999)
]));
$this->assertResponseIsNotFound($response);
}
/**
* Test get media picture
*
* @return void
*/
public function testGetMediaListByArticle()
{
\mt_srand();
$rand = \mt_rand(15, 60);
for ($i = 0; $i < $rand; $i++) {
\mt_srand();
$article = Article::query()
->inRandomOrder(\mt_rand())->first();
$response = $this->callApiWithToken('get', route('api.articles.media.index', [
'apiNoArticle' => $article->noArt
]));
$this->assertResponseIsMediaListResource($response);
}
}
// * PRIVATE METHODS
/**
* Assert the response contains a not found message.
*
* @param TestResponse $response
* @return void
*/
private function assertResponseIsNotFound(TestResponse $response)
{
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::KO());
$json->assertStructure(['data' => [
'code', 'message'
]], $json->json());
$this->assertEquals(ApiError::modelNotFound()->value, $json->json('data.code'));
}
/**
* Assert the response contains a media list resource.
*
* @param TestResponse $response
* @return void
*/
private function assertResponseIsMediaListResource(TestResponse $response)
{
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::OK());
$json->assertStructure([
'data' => [
'*' => [
'id', 'arcadia_id', 'libelle', 'uid',
'version', 'created_at', 'updated_at'
]
]
], $json->json());
}
/**
* Assert the response contains a media resource.
*
* @param TestResponse $response
* @return void
*/
private function assertResponseIsMediaResource(TestResponse $response)
{
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::OK());
$json->assertStructure(['data' => [
'id', 'arcadia_id', 'libelle', 'uid',
'version', 'created_at', 'updated_at'
]], $json->json());
$this->assertInstanceOf(Carbon::class, Carbon::parse($json->json('data.created_at')));
$this->assertInstanceOf(Carbon::class, Carbon::parse($json->json('data.updated_at')));
$this->assertDatabaseHas((new Media())->getTable(), [
'id' => $json->json('data.id'),
'arcadia_id' => $json->json('data.arcadia_id'),
'libelle' => $json->json('data.libelle'),
'uid' => $json->json('data.uid'),
'version' => $json->json('data.version'),
]);
}
/**
* Assert the response contains a media picture resource.
*
* @param TestResponse $response
* @return void
*/
private function assertResponseIsMediaPictureResource(TestResponse $response)
{
$json = $response->decodeResponseJson();
$this->assertApiResponseStatusIs($json, ApiStatus::OK());
$json->assertStructure(['data' => [
'base64_bin', 'uid', 'extension'
]], $json->json());
$this->assertTrue(
base64_encode(base64_decode($json->json('data.base64_bin'), true)) === $json->json('data.base64_bin'),
'Should be base64 encoded'
);
$this->assertTrue(
\in_array($json->json('data.extension'), collect(MediaExtension::cases())->pluck('value')->all()),
'Extension should exist and be a MediaExtension enum'
);
$this->assertDatabaseHas((new Media())->getTable(), [
'uid' => $json->json('data.uid'),
'extension' => $json->json('data.extension'),
]);
$tmpFilePath = 'tmp/' . $json->json('data.uid') . '.' . $json->json('data.extension');
Storage::put(
$tmpFilePath,
\base64_decode($json->json('data.base64_bin'))
);
$ext = $json->json('data.extension');
$mt = new MimeTypes();
$guessedExts = $mt->getExtensions($mt->guessMimeType(Storage::path($tmpFilePath)));
$strGuessedExts = \implode(',', $guessedExts);
Storage::delete($tmpFilePath);
$this->assertTrue(
\in_array($ext, $guessedExts),
"File guessed extensions '{$strGuessedExts}' must contain name extension '{$ext}'"
);
}
}

View file

@ -1,20 +0,0 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testExample()
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

View file

@ -1,18 +0,0 @@
<?php
namespace Tests\Unit;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testExample()
{
$this->assertTrue(true);
}
}