diff --git a/.env.testing b/.env.testing new file mode 100644 index 0000000..51ce4a9 --- /dev/null +++ b/.env.testing @@ -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 diff --git a/.env.testing.example b/.env.testing.example new file mode 100644 index 0000000..7f9f1e2 --- /dev/null +++ b/.env.testing.example @@ -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= diff --git a/README.md b/README.md index 08aa441..afeb2f0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/Enums/Legacy/ApiError.php b/app/Enums/Legacy/ApiError.php new file mode 100644 index 0000000..37e0fdf --- /dev/null +++ b/app/Enums/Legacy/ApiError.php @@ -0,0 +1,93 @@ + 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' + ]; + } +} diff --git a/app/Enums/Legacy/ApiStatus.php b/app/Enums/Legacy/ApiStatus.php new file mode 100644 index 0000000..ee34d73 --- /dev/null +++ b/app/Enums/Legacy/ApiStatus.php @@ -0,0 +1,18 @@ +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 + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 7f151fa..56f9fe3 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -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, \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> */ - 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); + } } diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php new file mode 100644 index 0000000..f70fc26 --- /dev/null +++ b/app/Http/Controllers/Api/ApiController.php @@ -0,0 +1,19 @@ +json(); + } +} diff --git a/app/Http/Controllers/Api/MediaController.php b/app/Http/Controllers/Api/MediaController.php new file mode 100644 index 0000000..65049b7 --- /dev/null +++ b/app/Http/Controllers/Api/MediaController.php @@ -0,0 +1,86 @@ + */ + $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); + } +} diff --git a/app/Http/Controllers/MediaController.php b/app/Http/Controllers/MediaController.php index 667e4cd..1f71574 100644 --- a/app/Http/Controllers/MediaController.php +++ b/app/Http/Controllers/MediaController.php @@ -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 { diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 81799ec..c6954c5 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -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, + ] + ) ]); } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 7d17f54..487fc6a 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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 ], ]; diff --git a/app/Http/Middleware/ApiCheckTimeStamps.php b/app/Http/Middleware/ApiCheckTimeStamps.php new file mode 100644 index 0000000..ec76ec5 --- /dev/null +++ b/app/Http/Middleware/ApiCheckTimeStamps.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/app/Http/Middleware/ApiResponse.php b/app/Http/Middleware/ApiResponse.php new file mode 100644 index 0000000..df261b3 --- /dev/null +++ b/app/Http/Middleware/ApiResponse.php @@ -0,0 +1,59 @@ +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)); + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index c3ee348..1db363e 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -15,6 +15,7 @@ class Authenticate extends Middleware */ protected function redirectTo($request) { + // phpcs:enable if (! $request->expectsJson()) { return route('login'); } diff --git a/app/Http/Middleware/JsonOnly.php b/app/Http/Middleware/JsonOnly.php new file mode 100644 index 0000000..ce9249f --- /dev/null +++ b/app/Http/Middleware/JsonOnly.php @@ -0,0 +1,31 @@ +isJson() or + !$request->expectsJson() or + is_null(json_decode($request->getContent())) + ) { + abort(400, 'Invalid JSON'); + } + + return $next($request); + } +} diff --git a/app/Http/Resources/Api/ApiResponse.php b/app/Http/Resources/Api/ApiResponse.php new file mode 100644 index 0000000..aed6557 --- /dev/null +++ b/app/Http/Resources/Api/ApiResponse.php @@ -0,0 +1,42 @@ +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') + ]; + } +} diff --git a/app/Http/Resources/Api/MediaListResource.php b/app/Http/Resources/Api/MediaListResource.php new file mode 100644 index 0000000..dc1f81b --- /dev/null +++ b/app/Http/Resources/Api/MediaListResource.php @@ -0,0 +1,23 @@ +collection->map(function (Media $media) use ($request) { + return (new MediaResource($media))->toArray($request); + }); + } +} diff --git a/app/Http/Resources/Api/MediaPictureResource.php b/app/Http/Resources/Api/MediaPictureResource.php new file mode 100644 index 0000000..94c685b --- /dev/null +++ b/app/Http/Resources/Api/MediaPictureResource.php @@ -0,0 +1,51 @@ + '', + '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; + } +} diff --git a/app/Http/Resources/Api/MediaResource.php b/app/Http/Resources/Api/MediaResource.php new file mode 100644 index 0000000..bbe0c62 --- /dev/null +++ b/app/Http/Resources/Api/MediaResource.php @@ -0,0 +1,61 @@ + 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; + } +} diff --git a/app/Jobs/SetApiTokenStats.php b/app/Jobs/SetApiTokenStats.php new file mode 100644 index 0000000..ceb2731 --- /dev/null +++ b/app/Jobs/SetApiTokenStats.php @@ -0,0 +1,80 @@ +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)) + ]; + } +} diff --git a/app/Lib/Helper.php b/app/Lib/Helper.php index eb10d89..6cc274a 100644 --- a/app/Lib/Helper.php +++ b/app/Lib/Helper.php @@ -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'); diff --git a/app/Listeners/HistorizeMediaEvent.php b/app/Listeners/HistorizeMediaEvent.php index 377df1a..29bc00d 100644 --- a/app/Listeners/HistorizeMediaEvent.php +++ b/app/Listeners/HistorizeMediaEvent.php @@ -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; diff --git a/app/Models/Api/ApiToken.php b/app/Models/Api/ApiToken.php index 6b795af..6c08438 100644 --- a/app/Models/Api/ApiToken.php +++ b/app/Models/Api/ApiToken.php @@ -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 * diff --git a/app/Models/Api/ApiTokenStat.php b/app/Models/Api/ApiTokenStat.php new file mode 100644 index 0000000..65b8101 --- /dev/null +++ b/app/Models/Api/ApiTokenStat.php @@ -0,0 +1,48 @@ + '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); + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index b791844..1f2b609 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -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'); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index afb0cf8..34d1e7a 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -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; + }); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index aed4b9a..9867adc 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -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')); }); } } diff --git a/composer.json b/composer.json index 2c9516c..c581952 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/composer.lock b/composer.lock index 8bb5911..e8a2628 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/api.php b/config/api.php new file mode 100644 index 0000000..8f8c4df --- /dev/null +++ b/config/api.php @@ -0,0 +1,30 @@ + 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 +]; diff --git a/config/auth.php b/config/auth.php index 7c96877..9c4ff3f 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,10 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'api-hmac' + ], ], /* diff --git a/config/chunk-upload.php b/config/chunk-upload.php index 3e42b9e..18becd9 100644 --- a/config/chunk-upload.php +++ b/config/chunk-upload.php @@ -1,41 +1,37 @@ [ - /* - * 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' => [], ], ]; diff --git a/database/migrations/2022_06_21_172343_move_public_medias_to_storage.php b/database/migrations/2022_06_21_172343_move_public_medias_to_storage.php index 17a5350..0fa896c 100644 --- a/database/migrations/2022_06_21_172343_move_public_medias_to_storage.php +++ b/database/migrations/2022_06_21_172343_move_public_medias_to_storage.php @@ -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'), + ]); } /** diff --git a/database/migrations/2022_07_20_135941_media_integers.php b/database/migrations/2022_07_20_135941_media_integers.php index bd44515..2810425 100644 --- a/database/migrations/2022_07_20_135941_media_integers.php +++ b/database/migrations/2022_07_20_135941_media_integers.php @@ -50,6 +50,7 @@ return new class extends Migration throw new \RuntimeException('Failed to convert size'); } } //end switch + $media->timestamps = false; $media->saveQuietly(); }); diff --git a/database/migrations/2022_07_25_110851_change_history_type_on_historiques.php b/database/migrations/2022_07_25_110851_change_history_type_on_historiques.php index 496a092..462ec28 100644 --- a/database/migrations/2022_07_25_110851_change_history_type_on_historiques.php +++ b/database/migrations/2022_07_25_110851_change_history_type_on_historiques.php @@ -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) diff --git a/database/migrations/2022_07_26_111732_create_api_token_stats_table.php b/database/migrations/2022_07_26_111732_create_api_token_stats_table.php new file mode 100644 index 0000000..f5164ce --- /dev/null +++ b/database/migrations/2022_07_26_111732_create_api_token_stats_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/database/migrations/2022_07_28_144413_create_jobs_table.php b/database/migrations/2022_07_28_144413_create_jobs_table.php new file mode 100644 index 0000000..a786a89 --- /dev/null +++ b/database/migrations/2022_07_28_144413_create_jobs_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/database/seeders/ApiTokenSeeder.php b/database/seeders/ApiTokenSeeder.php index 75b67de..fb3a4cf 100644 --- a/database/seeders/ApiTokenSeeder.php +++ b/database/seeders/ApiTokenSeeder.php @@ -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(); } } diff --git a/phpunit.xml b/phpunit.xml index 1b245ce..31f8458 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,11 +5,8 @@ colors="true"> - - ./tests/Unit - - - ./tests/Feature + + ./tests/Api diff --git a/routes/api.php b/routes/api.php index b3d9bbc..8228f9f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1 +1,24 @@ 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'); + }); +}); diff --git a/tests/Api/ApiBase.php b/tests/Api/ApiBase.php new file mode 100644 index 0000000..6cfd932 --- /dev/null +++ b/tests/Api/ApiBase.php @@ -0,0 +1,89 @@ +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); + } +} diff --git a/tests/Api/ApiBaseTest.php b/tests/Api/ApiBaseTest.php new file mode 100644 index 0000000..dd8d8da --- /dev/null +++ b/tests/Api/ApiBaseTest.php @@ -0,0 +1,186 @@ +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')); + } +} diff --git a/tests/Api/ApiMediaTest.php b/tests/Api/ApiMediaTest.php new file mode 100644 index 0000000..6c9f269 --- /dev/null +++ b/tests/Api/ApiMediaTest.php @@ -0,0 +1,259 @@ +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}'" + ); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index f4e2e2c..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,20 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 6e7911c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,18 +0,0 @@ -assertTrue(true); - } -}