From 38710677191cde9c1990d2a0d924d63a8bbfb4e7 Mon Sep 17 00:00:00 2001 From: Syndesi Date: Sun, 28 Apr 2024 10:38:33 +0200 Subject: [PATCH 1/7] manual 2024 work --- .env | 4 + CHANGELOG.md | 9 + config/services.yaml | 10 + docker/php-embed/php.dev.ini | 2 + docker/php-embed/php.prod.ini | 2 + .../delete-element/204-response-header.txt | 2 +- .../delete-element/401-response-header.txt | 2 +- .../delete-element/404-response-header.txt | 2 +- .../delete-element/412-response-header.txt | 2 +- .../delete-element/429-response-header.txt | 2 +- .../get-children/200-response-header.txt | 2 +- .../get-children/304-response-header.txt | 2 +- .../get-children/401-response-header.txt | 2 +- .../get-children/404-response-header.txt | 2 +- .../get-children/412-response-header.txt | 2 +- .../get-children/429-response-header.txt | 2 +- .../get-element/200-node-response-header.txt | 2 +- .../200-relation-response-header.txt | 2 +- .../get-element/304-response-header.txt | 2 +- .../get-element/401-response-header.txt | 2 +- .../get-element/404-response-header.txt | 2 +- .../get-element/412-response-header.txt | 2 +- .../get-element/429-response-header.txt | 2 +- .../element/get-index/200-response-header.txt | 2 +- .../element/get-index/304-response-header.txt | 2 +- .../element/get-index/401-response-header.txt | 2 +- .../element/get-index/412-response-header.txt | 2 +- .../element/get-index/429-response-header.txt | 2 +- .../get-parents/200-response-header.txt | 2 +- .../get-parents/304-response-header.txt | 2 +- .../get-parents/401-response-header.txt | 2 +- .../get-parents/404-response-header.txt | 2 +- .../get-parents/412-response-header.txt | 2 +- .../get-parents/429-response-header.txt | 2 +- .../get-related/200-response-header.txt | 2 +- .../get-related/304-response-header.txt | 2 +- .../get-related/401-response-header.txt | 2 +- .../get-related/404-response-header.txt | 2 +- .../get-related/412-response-header.txt | 2 +- .../get-related/429-response-header.txt | 2 +- .../patch-element/204-response-header.txt | 2 +- .../patch-element/401-response-header.txt | 2 +- .../patch-element/404-response-header.txt | 2 +- .../patch-element/412-response-header.txt | 2 +- .../patch-element/429-response-header.txt | 2 +- .../post-element/204-response-header.txt | 2 +- .../post-element/400-response-header.txt | 2 +- .../post-element/401-response-header.txt | 2 +- .../post-element/429-response-header.txt | 2 +- .../post-index/204-response-header.txt | 2 +- .../post-index/400-response-header.txt | 2 +- .../post-index/401-response-header.txt | 2 +- .../post-index/404-response-header.txt | 2 +- .../post-index/429-response-header.txt | 2 +- .../put-element/204-response-header.txt | 2 +- .../put-element/401-response-header.txt | 2 +- .../put-element/404-response-header.txt | 2 +- .../put-element/412-response-header.txt | 2 +- .../put-element/429-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../get-403-forbidden/200-response-header.txt | 2 +- .../get-404-not-found/200-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../403-response-header.txt | 2 +- .../200-response-header.txt | 2 +- .../user/delete-token/204-response-header.txt | 2 +- .../user/delete-token/401-response-header.txt | 2 +- .../user/delete-token/429-response-header.txt | 2 +- .../user/get-me/200-response-header.txt | 2 +- .../user/get-me/401-response-header.txt | 2 +- .../user/get-me/429-response-header.txt | 2 +- .../user/get-token/200-response-header.txt | 2 +- .../user/get-token/401-response-header.txt | 2 +- .../user/get-token/403-response-header.txt | 2 +- .../204-response-header.txt | 2 +- .../400-response-header.txt | 2 +- .../401-response-header.txt | 2 +- .../403-response-header.txt | 2 +- .../429-response-header.txt | 2 +- .../204-response-header.txt | 2 +- .../400-response-header.txt | 2 +- .../401-response-header.txt | 2 +- .../403-response-header.txt | 2 +- .../429-response-header.txt | 2 +- .../post-register-old/201-response-header.txt | 2 +- .../post-register-old/400-response-header.txt | 2 +- .../post-register-old/403-response-header.txt | 2 +- .../post-register/201-response-header.txt | 2 +- .../post-register/400-response-header.txt | 2 +- .../post-register/403-response-header.txt | 2 +- .../post-token-old/201-response-header.txt | 2 +- .../post-token-old/400-response-header.txt | 2 +- .../post-token-old/401-response-header.txt | 2 +- .../user/post-token/201-response-header.txt | 2 +- .../user/post-token/400-response-header.txt | 2 +- .../user/post-token/401-response-header.txt | 2 +- .../src/DependencyInjection/Configuration.php | 57 +++++ .../EmberNexusExtension.php | 2 +- .../src/Service/EmberNexusConfiguration.php | 237 ++++++++++++++++++ public/index.php | 2 +- src/Attribute/EndpointImplementsTusIo.php | 15 ++ src/Command/BackupCreateCommand.php | 26 +- src/Command/DatabaseDropCommand.php | 28 ++- src/Command/HealthcheckCommand.php | 14 ++ src/Command/TestCommand.php | 58 +++++ src/Contract/LockInterface.php | 18 ++ .../error-412-tus-io-precondition-failed.txt | 11 + .../File/GetElementFileController.php | 68 ++++- .../File/GetTusUploadController.php | 44 ++++ .../File/PostElementFileController.php | 95 ++++++- .../TusIoControllerEventListener.php | 37 +++ .../TusIoResponseEventListener.php | 25 ++ src/EventSystem/Response/config.yaml | 0 ...t412PreconditionFailedExceptionFactory.php | 5 +- ...ablePreconditionFailedExceptionFactory.php | 31 +++ src/Factory/S3ClientFactory.php | 30 +++ src/Factory/UploadObjectFactory.php | 0 src/Response/BinaryStreamResponse.php | 41 +++ src/Service/LockService.php | 48 ++++ src/Service/RequestUtilService.php | 32 +++ src/Service/StorageUtilService.php | 71 ++++++ src/Service/TusIoService.php | 26 ++ src/Type/Lock/FileUploadCheckLock.php | 71 ++++++ src/Type/RedisPrefixType.php | 1 + src/Type/TusIoExtensionType.php | 16 ++ src/Type/TusIoHeaderType.php | 21 ++ src/Type/Upload.php | 133 ++++++++++ src/Type/UploadConcatType.php | 11 + src/Type/UploadVariantType.php | 10 + src/Type/UploadVariantVersionType.php | 10 + .../_01_01_NoConnectionTest.php | 12 + .../_01_02_NoRelevantConnectionTest.php | 12 + ...itherStartOrEndNodeOfRelationshipsTest.php | 24 ++ .../Service/StorageUtilServiceTest.php | 106 ++++++++ 142 files changed, 1538 insertions(+), 135 deletions(-) create mode 100644 src/Attribute/EndpointImplementsTusIo.php create mode 100644 src/Command/TestCommand.php create mode 100644 src/Contract/LockInterface.php create mode 100644 src/Controller/Error/assets/error-412-tus-io-precondition-failed.txt create mode 100644 src/Controller/File/GetTusUploadController.php create mode 100644 src/EventSystem/Controller/EventListener/TusIoControllerEventListener.php create mode 100644 src/EventSystem/Response/EventListener/TusIoResponseEventListener.php create mode 100644 src/EventSystem/Response/config.yaml create mode 100644 src/Factory/Exception/Client412TusResumablePreconditionFailedExceptionFactory.php create mode 100644 src/Factory/S3ClientFactory.php create mode 100644 src/Factory/UploadObjectFactory.php create mode 100644 src/Response/BinaryStreamResponse.php create mode 100644 src/Service/LockService.php create mode 100644 src/Service/StorageUtilService.php create mode 100644 src/Service/TusIoService.php create mode 100644 src/Type/Lock/FileUploadCheckLock.php create mode 100644 src/Type/TusIoExtensionType.php create mode 100644 src/Type/TusIoHeaderType.php create mode 100644 src/Type/Upload.php create mode 100644 src/Type/UploadConcatType.php create mode 100644 src/Type/UploadVariantType.php create mode 100644 src/Type/UploadVariantVersionType.php create mode 100644 tests/UnitTests/Service/StorageUtilServiceTest.php diff --git a/.env b/.env index a2946345..7dc13b77 100755 --- a/.env +++ b/.env @@ -28,4 +28,8 @@ ELASTIC_AUTH=elasticsearch:9200 REDIS_AUTH=tcp://redis?password=redis-password RABBITMQ_AUTH=amqp://user:password@rabbitmq:5672 +S3_ENDPOINT=https://ember-nexus-dev-minio:9000 +S3_ACCESS_KEY_ID=key +S3_SECRET_ACCESS_KEY=key + REFERENCE_DATASET_VERSION=0.0.28 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1d297a..e5db4e63 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added - Add taskfile.dev development environment into the API repository itself, closes #461. +- Add S3 status check to healthcheck command, closes #200. +- Add support to delete files in S3 / object storage through the `php bin/console database:drop` command, closes #309. +- Add support for locks, related to #119. ### Changed - Upgrade FrankenPHP to 1.12.1, related to #457 and #461. - Upgrade PHP dependencies, related to #457 and #461. - Upgrade GitHub Actions in CI/CD. +- Increase max post limit from 2 MB (PHP default) to 100 MB, related to #119. +- Change header `Access-Control-Allow-Headers` to `*`, due to growing number of supported headers. +### Fixed +- Fix deprecation "The "Symfony\Component\HttpKernel\DependencyInjection\Extension" class is considered internal since + Symfony 7.1, to be deprecated in 8.1; use Symfony\Component\DependencyInjection\Extension\Extension instead. It may + change without further notice.". ## 0.1.31 - 2026-02-10 ### Changed diff --git a/config/services.yaml b/config/services.yaml index eae0e4d6..fa7dec79 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -181,3 +181,13 @@ services: PhpAmqpLib\Connection\AMQPStreamConnection: factory: ['@App\Factory\RabbitMQFactory', 'createRabbitMQ'] class: PhpAmqpLib\Connection\AMQPStreamConnection + + App\Factory\S3ClientFactory: + arguments: + $s3Endpoint: '%env(S3_ENDPOINT)%' + $s3AccessKeyId: '%env(S3_ACCESS_KEY_ID)%' + $s3SecretAccessKey: '%env(S3_SECRET_ACCESS_KEY)%' + + AsyncAws\S3\S3Client: + factory: ['@App\Factory\S3ClientFactory', 'createS3Client'] + class: AsyncAws\S3\S3Client diff --git a/docker/php-embed/php.dev.ini b/docker/php-embed/php.dev.ini index c1b92717..30f05c74 100644 --- a/docker/php-embed/php.dev.ini +++ b/docker/php-embed/php.dev.ini @@ -1,5 +1,7 @@ expose_php = Off memory_limit = -1 +upload_max_filesize = 100M +post_max_size = 100M error_log = /dev/stderr xdebug.mode=profile diff --git a/docker/php-embed/php.prod.ini b/docker/php-embed/php.prod.ini index ef68f622..82f6f0e8 100644 --- a/docker/php-embed/php.prod.ini +++ b/docker/php-embed/php.prod.ini @@ -1,3 +1,5 @@ expose_php = Off memory_limit = -1 +upload_max_filesize = 100M +post_max_size = 100M error_log = /dev/stderr diff --git a/docs/api-endpoints/element/delete-element/204-response-header.txt b/docs/api-endpoints/element/delete-element/204-response-header.txt index 5587e440..ece7090c 100644 --- a/docs/api-endpoints/element/delete-element/204-response-header.txt +++ b/docs/api-endpoints/element/delete-element/204-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/delete-element/401-response-header.txt b/docs/api-endpoints/element/delete-element/401-response-header.txt index b7f1d0d2..7c496222 100644 --- a/docs/api-endpoints/element/delete-element/401-response-header.txt +++ b/docs/api-endpoints/element/delete-element/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/delete-element/404-response-header.txt b/docs/api-endpoints/element/delete-element/404-response-header.txt index 127e2afa..f4bfe07d 100644 --- a/docs/api-endpoints/element/delete-element/404-response-header.txt +++ b/docs/api-endpoints/element/delete-element/404-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/delete-element/412-response-header.txt b/docs/api-endpoints/element/delete-element/412-response-header.txt index 3ce2f21e..f08fbe4c 100644 --- a/docs/api-endpoints/element/delete-element/412-response-header.txt +++ b/docs/api-endpoints/element/delete-element/412-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/delete-element/429-response-header.txt b/docs/api-endpoints/element/delete-element/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/element/delete-element/429-response-header.txt +++ b/docs/api-endpoints/element/delete-element/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/element/get-children/200-response-header.txt b/docs/api-endpoints/element/get-children/200-response-header.txt index 221b6f7e..1c1642ca 100644 --- a/docs/api-endpoints/element/get-children/200-response-header.txt +++ b/docs/api-endpoints/element/get-children/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-children/304-response-header.txt b/docs/api-endpoints/element/get-children/304-response-header.txt index 3cc8f8d8..4abaf276 100644 --- a/docs/api-endpoints/element/get-children/304-response-header.txt +++ b/docs/api-endpoints/element/get-children/304-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-children/401-response-header.txt b/docs/api-endpoints/element/get-children/401-response-header.txt index 17762cd0..7f751692 100644 --- a/docs/api-endpoints/element/get-children/401-response-header.txt +++ b/docs/api-endpoints/element/get-children/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-children/404-response-header.txt b/docs/api-endpoints/element/get-children/404-response-header.txt index 17762cd0..7f751692 100644 --- a/docs/api-endpoints/element/get-children/404-response-header.txt +++ b/docs/api-endpoints/element/get-children/404-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-children/412-response-header.txt b/docs/api-endpoints/element/get-children/412-response-header.txt index 3ce2f21e..f08fbe4c 100644 --- a/docs/api-endpoints/element/get-children/412-response-header.txt +++ b/docs/api-endpoints/element/get-children/412-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-children/429-response-header.txt b/docs/api-endpoints/element/get-children/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/element/get-children/429-response-header.txt +++ b/docs/api-endpoints/element/get-children/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/element/get-element/200-node-response-header.txt b/docs/api-endpoints/element/get-element/200-node-response-header.txt index 9259b18e..2dde0d23 100644 --- a/docs/api-endpoints/element/get-element/200-node-response-header.txt +++ b/docs/api-endpoints/element/get-element/200-node-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-element/200-relation-response-header.txt b/docs/api-endpoints/element/get-element/200-relation-response-header.txt index 100d33a0..c61e8be7 100644 --- a/docs/api-endpoints/element/get-element/200-relation-response-header.txt +++ b/docs/api-endpoints/element/get-element/200-relation-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-element/304-response-header.txt b/docs/api-endpoints/element/get-element/304-response-header.txt index 3cc8f8d8..4abaf276 100644 --- a/docs/api-endpoints/element/get-element/304-response-header.txt +++ b/docs/api-endpoints/element/get-element/304-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-element/401-response-header.txt b/docs/api-endpoints/element/get-element/401-response-header.txt index cd60e44a..baead971 100644 --- a/docs/api-endpoints/element/get-element/401-response-header.txt +++ b/docs/api-endpoints/element/get-element/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-element/404-response-header.txt b/docs/api-endpoints/element/get-element/404-response-header.txt index 3e554fe4..e324243a 100644 --- a/docs/api-endpoints/element/get-element/404-response-header.txt +++ b/docs/api-endpoints/element/get-element/404-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-element/412-response-header.txt b/docs/api-endpoints/element/get-element/412-response-header.txt index 3ce2f21e..f08fbe4c 100644 --- a/docs/api-endpoints/element/get-element/412-response-header.txt +++ b/docs/api-endpoints/element/get-element/412-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-element/429-response-header.txt b/docs/api-endpoints/element/get-element/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/element/get-element/429-response-header.txt +++ b/docs/api-endpoints/element/get-element/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/element/get-index/200-response-header.txt b/docs/api-endpoints/element/get-index/200-response-header.txt index 7f5f0928..8e6d8a2a 100644 --- a/docs/api-endpoints/element/get-index/200-response-header.txt +++ b/docs/api-endpoints/element/get-index/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-index/304-response-header.txt b/docs/api-endpoints/element/get-index/304-response-header.txt index 3cc8f8d8..4abaf276 100644 --- a/docs/api-endpoints/element/get-index/304-response-header.txt +++ b/docs/api-endpoints/element/get-index/304-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-index/401-response-header.txt b/docs/api-endpoints/element/get-index/401-response-header.txt index a1c4c53c..99cc7fba 100644 --- a/docs/api-endpoints/element/get-index/401-response-header.txt +++ b/docs/api-endpoints/element/get-index/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-index/412-response-header.txt b/docs/api-endpoints/element/get-index/412-response-header.txt index 3ce2f21e..f08fbe4c 100644 --- a/docs/api-endpoints/element/get-index/412-response-header.txt +++ b/docs/api-endpoints/element/get-index/412-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-index/429-response-header.txt b/docs/api-endpoints/element/get-index/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/element/get-index/429-response-header.txt +++ b/docs/api-endpoints/element/get-index/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/element/get-parents/200-response-header.txt b/docs/api-endpoints/element/get-parents/200-response-header.txt index 2a8af605..d60cef26 100644 --- a/docs/api-endpoints/element/get-parents/200-response-header.txt +++ b/docs/api-endpoints/element/get-parents/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-parents/304-response-header.txt b/docs/api-endpoints/element/get-parents/304-response-header.txt index 3cc8f8d8..4abaf276 100644 --- a/docs/api-endpoints/element/get-parents/304-response-header.txt +++ b/docs/api-endpoints/element/get-parents/304-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-parents/401-response-header.txt b/docs/api-endpoints/element/get-parents/401-response-header.txt index 41097212..18fe0a15 100644 --- a/docs/api-endpoints/element/get-parents/401-response-header.txt +++ b/docs/api-endpoints/element/get-parents/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-parents/404-response-header.txt b/docs/api-endpoints/element/get-parents/404-response-header.txt index 41097212..18fe0a15 100644 --- a/docs/api-endpoints/element/get-parents/404-response-header.txt +++ b/docs/api-endpoints/element/get-parents/404-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-parents/412-response-header.txt b/docs/api-endpoints/element/get-parents/412-response-header.txt index 3ce2f21e..f08fbe4c 100644 --- a/docs/api-endpoints/element/get-parents/412-response-header.txt +++ b/docs/api-endpoints/element/get-parents/412-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-parents/429-response-header.txt b/docs/api-endpoints/element/get-parents/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/element/get-parents/429-response-header.txt +++ b/docs/api-endpoints/element/get-parents/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/element/get-related/200-response-header.txt b/docs/api-endpoints/element/get-related/200-response-header.txt index 211ffa95..311fc923 100644 --- a/docs/api-endpoints/element/get-related/200-response-header.txt +++ b/docs/api-endpoints/element/get-related/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-related/304-response-header.txt b/docs/api-endpoints/element/get-related/304-response-header.txt index 26ad85f7..67680814 100644 --- a/docs/api-endpoints/element/get-related/304-response-header.txt +++ b/docs/api-endpoints/element/get-related/304-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-related/401-response-header.txt b/docs/api-endpoints/element/get-related/401-response-header.txt index f4c0c56d..837bc6a8 100644 --- a/docs/api-endpoints/element/get-related/401-response-header.txt +++ b/docs/api-endpoints/element/get-related/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-related/404-response-header.txt b/docs/api-endpoints/element/get-related/404-response-header.txt index f4c0c56d..837bc6a8 100644 --- a/docs/api-endpoints/element/get-related/404-response-header.txt +++ b/docs/api-endpoints/element/get-related/404-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-related/412-response-header.txt b/docs/api-endpoints/element/get-related/412-response-header.txt index 3ce2f21e..f08fbe4c 100644 --- a/docs/api-endpoints/element/get-related/412-response-header.txt +++ b/docs/api-endpoints/element/get-related/412-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/get-related/429-response-header.txt b/docs/api-endpoints/element/get-related/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/element/get-related/429-response-header.txt +++ b/docs/api-endpoints/element/get-related/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/element/patch-element/204-response-header.txt b/docs/api-endpoints/element/patch-element/204-response-header.txt index b15369f3..114d5c86 100644 --- a/docs/api-endpoints/element/patch-element/204-response-header.txt +++ b/docs/api-endpoints/element/patch-element/204-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/patch-element/401-response-header.txt b/docs/api-endpoints/element/patch-element/401-response-header.txt index 8a088f12..fe972e89 100644 --- a/docs/api-endpoints/element/patch-element/401-response-header.txt +++ b/docs/api-endpoints/element/patch-element/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/patch-element/404-response-header.txt b/docs/api-endpoints/element/patch-element/404-response-header.txt index 8a088f12..fe972e89 100644 --- a/docs/api-endpoints/element/patch-element/404-response-header.txt +++ b/docs/api-endpoints/element/patch-element/404-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/patch-element/412-response-header.txt b/docs/api-endpoints/element/patch-element/412-response-header.txt index 3ce2f21e..f08fbe4c 100644 --- a/docs/api-endpoints/element/patch-element/412-response-header.txt +++ b/docs/api-endpoints/element/patch-element/412-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/patch-element/429-response-header.txt b/docs/api-endpoints/element/patch-element/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/element/patch-element/429-response-header.txt +++ b/docs/api-endpoints/element/patch-element/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/element/post-element/204-response-header.txt b/docs/api-endpoints/element/post-element/204-response-header.txt index 8dc7fd05..ef8201f0 100644 --- a/docs/api-endpoints/element/post-element/204-response-header.txt +++ b/docs/api-endpoints/element/post-element/204-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/post-element/400-response-header.txt b/docs/api-endpoints/element/post-element/400-response-header.txt index fc37a46f..8cbb1246 100644 --- a/docs/api-endpoints/element/post-element/400-response-header.txt +++ b/docs/api-endpoints/element/post-element/400-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/post-element/401-response-header.txt b/docs/api-endpoints/element/post-element/401-response-header.txt index 8a088f12..fe972e89 100644 --- a/docs/api-endpoints/element/post-element/401-response-header.txt +++ b/docs/api-endpoints/element/post-element/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/post-element/429-response-header.txt b/docs/api-endpoints/element/post-element/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/element/post-element/429-response-header.txt +++ b/docs/api-endpoints/element/post-element/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/element/post-index/204-response-header.txt b/docs/api-endpoints/element/post-index/204-response-header.txt index 48989d44..d2c62763 100644 --- a/docs/api-endpoints/element/post-index/204-response-header.txt +++ b/docs/api-endpoints/element/post-index/204-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/post-index/400-response-header.txt b/docs/api-endpoints/element/post-index/400-response-header.txt index b459d8ec..7e0ad98b 100644 --- a/docs/api-endpoints/element/post-index/400-response-header.txt +++ b/docs/api-endpoints/element/post-index/400-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/post-index/401-response-header.txt b/docs/api-endpoints/element/post-index/401-response-header.txt index b459d8ec..7e0ad98b 100644 --- a/docs/api-endpoints/element/post-index/401-response-header.txt +++ b/docs/api-endpoints/element/post-index/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/post-index/404-response-header.txt b/docs/api-endpoints/element/post-index/404-response-header.txt index 5c2d076c..ba72e465 100644 --- a/docs/api-endpoints/element/post-index/404-response-header.txt +++ b/docs/api-endpoints/element/post-index/404-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/post-index/429-response-header.txt b/docs/api-endpoints/element/post-index/429-response-header.txt index 4cac8ac5..9ff1700c 100644 --- a/docs/api-endpoints/element/post-index/429-response-header.txt +++ b/docs/api-endpoints/element/post-index/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * +Access-Control-Allow-Headers: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/element/put-element/204-response-header.txt b/docs/api-endpoints/element/put-element/204-response-header.txt index 3ec9b476..7f8490ed 100644 --- a/docs/api-endpoints/element/put-element/204-response-header.txt +++ b/docs/api-endpoints/element/put-element/204-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/put-element/401-response-header.txt b/docs/api-endpoints/element/put-element/401-response-header.txt index 8a088f12..fe972e89 100644 --- a/docs/api-endpoints/element/put-element/401-response-header.txt +++ b/docs/api-endpoints/element/put-element/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/put-element/404-response-header.txt b/docs/api-endpoints/element/put-element/404-response-header.txt index 8a088f12..fe972e89 100644 --- a/docs/api-endpoints/element/put-element/404-response-header.txt +++ b/docs/api-endpoints/element/put-element/404-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/put-element/412-response-header.txt b/docs/api-endpoints/element/put-element/412-response-header.txt index 3ce2f21e..f08fbe4c 100644 --- a/docs/api-endpoints/element/put-element/412-response-header.txt +++ b/docs/api-endpoints/element/put-element/412-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/element/put-element/429-response-header.txt b/docs/api-endpoints/element/put-element/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/element/put-element/429-response-header.txt +++ b/docs/api-endpoints/element/put-element/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/error/get-400-bad-content/200-response-header.txt b/docs/api-endpoints/error/get-400-bad-content/200-response-header.txt index 4f1c1c48..015cf16b 100644 --- a/docs/api-endpoints/error/get-400-bad-content/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-bad-content/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-400-forbidden-property/200-response-header.txt b/docs/api-endpoints/error/get-400-forbidden-property/200-response-header.txt index 102706a7..56d0cebd 100644 --- a/docs/api-endpoints/error/get-400-forbidden-property/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-forbidden-property/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-400-incomplete-mutual-dependency/200-response-header.txt b/docs/api-endpoints/error/get-400-incomplete-mutual-dependency/200-response-header.txt index b9bb3c0e..d31463f0 100644 --- a/docs/api-endpoints/error/get-400-incomplete-mutual-dependency/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-incomplete-mutual-dependency/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-400-missing-property/200-response-header.txt b/docs/api-endpoints/error/get-400-missing-property/200-response-header.txt index b9bb3c0e..d31463f0 100644 --- a/docs/api-endpoints/error/get-400-missing-property/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-missing-property/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-400-reserved-identifier/200-response-header.txt b/docs/api-endpoints/error/get-400-reserved-identifier/200-response-header.txt index 4f1c1c48..015cf16b 100644 --- a/docs/api-endpoints/error/get-400-reserved-identifier/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-reserved-identifier/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-401-unauthorized/200-response-header.txt b/docs/api-endpoints/error/get-401-unauthorized/200-response-header.txt index 4f1c1c48..015cf16b 100644 --- a/docs/api-endpoints/error/get-401-unauthorized/200-response-header.txt +++ b/docs/api-endpoints/error/get-401-unauthorized/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-403-forbidden/200-response-header.txt b/docs/api-endpoints/error/get-403-forbidden/200-response-header.txt index 9a3a1297..f1cbdeb2 100644 --- a/docs/api-endpoints/error/get-403-forbidden/200-response-header.txt +++ b/docs/api-endpoints/error/get-403-forbidden/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-404-not-found/200-response-header.txt b/docs/api-endpoints/error/get-404-not-found/200-response-header.txt index 4f1c1c48..015cf16b 100644 --- a/docs/api-endpoints/error/get-404-not-found/200-response-header.txt +++ b/docs/api-endpoints/error/get-404-not-found/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-412-precondition-failed/200-response-header.txt b/docs/api-endpoints/error/get-412-precondition-failed/200-response-header.txt index b9bb3c0e..d31463f0 100644 --- a/docs/api-endpoints/error/get-412-precondition-failed/200-response-header.txt +++ b/docs/api-endpoints/error/get-412-precondition-failed/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-500-internal-server-error/200-response-header.txt b/docs/api-endpoints/error/get-500-internal-server-error/200-response-header.txt index 525ecc8f..5531dbdd 100644 --- a/docs/api-endpoints/error/get-500-internal-server-error/200-response-header.txt +++ b/docs/api-endpoints/error/get-500-internal-server-error/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/error/get-501-not-implemented/200-response-header.txt b/docs/api-endpoints/error/get-501-not-implemented/200-response-header.txt index 4f1c1c48..015cf16b 100644 --- a/docs/api-endpoints/error/get-501-not-implemented/200-response-header.txt +++ b/docs/api-endpoints/error/get-501-not-implemented/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/system/get-instance-configuration/200-response-header.txt b/docs/api-endpoints/system/get-instance-configuration/200-response-header.txt index d98fe42a..cb55f9b8 100644 --- a/docs/api-endpoints/system/get-instance-configuration/200-response-header.txt +++ b/docs/api-endpoints/system/get-instance-configuration/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/system/get-instance-configuration/403-response-header.txt b/docs/api-endpoints/system/get-instance-configuration/403-response-header.txt index ff1216a1..6d5f4750 100644 --- a/docs/api-endpoints/system/get-instance-configuration/403-response-header.txt +++ b/docs/api-endpoints/system/get-instance-configuration/403-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/system/get-well-known-security-txt/200-response-header.txt b/docs/api-endpoints/system/get-well-known-security-txt/200-response-header.txt index 10efbed7..26ce312b 100644 --- a/docs/api-endpoints/system/get-well-known-security-txt/200-response-header.txt +++ b/docs/api-endpoints/system/get-well-known-security-txt/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/delete-token/204-response-header.txt b/docs/api-endpoints/user/delete-token/204-response-header.txt index 808ba93e..56c80b8b 100644 --- a/docs/api-endpoints/user/delete-token/204-response-header.txt +++ b/docs/api-endpoints/user/delete-token/204-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/delete-token/401-response-header.txt b/docs/api-endpoints/user/delete-token/401-response-header.txt index 4612bf2d..07c7a7ef 100644 --- a/docs/api-endpoints/user/delete-token/401-response-header.txt +++ b/docs/api-endpoints/user/delete-token/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/delete-token/429-response-header.txt b/docs/api-endpoints/user/delete-token/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/user/delete-token/429-response-header.txt +++ b/docs/api-endpoints/user/delete-token/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/user/get-me/200-response-header.txt b/docs/api-endpoints/user/get-me/200-response-header.txt index 7b0a7a3b..74595220 100644 --- a/docs/api-endpoints/user/get-me/200-response-header.txt +++ b/docs/api-endpoints/user/get-me/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/get-me/401-response-header.txt b/docs/api-endpoints/user/get-me/401-response-header.txt index 97486c22..e3593080 100644 --- a/docs/api-endpoints/user/get-me/401-response-header.txt +++ b/docs/api-endpoints/user/get-me/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/get-me/429-response-header.txt b/docs/api-endpoints/user/get-me/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/user/get-me/429-response-header.txt +++ b/docs/api-endpoints/user/get-me/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/user/get-token/200-response-header.txt b/docs/api-endpoints/user/get-token/200-response-header.txt index 7b0a7a3b..74595220 100644 --- a/docs/api-endpoints/user/get-token/200-response-header.txt +++ b/docs/api-endpoints/user/get-token/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/get-token/401-response-header.txt b/docs/api-endpoints/user/get-token/401-response-header.txt index 6bd2f6f1..648f10ca 100644 --- a/docs/api-endpoints/user/get-token/401-response-header.txt +++ b/docs/api-endpoints/user/get-token/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/get-token/403-response-header.txt b/docs/api-endpoints/user/get-token/403-response-header.txt index 6bd2f6f1..648f10ca 100644 --- a/docs/api-endpoints/user/get-token/403-response-header.txt +++ b/docs/api-endpoints/user/get-token/403-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-change-password-old/204-response-header.txt b/docs/api-endpoints/user/post-change-password-old/204-response-header.txt index 9b1af198..e37e0075 100644 --- a/docs/api-endpoints/user/post-change-password-old/204-response-header.txt +++ b/docs/api-endpoints/user/post-change-password-old/204-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-change-password-old/400-response-header.txt b/docs/api-endpoints/user/post-change-password-old/400-response-header.txt index f447e880..de6a7dce 100644 --- a/docs/api-endpoints/user/post-change-password-old/400-response-header.txt +++ b/docs/api-endpoints/user/post-change-password-old/400-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-change-password-old/401-response-header.txt b/docs/api-endpoints/user/post-change-password-old/401-response-header.txt index 837fda70..83c3ceec 100644 --- a/docs/api-endpoints/user/post-change-password-old/401-response-header.txt +++ b/docs/api-endpoints/user/post-change-password-old/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-change-password-old/403-response-header.txt b/docs/api-endpoints/user/post-change-password-old/403-response-header.txt index 835dcbcd..ee6d8e82 100644 --- a/docs/api-endpoints/user/post-change-password-old/403-response-header.txt +++ b/docs/api-endpoints/user/post-change-password-old/403-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-change-password-old/429-response-header.txt b/docs/api-endpoints/user/post-change-password-old/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/user/post-change-password-old/429-response-header.txt +++ b/docs/api-endpoints/user/post-change-password-old/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/user/post-change-password/204-response-header.txt b/docs/api-endpoints/user/post-change-password/204-response-header.txt index 9b1af198..e37e0075 100644 --- a/docs/api-endpoints/user/post-change-password/204-response-header.txt +++ b/docs/api-endpoints/user/post-change-password/204-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-change-password/400-response-header.txt b/docs/api-endpoints/user/post-change-password/400-response-header.txt index f447e880..de6a7dce 100644 --- a/docs/api-endpoints/user/post-change-password/400-response-header.txt +++ b/docs/api-endpoints/user/post-change-password/400-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-change-password/401-response-header.txt b/docs/api-endpoints/user/post-change-password/401-response-header.txt index 837fda70..83c3ceec 100644 --- a/docs/api-endpoints/user/post-change-password/401-response-header.txt +++ b/docs/api-endpoints/user/post-change-password/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-change-password/403-response-header.txt b/docs/api-endpoints/user/post-change-password/403-response-header.txt index 835dcbcd..ee6d8e82 100644 --- a/docs/api-endpoints/user/post-change-password/403-response-header.txt +++ b/docs/api-endpoints/user/post-change-password/403-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-change-password/429-response-header.txt b/docs/api-endpoints/user/post-change-password/429-response-header.txt index 4cac8ac5..0f6ebffa 100644 --- a/docs/api-endpoints/user/post-change-password/429-response-header.txt +++ b/docs/api-endpoints/user/post-change-password/429-response-header.txt @@ -1,6 +1,6 @@ Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE Content-Type: application/json; charset=utf-8 diff --git a/docs/api-endpoints/user/post-register-old/201-response-header.txt b/docs/api-endpoints/user/post-register-old/201-response-header.txt index 95d7bdaf..a3660157 100644 --- a/docs/api-endpoints/user/post-register-old/201-response-header.txt +++ b/docs/api-endpoints/user/post-register-old/201-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-register-old/400-response-header.txt b/docs/api-endpoints/user/post-register-old/400-response-header.txt index 95829776..b09a0f20 100644 --- a/docs/api-endpoints/user/post-register-old/400-response-header.txt +++ b/docs/api-endpoints/user/post-register-old/400-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-register-old/403-response-header.txt b/docs/api-endpoints/user/post-register-old/403-response-header.txt index eb031ae0..77bf2279 100644 --- a/docs/api-endpoints/user/post-register-old/403-response-header.txt +++ b/docs/api-endpoints/user/post-register-old/403-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-register/201-response-header.txt b/docs/api-endpoints/user/post-register/201-response-header.txt index 95d7bdaf..a3660157 100644 --- a/docs/api-endpoints/user/post-register/201-response-header.txt +++ b/docs/api-endpoints/user/post-register/201-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-register/400-response-header.txt b/docs/api-endpoints/user/post-register/400-response-header.txt index 95829776..b09a0f20 100644 --- a/docs/api-endpoints/user/post-register/400-response-header.txt +++ b/docs/api-endpoints/user/post-register/400-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-register/403-response-header.txt b/docs/api-endpoints/user/post-register/403-response-header.txt index eb031ae0..77bf2279 100644 --- a/docs/api-endpoints/user/post-register/403-response-header.txt +++ b/docs/api-endpoints/user/post-register/403-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-token-old/201-response-header.txt b/docs/api-endpoints/user/post-token-old/201-response-header.txt index 09a900a8..5c78b3c0 100644 --- a/docs/api-endpoints/user/post-token-old/201-response-header.txt +++ b/docs/api-endpoints/user/post-token-old/201-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-token-old/400-response-header.txt b/docs/api-endpoints/user/post-token-old/400-response-header.txt index 119ec076..7862b9ce 100644 --- a/docs/api-endpoints/user/post-token-old/400-response-header.txt +++ b/docs/api-endpoints/user/post-token-old/400-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-token-old/401-response-header.txt b/docs/api-endpoints/user/post-token-old/401-response-header.txt index 6813e858..35897218 100644 --- a/docs/api-endpoints/user/post-token-old/401-response-header.txt +++ b/docs/api-endpoints/user/post-token-old/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-token/201-response-header.txt b/docs/api-endpoints/user/post-token/201-response-header.txt index 09a900a8..5c78b3c0 100644 --- a/docs/api-endpoints/user/post-token/201-response-header.txt +++ b/docs/api-endpoints/user/post-token/201-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-token/400-response-header.txt b/docs/api-endpoints/user/post-token/400-response-header.txt index 119ec076..7862b9ce 100644 --- a/docs/api-endpoints/user/post-token/400-response-header.txt +++ b/docs/api-endpoints/user/post-token/400-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/user/post-token/401-response-header.txt b/docs/api-endpoints/user/post-token/401-response-header.txt index 6813e858..35897218 100644 --- a/docs/api-endpoints/user/post-token/401-response-header.txt +++ b/docs/api-endpoints/user/post-token/401-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php index afde0550..ea4faa20 100644 --- a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php +++ b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php @@ -17,6 +17,8 @@ class Configuration implements ConfigurationInterface public const int CACHE_ETAG_DEFAULT_UPPER_LIMIT_IN_COLLECTION_ENDPOINTS = 100; public const int EXPRESSION_WARNING_LENGTH = 512; public const int EXPRESSION_MAX_LENGTH = 10 * 1024; + public const int SIZE_OF_5TB_IN_BYTES = 5 * 1024 * 1024 * 1024 * 1024; + public const int SIZE_OF_100MB_IN_BYTES = 100 * 1024 * 1024; public function getConfigTreeBuilder(): TreeBuilder { @@ -151,6 +153,61 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() + ->arrayNode(EmberNexusConfiguration::FILE) + ->info('Configures behavior related to files.') + ->addDefaultsIfNotSet() + ->children() + ->integerNode(EmberNexusConfiguration::FILE_MAX_FILE_SIZE_IN_BYTES) + ->info('Maximum supported file size in bytes. By default set to maximum file size of S3 (5TB).') + ->min(1) + ->defaultValue(self::SIZE_OF_5TB_IN_BYTES) + ->end() + ->integerNode(EmberNexusConfiguration::FILE_UPLOAD_EXPIRES_IN_SECONDS_AFTER_FIRST_REQUEST) + ->info('Number of seconds after which unfinished uploads are expired.') + ->min(0) + ->defaultValue(self::THREE_HOURS_IN_SECONDS) + ->end() + ->integerNode(EmberNexusConfiguration::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES) + ->info('Minimum size in bytes of uploaded chunks.') + ->min(1) + ->defaultValue(8) + ->end() + ->integerNode(EmberNexusConfiguration::FILE_UPLOAD_MAX_CHUNK_SIZE_IN_BYTES) + ->info('Maximum size in bytes of uploaded chunks. Limited by the S3 provider and the PHP configuration.') + ->min(1) + ->defaultValue(self::SIZE_OF_100MB_IN_BYTES) + ->end() + ->scalarNode(EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET) + ->info('Name of the S3 bucket used for storage of files.') + ->defaultValue('api-storage') + ->end() + ->scalarNode(EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET) + ->info('Name of the S3 bucket used for temporary storage of uploads.') + ->defaultValue('api-upload') + ->end() + ->integerNode(EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVELS) + ->info('Number of levels (folders) used within the S3 storage bucket.') + ->min(1) + ->defaultValue(2) + ->end() + ->integerNode(EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVEL_LENGTH) + ->info('Number of characters used for each level (folder) in the S3 storage bucket.') + ->min(1) + ->defaultValue(2) + ->end() + ->integerNode(EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVELS) + ->info('Number of levels (folders) used within the S3 upload bucket.') + ->min(1) + ->defaultValue(2) + ->end() + ->integerNode(EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVEL_LENGTH) + ->info('Number of characters used for each level (folder) in the S3 upload bucket.') + ->min(1) + ->defaultValue(2) + ->end() + ->end() + ->end() + ->end() ; diff --git a/lib/EmberNexusBundle/src/DependencyInjection/EmberNexusExtension.php b/lib/EmberNexusBundle/src/DependencyInjection/EmberNexusExtension.php index 5fc0e9da..e78dcbf5 100644 --- a/lib/EmberNexusBundle/src/DependencyInjection/EmberNexusExtension.php +++ b/lib/EmberNexusBundle/src/DependencyInjection/EmberNexusExtension.php @@ -7,7 +7,7 @@ use EmberNexusBundle\Service\EmberNexusConfiguration; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\DependencyInjection\Extension\Extension; class EmberNexusExtension extends Extension { diff --git a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php index 764bfee1..2ce84aad 100644 --- a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php +++ b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php @@ -45,6 +45,18 @@ class EmberNexusConfiguration public const string FEATURE_FLAG = 'featureFlag'; public const string FEATURE_FLAG_280_OLD_UNIQUE_USER_IDENTIFIER_DISABLED = '280_OldUniqueUserIdentifierDisabled'; + public const string FILE = 'file'; + public const string FILE_MAX_FILE_SIZE_IN_BYTES = 'maxFileSizeInBytes'; + public const string FILE_UPLOAD_EXPIRES_IN_SECONDS_AFTER_FIRST_REQUEST = 'uploadExpiresInSecondsAfterFirstRequest'; + public const string FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES = 'uploadMinChunkSizeInBytes'; + public const string FILE_UPLOAD_MAX_CHUNK_SIZE_IN_BYTES = 'uploadMaxChunkSizeInBytes'; + public const string FILE_S3_STORAGE_BUCKET = 'S3StorageBucket'; + public const string FILE_S3_UPLOAD_BUCKET = 'S3UploadBucket'; + public const string FILE_S3_STORAGE_BUCKET_LEVELS = 'S3StorageBucketLevels'; + public const string FILE_S3_STORAGE_BUCKET_LEVEL_LENGTH = 'S3StorageBucketLevelLength'; + public const string FILE_S3_UPLOAD_BUCKET_LEVELS = 'S3UploadBucketLevels'; + public const string FILE_S3_UPLOAD_BUCKET_LEVEL_LENGTH = 'S3UploadBucketLevelLength'; + private int $pageSizeMin; private int $pageSizeDefault; private int $pageSizeMax; @@ -64,6 +76,17 @@ class EmberNexusConfiguration private int $cacheEtagUpperLimitInCollectionEndpoints; private bool $featureFlag280OldUniqueUserIdentifierDisabled; + private int $fileMaxFileSizeInBytes; + private int $fileUploadExpiresInSecondsAfterFirstRequest; + private int $fileUploadMinChunkSizeInBytes; + private int $fileUploadMaxChunkSizeInBytes; + private string $fileS3StorageBucket; + private string $fileS3UploadBucket; + private int $fileS3StorageBucketLevels; + private int $fileS3StorageBucketLevelLength; + private int $fileS3UploadBucketLevels; + private int $fileS3UploadBucketLevelLength; + private static function getValueFromConfig(array $configuration, array $keyParts): mixed { $currentKeyParts = []; @@ -250,6 +273,100 @@ public static function createFromConfiguration(array $configuration): self ); $emberNexusConfiguration->setFeatureFlag280OldUniqueUserIdentifierDisabled($value); + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_MAX_FILE_SIZE_IN_BYTES, + ] + ); + $emberNexusConfiguration->setFileMaxFileSizeInBytes($value); + + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_UPLOAD_EXPIRES_IN_SECONDS_AFTER_FIRST_REQUEST, + ] + ); + $emberNexusConfiguration->setFileUploadExpiresInSecondsAfterFirstRequest($value); + + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES, + ] + ); + $emberNexusConfiguration->setFileUploadMinChunkSizeInBytes($value); + + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_UPLOAD_MAX_CHUNK_SIZE_IN_BYTES, + ] + ); + $emberNexusConfiguration->setFileUploadMaxChunkSizeInBytes($value); + + if ($emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes() < $emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()) { + throw new Exception(sprintf('%s.%s can not be smaller than %s.%s.', self::FILE, self::FILE_MAX_FILE_SIZE_IN_BYTES, self::FILE, self::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES)); + } + + $value = (string) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_S3_STORAGE_BUCKET, + ] + ); + $emberNexusConfiguration->setFileS3StorageBucket($value); + + $value = (string) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_S3_UPLOAD_BUCKET, + ] + ); + $emberNexusConfiguration->setFileS3UploadBucket($value); + + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_S3_STORAGE_BUCKET_LEVELS, + ] + ); + $emberNexusConfiguration->setFileS3StorageBucketLevels($value); + + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_S3_STORAGE_BUCKET_LEVEL_LENGTH, + ] + ); + $emberNexusConfiguration->setFileS3StorageBucketLevelLength($value); + + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_S3_UPLOAD_BUCKET_LEVELS, + ] + ); + $emberNexusConfiguration->setFileS3UploadBucketLevels($value); + + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_S3_UPLOAD_BUCKET_LEVEL_LENGTH, + ] + ); + $emberNexusConfiguration->setFileS3UploadBucketLevelLength($value); + return $emberNexusConfiguration; } @@ -474,4 +591,124 @@ public function setFeatureFlag280OldUniqueUserIdentifierDisabled(bool $featureFl return $this; } + + public function getFileMaxFileSizeInBytes(): int + { + return $this->fileMaxFileSizeInBytes; + } + + public function setFileMaxFileSizeInBytes(int $fileMaxFileSizeInBytes): self + { + $this->fileMaxFileSizeInBytes = $fileMaxFileSizeInBytes; + + return $this; + } + + public function getFileUploadExpiresInSecondsAfterFirstRequest(): int + { + return $this->fileUploadExpiresInSecondsAfterFirstRequest; + } + + public function setFileUploadExpiresInSecondsAfterFirstRequest(int $fileUploadExpiresInSecondsAfterFirstRequest): self + { + $this->fileUploadExpiresInSecondsAfterFirstRequest = $fileUploadExpiresInSecondsAfterFirstRequest; + + return $this; + } + + public function getFileUploadMinChunkSizeInBytes(): int + { + return $this->fileUploadMinChunkSizeInBytes; + } + + public function setFileUploadMinChunkSizeInBytes(int $fileUploadMinChunkSizeInBytes): self + { + $this->fileUploadMinChunkSizeInBytes = $fileUploadMinChunkSizeInBytes; + + return $this; + } + + public function getFileUploadMaxChunkSizeInBytes(): int + { + return $this->fileUploadMaxChunkSizeInBytes; + } + + public function setFileUploadMaxChunkSizeInBytes(int $fileUploadMaxChunkSizeInBytes): self + { + $this->fileUploadMaxChunkSizeInBytes = $fileUploadMaxChunkSizeInBytes; + + return $this; + } + + public function getFileS3StorageBucket(): string + { + return $this->fileS3StorageBucket; + } + + public function setFileS3StorageBucket(string $fileS3StorageBucket): self + { + $this->fileS3StorageBucket = $fileS3StorageBucket; + + return $this; + } + + public function getFileS3UploadBucket(): string + { + return $this->fileS3UploadBucket; + } + + public function setFileS3UploadBucket(string $fileS3UploadBucket): self + { + $this->fileS3UploadBucket = $fileS3UploadBucket; + + return $this; + } + + public function getFileS3StorageBucketLevels(): int + { + return $this->fileS3StorageBucketLevels; + } + + public function setFileS3StorageBucketLevels(int $fileS3StorageBucketLevels): self + { + $this->fileS3StorageBucketLevels = $fileS3StorageBucketLevels; + + return $this; + } + + public function getFileS3StorageBucketLevelLength(): int + { + return $this->fileS3StorageBucketLevelLength; + } + + public function setFileS3StorageBucketLevelLength(int $fileS3StorageBucketLevelLength): self + { + $this->fileS3StorageBucketLevelLength = $fileS3StorageBucketLevelLength; + + return $this; + } + + public function getFileS3UploadBucketLevels(): int + { + return $this->fileS3UploadBucketLevels; + } + + public function setFileS3UploadBucketLevels(int $fileS3UploadBucketLevels): self + { + $this->fileS3UploadBucketLevels = $fileS3UploadBucketLevels; + + return $this; + } + + public function getFileS3UploadBucketLevelLength(): int + { + return $this->fileS3UploadBucketLevelLength; + } + + public function setFileS3UploadBucketLevelLength(int $fileS3UploadBucketLevelLength): self + { + $this->fileS3UploadBucketLevelLength = $fileS3UploadBucketLevelLength; + + return $this; + } } diff --git a/public/index.php b/public/index.php index e002b0ac..9e893942 100755 --- a/public/index.php +++ b/public/index.php @@ -7,7 +7,7 @@ require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; header('Access-Control-Allow-Origin: *'); -header('Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method'); +header('Access-Control-Allow-Headers: *'); header('Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK'); header('Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK'); header('Access-Control-Expose-Headers: ETag, Location'); diff --git a/src/Attribute/EndpointImplementsTusIo.php b/src/Attribute/EndpointImplementsTusIo.php new file mode 100644 index 00000000..7a795843 --- /dev/null +++ b/src/Attribute/EndpointImplementsTusIo.php @@ -0,0 +1,15 @@ +getHex()->toString(); - $folders = ceil(log($this->nodeCount, 256)); - $folderParts = []; - for ($i = 0; $i < $folders; ++$i) { - $folderParts[] = substr($nodeIdAsHex, 2 * $i, 2); - } + $levels = (int) ceil(log($this->nodeCount, 256)); return sprintf( - '%s/node/%s/%s.json', + '%s/node/%s.json', $this->backupName, - implode('/', $folderParts), - $nodeId->toString() + $this->storageUtilService->uuidToNestedFolderStructure($nodeId, $levels) ); } private function getRelationPath(UuidInterface $relationId): string { - $relationIdAsHex = $relationId->getHex()->toString(); - $folders = ceil(log($this->relationCount, 256)); - $folderParts = []; - for ($i = 0; $i < $folders; ++$i) { - $folderParts[] = substr($relationIdAsHex, 2 * $i, 2); - } + $levels = (int) ceil(log($this->relationCount, 256)); return sprintf( - '%s/relation/%s/%s.json', + '%s/relation/%s.json', $this->backupName, - implode('/', $folderParts), - $relationId->toString() + $this->storageUtilService->uuidToNestedFolderStructure($relationId, $levels) ); } diff --git a/src/Command/DatabaseDropCommand.php b/src/Command/DatabaseDropCommand.php index 1bd6c1e8..a7efcdc2 100755 --- a/src/Command/DatabaseDropCommand.php +++ b/src/Command/DatabaseDropCommand.php @@ -6,6 +6,8 @@ use App\Style\EmberNexusStyle; use App\Type\RabbitMQQueueType; +use AsyncAws\S3\S3Client; +use EmberNexusBundle\Service\EmberNexusConfiguration; use Laudis\Neo4j\Databags\Statement; use PhpAmqpLib\Connection\AMQPStreamConnection; use Predis\Client; @@ -35,6 +37,8 @@ public function __construct( private ElasticEntityManager $elasticEntityManager, private Client $redisClient, private AMQPStreamConnection $AMQPStreamConnection, + private S3Client $s3Client, + private EmberNexusConfiguration $emberNexusConfiguration ) { parent::__construct(); } @@ -111,8 +115,28 @@ private function deleteMongo(): void private function deleteObjectStorage(): void { $this->io->startSection('Task 3 of 6: Object Storage'); - $this->io->writeln('Deleting object storage data...'); - $this->io->stopSection('Object storage is currently not implemented, nothing to delete.'); + $this->io->writeln('Deleting Object data...'); + do { + $objects = $this->s3Client->listObjectsV2([ + 'Bucket' => $this->emberNexusConfiguration->getFileS3StorageBucket(), + ]); + $keyCount = $objects->getKeyCount(); + if ($keyCount > 0) { + $objectsToBeDeleted = []; + foreach ($objects->getContents() as $object) { + $objectsToBeDeleted[] = [ + 'Key' => $object->getKey(), + ]; + } + $this->s3Client->deleteObjects([ + 'Bucket' => $this->emberNexusConfiguration->getFileS3StorageBucket(), + 'Delete' => [ + 'Objects' => $objectsToBeDeleted, + ], + ]); + } + } while ($keyCount > 0); + $this->io->stopSection('Successfully deleted object storage.'); } private function deleteElastic(): void diff --git a/src/Command/HealthcheckCommand.php b/src/Command/HealthcheckCommand.php index faf3ce46..4e97b7bc 100755 --- a/src/Command/HealthcheckCommand.php +++ b/src/Command/HealthcheckCommand.php @@ -6,6 +6,8 @@ use App\Factory\Exception\Server500LogicExceptionFactory; use App\Style\EmberNexusStyle; +use AsyncAws\S3\S3Client; +use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Request; @@ -36,6 +38,8 @@ public function __construct( private RedisClient $redisClient, private AMQPStreamConnection $AMQPStreamConnection, private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private S3Client $s3Client, + private EmberNexusConfiguration $emberNexusConfiguration ) { parent::__construct(); } @@ -116,6 +120,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $rabbitMqVersion )); + $isStorageBucketOnline = $this->s3Client->bucketExists(['Bucket' => $this->emberNexusConfiguration->getFileS3StorageBucket()])->isSuccess(); + if (!$isStorageBucketOnline) { + throw new Exception(sprintf('Unable to connect to bucket %s.', $this->emberNexusConfiguration->getFileS3StorageBucket())); + } + $isUploadBucketOnline = $this->s3Client->bucketExists(['Bucket' => $this->emberNexusConfiguration->getFileS3UploadBucket()])->isSuccess(); + if (!$isUploadBucketOnline) { + throw new Exception(sprintf('Unable to connect to bucket %s.', $this->emberNexusConfiguration->getFileS3UploadBucket())); + } + $this->io->writeln('S3 buckets: online'); + $this->io->stopSection('All databases are online.'); $this->io->startSection('Check internal services'); diff --git a/src/Command/TestCommand.php b/src/Command/TestCommand.php new file mode 100644 index 00000000..54a8b0ae --- /dev/null +++ b/src/Command/TestCommand.php @@ -0,0 +1,58 @@ +io = new EmberNexusStyle($input, $output); + + $this->io->title('Test'); + + // $buckets = $this->s3Client->listBuckets(); + // print_r($buckets->getBuckets()); + // + // $res = $this->s3Client->bucketExists(['Bucket' => 'api-upload2']); + // print_r($res->getState()); + // print_r($res->isSuccess() ? 'true' : 'false'); + + $lock = new FileUploadCheckLock( + Uuid::fromString('119bc6f4-a9cd-4d6a-bbaf-768061542ef9'), + Uuid::fromString('21850a04-ac44-4b14-a64d-6d498e4134a9'), + ); + + $this->lockService->acquireLock($lock); + $this->lockService->releaseLock($lock); + + return Command::SUCCESS; + } +} diff --git a/src/Contract/LockInterface.php b/src/Contract/LockInterface.php new file mode 100644 index 00000000..4226a5c6 --- /dev/null +++ b/src/Contract/LockInterface.php @@ -0,0 +1,18 @@ +server501NotImplementedExceptionFactory->createFromTemplate(); + $elementId = UuidV4::fromString($id); + $userId = $this->authProvider->getUserId(); + + if (!$this->accessChecker->hasAccessToElement($userId, $elementId, AccessType::READ)) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + $objectConfig = [ + 'Bucket' => $this->emberNexusConfiguration->getFileS3StorageBucket(), + 'Key' => $this->storageUtilService->getStorageBucketKey($elementId), + ]; + $status = $this->s3Client->objectExists($objectConfig); + + if (!$status->isSuccess()) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + $object = $this->s3Client->getObject($objectConfig); + + return new BinaryStreamResponse($object); + + // $stream = $object->getBody()->getContentAsResource(); + // + // $response = new StreamedResponse(); + // $response->headers->set('Content-Length', (string) ($object->getContentLength() ?? 0)); + // $response->headers->set('Content-Type', 'application/octet-stream'); + // + // $response->setCallback(function () use ($stream): void { + // while (!feof($stream)) { + // $buffer = fread($stream, StorageUtilService::STREAM_CHUNK_SIZE); + // if (false === $buffer || 0 === strlen($buffer)) { + // break; + // } + // echo $buffer; + // // ob_flush(); + // flush(); + // } + // fclose($stream); + // }); + // + // return $response; } } diff --git a/src/Controller/File/GetTusUploadController.php b/src/Controller/File/GetTusUploadController.php new file mode 100644 index 00000000..4264c96b --- /dev/null +++ b/src/Controller/File/GetTusUploadController.php @@ -0,0 +1,44 @@ + Regex::UUID_V4_CONTROLLER, + ], + methods: ['GET'] + )] + public function getTusUpload(string $id): Response + { + $uploadId = UuidV4::fromString($id); + + $response = new JsonResponse( + [ + 'upload-id' => $uploadId, + ], + Response::HTTP_OK, + [ + 'Location' => sprintf('/tus-upload/%s', $uploadId), + ] + ); + + return $response; + } +} diff --git a/src/Controller/File/PostElementFileController.php b/src/Controller/File/PostElementFileController.php index 6cb5db36..9fd00255 100644 --- a/src/Controller/File/PostElementFileController.php +++ b/src/Controller/File/PostElementFileController.php @@ -4,12 +4,28 @@ namespace App\Controller\File; -use App\Factory\Exception\Server501NotImplementedExceptionFactory; +use App\Attribute\EndpointImplementsTusIo; +use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Factory\Exception\Server500LogicExceptionFactory; use App\Helper\Regex; +use App\Response\NoContentResponse; +use App\Security\AccessChecker; +use App\Security\AuthProvider; +use App\Service\LockService; +use App\Type\AccessType; +use App\Type\Lock\FileUploadCheckLock; +use Exception; +use Laudis\Neo4j\Databags\Statement; +use Laudis\Neo4j\Types\CypherMap; +use Laudis\Neo4j\Types\Node; +use Ramsey\Uuid\Rfc4122\UuidV4; +use Ramsey\Uuid\Uuid; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; /** * @SuppressWarnings("PHPMD.UnusedFormalParameter") @@ -18,7 +34,13 @@ class PostElementFileController extends AbstractController { public function __construct( private Server501NotImplementedExceptionFactory $server501NotImplementedExceptionFactory, - ) { + private AuthProvider $authProvider, + private AccessChecker $accessChecker, + private LockService $lockService, + private CypherEntityManager $cypherEntityManager, + private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, + private Server500LogicExceptionFactory $server500LogicExceptionFactory) + { } #[Route( @@ -29,8 +51,75 @@ public function __construct( ], methods: ['POST'] )] + #[EndpointImplementsTusIo] public function postElementFile(string $id, Request $request): Response { - throw $this->server501NotImplementedExceptionFactory->createFromTemplate(); + $elementId = UuidV4::fromString($id); + $userId = $this->authProvider->getUserId(); + + // note: update is used because files are optional parts of already existing elements + if (!$this->accessChecker->hasAccessToElement($userId, $elementId, AccessType::UPDATE)) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + $lock = new FileUploadCheckLock($elementId, $userId); + $this->lockService->acquireLock($lock); + + $cypherClient = $this->cypherEntityManager->getClient(); + $currentlyHappeningUploads = $cypherClient->runStatement(Statement::create( + "MATCH (upload:FileUpload {uploadForElement: \$elementId})\n". + "WHERE upload.status <> 'finished'\n". + "RETURN upload\n". + "ORDER BY upload.id", + [ + 'elementId' => $elementId->toString() + ] + )); + + $canCreateNewUpload = true; + foreach ($currentlyHappeningUploads as $currentlyHappeningUploadsRow) { + /** + * @var $currentlyHappeningUploadsRow CypherMap + */ + $upload = $currentlyHappeningUploadsRow->get('upload'); + /** + * @var $upload Node + */ + $uploadProperties = $upload->getProperties(); + + + if (!$uploadProperties->hasKey('id')) { + throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to handle file upload with attribute \'id\' missing.'); + } + $uploadId = Uuid::fromString($uploadProperties->get('id')); + + + if (!$uploadProperties->hasKey('variant')) { + throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf( + "Unable to handle upload '%s' with missing attribute 'variant'.", + $uploadId->toString() + )); + } + $variant = $uploadProperties->get('variant'); + + if ($variant !== 'tus.io') { + throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf( + "Unable to handle upload '%s' of unknown variant '%s'.", + $uploadId->toString(), + $variant + )); + } + + + } +// $nodeIds = []; +// foreach ($res as $resultSet) { +// $nodeIds[] = UuidV4::fromString($resultSet->get('element.id')); +// } + + + $this->lockService->releaseLock($lock); + + return new NoContentResponse(); } } diff --git a/src/EventSystem/Controller/EventListener/TusIoControllerEventListener.php b/src/EventSystem/Controller/EventListener/TusIoControllerEventListener.php new file mode 100644 index 00000000..2120ff8f --- /dev/null +++ b/src/EventSystem/Controller/EventListener/TusIoControllerEventListener.php @@ -0,0 +1,37 @@ +getAttributes(EndpointImplementsTusIo::class); + if (0 === count($attributes)) { + return; + } + + $this->tusIoService->setIsTusIoEndpoint(true); + + if (!$event->getRequest()->headers->has('Tus-Resumable')) { + throw $this->client412TusResumablePreconditionFailedExceptionFactory->createFromTemplate(); + } + + if ('1.0.0' !== $event->getRequest()->headers->get('Tus-Resumable')) { + throw $this->client412TusResumablePreconditionFailedExceptionFactory->createFromTemplate(); + } + } +} diff --git a/src/EventSystem/Response/EventListener/TusIoResponseEventListener.php b/src/EventSystem/Response/EventListener/TusIoResponseEventListener.php new file mode 100644 index 00000000..c9423daa --- /dev/null +++ b/src/EventSystem/Response/EventListener/TusIoResponseEventListener.php @@ -0,0 +1,25 @@ +tusIoService->isTusIoEndpoint()) { + return; + } + header('Tus-Resumable: 1.0.0'); + header('Tus-Extension: creation,creation-defer-length,creation-with-upload,expiration,checksum,checksum-trailer,termination,concatenation'); + } +} diff --git a/src/EventSystem/Response/config.yaml b/src/EventSystem/Response/config.yaml new file mode 100644 index 00000000..e69de29b diff --git a/src/Factory/Exception/Client412PreconditionFailedExceptionFactory.php b/src/Factory/Exception/Client412PreconditionFailedExceptionFactory.php index d2d8e508..27206c23 100644 --- a/src/Factory/Exception/Client412PreconditionFailedExceptionFactory.php +++ b/src/Factory/Exception/Client412PreconditionFailedExceptionFactory.php @@ -14,7 +14,7 @@ public function __construct( ) { } - public function createFromTemplate(): Client412PreconditionFailedException + public function createFromTemplate(?string $detail = 'Precondition does not match.'): Client412PreconditionFailedException { return new Client412PreconditionFailedException( $this->urlGenerator->generate( @@ -24,7 +24,8 @@ public function createFromTemplate(): Client412PreconditionFailedException 'name' => 'precondition-failed', ], UrlGeneratorInterface::ABSOLUTE_URL - ) + ), + $detail ); } } diff --git a/src/Factory/Exception/Client412TusResumablePreconditionFailedExceptionFactory.php b/src/Factory/Exception/Client412TusResumablePreconditionFailedExceptionFactory.php new file mode 100644 index 00000000..d86f91e2 --- /dev/null +++ b/src/Factory/Exception/Client412TusResumablePreconditionFailedExceptionFactory.php @@ -0,0 +1,31 @@ +urlGenerator->generate( + 'exception-detail', + [ + 'code' => '412', + 'name' => 'tus-io-precondition-failed', + ], + UrlGeneratorInterface::ABSOLUTE_URL + ), + detail: "Tus.io endpoints require the HTTP header 'Tus-Resumable' to be set to a valid version. Supported versions are '1.0.0'." + ); + } +} diff --git a/src/Factory/S3ClientFactory.php b/src/Factory/S3ClientFactory.php new file mode 100644 index 00000000..ce43cb3c --- /dev/null +++ b/src/Factory/S3ClientFactory.php @@ -0,0 +1,30 @@ + $this->s3Endpoint, + Configuration::OPTION_PATH_STYLE_ENDPOINT => true, + Configuration::OPTION_ACCESS_KEY_ID => $this->s3AccessKeyId, + Configuration::OPTION_SECRET_ACCESS_KEY => $this->s3SecretAccessKey, + ]); + + return new S3Client($configuration); + } +} diff --git a/src/Factory/UploadObjectFactory.php b/src/Factory/UploadObjectFactory.php new file mode 100644 index 00000000..e69de29b diff --git a/src/Response/BinaryStreamResponse.php b/src/Response/BinaryStreamResponse.php new file mode 100644 index 00000000..581f9f35 --- /dev/null +++ b/src/Response/BinaryStreamResponse.php @@ -0,0 +1,41 @@ +getBody()->getContentAsResource(); + + $this->headers->set('Content-Length', (string) ($object->getContentLength() ?? 0)); + $this->headers->set('Content-Type', 'application/octet-stream'); + + $this->setCallback(function () use ($stream): void { + while (!feof($stream)) { + $buffer = fread($stream, StorageUtilService::STREAM_CHUNK_SIZE); + if (false === $buffer || 0 === strlen($buffer)) { + break; + } + echo $buffer; + // todo is this comment safe to remove? + // ob_flush(); + flush(); + } + fclose($stream); + }); + } + + public function setEtagFromEtagInstance(Etag $etag): static + { + return parent::setEtag((string) $etag); + } +} diff --git a/src/Service/LockService.php b/src/Service/LockService.php new file mode 100644 index 00000000..f007f2fc --- /dev/null +++ b/src/Service/LockService.php @@ -0,0 +1,48 @@ +redisClient->set($lock->getLockKey(), $lock->getLockValue(), 'EX', $lock->getLockTimeoutInSeconds(), 'NX'); + + if ($status instanceof Status) { + if ('OK' === $status->getPayload()) { + $lock->setLocked(true); + + return $lock; + } + } + + throw $this->client412PreconditionFailedExceptionFactory->createFromTemplate('Unable to acquire lock. Please retry your request at a later time.'); + } + + public function releaseLock(LockInterface $lock): LockInterface + { + if (!$lock->isLocked()) { + throw $this->server500LogicExceptionFactory->createFromTemplate('Releasing lock of unlocked lock is not possible.'); + } + $this->redisClient->del($lock->getLockKey()); + + $lock->setLocked(false); + + return $lock; + } +} diff --git a/src/Service/RequestUtilService.php b/src/Service/RequestUtilService.php index f3a079ea..cf8bc77a 100644 --- a/src/Service/RequestUtilService.php +++ b/src/Service/RequestUtilService.php @@ -8,8 +8,12 @@ use App\Exception\Client400MissingPropertyException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client400MissingPropertyExceptionFactory; +use App\Type\TusIoExtensionType; +use App\Type\TusIoHeaderType; use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; class RequestUtilService { @@ -153,4 +157,32 @@ public function getStringFromBody(string $key, array $body): string return $value; } + + public function validateTusResumableHttpHeader(Request $request): void + { + // The Tus-Resumable header MUST be included in every request and response except for OPTIONS requests. + if (Request::METHOD_OPTIONS === $request->getMethod()) { + return; + } + if ($request->headers->has(TusIoHeaderType::TUS_RESUMABLE->value)) { + } + } + + public function setCommonTusHeaders(Response $response): void + { + $response->headers->set(TusIoHeaderType::TUS_VERSION->value, '1.0.0'); + $response->headers->set(TusIoHeaderType::TUS_RESUMABLE->value, '1.0.0'); + + $supportedExtensions = [ + TusIoExtensionType::CREATION_WITH_UPLOAD->value, + TusIoExtensionType::CREATION_DEFER_LENGTH->value, + TusIoExtensionType::EXPIRATION->value, + TusIoExtensionType::CHECKSUM->value, + TusIoExtensionType::TERMINATION->value, + ]; + $response->headers->set(TusIoHeaderType::TUS_EXTENSION->value, implode(',', $supportedExtensions)); + + // 5 TB, max limit of S3 + $response->headers->set(TusIoHeaderType::TUS_MAX_SIZE->value, (string) (5 * 1024 * 1024 * 1024 * 1024)); + } } diff --git a/src/Service/StorageUtilService.php b/src/Service/StorageUtilService.php new file mode 100644 index 00000000..50c04b19 --- /dev/null +++ b/src/Service/StorageUtilService.php @@ -0,0 +1,71 @@ +uuidToNestedFolderStructure( + $id, + $this->emberNexusConfiguration->getFileS3StorageBucketLevels(), + $this->emberNexusConfiguration->getFileS3StorageBucketLevelLength(), + ) + ); + } + + public function getUploadBucketKey(UuidInterface $id): string + { + return sprintf( + '%s.bin', + $this->uuidToNestedFolderStructure( + $id, + $this->emberNexusConfiguration->getFileS3UploadBucketLevels(), + $this->emberNexusConfiguration->getFileS3UploadBucketLevelLength(), + ) + ); + } + + public function uuidToNestedFolderStructure(UuidInterface $uuid, int $levels = 0, int $levelLength = 2): string + { + $uuidAsHexString = $uuid->getHex()->toString(); + if ($levels < 0) { + throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to generate nested folder structure from uuid with negative level argument.', ['levels' => $levels]); + } + if ($levelLength < 1) { + throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to generate nested folder structure from uuid with level length less than 1.', ['levelLength' => $levelLength]); + } + if ($levels * $levelLength >= strlen($uuidAsHexString)) { + throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to generate nested folder structure as long as product of levels and level length exceeds length of uuid without dashes.', ['levels' => $levels, 'levelLength' => $levelLength, 'product' => $levels * $levelLength, 'limit' => strlen($uuidAsHexString) - 1]); + } + + $parts = []; + for ($i = 0; $i < $levels; ++$i) { + $parts[] = substr($uuidAsHexString, $levelLength * $i, $levelLength); + } + + return sprintf( + '%s%s%s', + implode('/', $parts), + $levels > 0 ? '/' : '', + $uuid->toString() + ); + } +} diff --git a/src/Service/TusIoService.php b/src/Service/TusIoService.php new file mode 100644 index 00000000..16f51316 --- /dev/null +++ b/src/Service/TusIoService.php @@ -0,0 +1,26 @@ +isTusIoEndpoint; + } + + public function setIsTusIoEndpoint(bool $isTusIoEndpoint): self + { + $this->isTusIoEndpoint = $isTusIoEndpoint; + + return $this; + } +} diff --git a/src/Type/Lock/FileUploadCheckLock.php b/src/Type/Lock/FileUploadCheckLock.php new file mode 100644 index 00000000..7803710f --- /dev/null +++ b/src/Type/Lock/FileUploadCheckLock.php @@ -0,0 +1,71 @@ +elementId; + } + + public function setElementId(UuidInterface $elementId): self + { + $this->elementId = $elementId; + + return $this; + } + + public function getUserId(): UuidInterface + { + return $this->userId; + } + + public function setUserId(UuidInterface $userId): self + { + $this->userId = $userId; + + return $this; + } + + public function getLockKey(): string + { + return sprintf('%s%s', RedisPrefixType::LOCK_FILE_UPLOAD_CHECK->value, $this->elementId); + } + + public function getLockValue(): string + { + return $this->userId->toString(); + } + + public function getLockTimeoutInSeconds(): ?int + { + return 30; + } + + public function isLocked(): bool + { + return $this->isLocked; + } + + public function setLocked(bool $locked): LockInterface + { + $this->isLocked = $locked; + + return $this; + } +} diff --git a/src/Type/RedisPrefixType.php b/src/Type/RedisPrefixType.php index 82f02755..69deb7ea 100644 --- a/src/Type/RedisPrefixType.php +++ b/src/Type/RedisPrefixType.php @@ -14,4 +14,5 @@ enum RedisPrefixType: string case ETAG_PARENTS_COLLECTION = 'etag:parents:'; case ETAG_RELATED_COLLECTION = 'etag:related:'; case ETAG_INDEX_COLLECTION = 'etag:index:'; + case LOCK_FILE_UPLOAD_CHECK = 'lock:file-upload-check:'; } diff --git a/src/Type/TusIoExtensionType.php b/src/Type/TusIoExtensionType.php new file mode 100644 index 00000000..5c0bbc2c --- /dev/null +++ b/src/Type/TusIoExtensionType.php @@ -0,0 +1,16 @@ +variant; + } + + public function setVariant(UploadVariantType $variant): self + { + $this->variant = $variant; + + return $this; + } + + public function getVariantVersion(): UploadVariantVersionType + { + return $this->variantVersion; + } + + public function setVariantVersion(UploadVariantVersionType $variantVersion): self + { + $this->variantVersion = $variantVersion; + + return $this; + } + + public function getConcat(): ?UploadConcatType + { + return $this->concat; + } + + public function setConcat(?UploadConcatType $concat): self + { + $this->concat = $concat; + + return $this; + } + + public function getUploadLength(): ?int + { + return $this->uploadLength; + } + + public function setUploadLength(?int $uploadLength): self + { + $this->uploadLength = $uploadLength; + + return $this; + } + + public function isUploadLengthDeferred(): bool + { + return $this->isUploadLengthDeferred; + } + + public function setIsUploadLengthDeferred(bool $isUploadLengthDeferred): self + { + $this->isUploadLengthDeferred = $isUploadLengthDeferred; + + return $this; + } + + public function getUploadOffset(): int + { + return $this->uploadOffset; + } + + public function setUploadOffset(int $uploadOffset): self + { + $this->uploadOffset = $uploadOffset; + + return $this; + } + + public function getCreated(): DateTime + { + return $this->created; + } + + public function setCreated(DateTime $created): self + { + $this->created = $created; + + return $this; + } + + public function getUpdated(): ?DateTime + { + return $this->updated; + } + + public function setUpdated(?DateTime $updated): self + { + $this->updated = $updated; + + return $this; + } + + public function getExpires(): ?DateTime + { + return $this->expires; + } + + public function setExpires(?DateTime $expires): self + { + $this->expires = $expires; + + return $this; + } +} diff --git a/src/Type/UploadConcatType.php b/src/Type/UploadConcatType.php new file mode 100644 index 00000000..2c663ce2 --- /dev/null +++ b/src/Type/UploadConcatType.php @@ -0,0 +1,11 @@ +markTestSkipped('WIP'); $response = $this->runGetRequest(sprintf('/%s/file', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -141,6 +142,7 @@ public function test1010220(): void */ public function test1010221(): void { + $this->markTestSkipped('WIP'); $response = $this->runPostRequest( sprintf('/%s/file', self::DATA), self::TOKEN, @@ -161,6 +163,7 @@ public function test1010221(): void */ public function test1010222(): void { + $this->markTestSkipped('WIP'); $response = $this->runPutRequest(sprintf('/%s/file', self::DATA), self::TOKEN, []); $this->assertIsProblemResponse($response, 501); } @@ -172,6 +175,7 @@ public function test1010222(): void */ public function test1010223(): void { + $this->markTestSkipped('WIP'); $response = $this->runPatchRequest(sprintf('/%s/file', self::DATA), self::TOKEN, []); $this->assertIsProblemResponse($response, 501); } @@ -183,6 +187,7 @@ public function test1010223(): void */ public function test1010224(): void { + $this->markTestSkipped('WIP'); $response = $this->runDeleteRequest(sprintf('/%s/file', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -194,6 +199,7 @@ public function test1010224(): void */ public function test1010230(): void { + $this->markTestSkipped('WIP'); $response = $this->runCopyRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -205,6 +211,7 @@ public function test1010230(): void */ public function test1010231(): void { + $this->markTestSkipped('WIP'); $response = $this->runLockRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -216,6 +223,7 @@ public function test1010231(): void */ public function test1010232(): void { + $this->markTestSkipped('WIP'); $response = $this->runUnlockRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -227,6 +235,7 @@ public function test1010232(): void */ public function test1010233(): void { + $this->markTestSkipped('WIP'); $response = $this->runMkcolRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -238,6 +247,7 @@ public function test1010233(): void */ public function test1010234(): void { + $this->markTestSkipped('WIP'); $response = $this->runMoveRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -249,6 +259,7 @@ public function test1010234(): void */ public function test1010235(): void { + $this->markTestSkipped('WIP'); $response = $this->runPropfindRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -260,6 +271,7 @@ public function test1010235(): void */ public function test1010236(): void { + $this->markTestSkipped('WIP'); $response = $this->runProppatchRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } diff --git a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php index 2c258df9..9db2f883 100644 --- a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php +++ b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php @@ -131,6 +131,7 @@ public function test1020208(): void */ public function test1020220(): void { + $this->markTestSkipped('WIP'); $response = $this->runGetRequest(sprintf('/%s/file', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -142,6 +143,7 @@ public function test1020220(): void */ public function test1020221(): void { + $this->markTestSkipped('WIP'); $response = $this->runPostRequest( sprintf('/%s/file', self::RELATION), self::TOKEN, @@ -162,6 +164,7 @@ public function test1020221(): void */ public function test1020222(): void { + $this->markTestSkipped('WIP'); $response = $this->runPutRequest(sprintf('/%s/file', self::RELATION), self::TOKEN, []); $this->assertIsProblemResponse($response, 501); } @@ -173,6 +176,7 @@ public function test1020222(): void */ public function test1020223(): void { + $this->markTestSkipped('WIP'); $response = $this->runPatchRequest(sprintf('/%s/file', self::RELATION), self::TOKEN, []); $this->assertIsProblemResponse($response, 501); } @@ -184,6 +188,7 @@ public function test1020223(): void */ public function test1020224(): void { + $this->markTestSkipped('WIP'); $response = $this->runDeleteRequest(sprintf('/%s/file', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -195,6 +200,7 @@ public function test1020224(): void */ public function test1020230(): void { + $this->markTestSkipped('WIP'); $response = $this->runCopyRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -206,6 +212,7 @@ public function test1020230(): void */ public function test1020231(): void { + $this->markTestSkipped('WIP'); $response = $this->runLockRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -217,6 +224,7 @@ public function test1020231(): void */ public function test1020232(): void { + $this->markTestSkipped('WIP'); $response = $this->runUnlockRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -228,6 +236,7 @@ public function test1020232(): void */ public function test1020233(): void { + $this->markTestSkipped('WIP'); $response = $this->runMkcolRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -239,6 +248,7 @@ public function test1020233(): void */ public function test1020234(): void { + $this->markTestSkipped('WIP'); $response = $this->runMoveRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -250,6 +260,7 @@ public function test1020234(): void */ public function test1020235(): void { + $this->markTestSkipped('WIP'); $response = $this->runPropfindRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -261,6 +272,7 @@ public function test1020235(): void */ public function test1020236(): void { + $this->markTestSkipped('WIP'); $response = $this->runProppatchRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } diff --git a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php index a110a26c..554a808b 100644 --- a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php +++ b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php @@ -153,6 +153,7 @@ public function test1030408(): void */ public function test1030420(): void { + $this->markTestSkipped('WIP'); $response = $this->runGetRequest(sprintf('/%s/file', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -164,6 +165,7 @@ public function test1030420(): void */ public function test1030421(): void { + $this->markTestSkipped('WIP'); $response = $this->runPostRequest( sprintf('/%s/file', self::RELATION_1), self::TOKEN, @@ -184,6 +186,7 @@ public function test1030421(): void */ public function test1030422(): void { + $this->markTestSkipped('WIP'); $response = $this->runPutRequest(sprintf('/%s/file', self::RELATION_1), self::TOKEN, []); $this->assertIsProblemResponse($response, 501); } @@ -195,6 +198,7 @@ public function test1030422(): void */ public function test1030423(): void { + $this->markTestSkipped('WIP'); $response = $this->runPatchRequest(sprintf('/%s/file', self::RELATION_1), self::TOKEN, []); $this->assertIsProblemResponse($response, 501); } @@ -206,6 +210,7 @@ public function test1030423(): void */ public function test1030424(): void { + $this->markTestSkipped('WIP'); $response = $this->runDeleteRequest(sprintf('/%s/file', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -217,6 +222,7 @@ public function test1030424(): void */ public function test1030430(): void { + $this->markTestSkipped('WIP'); $response = $this->runCopyRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -228,6 +234,7 @@ public function test1030430(): void */ public function test1030431(): void { + $this->markTestSkipped('WIP'); $response = $this->runLockRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -239,6 +246,7 @@ public function test1030431(): void */ public function test1030432(): void { + $this->markTestSkipped('WIP'); $response = $this->runUnlockRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -250,6 +258,7 @@ public function test1030432(): void */ public function test1030433(): void { + $this->markTestSkipped('WIP'); $response = $this->runMkcolRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -261,6 +270,7 @@ public function test1030433(): void */ public function test1030434(): void { + $this->markTestSkipped('WIP'); $response = $this->runMoveRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -272,6 +282,7 @@ public function test1030434(): void */ public function test1030435(): void { + $this->markTestSkipped('WIP'); $response = $this->runPropfindRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -283,6 +294,7 @@ public function test1030435(): void */ public function test1030436(): void { + $this->markTestSkipped('WIP'); $response = $this->runProppatchRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -396,6 +408,7 @@ public function test1030608(): void */ public function test1030620(): void { + $this->markTestSkipped('WIP'); $response = $this->runGetRequest(sprintf('/%s/file', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -407,6 +420,7 @@ public function test1030620(): void */ public function test1030621(): void { + $this->markTestSkipped('WIP'); $response = $this->runPostRequest( sprintf('/%s/file', self::RELATION_2), self::TOKEN, @@ -427,6 +441,7 @@ public function test1030621(): void */ public function test1030622(): void { + $this->markTestSkipped('WIP'); $response = $this->runPutRequest(sprintf('/%s/file', self::RELATION_2), self::TOKEN, []); $this->assertIsProblemResponse($response, 501); } @@ -438,6 +453,7 @@ public function test1030622(): void */ public function test1030623(): void { + $this->markTestSkipped('WIP'); $response = $this->runPatchRequest(sprintf('/%s/file', self::RELATION_2), self::TOKEN, []); $this->assertIsProblemResponse($response, 501); } @@ -449,6 +465,7 @@ public function test1030623(): void */ public function test1030624(): void { + $this->markTestSkipped('WIP'); $response = $this->runDeleteRequest(sprintf('/%s/file', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -460,6 +477,7 @@ public function test1030624(): void */ public function test1030630(): void { + $this->markTestSkipped('WIP'); $response = $this->runCopyRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -471,6 +489,7 @@ public function test1030630(): void */ public function test1030631(): void { + $this->markTestSkipped('WIP'); $response = $this->runLockRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -482,6 +501,7 @@ public function test1030631(): void */ public function test1030632(): void { + $this->markTestSkipped('WIP'); $response = $this->runUnlockRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -493,6 +513,7 @@ public function test1030632(): void */ public function test1030633(): void { + $this->markTestSkipped('WIP'); $response = $this->runMkcolRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -504,6 +525,7 @@ public function test1030633(): void */ public function test1030634(): void { + $this->markTestSkipped('WIP'); $response = $this->runMoveRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -515,6 +537,7 @@ public function test1030634(): void */ public function test1030635(): void { + $this->markTestSkipped('WIP'); $response = $this->runPropfindRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -526,6 +549,7 @@ public function test1030635(): void */ public function test1030636(): void { + $this->markTestSkipped('WIP'); $response = $this->runProppatchRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } diff --git a/tests/UnitTests/Service/StorageUtilServiceTest.php b/tests/UnitTests/Service/StorageUtilServiceTest.php new file mode 100644 index 00000000..8a55f829 --- /dev/null +++ b/tests/UnitTests/Service/StorageUtilServiceTest.php @@ -0,0 +1,106 @@ +createMock(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('url'); + $server500Bag = $this->createMock(ParameterBagInterface::class); + $server500Bag->method('get')->willReturn('dev'); + $server500LogicExceptionFactory = new Server500LogicExceptionFactory( + $urlGenerator, + $server500Bag, + $this->createMock(LoggerInterface::class) + ); + + return new StorageUtilService( + $emberNexusConfiguration ?? $this->createMock(EmberNexusConfiguration::class), + $server500LogicExceptionFactory, + ); + } + + public function testUuidToNestedFolderStructure(): void + { + $storageUtilService = $this->getStorageUtilService(); + + $uuid = Uuid::fromString('3207d629-7199-4c2d-9b2a-b0fba10fe309'); + + try { + $storageUtilService->uuidToNestedFolderStructure($uuid, -1); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var $e Server500LogicErrorException + */ + $this->assertSame('Unable to generate nested folder structure from uuid with negative level argument.', $e->getDetail()); + } + + try { + $storageUtilService->uuidToNestedFolderStructure($uuid, 0, -1); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var $e Server500LogicErrorException + */ + $this->assertSame('Unable to generate nested folder structure from uuid with level length less than 1.', $e->getDetail()); + } + + try { + $storageUtilService->uuidToNestedFolderStructure($uuid, 0, 0); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var $e Server500LogicErrorException + */ + $this->assertSame('Unable to generate nested folder structure from uuid with level length less than 1.', $e->getDetail()); + } + + try { + $storageUtilService->uuidToNestedFolderStructure($uuid, 8, 9); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var $e Server500LogicErrorException + */ + $this->assertSame('Unable to generate nested folder structure as long as product of levels and level length exceeds length of uuid without dashes.', $e->getDetail()); + } + + $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid); + $this->assertSame('3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 1, 1); + $this->assertSame('3/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 1, 3); + $this->assertSame('320/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 3, 1); + $this->assertSame('3/2/0/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 2, 2); + $this->assertSame('32/07/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 6, 5); + $this->assertSame('3207d/62971/994c2/d9b2a/b0fba/10fe3/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 15, 2); + $this->assertSame('32/07/d6/29/71/99/4c/2d/9b/2a/b0/fb/a1/0f/e3/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + } +} From c7d13e8dc258f96dcfe68a64e185210c5292d59a Mon Sep 17 00:00:00 2001 From: Syndesi Date: Fri, 2 Jan 2026 13:52:48 +0100 Subject: [PATCH 2/7] manual 2026 work I --- .env | 8 +- .env.test | 6 +- .github/workflows/ci-test.yml | 3 - CHANGELOG.md | 16 +- README.md | 63 +- composer.json | 17 +- composer.lock | 1449 ++++++++++------ config/reference.php | 149 +- docker/Caddyfile | 3 + docker/Dockerfile | 1 + docker/php-embed/php.dev.ini | 7 +- docker/php-embed/php.prod.ini | 7 +- docs/README.md | 63 +- docs/_sidebar.md | 82 +- .../error/get-400-reserved-type.md | 21 + .../200-response-body.txt | 15 + .../200-response-header.txt | 10 + .../api-endpoints/file/delete-element-file.md | 26 +- .../200-response-body.json | 6 + .../200-response-header.txt | 10 + docs/api-endpoints/file/get-element-file.md | 25 +- .../get-element-file/200-response-body.json | 6 + .../get-element-file/200-response-header.txt | 10 + docs/api-endpoints/file/patch-element-file.md | 4 - docs/api-endpoints/file/post-element-file.md | 28 +- .../post-element-file/200-response-body.json | 6 + .../post-element-file/200-response-header.txt | 10 + docs/api-endpoints/file/put-element-file.md | 28 +- .../put-element-file/200-response-body.json | 6 + .../put-element-file/200-response-header.txt | 10 + .../200-response-header.txt | 2 +- .../403-response-header.txt | 2 +- .../200-response-body.json | 6 + docs/api-endpoints/upload/delete-upload.md | 24 + .../delete-upload/200-response-body.json | 6 + .../delete-upload/200-response-header.txt | 10 + docs/api-endpoints/upload/head-upload.md | 24 + .../upload/head-upload/200-response-body.json | 6 + .../head-upload/200-response-header.txt | 10 + docs/api-endpoints/upload/patch-upload.md | 27 + .../patch-upload/200-response-body.json | 6 + .../patch-upload/200-response-header.txt | 10 + docs/api-endpoints/webdav/copy-element.md | 4 - docs/api-endpoints/webdav/lock-element.md | 4 - docs/api-endpoints/webdav/mkcol-folder.md | 4 - docs/api-endpoints/webdav/move-element.md | 4 - docs/api-endpoints/webdav/propfind-element.md | 4 - .../api-endpoints/webdav/proppatch-element.md | 4 - docs/api-endpoints/webdav/unlock-element.md | 4 - docs/assets/custom.css | 3 +- docs/commands/assets/backup-create-help.html | 22 - docs/commands/assets/backup-create.html | 45 +- docs/commands/assets/backup-list.html | 5 +- docs/commands/assets/backup-load.html | 1 - docs/commands/assets/database-drop.html | 4 +- docs/commands/assets/system-healthcheck.html | 9 +- docs/commands/assets/token-create.html | 4 +- docs/commands/assets/token-revoke.html | 20 +- docs/commands/assets/user-create.html | 2 +- .../initial-request-response-header.txt | 2 +- .../caching/invalid-etag-response-header.txt | 2 +- .../caching/not-modified-response-header.txt | 2 +- docs/development/best-practices.md | 66 - docs/development/long-term-plans.md | 24 - docs/example/default-parameters.yaml | 36 + .../examples/NotFoundProblemExample.json | 9 + docs/open-api/parameters/If-Match.json | 10 + docs/open-api/parameters/If-None-Match.json | 10 + docs/open-api/paths/Element/GetElement.json | 142 ++ docs/open-api/schemas/Element.json | 44 + docs/open-api/schemas/ElementId.json | 6 + docs/open-api/schemas/Problem.json | 20 + docs/open-api/swagger.json | 259 +++ docs/open-api/swagger_old.json | 261 +++ docs/open-api/swagger_old.yml | 192 +++ docs/open-api/swagger_template.json | 10 + .../find-nested-elements/response-header.txt | 2 +- .../find-tagged-elements/response-header.txt | 2 +- .../response-header.txt | 2 +- .../response-header.txt | 2 +- .../keyword-search/response-header.txt | 2 +- .../response-header.txt | 2 +- .../query-string-search/response-header.txt | 2 +- .../response-header.txt | 2 +- .../text-prefix-search/response-header.txt | 2 +- .../wildcard-search/response-header.txt | 2 +- .../response-header.txt | 2 +- .../response-header.txt | 2 +- .../response-header.txt | 2 +- .../response-header.txt | 2 +- .../response-header.txt | 2 +- .../response-header.txt | 2 +- .../src/DependencyInjection/Configuration.php | 26 +- .../src/Service/EmberNexusConfiguration.php | 84 +- src/Attribute/EndpointImplementsTusIo.php | 15 - src/Command/BackupCreateCommand.php | 137 +- src/Command/BackupLoadCommand.php | 81 +- .../Cron/DeleteExpiredUploadsCommand.php | 55 + src/Command/Cron/ReindexFilesCommand.php | 55 + src/Command/Cron/UpdateOwnershipCommand.php | 55 + src/Command/DatabaseDropCommand.php | 2 +- src/Command/HealthcheckCommand.php | 155 +- src/Command/TestCommand.php | 58 - src/Command/TokenCreateCommand.php | 6 +- src/Command/TokenRevokeCommand.php | 18 +- src/Console/EmberNexusOutputWrapper.php | 5 + src/Contract/EtagCapableResponseInterface.php | 12 + src/Contract/LockInterface.php | 18 - .../Request/PartialUploadRequestInterface.php | 21 + .../ResumableUploadRequestInterface.php | 25 + src/Contract/S3/FileOperationInterface.php | 12 + .../S3/MergeFileChunksOperationInterface.php | 21 + .../S3/UploadFileChunkOperationInterface.php | 21 + .../S3/UploadFileOperationInterface.php | 27 + src/Contract/UploadInterface.php | 29 + .../Element/DeleteElementController.php | 7 +- .../Element/GetChildrenController.php | 12 +- src/Controller/Element/GetIndexController.php | 8 +- .../Element/GetParentsController.php | 12 +- .../Element/GetRelatedController.php | 12 +- .../Element/PatchElementController.php | 7 +- .../Element/PostElementController.php | 4 +- .../Element/PostIndexController.php | 12 +- .../Element/PutElementController.php | 7 +- .../Error/ExceptionDetailController.php | 2 +- .../Error/assets/error-400-reserved-type.txt | 8 + .../Error/assets/error-409-conflict.txt | 9 + .../Error/assets/error-410-gone.txt | 9 + .../error-412-tus-io-precondition-failed.txt | 11 - .../File/DeleteElementFileController.php | 45 +- .../File/GetElementFileController.php | 66 +- .../File/GetTusUploadController.php | 44 - .../File/PatchElementFileController.php | 36 - .../File/PostElementFileController.php | 90 +- .../File/PutElementFileController.php | 24 +- .../Search/PostSearchController.php | 2 +- .../System/GetGraphStructureController.php | 2 +- .../GetInstanceConfigurationController.php | 8 +- .../GetWellKnownSecurityTxtController.php | 2 +- .../Upload/DeleteUploadController.php | 73 + .../Upload/HeadUploadController.php | 53 + .../Upload/PatchUploadController.php | 143 ++ src/Controller/User/DeleteTokenController.php | 9 +- src/Controller/User/GetTokenController.php | 6 +- .../User/PostChangePasswordController.php | 2 +- .../User/PostRegisterController.php | 11 +- src/Controller/User/PostTokenController.php | 2 +- .../IfNoneMatchControllerEventListener.php | 3 +- .../TusIoControllerEventListener.php | 37 - ...pertyElementDefragmentizeEventListener.php | 14 + ...ationElementDefragmentizeEventListener.php | 14 +- .../Event/ElementFileDeleteEvent.php | 24 + .../ElasticsearchFileDeleteEventListener.php | 29 + .../Event/ElementFileReplaceEvent.php | 24 + .../ElasticsearchFileReplaceEventListener.php | 29 + ...ropertyElementFragmentizeEventListener.php | 2 + .../NodeElementFragmentizeEventListener.php | 8 +- ...elationElementFragmentizeEventListener.php | 12 +- .../ExpireEtagOnChangeEventListener.php | 24 +- .../OwnershipChangeEventListener.php | 22 +- src/EventSystem/Etag/Event/FileEtagEvent.php | 39 + .../LiveFileEtagEventListener.php | 49 + ...disChildrenCollectionEtagEventListener.php | 2 +- .../RedisElementEtagEventListener.php | 2 +- .../RedisFileEtagEventListener.php | 62 + .../RedisIndexCollectionEtagEventListener.php | 2 +- ...edisParentsCollectionEtagEventListener.php | 2 +- ...edisRelatedCollectionEtagEventListener.php | 2 +- .../EventListener/ExceptionEventListener.php | 3 +- ...NormalizedValueToRawValueEventListener.php | 3 +- ...piKeyCheckOnKernelRequestEventListener.php | 10 +- .../EtagResponseEventListener.php | 10 +- .../TusIoResponseEventListener.php | 25 - src/EventSystem/Response/config.yaml | 0 .../CypherPathSearchStepEventListener.php | 12 +- .../Client400ReservedTypeException.php | 21 + src/Exception/Client409ConflictException.php | 25 + src/Exception/Client410GoneException.php | 21 + src/Exception/ProblemJsonException.php | 27 +- ...mpleteMutualDependencyExceptionFactory.php | 6 +- .../Client400ReservedTypeExceptionFactory.php | 34 + .../Client409ConflictExceptionFactory.php | 35 + .../Client410GoneExceptionFactory.php | 30 + ...t412PreconditionFailedExceptionFactory.php | 4 +- ...ablePreconditionFailedExceptionFactory.php | 31 - ...> Server500LogicErrorExceptionFactory.php} | 8 +- src/Factory/RabbitMQFactory.php | 24 +- src/Factory/S3ClientFactory.php | 7 +- src/Factory/Type/RedisKeyFactory.php | 8 + .../Request/PartialUploadRequestFactory.php | 37 + .../Request/ResumableUploadRequestFactory.php | 45 + .../Response/NoContentResponseFactory.php | 51 + src/Factory/Type/S3/FileOperationFactory.php | 48 + .../S3/MergeFileChunksOperationFactory.php | 56 + .../S3/UploadFileChunkOperationFactory.php | 63 + .../Type/S3/UploadFileOperationFactory.php | 85 + src/Factory/Type/UploadFactory.php | 81 + src/Factory/UploadObjectFactory.php | 0 src/Helper/Neo4jClientHelper.php | 8 +- src/Response/BinaryStreamResponse.php | 41 - src/Response/CollectionResponse.php | 9 - src/Response/ElementResponse.php | 9 - src/Security/AccessChecker.php | 22 +- src/Security/AuthProvider.php | 6 +- src/Security/TokenGenerator.php | 6 +- src/Service/CollectionService.php | 8 +- .../CreateElementFromRawDataService.php | 12 + src/Service/ElementManager.php | 26 +- src/Service/ElementResponseService.php | 8 +- src/Service/ElementService.php | 68 + src/Service/ElementToRawService.php | 8 + src/Service/EtagCalculatorService.php | 63 +- src/Service/EtagService.php | 4 + src/Service/FilePropertyService.php | 57 + src/Service/FileService.php | 124 ++ src/Service/HeaderParseService.php | 121 ++ src/Service/LockService.php | 48 - src/Service/MimeTypeService.php | 26 + src/Service/PropertyParseService.php | 162 ++ src/Service/QueueService.php | 28 + src/Service/RawToElementService.php | 12 +- src/Service/RequestUtilService.php | 32 - src/Service/S3Service.php | 334 ++++ src/Service/SecurityUtilService.php | 10 +- src/Service/StorageUtilService.php | 71 - src/Service/StringService.php | 73 + src/Service/TusIoService.php | 26 - .../UpdateElementFromRawDataService.php | 6 +- src/Service/UploadCreationService.php | 133 ++ src/Service/UploadService.php | 57 + src/Style/EmberNexusStyle.php | 34 +- src/Type/EtagCalculator.php | 10 + src/Type/EtagType.php | 1 + src/Type/FileProperty.php | 39 + src/Type/Lock/FileUploadCheckLock.php | 71 - src/Type/RabbitMQQueueType.php | 3 +- src/Type/RedisPrefixType.php | 2 +- src/Type/Request/PartialUploadRequest.php | 50 + src/Type/Request/ResumableUploadRequest.php | 58 + src/Type/Response/BinaryStreamResponse.php | 52 + src/Type/Response/CollectionResponse.php | 16 + src/{ => Type}/Response/CreatedResponse.php | 8 +- src/Type/Response/ElementResponse.php | 16 + src/{ => Type}/Response/JsonResponse.php | 8 +- src/{ => Type}/Response/NoContentResponse.php | 2 +- .../Response/NotModifiedResponse.php | 5 +- .../Response/ProblemJsonResponse.php | 2 +- src/{ => Type}/Response/TextResponse.php | 2 +- src/Type/S3/FileOperation.php | 26 + src/Type/S3/MergeFileChunksOperation.php | 50 + src/Type/S3/UploadFileChunkOperation.php | 50 + src/Type/S3/UploadFileOperation.php | 68 + src/Type/TusIoExtensionType.php | 16 - src/Type/TusIoHeaderType.php | 21 - src/Type/Upload.php | 120 +- src/Type/UploadConcatType.php | 11 - src/Type/UploadVariantType.php | 10 - src/Type/UploadVariantVersionType.php | 10 - src/Wrapper/ContentDispositionWrapper.php | 18 + src/Wrapper/S3ClientWrapper.php | 27 + taskfile.yml | 4 + tests/.php-cs-fixer.dist.php | 1 + .../Backup/BackupCreateTest.php | 2 +- .../Backup/BackupFetchTest.php | 2 +- .../Backup/BackupListTest.php | 2 +- .../Backup/BackupLoadTest.php | 2 +- .../BaseCommandTestCase.php | 2 +- .../Cache/CacheClearEtagTest.php | 2 +- .../Database/DatabaseDropTest.php | 2 +- .../ConfigDumpReferenceEmberNexusTest.php | 2 +- .../System/SystemHealthcheckTest.php | 2 +- .../Token/TokenCreateTest.php | 2 +- .../Token/TokenRevokeTest.php | 2 +- .../User/UserCreateTest.php | 2 +- .../docker-compose.yml | 34 + .../BaseRequestTestCase.php | 2 +- .../Element/DeleteElementTest.php | 2 +- .../Element/GetChildrenTest.php | 2 +- .../Element/GetElementTest.php | 2 +- .../Element/GetIndexTest.php | 2 +- .../Element/GetParentsTest.php | 2 +- .../Element/GetRelatedTest.php | 2 +- .../Element/PatchElementTest.php | 2 +- .../Element/PostElementTest.php | 2 +- .../Element/PostIndexTest.php | 2 +- .../Element/PutElementTest.php | 2 +- .../Error/Get400BadContentTest.php | 2 +- .../Error/Get400ForbiddenPropertyTest.php | 2 +- .../Get400IncompleteMutualDependencyTest.php | 2 +- .../Error/Get400MissingPropertyTest.php | 2 +- .../Error/Get400ReservedIdentifierTest.php | 2 +- .../Error/Get401UnauthorizedTest.php | 2 +- .../Error/Get403ForbiddenTest.php | 2 +- .../Error/Get404NotFoundTest.php | 2 +- .../Error/Get412PreconditionFailedTest.php | 2 +- .../Error/Get500InternalServerErrorTest.php | 2 +- .../Error/Get501NotImplementedTest.php | 2 +- .../FindNestedElementsTest.php | 2 +- .../FindTaggedElementsTest.php | 2 +- .../AdvancedFullTextSearchTest.php | 2 +- .../KeywordSearchTest.php | 2 +- ...edElasticsearchQueryDSLMixinSearchTest.php | 2 +- .../QueryStringSearchTest.php | 2 +- .../SimpleFullTextSearchTest.php | 2 +- .../TextPrefixSearchTest.php | 2 +- .../WildcardSearchTest.php | 2 +- .../ExplicitElementIdsHydrationTest.php | 2 +- .../ImplicitElementsHydrationTest.php | 2 +- .../ImplicitPathsHydrationTest.php | 2 +- .../ParameterizedElementIdsHydrationTest.php | 2 +- ...athEsQueryDslMixinElementHydrationTest.php | 2 +- ...DslMixinCypherPathElementHydrationTest.php | 2 +- .../System/AGetInstanceConfigurationTest.php | 2 +- .../System/GetGraphStructureTest.php | 2 +- .../System/GetWellKnownSecurityTxtTest.php | 2 +- .../User/DeleteTokenTest.php | 2 +- .../User/GetMeTest.php | 2 +- .../User/GetTokenTest.php | 2 +- .../User/PostChangePasswordOldTest.php | 2 +- .../User/PostChangePasswordTest.php | 2 +- .../User/PostRegisterOldTest.php | 2 +- .../User/PostRegisterTest.php | 2 +- .../User/PostTokenOldTest.php | 2 +- .../User/PostTokenTest.php | 2 +- .../docker-compose.yml | 34 + .../System/GetGraphStructureTest.php | 2 +- .../System/GetInstanceConfigurationTest.php | 2 +- .../User/PostRegisterTest.php | 2 +- tests/FeatureTests/Asset/.gitignore | 1 + tests/FeatureTests/Asset/cherry-blossoms.jpg | Bin 0 -> 63933 bytes tests/FeatureTests/BaseRequestTestCase.php | 145 +- .../Command/RevokeTokenGroupTest.php | 2 +- .../Command/RevokeTokenIssuedAfterTest.php | 2 +- .../Command/RevokeTokenIssuedBeforeTest.php | 2 +- ...evokeTokenIssuedWithExpirationDateTest.php | 2 +- ...keTokenIssuedWithoutExpirationDateTest.php | 2 +- .../Command/RevokeTokenUserTest.php | 2 +- .../Element/DeleteElementNodeTest.php | 2 +- .../Element/DeleteElementRelationTest.php | 2 +- .../Endpoint/Element/GetChildrenTest.php | 2 +- .../Endpoint/Element/GetElementNodeTest.php | 2 +- .../Element/GetElementRelationTest.php | 2 +- .../Endpoint/Element/GetIndexTest.php | 2 +- .../Endpoint/Element/GetParentsTest.php | 2 +- .../Endpoint/Element/GetRelatedTest.php | 2 +- .../Endpoint/Element/PatchElementNodeTest.php | 2 +- .../Element/PatchElementRelationTest.php | 2 +- .../Endpoint/Element/PostElementNodeTest.php | 2 +- .../Endpoint/Element/PostIndexNodeTest.php | 2 +- .../Element/PostIndexRelationTest.php | 2 +- .../Endpoint/Element/PutElementNodeTest.php | 2 +- .../Element/PutElementRelationTest.php | 2 +- .../Endpoint/Error/Get400BadContentTest.php | 2 +- .../Error/Get400ForbiddenPropertyTest.php | 2 +- .../Get400IncompleteMutualDependencyTest.php | 2 +- .../Error/Get400MissingPropertyTest.php | 2 +- .../Error/Get400ReservedIdentifierTest.php | 2 +- .../Endpoint/Error/Get400ReservedTypeTest.php | 16 + .../Endpoint/Error/Get401UnauthorizedTest.php | 2 +- .../Endpoint/Error/Get403ForbiddenTest.php | 2 +- .../Endpoint/Error/Get404NotFoundTest.php | 2 +- .../Error/Get501NotImplementedTest.php | 2 +- .../Endpoint/File/DeleteFileTest.php | 141 +- .../Endpoint/File/GetFileTest.php | 36 +- .../Endpoint/File/PatchFileTest.php | 22 - .../File/PostFileInSingleRequestTest.php | 190 +++ .../Endpoint/File/PostFileTest.php | 22 - .../File/PutFileInSingleRequestTest.php | 190 +++ .../Endpoint/File/PutFileTest.php | 22 - .../Endpoint/Search/SearchTest.php | 2 +- .../System/GetInstanceConfigurationTest.php | 2 +- .../System/GetWellKnownSecurityTxtTest.php | 2 +- .../Endpoint/User/DeleteTokenTest.php | 2 +- .../FeatureTests/Endpoint/User/GetMeTest.php | 2 +- .../Endpoint/User/GetTokenTest.php | 2 +- .../User/PostChangePasswordOldTest.php | 2 +- .../Endpoint/User/PostChangePasswordTest.php | 2 +- .../Endpoint/User/PostRegisterOldTest.php | 2 +- .../Endpoint/User/PostRegisterTest.php | 2 +- .../Endpoint/User/PostTokenOldTest.php | 2 +- .../Endpoint/User/PostTokenTest.php | 2 +- .../Endpoint/WebDAV/CopyElementTest.php | 2 +- .../Endpoint/WebDAV/LockElementTest.php | 2 +- .../Endpoint/WebDAV/MkcolElementTest.php | 2 +- .../Endpoint/WebDAV/MoveElementTest.php | 2 +- .../Endpoint/WebDAV/PropfindElementTest.php | 2 +- .../Endpoint/WebDAV/ProppatchElementTest.php | 2 +- .../Endpoint/WebDAV/UnlockElementTest.php | 2 +- .../General/Edgecases/NoRouteTest.php | 2 +- .../General/Etag/CreateNodeEtagTest.php | 2 +- .../Etag/CreateNormalRelationEtagTest.php | 2 +- .../Etag/CreateOwnsRelationEtagTest.php | 2 +- .../General/Etag/DeleteNodeEtagTest.php | 2 +- .../Etag/DeleteNormalRelationEtagTest.php | 2 +- .../Etag/DeleteOwnsRelationEtagTest.php | 2 +- .../General/Etag/MaximumEtagTest.php | 2 +- .../General/Etag/UpdateNodeEtagTest.php | 2 +- .../Etag/UpdateNormalRelationEtagTest.php | 2 +- .../Etag/UpdateOwnsRelationEtagTest.php | 2 +- .../Exception/MalformedJsonRequestTest.php | 2 +- .../File/MultiPartUploadLifecycleTest.php | 128 ++ .../File/SinglePartUploadLifecycleTest.php | 107 ++ tests/FeatureTests/General/IfMatchTest.php | 2 +- .../FeatureTests/General/IfNoneMatchTest.php | 2 +- .../OwnershipModel/OwnershipModelTest.php | 2 +- .../Pagination/ChildrenPaginationTest.php | 2 +- .../Pagination/IndexPaginationTest.php | 2 +- .../Pagination/ParentsPaginationTest.php | 2 +- .../Pagination/RelatedPaginationTest.php | 2 +- .../FeatureTests/Security/DeleteTokenTest.php | 2 +- .../_01_01_NoConnectionTest.php | 2 +- .../_01_02_NoRelevantConnectionTest.php | 2 +- ...itherStartOrEndNodeOfRelationshipsTest.php | 2 +- ...04_OwnsRelationshipsAreDirectionalTest.php | 2 +- .../_02_01_ImmediateNodeOwnershipTest.php | 2 +- .../_07_01_SearchAccessTest.php | 2 +- .../TokenDoesNotReturnInternalFieldsTest.php | 2 +- .../FeatureTests/Security/TokenStateTest.php | 2 +- .../_02_02_ImmediateRelationOwnershipTest.php | 2 +- .../Security/_02_03_PathNodeOwnershipTest.php | 2 +- .../_02_04_PathRelationOwnershipTest.php | 2 +- ..._03_01_GroupImmediateNodeOwnershipTest.php | 2 +- ...02_GroupImmediateRelationOwnershipTest.php | 2 +- .../_03_03_GroupPathNodeOwnershipTest.php | 2 +- .../_03_04_GroupPathRelationOwnershipTest.php | 2 +- ...04_01_GroupsImmediateNodeOwnershipTest.php | 2 +- ...2_GroupsImmediateRelationOwnershipTest.php | 2 +- .../_04_03_GroupsPathNodeOwnershipTest.php | 2 +- .../_04_04_GroupsPathNodeOwnershipTest.php | 2 +- .../_05_01_MixedNodeOwnershipTest.php | 2 +- .../_05_02_MixedRelationOwnershipTest.php | 2 +- .../Security/_06_01_LimitedReadAccessTest.php | 2 +- ..._01_IsInGroupAfterOwnsHaveNoEffectTest.php | 2 +- ...eDirectAccessButNotToRelatedGroupsTest.php | 2 +- .../Security/_99_05_TokenHasNoOwnerTest.php | 2 +- .../Security/_99_06_TokenHasTwoOwnersTest.php | 2 +- .../docker-compose-neo4j-5.20.yml | 34 + .../FeatureTests/docker-compose-neo4j-5.7.yml | 34 + .../docker-compose-neo4j-latest.yml | 34 + .../Console/EmberNexusOutputWrapperTest.php | 24 + .../User/PostChangePasswordControllerTest.php | 2 +- .../User/PostRegisterControllerTest.php | 8 +- .../User/PostTokenControllerTest.php | 2 +- ...IfNoneMatchControllerEventListenerTest.php | 2 +- .../Event/ElementFileDeleteEventTest.php | 24 + .../Event/ElementFileReplaceEventTest.php | 24 + .../Etag/Event/FileEtagEventTest.php | 38 + .../LiveFileEtagEventListenerTest.php | 80 + .../RedisFileEtagEventListenerTest.php | 161 ++ .../ExceptionEventListenerTest.php | 2 +- .../EtagResponseEventListenerTest.php | 4 +- .../SearchStep/Event/SearchStepEventTest.php | 2 +- .../CypherPathSearchStepEventListenerTest.php | 6 +- .../Client400BadGrammarExceptionTest.php | 2 +- .../Client400ReservedTypeExceptionTest.php | 73 + .../Client409ConflictExceptionTest.php | 73 + .../Exception/Client410GoneExceptionTest.php | 73 + .../Exception/ProblemJsonExceptionTest.php | 7 +- ...tionElementFragmentizeEventFactoryTest.php | 2 +- ...lient400BadGrammarExceptionFactoryTest.php | 2 +- ...teMutualDependencyExceptionFactoryTest.php | 16 +- ...ent400ReservedTypeExceptionFactoryTest.php | 43 + .../Client409ConflictExceptionFactoryTest.php | 47 + .../Client410GoneExceptionFactoryTest.php | 44 + .../Server500LogicExceptionFactoryTest.php | 8 +- .../Factory/Type/RedisKeyFactoryTest.php | 8 + .../PartialUploadRequestFactoryTest.php | 49 + .../ResumableUploadRequestFactoryTest.php | 150 ++ .../Response/NoContentResponseFactoryTest.php | 87 + .../Type/S3/FileOperationFactoryTest.php | 115 ++ .../MergeFileChunksOperationFactoryTest.php | 158 ++ .../UploadFileChunkOperationFactoryTest.php | 149 ++ .../S3/UploadFileOperationFactoryTest.php | 229 +++ .../Factory/Type/UploadFactoryTest.php | 201 +++ .../Response/CollectionResponseTest.php | 12 +- .../Response/CreatedResponseTest.php | 4 +- .../Response/ElementResponseTest.php | 12 +- tests/UnitTests/Response/JsonResponseTest.php | 12 +- .../Response/NoContentResponseTest.php | 2 +- .../Response/NotModifiedResponseTest.php | 11 +- .../Response/ProblemJsonResponseTest.php | 2 +- tests/UnitTests/Response/TextResponseTest.php | 2 +- tests/UnitTests/Security/AuthProviderTest.php | 10 +- .../UnitTests/Service/AppStateServiceTest.php | 2 +- .../ElementDefragmentizeServiceTest.php | 2 +- .../Service/ElementFragmentizeServiceTest.php | 2 +- .../Service/ElementResponseServiceTest.php | 10 +- .../Service/EtagCalculatorServiceTest.php | 221 ++- tests/UnitTests/Service/EtagServiceTest.php | 31 + .../Service/ExpressionServiceTest.php | 2 +- tests/UnitTests/Service/FileServiceTest.php | 272 +++ .../Service/HeaderParseServiceTest.php | 500 ++++++ .../UnitTests/Service/MimeTypeServiceTest.php | 51 + .../ResetElementPropertiesServiceTest.php | 2 +- tests/UnitTests/Service/S3ServiceTest.php | 1491 +++++++++++++++++ .../Service/SecurityUtilServiceTest.php | 83 +- .../Service/StorageUtilServiceTest.php | 106 -- tests/UnitTests/Service/StringServiceTest.php | 73 + tests/UnitTests/Type/EtagCalculatorTest.php | 22 + .../Type/Request/PartialUploadRequestTest.php | 43 + .../Request/ResumableUploadRequestTest.php | 47 + tests/UnitTests/Type/S3/FileOperationTest.php | 23 + .../Type/S3/MergeFileChunksOperationTest.php | 32 + .../Type/S3/UploadFileChunkOperationTest.php | 32 + .../Type/S3/UploadFileOperationTest.php | 38 + tests/UnitTests/Type/UploadTest.php | 43 + tests/phpunit.xml.dist | 68 +- tools/docker-compose.yml | 31 + tools/generate-swagger.sh | 52 + 509 files changed, 12887 insertions(+), 2697 deletions(-) create mode 100644 docs/api-endpoints/error/get-400-reserved-type.md create mode 100644 docs/api-endpoints/error/get-400-reserved-type/200-response-body.txt create mode 100644 docs/api-endpoints/error/get-400-reserved-type/200-response-header.txt create mode 100644 docs/api-endpoints/file/delete-element-file/200-response-body.json create mode 100644 docs/api-endpoints/file/delete-element-file/200-response-header.txt create mode 100644 docs/api-endpoints/file/get-element-file/200-response-body.json create mode 100644 docs/api-endpoints/file/get-element-file/200-response-header.txt delete mode 100644 docs/api-endpoints/file/patch-element-file.md create mode 100644 docs/api-endpoints/file/post-element-file/200-response-body.json create mode 100644 docs/api-endpoints/file/post-element-file/200-response-header.txt create mode 100644 docs/api-endpoints/file/put-element-file/200-response-body.json create mode 100644 docs/api-endpoints/file/put-element-file/200-response-header.txt create mode 100644 docs/api-endpoints/upload/delete-upload.md create mode 100644 docs/api-endpoints/upload/delete-upload/200-response-body.json create mode 100644 docs/api-endpoints/upload/delete-upload/200-response-header.txt create mode 100644 docs/api-endpoints/upload/head-upload.md create mode 100644 docs/api-endpoints/upload/head-upload/200-response-body.json create mode 100644 docs/api-endpoints/upload/head-upload/200-response-header.txt create mode 100644 docs/api-endpoints/upload/patch-upload.md create mode 100644 docs/api-endpoints/upload/patch-upload/200-response-body.json create mode 100644 docs/api-endpoints/upload/patch-upload/200-response-header.txt delete mode 100644 docs/api-endpoints/webdav/copy-element.md delete mode 100644 docs/api-endpoints/webdav/lock-element.md delete mode 100644 docs/api-endpoints/webdav/mkcol-folder.md delete mode 100644 docs/api-endpoints/webdav/move-element.md delete mode 100644 docs/api-endpoints/webdav/propfind-element.md delete mode 100644 docs/api-endpoints/webdav/proppatch-element.md delete mode 100644 docs/api-endpoints/webdav/unlock-element.md delete mode 100644 docs/development/best-practices.md delete mode 100644 docs/development/long-term-plans.md create mode 100644 docs/open-api/examples/NotFoundProblemExample.json create mode 100644 docs/open-api/parameters/If-Match.json create mode 100644 docs/open-api/parameters/If-None-Match.json create mode 100644 docs/open-api/paths/Element/GetElement.json create mode 100644 docs/open-api/schemas/Element.json create mode 100644 docs/open-api/schemas/ElementId.json create mode 100644 docs/open-api/schemas/Problem.json create mode 100644 docs/open-api/swagger.json create mode 100644 docs/open-api/swagger_old.json create mode 100644 docs/open-api/swagger_old.yml create mode 100644 docs/open-api/swagger_template.json delete mode 100644 src/Attribute/EndpointImplementsTusIo.php create mode 100755 src/Command/Cron/DeleteExpiredUploadsCommand.php create mode 100755 src/Command/Cron/ReindexFilesCommand.php create mode 100755 src/Command/Cron/UpdateOwnershipCommand.php delete mode 100644 src/Command/TestCommand.php create mode 100755 src/Contract/EtagCapableResponseInterface.php delete mode 100644 src/Contract/LockInterface.php create mode 100755 src/Contract/Request/PartialUploadRequestInterface.php create mode 100755 src/Contract/Request/ResumableUploadRequestInterface.php create mode 100755 src/Contract/S3/FileOperationInterface.php create mode 100755 src/Contract/S3/MergeFileChunksOperationInterface.php create mode 100755 src/Contract/S3/UploadFileChunkOperationInterface.php create mode 100755 src/Contract/S3/UploadFileOperationInterface.php create mode 100644 src/Contract/UploadInterface.php create mode 100644 src/Controller/Error/assets/error-400-reserved-type.txt create mode 100644 src/Controller/Error/assets/error-409-conflict.txt create mode 100644 src/Controller/Error/assets/error-410-gone.txt delete mode 100644 src/Controller/Error/assets/error-412-tus-io-precondition-failed.txt delete mode 100644 src/Controller/File/GetTusUploadController.php delete mode 100644 src/Controller/File/PatchElementFileController.php create mode 100644 src/Controller/Upload/DeleteUploadController.php create mode 100644 src/Controller/Upload/HeadUploadController.php create mode 100644 src/Controller/Upload/PatchUploadController.php delete mode 100644 src/EventSystem/Controller/EventListener/TusIoControllerEventListener.php create mode 100644 src/EventSystem/ElementFileDelete/Event/ElementFileDeleteEvent.php create mode 100644 src/EventSystem/ElementFileDelete/EventListener/ElasticsearchFileDeleteEventListener.php create mode 100644 src/EventSystem/ElementFileReplace/Event/ElementFileReplaceEvent.php create mode 100644 src/EventSystem/ElementFileReplace/EventListener/ElasticsearchFileReplaceEventListener.php create mode 100644 src/EventSystem/Etag/Event/FileEtagEvent.php create mode 100644 src/EventSystem/Etag/EventListener/LiveFileEtagEventListener.php create mode 100644 src/EventSystem/Etag/EventListener/RedisFileEtagEventListener.php delete mode 100644 src/EventSystem/Response/EventListener/TusIoResponseEventListener.php delete mode 100644 src/EventSystem/Response/config.yaml create mode 100644 src/Exception/Client400ReservedTypeException.php create mode 100644 src/Exception/Client409ConflictException.php create mode 100644 src/Exception/Client410GoneException.php create mode 100644 src/Factory/Exception/Client400ReservedTypeExceptionFactory.php create mode 100644 src/Factory/Exception/Client409ConflictExceptionFactory.php create mode 100644 src/Factory/Exception/Client410GoneExceptionFactory.php delete mode 100644 src/Factory/Exception/Client412TusResumablePreconditionFailedExceptionFactory.php rename src/Factory/Exception/{Server500LogicExceptionFactory.php => Server500LogicErrorExceptionFactory.php} (85%) create mode 100644 src/Factory/Type/Request/PartialUploadRequestFactory.php create mode 100644 src/Factory/Type/Request/ResumableUploadRequestFactory.php create mode 100644 src/Factory/Type/Response/NoContentResponseFactory.php create mode 100644 src/Factory/Type/S3/FileOperationFactory.php create mode 100644 src/Factory/Type/S3/MergeFileChunksOperationFactory.php create mode 100644 src/Factory/Type/S3/UploadFileChunkOperationFactory.php create mode 100644 src/Factory/Type/S3/UploadFileOperationFactory.php create mode 100644 src/Factory/Type/UploadFactory.php delete mode 100644 src/Factory/UploadObjectFactory.php delete mode 100644 src/Response/BinaryStreamResponse.php delete mode 100755 src/Response/CollectionResponse.php delete mode 100755 src/Response/ElementResponse.php create mode 100644 src/Service/ElementService.php create mode 100644 src/Service/FilePropertyService.php create mode 100644 src/Service/FileService.php create mode 100644 src/Service/HeaderParseService.php delete mode 100644 src/Service/LockService.php create mode 100644 src/Service/MimeTypeService.php create mode 100644 src/Service/PropertyParseService.php create mode 100644 src/Service/QueueService.php create mode 100644 src/Service/S3Service.php delete mode 100644 src/Service/StorageUtilService.php create mode 100644 src/Service/StringService.php delete mode 100644 src/Service/TusIoService.php create mode 100755 src/Service/UploadCreationService.php create mode 100755 src/Service/UploadService.php create mode 100644 src/Type/FileProperty.php delete mode 100644 src/Type/Lock/FileUploadCheckLock.php create mode 100644 src/Type/Request/PartialUploadRequest.php create mode 100644 src/Type/Request/ResumableUploadRequest.php create mode 100644 src/Type/Response/BinaryStreamResponse.php create mode 100755 src/Type/Response/CollectionResponse.php rename src/{ => Type}/Response/CreatedResponse.php (71%) create mode 100755 src/Type/Response/ElementResponse.php rename src/{ => Type}/Response/JsonResponse.php (80%) rename src/{ => Type}/Response/NoContentResponse.php (93%) rename src/{ => Type}/Response/NotModifiedResponse.php (77%) rename src/{ => Type}/Response/ProblemJsonResponse.php (94%) rename src/{ => Type}/Response/TextResponse.php (94%) create mode 100644 src/Type/S3/FileOperation.php create mode 100644 src/Type/S3/MergeFileChunksOperation.php create mode 100644 src/Type/S3/UploadFileChunkOperation.php create mode 100644 src/Type/S3/UploadFileOperation.php delete mode 100644 src/Type/TusIoExtensionType.php delete mode 100644 src/Type/TusIoHeaderType.php delete mode 100644 src/Type/UploadConcatType.php delete mode 100644 src/Type/UploadVariantType.php delete mode 100644 src/Type/UploadVariantVersionType.php create mode 100644 src/Wrapper/ContentDispositionWrapper.php create mode 100644 src/Wrapper/S3ClientWrapper.php create mode 100644 tests/FeatureTests/Asset/.gitignore create mode 100644 tests/FeatureTests/Asset/cherry-blossoms.jpg create mode 100644 tests/FeatureTests/Endpoint/Error/Get400ReservedTypeTest.php delete mode 100644 tests/FeatureTests/Endpoint/File/PatchFileTest.php create mode 100644 tests/FeatureTests/Endpoint/File/PostFileInSingleRequestTest.php delete mode 100644 tests/FeatureTests/Endpoint/File/PostFileTest.php create mode 100644 tests/FeatureTests/Endpoint/File/PutFileInSingleRequestTest.php delete mode 100644 tests/FeatureTests/Endpoint/File/PutFileTest.php create mode 100644 tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php create mode 100644 tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php create mode 100644 tests/UnitTests/EventSystem/ElementFileDelete/Event/ElementFileDeleteEventTest.php create mode 100644 tests/UnitTests/EventSystem/ElementFileReplace/Event/ElementFileReplaceEventTest.php create mode 100644 tests/UnitTests/EventSystem/Etag/Event/FileEtagEventTest.php create mode 100644 tests/UnitTests/EventSystem/Etag/EventListener/LiveFileEtagEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/Etag/EventListener/RedisFileEtagEventListenerTest.php create mode 100644 tests/UnitTests/Exception/Client400ReservedTypeExceptionTest.php create mode 100644 tests/UnitTests/Exception/Client409ConflictExceptionTest.php create mode 100644 tests/UnitTests/Exception/Client410GoneExceptionTest.php create mode 100644 tests/UnitTests/Factory/Exception/Client400ReservedTypeExceptionFactoryTest.php create mode 100644 tests/UnitTests/Factory/Exception/Client409ConflictExceptionFactoryTest.php create mode 100644 tests/UnitTests/Factory/Exception/Client410GoneExceptionFactoryTest.php create mode 100644 tests/UnitTests/Factory/Type/Request/PartialUploadRequestFactoryTest.php create mode 100644 tests/UnitTests/Factory/Type/Request/ResumableUploadRequestFactoryTest.php create mode 100644 tests/UnitTests/Factory/Type/Response/NoContentResponseFactoryTest.php create mode 100644 tests/UnitTests/Factory/Type/S3/FileOperationFactoryTest.php create mode 100644 tests/UnitTests/Factory/Type/S3/MergeFileChunksOperationFactoryTest.php create mode 100644 tests/UnitTests/Factory/Type/S3/UploadFileChunkOperationFactoryTest.php create mode 100644 tests/UnitTests/Factory/Type/S3/UploadFileOperationFactoryTest.php create mode 100644 tests/UnitTests/Factory/Type/UploadFactoryTest.php create mode 100644 tests/UnitTests/Service/FileServiceTest.php create mode 100644 tests/UnitTests/Service/HeaderParseServiceTest.php create mode 100644 tests/UnitTests/Service/MimeTypeServiceTest.php create mode 100644 tests/UnitTests/Service/S3ServiceTest.php delete mode 100644 tests/UnitTests/Service/StorageUtilServiceTest.php create mode 100644 tests/UnitTests/Service/StringServiceTest.php create mode 100644 tests/UnitTests/Type/Request/PartialUploadRequestTest.php create mode 100644 tests/UnitTests/Type/Request/ResumableUploadRequestTest.php create mode 100644 tests/UnitTests/Type/S3/FileOperationTest.php create mode 100644 tests/UnitTests/Type/S3/MergeFileChunksOperationTest.php create mode 100644 tests/UnitTests/Type/S3/UploadFileChunkOperationTest.php create mode 100644 tests/UnitTests/Type/S3/UploadFileOperationTest.php create mode 100644 tests/UnitTests/Type/UploadTest.php create mode 100755 tools/generate-swagger.sh diff --git a/.env b/.env index 7dc13b77..c532eb0e 100755 --- a/.env +++ b/.env @@ -28,8 +28,8 @@ ELASTIC_AUTH=elasticsearch:9200 REDIS_AUTH=tcp://redis?password=redis-password RABBITMQ_AUTH=amqp://user:password@rabbitmq:5672 -S3_ENDPOINT=https://ember-nexus-dev-minio:9000 -S3_ACCESS_KEY_ID=key -S3_SECRET_ACCESS_KEY=key +S3_ENDPOINT=http://minio:9000 +S3_ACCESS_KEY_ID=accessKey +S3_SECRET_ACCESS_KEY=secretKey -REFERENCE_DATASET_VERSION=0.0.28 +REFERENCE_DATASET_VERSION=0.0.29 diff --git a/.env.test b/.env.test index 5138e28b..b329fa2d 100644 --- a/.env.test +++ b/.env.test @@ -2,8 +2,6 @@ KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 -PANTHER_APP_ENV=panther -PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots API_DOMAIN=http://api CYPHER_AUTH=bolt://neo4j:password@neo4j @@ -11,3 +9,7 @@ ELASTIC_AUTH=elasticsearch:9200 MONGO_AUTH=mongodb://mongodb:password@mongodb:27017 REDIS_AUTH=tcp://redis?password=redis-password ANONYMOUS_USER_UUID=2d376349-c5e2-42c8-8ce0-d6f525256cf7 + +S3_ENDPOINT=http://minio:9000 +S3_ACCESS_KEY_ID=accessKey +S3_SECRET_ACCESS_KEY=secretKey diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 4be41961..d77f46fb 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -196,7 +196,6 @@ jobs: - phpstan - psalm - cs-lint - - test-mutant - test-mess-detector strategy: matrix: @@ -245,7 +244,6 @@ jobs: - phpstan - psalm - cs-lint - - test-mutant - test-mess-detector steps: - uses: actions/checkout@v5 @@ -289,7 +287,6 @@ jobs: - phpstan - psalm - cs-lint - - test-mutant - test-mess-detector steps: - uses: actions/checkout@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index e5db4e63..fc69d691 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,18 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add taskfile.dev development environment into the API repository itself, closes #461. - Add S3 status check to healthcheck command, closes #200. - Add support to delete files in S3 / object storage through the `php bin/console database:drop` command, closes #309. -- Add support for locks, related to #119. +- Add support to back up files in S3 / object storage through the `php bin/console backup:create` command, closes #308. +- Add support for `Content-Disposition` HTTP header, including transliteration of non-latin-characters, closes #447. +- Add dependency to PHP library `cardinalby/content-disposition`. +- Add 'reserved type' exception; nodes of type 'User', 'Token' and 'Upload' can not be manually created through generic + API endpoints. +- Store value `true` as placeholder for non-scalar properties in Neo4j. Does not change API responses, but is available + for Neo4j based queries, e.g. to check whether a non-scalar property exists on the element. ### Changed - Upgrade FrankenPHP to 1.12.1, related to #457 and #461. +- Upgrade PHP to 8.5.4, related to #457 and #461. - Upgrade PHP dependencies, related to #457 and #461. - Upgrade GitHub Actions in CI/CD. -- Increase max post limit from 2 MB (PHP default) to 100 MB, related to #119. +- Increase max post limit from 2 MB (PHP default) to 101 MiB, related to #119. - Change header `Access-Control-Allow-Headers` to `*`, due to growing number of supported headers. + ### Fixed -- Fix deprecation "The "Symfony\Component\HttpKernel\DependencyInjection\Extension" class is considered internal since - Symfony 7.1, to be deprecated in 8.1; use Symfony\Component\DependencyInjection\Extension\Extension instead. It may - change without further notice.". +- Fix bug with deserialization of MongoDB documents. ## 0.1.31 - 2026-02-10 ### Changed diff --git a/README.md b/README.md index 96e2e559..47a60f05 100755 --- a/README.md +++ b/README.md @@ -7,50 +7,41 @@ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ember-nexus/api/ci-test.yml?label=CI) [![Discord](https://img.shields.io/discord/1135243882360221787?logo=discord&label=Discord&color=%235865f2)](https://discord.gg/qbQFBrJrRC) -Store anything, connect everything, search it all? Sure thing! 😃 +Ember Nexus is a knowledge graph API, which means that it is specialized in storing and retrieving your data. +It can be used as an alternative to Obsidian, Notion, your companies ERP or content management systems or as a blank +database for your own projects. As long as you need to store data and are interested in easy to use cutting edge +features - Ember Nexus will be useful for you! :D -Ember Nexus API is a dynamic and versatile REST-API that leverages the power of graphs to provide flexible and secure -data storage and retrieval for data-minded people. It is [source first](https://sourcefirst.com/) and can be hosted on -your own hardware - we currently support AMD and ARM architectures. +## How is Ember Nexus different to other solutions? -## Quick Links +Ember Nexus is built on three principles: -- [Getting Started](https://ember-nexus.github.io/api/#/getting-started/tech-stack) -- [Check out the documentation](https://ember-nexus.github.io/api) -- [Find us on Docker Hub](https://hub.docker.com/r/embernexus/api) -- [Demo API](https://reference-dataset.ember-nexus.dev/) +- Be easy to use. +- Be secure. +- Expose the advantages and modern features of its internal databases. -## Features +The "secret sauce" is the combination of databases Ember Nexus uses: -Ember Nexus offers a range of cutting-edge features to meet your data storage needs, including: +- **Neo4j**: A graph database, which stores relationships and core properties of your data elements. +- **MongoDB**: A document database, stores more complex properties which Neo4j can not handle itself. +- **Elasticsearch**: A search database, used for full text search, vector search and more. +- **S3 compatible object store**: Used to store binary files, up to 1 TB by default. -- **Graph-based data model**: Ember Nexus uses the graph database [Neo4j](https://neo4j.com/) internally, which enables - you to connect any data element to any other data element and efficiently search long and recursive relations. -- **Access through UUID**: Ember Nexus assigns a UUID to every storable element, making it easy to retrieve and manage - data. User can provide their own UUIDs as well, although this feature is disabled by default. -- **Near real-time search**: Ember Nexus supports full-text search that is fast and responsive. Most of - [Elasticsearch](https://www.elastic.co/elasticsearch/)'s features are directly available while not bypassing the - security model. -- **Secure by default**: User-created data is only visible to the owner by default. Sharing data requires explicit - rules, which are recursive and optionally limited in access scope and other features. -- **High-quality software**: We invested quite a bit of time to make sure that this software is as stable as possible. - We employ code linters, unit tests, feature tests, memory leak tests, mutant tests, and more. Most examples in the - documentation are automatically checked for deprecations. +Instead of using multiple specialized databases yourself, simply use Ember Nexus, and benefit of the exposed features +and without having to worry about data consistency, security aspects and more. -## Planned Features +It just works :D -See our [milestone](https://github.com/ember-nexus/api/milestones) and -[issue](https://github.com/ember-nexus/api/issues) list on GitHub for planned features. +## What can you find where? -## When should I use Ember Nexus API? +- [GitHub Repository](https://github.com/ember-nexus/api): Keep up to date with new releases, post issues and more. +- [API Documentation](https://ember-nexus.github.io/api/#/): For everyone who wants to develop own apps on top of + Ember Nexus. +- [Docker Hub](https://hub.docker.com/r/embernexus/api): Ready to use container images for AMD and ARM architectures. +- [Demo API](https://reference-dataset.ember-nexus.dev/): Instance which can be used for quick testing. Is being reset + every day, and uses the [reference dataset](https://github.com/ember-nexus/reference-dataset) as its data, which + includes eternal tokens. -Ember Nexus is a powerful tool for a range of use cases, including: +## Is there a graphical user interface (GUI)? -- **Private cloud**: Use Ember Nexus to create your own private cloud, similar to Nextcloud. -- **Websites**: Use Ember Nexus as a CMS or blog platform. -- **Data management systems**: Ember Nexus is ideal for building data management systems and archives. -- **Data lakes**: Ember Nexus makes it easy to create data lakes, where you can store and manage large volumes of data. -- **Interface between multiple systems**: Ember Nexus can serve as a bridge between multiple systems, making it easy to - integrate and manage data across your entire infrastructure. - -Experience the power of Ember Nexus for yourself - [try it out today](https://ember-nexus.github.io/api/#/)! +It is currently being developed, but not yet ready to be used. diff --git a/composer.json b/composer.json index 3e494ad0..5939c3cc 100644 --- a/composer.json +++ b/composer.json @@ -7,9 +7,11 @@ "require": { "php": ">=8.5", "ext-ctype": "*", + "ext-fileinfo": "*", "ext-iconv": "*", "antlr/antlr4-php-runtime": "^0.9.1", - "async-aws/s3": "^2.2", + "async-aws/s3": "^3.1", + "cardinalby/content-disposition": "^1.1", "guzzlehttp/guzzle": "^7.8", "league/flysystem": "^3.23", "league/flysystem-bundle": "^3.3", @@ -21,6 +23,7 @@ "symfony/asset": "7.4.*", "symfony/console": "7.4.*", "symfony/dotenv": "7.4.*", + "symfony/emoji": "7.4.*", "symfony/event-dispatcher": "7.4.*", "symfony/filesystem": "7.4.*", "symfony/flex": "^2.4", @@ -35,24 +38,24 @@ "syndesi/elastic-entity-manager": "^0.1.0", "syndesi/mongo-entity-manager": "^0.2.0", "thecodingmachine/safe": "^v3.3", - "tuupola/base58": "^2.1" + "tuupola/base58": "^2.2" }, "require-dev": { "beste/psr-testlogger": "^1.0", - "brianium/paratest": "^7.13", + "brianium/paratest": "^7.22", "friendsofphp/php-cs-fixer": "^3.48", "infection/infection": "^0.32", "phpbench/phpbench": "^1.2", "phpmd/phpmd": "^2.15", "phpspec/prophecy-phpunit": "^2.1", "phpstan/phpstan": "^2.1", - "phpunit/php-code-coverage": "^12", - "phpunit/phpunit": "^12", + "phpunit/php-code-coverage": "^14", + "phpunit/phpunit": "^13", "symfony/maker-bundle": "^1.53", "symfony/phpunit-bridge": "^7.2", "symfony/web-profiler-bundle": "7.4.*", "thecodingmachine/phpstan-safe-rule": "^1.4", - "vimeo/psalm": "^6.2" + "vimeo/psalm": "^6.14" }, "config": { "allow-plugins": { @@ -112,7 +115,7 @@ "test:example-generation-command": "php bin/console cache:clear && ./bin/test-feature-command-prepare && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationCommand --exclude-group deprecated && php bin/console cache:clear", "test:coverage:xml": "export XDEBUG_MODE=coverage && php ./vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.xml.dist --coverage-clover coverage.xml", "test:coverage:html": "export XDEBUG_MODE=coverage && php ./vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.xml.dist --coverage-html ./tmp", - "test:mutant": "export XDEBUG_MODE=coverage && php vendor/infection/infection/bin/infection --configuration=tests/infection.json5 --threads=4", + "test:mutant": "export XDEBUG_MODE=coverage && php vendor/infection/infection/bin/infection --configuration=tests/infection.json5 --threads=max", "cs:list": "php vendor/friendsofphp/php-cs-fixer/php-cs-fixer fix --config=tests/.php-cs-fixer.dist.php --allow-risky=yes --dry-run", "cs:fix": "php vendor/friendsofphp/php-cs-fixer/php-cs-fixer fix --config=tests/.php-cs-fixer.dist.php --allow-risky=yes", "psalm": "php vendor/bin/psalm --config=tests/psalm.xml --show-info=true --no-cache", diff --git a/composer.lock b/composer.lock index 4a81b3b7..6188d11a 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": "6000e90ca0e3e3b88cefc3b93868eacd", + "content-hash": "4f2d062a8dff390133ae8a81e1d39211", "packages": [ { "name": "antlr/antlr4-php-runtime", @@ -135,16 +135,16 @@ }, { "name": "async-aws/s3", - "version": "2.10.0", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/async-aws/s3.git", - "reference": "9c826ab620dbdc99528f0ad0b728ef9d478714db" + "reference": "68664f2db5d990b7e8863624791366d6e8925398" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/async-aws/s3/zipball/9c826ab620dbdc99528f0ad0b728ef9d478714db", - "reference": "9c826ab620dbdc99528f0ad0b728ef9d478714db", + "url": "https://api.github.com/repos/async-aws/s3/zipball/68664f2db5d990b7e8863624791366d6e8925398", + "reference": "68664f2db5d990b7e8863624791366d6e8925398", "shasum": "" }, "require": { @@ -153,12 +153,17 @@ "ext-filter": "*", "ext-hash": "*", "ext-simplexml": "*", - "php": "^7.2.5 || ^8.0" + "php": "^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.42", + "symfony/error-handler": "^7.3.2 || ^8.0", + "symfony/phpunit-bridge": "^7.3.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.10-dev" + "dev-master": "3.1-dev" } }, "autoload": { @@ -179,7 +184,7 @@ "sdk" ], "support": { - "source": "https://github.com/async-aws/s3/tree/2.10.0" + "source": "https://github.com/async-aws/s3/tree/3.1.0" }, "funding": [ { @@ -191,7 +196,7 @@ "type": "github" } ], - "time": "2025-08-11T10:03:27+00:00" + "time": "2026-02-16T10:24:54+00:00" }, { "name": "brick/math", @@ -253,6 +258,48 @@ ], "time": "2026-02-10T14:33:43+00:00" }, + { + "name": "cardinalby/content-disposition", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/cardinalby/phpContentDisposition.git", + "reference": "651b4dd51239c81967f9fce08dfdb786a65d6319" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cardinalby/phpContentDisposition/zipball/651b4dd51239c81967f9fce08dfdb786a65d6319", + "reference": "651b4dd51239c81967f9fce08dfdb786a65d6319", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "phpunit/phpunit": "^10.0.19" + }, + "type": "library", + "autoload": { + "psr-4": { + "cardinalby\\ContentDisposition\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cardinal", + "homepage": "https://github.com/cardinalby/phpContentDisposition" + } + ], + "support": { + "issues": "https://github.com/cardinalby/phpContentDisposition/issues", + "source": "https://github.com/cardinalby/phpContentDisposition/tree/1.1.1" + }, + "time": "2023-05-23T22:16:05+00:00" + }, { "name": "clue/stream-filter", "version": "v1.7.0", @@ -762,16 +809,16 @@ }, { "name": "laudis/neo4j-php-client", - "version": "3.4.2", + "version": "3.4.3", "source": { "type": "git", "url": "https://github.com/neo4j-php/neo4j-php-client.git", - "reference": "1084918914cbb90d5ad0a2cd3c4fe17f8b0c64b5" + "reference": "f97858445e322b37424c52b3636069ec20c2df16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/neo4j-php/neo4j-php-client/zipball/1084918914cbb90d5ad0a2cd3c4fe17f8b0c64b5", - "reference": "1084918914cbb90d5ad0a2cd3c4fe17f8b0c64b5", + "url": "https://api.github.com/repos/neo4j-php/neo4j-php-client/zipball/f97858445e322b37424c52b3636069ec20c2df16", + "reference": "f97858445e322b37424c52b3636069ec20c2df16", "shasum": "" }, "require": { @@ -844,9 +891,9 @@ ], "support": { "issues": "https://github.com/neo4j-php/neo4j-php-client/issues", - "source": "https://github.com/neo4j-php/neo4j-php-client/tree/3.4.2" + "source": "https://github.com/neo4j-php/neo4j-php-client/tree/3.4.3" }, - "time": "2025-12-17T10:13:20+00:00" + "time": "2026-03-20T09:53:10+00:00" }, { "name": "laudis/typed-enum", @@ -896,16 +943,16 @@ }, { "name": "league/flysystem", - "version": "3.32.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -973,22 +1020,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-02-25T17:01:41+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-bundle", - "version": "3.6.2", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-bundle.git", - "reference": "123ab96910177751faf3b6cc85eecc360ec12a1f" + "reference": "5eb41be38fc3759f74c9e458a6a5f0ef5f49284a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-bundle/zipball/123ab96910177751faf3b6cc85eecc360ec12a1f", - "reference": "123ab96910177751faf3b6cc85eecc360ec12a1f", + "url": "https://api.github.com/repos/thephpleague/flysystem-bundle/zipball/5eb41be38fc3759f74c9e458a6a5f0ef5f49284a", + "reference": "5eb41be38fc3759f74c9e458a6a5f0ef5f49284a", "shasum": "" }, "require": { @@ -1043,9 +1090,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem-bundle/issues", - "source": "https://github.com/thephpleague/flysystem-bundle/tree/3.6.2" + "source": "https://github.com/thephpleague/flysystem-bundle/tree/3.7.0" }, - "time": "2026-02-05T15:26:57+00:00" + "time": "2026-03-28T22:14:56+00:00" }, { "name": "league/flysystem-local", @@ -1383,16 +1430,16 @@ }, { "name": "open-telemetry/api", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad" + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/df5197c6fd0ddd8e9883b87de042d9341300e2ad", - "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", "shasum": "" }, "require": { @@ -1449,20 +1496,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-01-21T04:14:03+00:00" + "time": "2026-02-25T13:24:05+00:00" }, { "name": "open-telemetry/context", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/context.git", - "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf" + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/d4c4470b541ce72000d18c339cfee633e4c8e0cf", - "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", "shasum": "" }, "require": { @@ -1504,11 +1551,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-09-19T00:05:49+00:00" + "time": "2025-10-19T06:44:33+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -1969,16 +2016,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.50", + "version": "3.0.51", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", "shasum": "" }, "require": { @@ -2059,7 +2106,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" }, "funding": [ { @@ -2075,7 +2122,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T02:57:58+00:00" + "time": "2026-04-10T01:33:53+00:00" }, { "name": "predis/predis", @@ -2822,16 +2869,16 @@ }, { "name": "symfony/asset", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/asset.git", - "reference": "d944ae87e4697af05aadeacfc5e603c3c18ef4fb" + "reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/d944ae87e4697af05aadeacfc5e603c3c18ef4fb", - "reference": "d944ae87e4697af05aadeacfc5e603c3c18ef4fb", + "url": "https://api.github.com/repos/symfony/asset/zipball/d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", + "reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", "shasum": "" }, "require": { @@ -2871,7 +2918,7 @@ "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset/tree/v7.4.6" + "source": "https://github.com/symfony/asset/tree/v7.4.8" }, "funding": [ { @@ -2891,20 +2938,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/cache", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "665522ec357540e66c294c08583b40ee576574f0" + "reference": "467464da294734b0fb17e853e5712abc8470f819" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/665522ec357540e66c294c08583b40ee576574f0", - "reference": "665522ec357540e66c294c08583b40ee576574f0", + "url": "https://api.github.com/repos/symfony/cache/zipball/467464da294734b0fb17e853e5712abc8470f819", + "reference": "467464da294734b0fb17e853e5712abc8470f819", "shasum": "" }, "require": { @@ -2975,7 +3022,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.7" + "source": "https://github.com/symfony/cache/tree/v7.4.8" }, "funding": [ { @@ -2995,7 +3042,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T08:14:57+00:00" + "time": "2026-03-30T15:15:47+00:00" }, { "name": "symfony/cache-contracts", @@ -3075,16 +3122,16 @@ }, { "name": "symfony/config", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5" + "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/6c17162555bfb58957a55bb0e43e00035b6ae3d5", - "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5", + "url": "https://api.github.com/repos/symfony/config/zipball/2d19dde43fa2ff720b9a40763ace7226594f503b", + "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b", "shasum": "" }, "require": { @@ -3130,7 +3177,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.7" + "source": "https://github.com/symfony/config/tree/v7.4.8" }, "funding": [ { @@ -3150,20 +3197,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T10:41:14+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", "shasum": "" }, "require": { @@ -3228,7 +3275,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v7.4.8" }, "funding": [ { @@ -3248,20 +3295,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db" + "reference": "f7025fd7b687c240426562f86ada06a93b1e771d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", - "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f7025fd7b687c240426562f86ada06a93b1e771d", + "reference": "f7025fd7b687c240426562f86ada06a93b1e771d", "shasum": "" }, "require": { @@ -3312,7 +3359,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.7" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.8" }, "funding": [ { @@ -3332,7 +3379,7 @@ "type": "tidelift" } ], - "time": "2026-03-03T07:48:48+00:00" + "time": "2026-03-31T06:50:29+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3403,16 +3450,16 @@ }, { "name": "symfony/dotenv", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "7e5529a0b02395cb4614cdf507495a4cef3115c5" + "reference": "5df79f11350166125fe754c85b87f7e13d735314" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/7e5529a0b02395cb4614cdf507495a4cef3115c5", - "reference": "7e5529a0b02395cb4614cdf507495a4cef3115c5", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/5df79f11350166125fe754c85b87f7e13d735314", + "reference": "5df79f11350166125fe754c85b87f7e13d735314", "shasum": "" }, "require": { @@ -3457,7 +3504,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.4.7" + "source": "https://github.com/symfony/dotenv/tree/v7.4.8" }, "funding": [ { @@ -3477,20 +3524,100 @@ "type": "tidelift" } ], - "time": "2026-03-03T07:48:48+00:00" + "time": "2026-03-30T12:55:43+00:00" + }, + { + "name": "symfony/emoji", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/emoji.git", + "reference": "b40b8ad11110a84f69e6751356a7ef4f4e962dc6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/emoji/zipball/b40b8ad11110a84f69e6751356a7ef4f4e962dc6", + "reference": "b40b8ad11110a84f69e6751356a7ef4f4e962dc6", + "shasum": "" + }, + "require": { + "ext-intl": "*", + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^7.1|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Emoji\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/data/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides access to emoji characters and sequences from the Unicode CLDR", + "homepage": "https://symfony.com", + "keywords": [ + "cldr", + "emoji", + "intl", + "transliterator", + "unicode" + ], + "support": { + "source": "https://github.com/symfony/emoji/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", "shasum": "" }, "require": { @@ -3539,7 +3666,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" }, "funding": [ { @@ -3559,20 +3686,20 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + "reference": "f57b899fa736fd71121168ef268f23c206083f0a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f57b899fa736fd71121168ef268f23c206083f0a", + "reference": "f57b899fa736fd71121168ef268f23c206083f0a", "shasum": "" }, "require": { @@ -3624,7 +3751,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.8" }, "funding": [ { @@ -3644,7 +3771,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:34+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -3724,16 +3851,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5", + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5", "shasum": "" }, "require": { @@ -3770,7 +3897,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.6" + "source": "https://github.com/symfony/filesystem/tree/v7.4.8" }, "funding": [ { @@ -3790,20 +3917,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -3838,7 +3965,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -3858,7 +3985,7 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/flex", @@ -3935,16 +4062,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "c94bc78c85d76af67918404a95d44940f66a7c2f" + "reference": "180533cfbac2144349044267db31d5d3df9957cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/c94bc78c85d76af67918404a95d44940f66a7c2f", - "reference": "c94bc78c85d76af67918404a95d44940f66a7c2f", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/180533cfbac2144349044267db31d5d3df9957cb", + "reference": "180533cfbac2144349044267db31d5d3df9957cb", "shasum": "" }, "require": { @@ -4069,7 +4196,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.4.7" + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.8" }, "funding": [ { @@ -4089,20 +4216,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T15:39:55+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", "shasum": "" }, "require": { @@ -4170,7 +4297,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.8" }, "funding": [ { @@ -4190,7 +4317,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -4272,16 +4399,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" + "reference": "9381209597ec66c25be154cbf2289076e64d1eab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", + "reference": "9381209597ec66c25be154cbf2289076e64d1eab", "shasum": "" }, "require": { @@ -4330,7 +4457,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" }, "funding": [ { @@ -4350,20 +4477,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" + "reference": "017e76ad089bac281553389269e259e155935e1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/017e76ad089bac281553389269e259e155935e1a", + "reference": "017e76ad089bac281553389269e259e155935e1a", "shasum": "" }, "require": { @@ -4449,7 +4576,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.8" }, "funding": [ { @@ -4469,20 +4596,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:33:18+00:00" + "time": "2026-03-31T20:57:01+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "69e98e7e10dae3daa896ef0f20e17a3928362d88" + "reference": "b52aeb44645a9a84a1795b973cc5c77a76df0720" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/69e98e7e10dae3daa896ef0f20e17a3928362d88", - "reference": "69e98e7e10dae3daa896ef0f20e17a3928362d88", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/b52aeb44645a9a84a1795b973cc5c77a76df0720", + "reference": "b52aeb44645a9a84a1795b973cc5c77a76df0720", "shasum": "" }, "require": { @@ -4532,7 +4659,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.6" + "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.8" }, "funding": [ { @@ -4552,20 +4679,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/monolog-bundle", - "version": "v3.11.1", + "version": "v3.11.2", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bundle.git", - "reference": "0e675a6e08f791ef960dc9c7e392787111a3f0c1" + "reference": "d87468010570b2ec766152184918ee8d267c7411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/0e675a6e08f791ef960dc9c7e392787111a3f0c1", - "reference": "0e675a6e08f791ef960dc9c7e392787111a3f0c1", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/d87468010570b2ec766152184918ee8d267c7411", + "reference": "d87468010570b2ec766152184918ee8d267c7411", "shasum": "" }, "require": { @@ -4612,7 +4739,7 @@ ], "support": { "issues": "https://github.com/symfony/monolog-bundle/issues", - "source": "https://github.com/symfony/monolog-bundle/tree/v3.11.1" + "source": "https://github.com/symfony/monolog-bundle/tree/v3.11.2" }, "funding": [ { @@ -4632,20 +4759,20 @@ "type": "tidelift" } ], - "time": "2025-12-08T07:58:26+00:00" + "time": "2026-04-02T18:23:01+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "b38026df55197f9e39a44f3215788edf83187b80" + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", - "reference": "b38026df55197f9e39a44f3215788edf83187b80", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", "shasum": "" }, "require": { @@ -4683,7 +4810,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" }, "funding": [ { @@ -4703,20 +4830,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", "shasum": "" }, "require": { @@ -4765,7 +4892,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" }, "funding": [ { @@ -4785,11 +4912,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -4850,7 +4977,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" }, "funding": [ { @@ -4874,16 +5001,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -4935,7 +5062,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" }, "funding": [ { @@ -4955,20 +5082,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php82", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "5d2ed36f7734637dacc025f179698031951b1692" + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692", - "reference": "5d2ed36f7734637dacc025f179698031951b1692", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", "shasum": "" }, "require": { @@ -5015,7 +5142,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0" }, "funding": [ { @@ -5035,20 +5162,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -5095,7 +5222,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" }, "funding": [ { @@ -5115,20 +5242,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -5175,7 +5302,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.36.0" }, "funding": [ { @@ -5195,20 +5322,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "2c408a6bb0313e6001a83628dc5506100474254e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", + "reference": "2c408a6bb0313e6001a83628dc5506100474254e", "shasum": "" }, "require": { @@ -5255,7 +5382,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" }, "funding": [ { @@ -5275,20 +5402,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-10T16:50:15+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", "shasum": "" }, "require": { @@ -5320,7 +5447,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.8" }, "funding": [ { @@ -5340,20 +5467,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", "shasum": "" }, "require": { @@ -5405,7 +5532,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v7.4.8" }, "funding": [ { @@ -5425,20 +5552,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/runtime", - "version": "v7.4.1", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f" + "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/876f902a6cb6b26c003de244188c06b2ba1c172f", - "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f", + "url": "https://api.github.com/repos/symfony/runtime/zipball/6d792a64fec1eae2f011cfe9ab5978a9eab3071e", + "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e", "shasum": "" }, "require": { @@ -5488,7 +5615,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.4.1" + "source": "https://github.com/symfony/runtime/tree/v7.4.8" }, "funding": [ { @@ -5508,7 +5635,7 @@ "type": "tidelift" } ], - "time": "2025-12-05T14:04:53+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/service-contracts", @@ -5599,16 +5726,16 @@ }, { "name": "symfony/stopwatch", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "8a24af0a2e8a872fb745047180649b8418303084" + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084", - "reference": "8a24af0a2e8a872fb745047180649b8418303084", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", "shasum": "" }, "require": { @@ -5641,7 +5768,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.4.0" + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" }, "funding": [ { @@ -5661,20 +5788,20 @@ "type": "tidelift" } ], - "time": "2025-08-04T07:05:15+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/string", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" + "reference": "114ac57257d75df748eda23dd003878080b8e688" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", + "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688", + "reference": "114ac57257d75df748eda23dd003878080b8e688", "shasum": "" }, "require": { @@ -5732,7 +5859,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.6" + "source": "https://github.com/symfony/string/tree/v7.4.8" }, "funding": [ { @@ -5752,20 +5879,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", "shasum": "" }, "require": { @@ -5819,7 +5946,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" }, "funding": [ { @@ -5839,20 +5966,20 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-30T13:44:50+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" + "reference": "398907e89a2a56fe426f7955c6fa943ec0c77225" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/398907e89a2a56fe426f7955c6fa943ec0c77225", + "reference": "398907e89a2a56fe426f7955c6fa943ec0c77225", "shasum": "" }, "require": { @@ -5900,7 +6027,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.8" }, "funding": [ { @@ -5920,20 +6047,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:15:23+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883", + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883", "shasum": "" }, "require": { @@ -5976,7 +6103,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.6" + "source": "https://github.com/symfony/yaml/tree/v7.4.8" }, "funding": [ { @@ -5996,7 +6123,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "syndesi/cypher-data-structures", @@ -7154,24 +7281,27 @@ }, { "name": "amphp/serialization", - "version": "v1.0.0", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/amphp/serialization.git", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.4" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "phpunit/phpunit": "^9 || ^8 || ^7" + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -7206,9 +7336,15 @@ ], "support": { "issues": "https://github.com/amphp/serialization/issues", - "source": "https://github.com/amphp/serialization/tree/master" + "source": "https://github.com/amphp/serialization/tree/v1.1.0" }, - "time": "2020-03-25T21:39:07+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" }, { "name": "amphp/socket", @@ -7434,16 +7570,16 @@ }, { "name": "brianium/paratest", - "version": "v7.19.2", + "version": "v7.22.3", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" + "reference": "1648aa798a5791babe1ddea6ca3b1aea37e0c5ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/1648aa798a5791babe1ddea6ca3b1aea37e0c5ee", + "reference": "1648aa798a5791babe1ddea6ca3b1aea37e0c5ee", "shasum": "" }, "require": { @@ -7453,25 +7589,25 @@ "ext-simplexml": "*", "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", - "phpunit/php-file-iterator": "^6.0.1 || ^7", - "phpunit/php-timer": "^8 || ^9", - "phpunit/phpunit": "^12.5.14 || ^13.0.5", - "sebastian/environment": "^8.0.3 || ^9", - "symfony/console": "^7.4.7 || ^8.0.7", - "symfony/process": "^7.4.5 || ^8.0.5" + "php": "~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^14", + "phpunit/php-file-iterator": "^7", + "phpunit/php-timer": "^9", + "phpunit/phpunit": "^13.1.3", + "sebastian/environment": "^9.2.0", + "symfony/console": "^7.4.8 || ^8.0.8", + "symfony/process": "^7.4.8 || ^8.0.8" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan": "^2.1.47", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.10", - "symfony/filesystem": "^7.4.6 || ^8.0.6" + "symfony/filesystem": "^7.4.8 || ^8.0.8" }, "bin": [ "bin/paratest", @@ -7511,7 +7647,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" + "source": "https://github.com/paratestphp/paratest/tree/v7.22.3" }, "funding": [ { @@ -7523,7 +7659,7 @@ "type": "paypal" } ], - "time": "2026-03-09T14:33:17+00:00" + "time": "2026-04-15T05:24:54+00:00" }, { "name": "clue/ndjson-react", @@ -8393,6 +8529,75 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "ergebnis/agent-detector", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64", + "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.16.0", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.46", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-04-10T13:45:13+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -8559,22 +8764,23 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.94.2", + "version": "v3.95.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63" + "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7787ceff91365ba7d623ec410b8f429cdebb4f63", - "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a9727678fbd12997f1d9de8f4a37824ed9df1065", + "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065", "shasum": "" }, "require": { "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.1.1", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", @@ -8599,18 +8805,18 @@ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.7.1", - "infection/infection": "^0.32.3", - "justinrainbow/json-schema": "^6.6.4", + "facile-it/paraunit": "^1.3.1 || ^2.8.0", + "infection/infection": "^0.32.6", + "justinrainbow/json-schema": "^6.8.0", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.9.1", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.7", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.7", - "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.51", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", "symfony/polyfill-php85": "^1.33", - "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.4", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.1" + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -8651,7 +8857,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.94.2" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.1" }, "funding": [ { @@ -8659,7 +8865,7 @@ "type": "github" } ], - "time": "2026-02-20T16:13:53+00:00" + "time": "2026-04-12T17:00:09+00:00" }, { "name": "infection/abstract-testframework-adapter", @@ -9089,16 +9295,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "v6.7.2", + "version": "6.8.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "6fea66c7204683af437864e7c4e7abf383d14bc0" + "reference": "89ac92bcfe5d0a8a4433c7b89d394553ae7250cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/6fea66c7204683af437864e7c4e7abf383d14bc0", - "reference": "6fea66c7204683af437864e7c4e7abf383d14bc0", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/89ac92bcfe5d0a8a4433c7b89d394553ae7250cc", + "reference": "89ac92bcfe5d0a8a4433c7b89d394553ae7250cc", "shasum": "" }, "require": { @@ -9158,9 +9364,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/v6.7.2" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.0" }, - "time": "2026-02-15T15:06:22+00:00" + "time": "2026-04-02T12:43:11+00:00" }, { "name": "kelunik/certificate", @@ -9956,16 +10162,16 @@ }, { "name": "phpbench/phpbench", - "version": "1.6.0", + "version": "1.6.1", "source": { "type": "git", "url": "https://github.com/phpbench/phpbench.git", - "reference": "fdeb35d0d277dd6b9e795df6cbbec9218f3b143e" + "reference": "661c8c6abbc7734986cf7bc6062c237fbb450461" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/fdeb35d0d277dd6b9e795df6cbbec9218f3b143e", - "reference": "fdeb35d0d277dd6b9e795df6cbbec9218f3b143e", + "url": "https://api.github.com/repos/phpbench/phpbench/zipball/661c8c6abbc7734986cf7bc6062c237fbb450461", + "reference": "661c8c6abbc7734986cf7bc6062c237fbb450461", "shasum": "" }, "require": { @@ -10043,7 +10249,7 @@ ], "support": { "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.6.0" + "source": "https://github.com/phpbench/phpbench/tree/1.6.1" }, "funding": [ { @@ -10051,7 +10257,7 @@ "type": "github" } ], - "time": "2026-03-21T18:08:17+00:00" + "time": "2026-03-22T10:27:20+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -10314,16 +10520,16 @@ }, { "name": "phpspec/prophecy", - "version": "v1.26.0", + "version": "v1.26.1", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "0da07c10d5fe64cd0c748f0523b47599400f2ed1" + "reference": "09c2e5949d676286358a62af818f8407167a9dd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/0da07c10d5fe64cd0c748f0523b47599400f2ed1", - "reference": "0da07c10d5fe64cd0c748f0523b47599400f2ed1", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/09c2e5949d676286358a62af818f8407167a9dd6", + "reference": "09c2e5949d676286358a62af818f8407167a9dd6", "shasum": "" }, "require": { @@ -10379,9 +10585,9 @@ ], "support": { "issues": "https://github.com/phpspec/prophecy/issues", - "source": "https://github.com/phpspec/prophecy/tree/v1.26.0" + "source": "https://github.com/phpspec/prophecy/tree/v1.26.1" }, - "time": "2026-02-24T15:40:48+00:00" + "time": "2026-04-13T14:35:16+00:00" }, { "name": "phpspec/prophecy-phpunit", @@ -10487,11 +10693,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.42", + "version": "2.1.50", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", - "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", "shasum": "" }, "require": { @@ -10536,20 +10742,20 @@ "type": "github" } ], - "time": "2026-03-17T14:58:32+00:00" + "time": "2026-04-17T13:10:32+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "14.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "24dc6fcf9f2a983de5b3f1199fb01e88d68e7474" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/24dc6fcf9f2a983de5b3f1199fb01e88d68e7474", + "reference": "24dc6fcf9f2a983de5b3f1199fb01e88d68e7474", "shasum": "" }, "require": { @@ -10557,17 +10763,17 @@ "ext-libxml": "*", "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", - "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", - "phpunit/php-text-template": "^5.0", - "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", - "sebastian/version": "^6.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0", + "sebastian/version": "^7.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^13.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10576,7 +10782,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5.x-dev" + "dev-main": "14.1.x-dev" } }, "autoload": { @@ -10605,7 +10811,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.3" }, "funding": [ { @@ -10625,32 +10831,32 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-04-18T05:41:54+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10678,7 +10884,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" }, "funding": [ { @@ -10698,28 +10904,28 @@ "type": "tidelift" } ], - "time": "2026-02-02T14:04:18+00:00" + "time": "2026-02-06T04:33:26+00:00" }, { "name": "phpunit/php-invoker", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-pcntl": "*" @@ -10727,7 +10933,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10754,40 +10960,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:58+00:00" + "time": "2026-02-06T04:34:47+00:00" }, { "name": "phpunit/php-text-template", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10814,40 +11032,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" } ], - "time": "2025-02-07T04:59:16+00:00" + "time": "2026-02-06T04:36:37+00:00" }, { "name": "phpunit/php-timer", - "version": "8.0.0", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -10874,28 +11104,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" } ], - "time": "2025-02-07T04:59:38+00:00" + "time": "2026-02-06T04:37:53+00:00" }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "13.1.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddd6401641861cdef94b922ef10d484f436e8dcd", + "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd", "shasum": "" }, "require": { @@ -10908,22 +11150,23 @@ "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", - "phpunit/php-file-iterator": "^6.0.1", - "phpunit/php-invoker": "^6.0.0", - "phpunit/php-text-template": "^5.0.0", - "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", - "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", - "sebastian/object-enumerator": "^7.0.0", - "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.3", - "sebastian/version": "^6.0.0", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.1.3", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.1.2", + "sebastian/diff": "^8.1.0", + "sebastian/environment": "^9.3.0", + "sebastian/exporter": "^8.0.2", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.0", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.0", + "sebastian/version": "^7.0.0", "staabm/side-effects-detector": "^1.0.5" }, "bin": [ @@ -10932,7 +11175,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5-dev" + "dev-main": "13.1-dev" } }, "autoload": { @@ -10964,31 +11207,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.7" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-04-18T06:14:52+00:00" }, { "name": "psr/clock", @@ -11912,28 +12139,28 @@ }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.2-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -11957,7 +12184,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" }, "funding": [ { @@ -11977,31 +12204,31 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-02-06T04:39:44+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", + "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "php": ">=8.4", + "sebastian/diff": "^8.1", + "sebastian/exporter": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -12009,7 +12236,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.1-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -12049,7 +12276,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.1.2" }, "funding": [ { @@ -12069,33 +12296,33 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-04-14T08:24:42+00:00" }, { "name": "sebastian/complexity", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -12119,41 +12346,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:25+00:00" + "time": "2026-02-06T04:41:32+00:00" }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "9c957d730257f49c873f3761674559bd90098a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/9c957d730257f49c873f3761674559bd90098a7d", + "reference": "9c957d730257f49c873f3761674559bd90098a7d", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0", + "phpunit/phpunit": "^13.0", "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -12186,35 +12425,47 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/8.1.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2026-04-05T12:02:33+00:00" }, { "name": "sebastian/environment", - "version": "8.0.4", + "version": "9.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" + "reference": "6767059a30e4277ac95ee034809e793528464768" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6767059a30e4277ac95ee034809e793528464768", + "reference": "6767059a30e4277ac95ee034809e793528464768", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-posix": "*" @@ -12222,7 +12473,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "9.3-dev" } }, "autoload": { @@ -12250,7 +12501,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.0" }, "funding": [ { @@ -12270,34 +12521,34 @@ "type": "tidelift" } ], - "time": "2026-03-15T07:05:40+00:00" + "time": "2026-04-15T12:14:03+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/9cee180ebe62259e3ed48df2212d1fc8cfd971bb", + "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -12340,7 +12591,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/8.0.2" }, "funding": [ { @@ -12360,35 +12611,104 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-04-15T12:38:05+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -12414,7 +12734,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0" }, "funding": [ { @@ -12434,33 +12754,33 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-02-06T04:45:13+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -12484,42 +12804,54 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-02-06T04:45:54+00:00" }, { "name": "sebastian/object-enumerator", - "version": "7.0.0", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -12542,40 +12874,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:48+00:00" + "time": "2026-02-06T04:46:36+00:00" }, { "name": "sebastian/object-reflector", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -12598,40 +12942,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:17+00:00" + "time": "2026-02-06T04:47:13+00:00" }, { "name": "sebastian/recursion-context", - "version": "7.0.1", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -12662,7 +13018,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" }, "funding": [ { @@ -12682,32 +13038,32 @@ "type": "tidelift" } ], - "time": "2025-08-13T04:44:59+00:00" + "time": "2026-02-06T04:51:28+00:00" }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "42412224607bd3931241bbd17f38e0f972f5a916" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -12731,7 +13087,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/7.0.0" }, "funding": [ { @@ -12751,29 +13107,29 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-02-06T04:52:09+00:00" }, { "name": "sebastian/version", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -12797,15 +13153,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" } ], - "time": "2025-02-07T05:00:38+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { "name": "seld/jsonlint", @@ -13092,16 +13460,16 @@ }, { "name": "symfony/phpunit-bridge", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "53c5a606cb4ae19c9466a5f8ffe60f61b0c93b5f" + "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/53c5a606cb4ae19c9466a5f8ffe60f61b0c93b5f", - "reference": "53c5a606cb4ae19c9466a5f8ffe60f61b0c93b5f", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/140bbbe1cd1c21a084494ccddeee33f3c3381d7d", + "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d", "shasum": "" }, "require": { @@ -13153,7 +13521,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.7" + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.8" }, "funding": [ { @@ -13173,7 +13541,7 @@ "type": "tidelift" } ], - "time": "2026-03-04T13:54:41+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/translation-contracts", @@ -13259,16 +13627,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "c67219ca6b79a57b64e36bbb2cd8ba741286587e" + "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/c67219ca6b79a57b64e36bbb2cd8ba741286587e", - "reference": "c67219ca6b79a57b64e36bbb2cd8ba741286587e", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/ac43e7e59298ed1ce98c8d228b651d46e907d02c", + "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c", "shasum": "" }, "require": { @@ -13284,7 +13652,7 @@ "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4", "symfony/http-foundation": "<6.4", "symfony/http-kernel": "<6.4", - "symfony/mime": "<6.4", + "symfony/mime": "<6.4.36|>7,<7.4.8|>8.0,<8.0.8", "symfony/serializer": "<6.4", "symfony/translation": "<6.4", "symfony/workflow": "<6.4" @@ -13305,7 +13673,7 @@ "symfony/http-foundation": "^7.3|^8.0", "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/intl": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4.36|^7.4.8|^8.0.8", "symfony/polyfill-intl-icu": "~1.0", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/routing": "^6.4|^7.0|^8.0", @@ -13350,7 +13718,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.4.7" + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.8" }, "funding": [ { @@ -13370,20 +13738,20 @@ "type": "tidelift" } ], - "time": "2026-03-04T15:37:05+00:00" + "time": "2026-03-30T15:17:09+00:00" }, { "name": "symfony/twig-bundle", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b" + "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/e8829e02ff96a391ed0703bac9e7ff0537480b6b", - "reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", + "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", "shasum": "" }, "require": { @@ -13440,7 +13808,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v7.4.4" + "source": "https://github.com/symfony/twig-bundle/tree/v7.4.8" }, "funding": [ { @@ -13460,20 +13828,20 @@ "type": "tidelift" } ], - "time": "2026-01-06T12:34:24+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "da9e91746fc9c575be8b5ff466b7572d98e7e1ae" + "reference": "79f039096c67cc1cc3f607d2ba72af86cd27e6a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/da9e91746fc9c575be8b5ff466b7572d98e7e1ae", - "reference": "da9e91746fc9c575be8b5ff466b7572d98e7e1ae", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/79f039096c67cc1cc3f607d2ba72af86cd27e6a4", + "reference": "79f039096c67cc1cc3f607d2ba72af86cd27e6a4", "shasum": "" }, "require": { @@ -13530,7 +13898,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.7" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.8" }, "funding": [ { @@ -13550,7 +13918,7 @@ "type": "tidelift" } ], - "time": "2026-03-03T13:57:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "thecodingmachine/phpstan-safe-rule", @@ -13860,16 +14228,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.6", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", "shasum": "" }, "require": { @@ -13916,9 +14284,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.6" + "source": "https://github.com/webmozarts/assert/tree/2.3.0" }, - "time": "2026-02-27T10:28:38+00:00" + "time": "2026-04-11T10:33:05+00:00" }, { "name": "webmozart/glob", @@ -13978,6 +14346,7 @@ "platform": { "php": ">=8.5", "ext-ctype": "*", + "ext-fileinfo": "*", "ext-iconv": "*" }, "platform-dev": {}, diff --git a/config/reference.php b/config/reference.php index 67538263..d7c5e20f 100644 --- a/config/reference.php +++ b/config/reference.php @@ -906,18 +906,134 @@ * } * @psalm-type FlysystemConfig = array{ * storages?: array, - * visibility?: scalar|Param|null, // Default: null - * directory_visibility?: scalar|Param|null, // Default: null - * retain_visibility?: bool|Param|null, // Default: null - * case_sensitive?: bool|Param, // Default: true - * disable_asserts?: bool|Param, // Default: false + * asyncaws?: array{ + * client?: scalar|Param|null, // The AsyncAws S3 client service name + * bucket?: scalar|Param|null, // The name of the AWS S3 bucket + * prefix?: scalar|Param|null, // Optional path prefix to prepend to all object keys // Default: "" + * }, + * aws?: array{ + * client?: scalar|Param|null, // The AWS S3 client service name + * bucket?: scalar|Param|null, // The name of the AWS S3 bucket + * prefix?: scalar|Param|null, // Optional path prefix to prepend to all object keys // Default: "" + * options?: list, + * streamReads?: bool|Param, // Whether to use streaming for file reads // Default: true + * }, + * azure?: array{ + * client?: scalar|Param|null, // The Azure Blob Storage client service name + * container?: scalar|Param|null, // The name of the Azure Blob Storage container + * prefix?: scalar|Param|null, // Optional path prefix to prepend to all blob names // Default: "" + * }, + * ftp?: array{ + * host?: scalar|Param|null, // FTP host + * username?: scalar|Param|null, // FTP username + * password?: scalar|Param|null, // FTP password + * port?: int|Param, // FTP port number // Default: 21 + * root?: scalar|Param|null, // FTP root directory // Default: "" + * passive?: bool|Param, // Use passive mode // Default: true + * ssl?: bool|Param, // Use SSL/TLS encryption // Default: false + * timeout?: int|Param, // Connection timeout in seconds // Default: 90 + * ignore_passive_address?: scalar|Param|null, // Ignore passive address // Default: null + * utf8?: bool|Param, // Enable UTF8 mode // Default: false + * transfer_mode?: scalar|Param|null, // Transfer mode (FTP_ASCII or FTP_BINARY constante on ftp extension) // Default: null + * system_type?: null|"windows"|"unix"|Param, // FTP system type // Default: null + * timestamps_on_unix_listings_enabled?: bool|Param, // Enable timestamps on Unix listings // Default: false + * recurse_manually?: bool|Param, // Recurse directories manually // Default: true + * use_raw_list_options?: bool|Param|null, // Use raw list options // Default: null + * connectivityChecker?: scalar|Param|null, // Connectivity checker service name // Default: null + * permissions?: array{ // Unix permissions configuration for files and directories + * file?: array{ // File permissions + * public?: int|Param, // Public file permissions // Default: 420 + * private?: int|Param, // Private file permissions // Default: 384 + * }, + * dir?: array{ // Directory permissions + * public?: int|Param, // Public directory permissions // Default: 493 + * private?: int|Param, // Private directory permissions // Default: 448 + * }, + * }, + * }, + * gcloud?: array{ + * client?: scalar|Param|null, // The Google Cloud Storage client service name + * bucket?: scalar|Param|null, // The name of the Google Cloud Storage bucket + * prefix?: scalar|Param|null, // Optional path prefix to prepend to all object keys // Default: "" + * visibility_handler?: scalar|Param|null, // Optional visibility handler service name // Default: null + * streamReads?: bool|Param, // Whether to use streaming for file reads // Default: false + * }, + * gridfs?: array{ + * bucket?: scalar|Param|null, // GridFS bucket service name (if using an existing bucket service) // Default: null + * prefix?: scalar|Param|null, // Optional path prefix to prepend to all file names // Default: "" + * database?: scalar|Param|null, // MongoDB database name // Default: null + * doctrine_connection?: scalar|Param|null, // Doctrine MongoDB connection name (mutually exclusive with mongodb_uri) + * mongodb_uri?: scalar|Param|null, // MongoDB connection URI (mutually exclusive with doctrine_connection) + * mongodb_uri_options?: list, + * mongodb_driver_options?: list, + * }, + * lazy?: array{ // Lazy adapter for runtime storage selection + * source?: scalar|Param|null, // The service name of the storage to use at runtime + * }, + * local?: array{ + * directory?: scalar|Param|null, // Directory path for local storage + * lock?: int|Param, // Lock flags for file operations // Default: 0 + * skip_links?: bool|Param, // Whether to skip symbolic links // Default: false + * lazy_root_creation?: bool|Param, // Whether to create the root directory lazily // Default: false + * permissions?: array{ // Unix permissions configuration for files and directories + * file?: array{ // File permissions + * public?: int|Param, // Public file permissions // Default: 420 + * private?: int|Param, // Private file permissions // Default: 384 + * }, + * dir?: array{ // Directory permissions + * public?: int|Param, // Public directory permissions // Default: 493 + * private?: int|Param, // Private directory permissions // Default: 448 + * }, + * }, + * }, + * memory?: array, + * sftp?: array{ + * host?: scalar|Param|null, // SFTP host + * username?: scalar|Param|null, // SFTP username + * password?: scalar|Param|null, // SFTP password (optional if using private key) // Default: null + * privateKey?: scalar|Param|null, // Path to private key file or private key content // Default: null + * passphrase?: scalar|Param|null, // Private key passphrase // Default: null + * port?: int|Param, // SFTP port number // Default: 22 + * timeout?: int|Param, // Connection timeout in seconds // Default: 90 + * hostFingerprint?: scalar|Param|null, // Host fingerprint for verification // Default: null + * connectivityChecker?: scalar|Param|null, // Connectivity checker service name // Default: null + * preferredAlgorithms?: list, + * root?: scalar|Param|null, // SFTP root directory // Default: "" + * permissions?: array{ // Unix permissions configuration for files and directories + * file?: array{ // File permissions + * public?: int|Param, // Public file permissions // Default: 420 + * private?: int|Param, // Private file permissions // Default: 384 + * }, + * dir?: array{ // Directory permissions + * public?: int|Param, // Public directory permissions // Default: 493 + * private?: int|Param, // Private directory permissions // Default: 448 + * }, + * }, + * }, + * webdav?: array{ + * client?: scalar|Param|null, // The WebDAV client service name + * prefix?: scalar|Param|null, // Optional path prefix to prepend to all paths // Default: "" + * visibility_handling?: "throw"|"ignore"|Param, // How to handle visibility operations // Default: "throw" + * manual_copy?: bool|Param, // Whether to handle copy operations manually // Default: false + * manual_move?: bool|Param, // Whether to handle move operations manually // Default: false + * }, + * bunnycdn?: array{ + * client?: scalar|Param|null, // The BunnyCDN client service name + * pull_zone?: scalar|Param|null, // The BunnyCDN pull zone name // Default: "" + * }, + * service?: scalar|Param|null, // Reference to a custom adapter service (alternative to registered adapter types) + * visibility?: scalar|Param|null, // Default visibility for files // Default: null + * directory_visibility?: scalar|Param|null, // Default visibility for directories // Default: null + * retain_visibility?: scalar|Param|null, // Keeps the original file visibility (public/private) when copying or moving. // Default: null + * case_sensitive?: bool|Param, // Deprecated: The "case_sensitive" option is deprecated and will be removed in 4.0. // Default: true + * disable_asserts?: bool|Param, // Deprecated: The "disable_asserts" option is deprecated and will be removed in 4.0. // Default: false * public_url?: list, - * path_normalizer?: scalar|Param|null, // Default: null - * public_url_generator?: scalar|Param|null, // Default: null - * temporary_url_generator?: scalar|Param|null, // Default: null - * read_only?: bool|Param, // Default: false + * path_normalizer?: scalar|Param|null, // Path normalizer service name (should implement League\Flysystem\PathNormalizer) // Default: null + * public_url_generator?: scalar|Param|null, // For adapter that do not provide public URLs or override adapter capabilities and public_url option, a public URL generator service name can be configured in the main Filesystem configuration (should implement League\Flysystem\PublicUrlGenerator) // Default: null + * temporary_url_generator?: scalar|Param|null, // For adapter that do not provide public URLs or override adapter capabilities, a temporary URL generator service name can be configured in the main Filesystem configuration (should implement League\Flysystem\TemporaryUrlGenerator) // Default: null + * read_only?: bool|Param, // Converts a file system to read-only // Default: false * }>, * } * @psalm-type EmberNexusConfig = array{ @@ -953,6 +1069,19 @@ * featureFlag?: array{ // Configures feature flags. * 280_OldUniqueUserIdentifierDisabled?: bool|Param, // If true, the original behavior of some user endpoints in versions before 0.1.6 will no longer be supported. Old behavior will be dropped with version 0.2.0. See GitHub issue #280. // Default: false * }, + * file?: array{ // Configures behavior related to files. + * maxFileSizeInBytes?: int|Param, // Maximum supported file size in bytes. Note: Upstream services like S3 have limits of their own (5TB). // Default: 10737418240 + * uploadExpiresInSecondsAfterFirstRequest?: int|Param, // Number of seconds after which unfinished uploads are expired. // Default: 10800 + * uploadChunkDigitsLength?: int|Param, // Number of digits for the upload chunk counter. Number of max chunks can be limited by upstream services, e.g. AWS can only support up to 10.000 chunks, i.e. a chunk digits length of 4. // Default: 4 + * uploadMinChunkSizeInBytes?: int|Param, // Minimum size in bytes of uploaded chunks. Limited by the S3 provider, e.g. AWS requires that chunks are at least 5 MiB big. // Default: 5242880 + * uploadMaxChunkSizeInBytes?: int|Param, // Maximum size in bytes of uploaded chunks. Limited by the S3 provider, Caddy and the PHP configuration. // Default: 105906176 + * S3StorageBucket?: scalar|Param|null, // Name of the S3 bucket used for storage of files. // Default: "api-storage" + * S3UploadBucket?: scalar|Param|null, // Name of the S3 bucket used for temporary storage of uploads. // Default: "api-upload" + * S3StorageBucketLevels?: int|Param, // Number of levels (folders) used within the S3 storage bucket. // Default: 3 + * S3StorageBucketLevelLength?: int|Param, // Number of characters used for each level (folder) in the S3 storage bucket. // Default: 2 + * S3UploadBucketLevels?: int|Param, // Number of levels (folders) used within the S3 upload bucket. // Default: 2 + * S3UploadBucketLevelLength?: int|Param, // Number of characters used for each level (folder) in the S3 upload bucket. // Default: 2 + * }, * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, diff --git a/docker/Caddyfile b/docker/Caddyfile index db3e87be..8bde6e5a 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -20,6 +20,9 @@ -Server -Set-Cookie } + request_body { + max_size 101MiB + } log { no_hostname output stdout diff --git a/docker/Dockerfile b/docker/Dockerfile index 9828b54f..d4820337 100755 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -47,6 +47,7 @@ RUN apk add --no-cache \ ca-certificates \ curl \ icu \ + icu-data-full \ libzip \ # https://github.com/docker-library/php/issues/494 openssl \ diff --git a/docker/php-embed/php.dev.ini b/docker/php-embed/php.dev.ini index 30f05c74..0a52b1d4 100644 --- a/docker/php-embed/php.dev.ini +++ b/docker/php-embed/php.dev.ini @@ -1,9 +1,12 @@ expose_php = Off memory_limit = -1 -upload_max_filesize = 100M -post_max_size = 100M error_log = /dev/stderr +# upload limited to 101 MiB +upload_max_filesize = 105906176 +post_max_size = 105906176 +max_file_uploads = 1 + xdebug.mode=profile xdebug.start_with_request=trigger xdebug.output_dir="/var/www/html/xdebug-trace" diff --git a/docker/php-embed/php.prod.ini b/docker/php-embed/php.prod.ini index 82f6f0e8..6b74b6b4 100644 --- a/docker/php-embed/php.prod.ini +++ b/docker/php-embed/php.prod.ini @@ -1,5 +1,8 @@ expose_php = Off memory_limit = -1 -upload_max_filesize = 100M -post_max_size = 100M error_log = /dev/stderr + +# upload limited to 101 MiB +upload_max_filesize = 105906176 +post_max_size = 105906176 +max_file_uploads = 1 diff --git a/docs/README.md b/docs/README.md index 0ba574a8..d44c5342 100755 --- a/docs/README.md +++ b/docs/README.md @@ -1,49 +1,40 @@ # Ember Nexus: Knowledge Graph API -Store anything, connect everything, search it all? Sure thing! 😃 +Ember Nexus is a knowledge graph API, which means that it is specialized in storing and retrieving your data. +It can be used as an alternative to Obsidian, Notion, your companies ERP or content management systems or as a blank +database for your own projects. As long as you need to store data and are interested in easy to use cutting edge +features - Ember Nexus will be useful for you! :D -Ember Nexus API is a dynamic and versatile REST-API that leverages the power of graphs to provide flexible and secure -data storage and retrieval for data-minded people. It is [source first](https://sourcefirst.com/) and can be hosted on -your own hardware - we currently support AMD and ARM architectures. +## How is Ember Nexus different to other solutions? -## Quick Links +Ember Nexus is built on three principles: -- [Getting Started](https://ember-nexus.github.io/api/#/getting-started/tech-stack) -- [Check out the documentation](https://ember-nexus.github.io/api) -- [Find us on Docker Hub](https://hub.docker.com/r/embernexus/api) -- [Demo API](https://reference-dataset.ember-nexus.dev/) +- Be easy to use. +- Be secure. +- Expose the advantages and modern features of its internal databases. -## Features +The "secret sauce" is the combination of databases Ember Nexus uses: -Ember Nexus offers a range of cutting-edge features to meet your data storage needs, including: +- **Neo4j**: A graph database, which stores relationships and core properties of your data elements. +- **MongoDB**: A document database, stores more complex properties which Neo4j can not handle itself. +- **Elasticsearch**: A search database, used for full text search, vector search and more. +- **S3 compatible object store**: Used to store binary files, up to 1 TB by default. -- **Graph-based data model**: Ember Nexus uses the graph database [Neo4j](https://neo4j.com/) internally, which enables - you to connect any data element to any other data element and efficiently search long and recursive relations. -- **Access through UUID**: Ember Nexus assigns a UUID to every storable element, making it easy to retrieve and manage - data. User can provide their own UUIDs as well, although this feature is disabled by default. -- **Near real-time search**: Ember Nexus supports full-text search that is fast and responsive. Most of - [Elasticsearch](https://www.elastic.co/elasticsearch/)'s features are directly available while not bypassing the - security model. -- **Secure by default**: User-created data is only visible to the owner by default. Sharing data requires explicit - rules, which are recursive and optionally limited in access scope and other features. -- **High-quality software**: We invested quite a bit of time to make sure that this software is as stable as possible. - We employ code linters, unit tests, feature tests, memory leak tests, mutant tests, and more. Most examples in the - documentation are automatically checked for deprecations. +Instead of using multiple specialized databases yourself, simply use Ember Nexus, and benefit of the exposed features +and without having to worry about data consistency, security aspects and more. -## Planned Features +It just works :D -See our [milestone](https://github.com/ember-nexus/api/milestones) and -[issue](https://github.com/ember-nexus/api/issues) list on GitHub for planned features. +## What can you find where? -## When should I use Ember Nexus API? +- [GitHub Repository](https://github.com/ember-nexus/api): Keep up to date with new releases, post issues and more. +- [API Documentation](https://ember-nexus.github.io/api/#/): For everyone who wants to develop own apps on top of + Ember Nexus. +- [Docker Hub](https://hub.docker.com/r/embernexus/api): Ready to use container images for AMD and ARM architectures. +- [Demo API](https://reference-dataset.ember-nexus.dev/): Instance which can be used for quick testing. Is being reset + every day, and uses the [reference dataset](https://github.com/ember-nexus/reference-dataset) as its data, which + includes eternal tokens. -Ember Nexus is a powerful tool for a range of use cases, including: +## Is there a graphical user interface (GUI)? -- **Private cloud**: Use Ember Nexus to create your own private cloud, similar to Nextcloud. -- **Websites**: Use Ember Nexus as a CMS or blog platform. -- **Data management systems**: Ember Nexus is ideal for building data management systems and archives. -- **Data lakes**: Ember Nexus makes it easy to create data lakes, where you can store and manage large volumes of data. -- **Interface between multiple systems**: Ember Nexus can serve as a bridge between multiple systems, making it easy to - integrate and manage data across your entire infrastructure. - -Experience the power of Ember Nexus for yourself - [try it out today](https://ember-nexus.github.io/api/#/)! +It is currently being developed, but not yet ready to be used. diff --git a/docs/_sidebar.md b/docs/_sidebar.md index f9bb4c85..99df8241 100755 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -10,33 +10,6 @@ - [Caching](/concepts/caching) - [Grammars](/concepts/grammars) -- Search Steps - - [Elasticsearch Query DSL Mixin](/search/step/elasticsearch-query-dsl-mixin) - - [Cypher Path Subset](/search/step/cypher-path-subset) - - [Element Hydration](/search/step/element-hydration) - -- Search Examples - - Elasticsearch Query DSL Mixins - - [Simple Full Text Search](/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search) - - [Advanced Full Text Search](/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search) - - [Keyword Search](/search/example/elasticsearch-query-dsl-mixin/keyword-search) - - [Text Prefix Search](/search/example/elasticsearch-query-dsl-mixin/text-prefix-search) - - [Wildcard Search](/search/example/elasticsearch-query-dsl-mixin/wildcard-search) - - [Query String Search](/search/example/elasticsearch-query-dsl-mixin/query-string-search) - - [Parameterized Search](/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search) - - Cypher Path Subset - - [Find Tagged Elements](/search/example/cypher-path-subset/find-tagged-elements) - - [Find Nested Elements](/search/example/cypher-path-subset/find-nested-elements) - - [Parameterized Cypher Search](/search/example/cypher-path-subset/parameterized-cypher-search) - - Element Hydration - - [Implicit Elements Hydration](/search/example/element-hydration/implicit-elements-hydration) - - [Implicit Paths Hydration](/search/example/element-hydration/implicit-paths-hydration) - - [Explicit ElementIds Hydration](/search/example/element-hydration/explicit-elementids-hydration) - - [Parameterized ElementIds Hydration](/search/example/element-hydration/parameterized-elementids-hydration) - - Polyglot - - [Elasticsearch -> Cypher -> Hydration](/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration) - - [Cypher -> Elasticsearch -> Hydration](/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration) - - API Endpoints - **User Endpoints** @@ -59,6 +32,15 @@ - [DELETE` / -` Delete Element Endpoint](/api-endpoints/element/delete-element) - **Search Endpoints** - [POST` /search -` Search Endpoint](/api-endpoints/search/post-search) + - **File Endpoints** + - [GET` //file -` Get Element File Endpoint](/api-endpoints/file/get-element-file) + - [POST` //file -` Create Element File Endpoint](/api-endpoints/file/post-element-file) + - [PUT` //file -` Replace Element File Endpoint](/api-endpoints/file/put-element-file) + - [DELETE` //file -` Delete Element File Endpoint](/api-endpoints/file/delete-element-file) + - **Upload Endpoints** + - [HEAD` /upload/ -` Head Upload Endpoint](/api-endpoints/upload/head-upload) + - [PATCH` /upload/ -` Patch Upload Endpoint](/api-endpoints/upload/patch-upload) + - [DELETE` /upload/ -` Delete Upload Endpoint](/api-endpoints/upload/delete-upload) - **System Endpoints** - [GET` /instance-configuration -` Get Instance Configuration Endpoint](/api-endpoints/system/get-instance-configuration) - [GET` /graph-structure -` Get Graph Structure Endpoint](/api-endpoints/system/get-graph-structure) @@ -69,26 +51,13 @@ - [GET` /error/400/incomplete-mutual-dependency -` Get Details for Error 400 Incomplete Mutual Dependency Endpoint](/api-endpoints/error/get-400-incomplete-mutual-dependency) - [GET` /error/400/missing-property -` Get Details for Error 400 Missing Property Endpoint](/api-endpoints/error/get-400-missing-property) - [GET` /error/400/reserved-identifier -` Get Details for Error 400 Reserved Identifier Endpoint](/api-endpoints/error/get-400-reserved-identifier) + - [GET` /error/400/reserved-type -` Get Details for Error 400 Reserved Type Endpoint](/api-endpoints/error/get-400-reserved-type) - [GET` /error/401/unauthorized -` Get Details for Error 401 Unauthorized Endpoint](/api-endpoints/error/get-401-unauthorized) - [GET` /error/403/forbidden -` Get Details for Error 403 Forbidden Endpoint](/api-endpoints/error/get-403-forbidden) - [GET` /error/404/not-found -` Get Details for Error 404 Not Found Endpoint](/api-endpoints/error/get-404-not-found) - [GET` /error/412/precondition-failed -` Get Details for Error 412 Precondition Failed Endpoint](/api-endpoints/error/get-412-precondition-failed) - [GET` /error/500/internal-server-error -` Get Details for Error 500 Internal Server Error Endpoint](/api-endpoints/error/get-500-internal-server-error) - [GET` /error/501/not-implemented -` Get Details for Error 501 Not Implemented Endpoint](/api-endpoints/error/get-501-not-implemented) - - **File Endpoints (TBI)** - - [🚧 GET` //file -` Get Element File Endpoint](/api-endpoints/file/get-element-file) - - [🚧 POST` //file -` Create Element File Endpoint](/api-endpoints/file/post-element-file) - - [🚧 PUT` //file -` Replace Element File Endpoint](/api-endpoints/file/put-element-file) - - [🚧 PATCH` //file -` Update Element File Endpoint](/api-endpoints/file/patch-element-file) - - [🚧 DELETE` //file -` Delete Element File Endpoint](/api-endpoints/file/delete-element-file) - - **WebDAV Endpoints (TBI)** - - [🚧 COPY` / -` Copy Element Endpoint](/api-endpoints/webdav/copy-element) - - [🚧 LOCK` / -` Lock Element Endpoint](/api-endpoints/webdav/lock-element) - - [🚧 UNLOCK` / -` Unlock Element Endpoint](/api-endpoints/webdav/unlock-element) - - [🚧 MKCOL` / -` Create Folder Endpoint](/api-endpoints/webdav/mkcol-folder) - - [🚧 MOVE` / -` Move Element Endpoint](/api-endpoints/webdav/move-element) - - [🚧 PROPFIND` / -` Find Element Property Endpoint](/api-endpoints/webdav/propfind-element) - - [🚧 PROPPATCH` / -` Change Element Property Endpoint](/api-endpoints/webdav/proppatch-element) - Commands - **System Commands** @@ -108,6 +77,33 @@ - **Database Commands** - [`database:drop`](/commands/database/database-drop) +- Search + - Search Step Types + - [Elasticsearch Query DSL Mixin](/search/step/elasticsearch-query-dsl-mixin) + - [Cypher Path Subset](/search/step/cypher-path-subset) + - [Element Hydration](/search/step/element-hydration) + - Search Examples + - Elasticsearch Query DSL Mixins + - [Simple Full Text Search](/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search) + - [Advanced Full Text Search](/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search) + - [Keyword Search](/search/example/elasticsearch-query-dsl-mixin/keyword-search) + - [Text Prefix Search](/search/example/elasticsearch-query-dsl-mixin/text-prefix-search) + - [Wildcard Search](/search/example/elasticsearch-query-dsl-mixin/wildcard-search) + - [Query String Search](/search/example/elasticsearch-query-dsl-mixin/query-string-search) + - [Parameterized Search](/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search) + - Cypher Path Subset + - [Find Tagged Elements](/search/example/cypher-path-subset/find-tagged-elements) + - [Find Nested Elements](/search/example/cypher-path-subset/find-nested-elements) + - [Parameterized Cypher Search](/search/example/cypher-path-subset/parameterized-cypher-search) + - Element Hydration + - [Implicit Elements Hydration](/search/example/element-hydration/implicit-elements-hydration) + - [Implicit Paths Hydration](/search/example/element-hydration/implicit-paths-hydration) + - [Explicit ElementIds Hydration](/search/example/element-hydration/explicit-elementids-hydration) + - [Parameterized ElementIds Hydration](/search/example/element-hydration/parameterized-elementids-hydration) + - Polyglot + - [Elasticsearch -> Cypher -> Hydration](/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration) + - [Cypher -> Elasticsearch -> Hydration](/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration) + - Security - [Ownership](/security/ownership) - [Access](/security/access) @@ -116,7 +112,3 @@ - [Predefined Data Types](/security/predefined-data-types) - [Passwords, Tokens and Hashing](/security/passwords-tokens-and-hashing) - [Security Tests](/security/test/general) - -- Development - - [Long Term Plans](/development/long-term-plans) - - [Best Practices](/development/best-practices) diff --git a/docs/api-endpoints/error/get-400-reserved-type.md b/docs/api-endpoints/error/get-400-reserved-type.md new file mode 100644 index 00000000..0c806667 --- /dev/null +++ b/docs/api-endpoints/error/get-400-reserved-type.md @@ -0,0 +1,21 @@ +# GET` /error/400/reserved-type`Get Details for Error 400 Reserved Type Endpoint + +## Request Example + +```bash +curl https://api.localhost/error/400/reserved-type +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./get-400-reserved-type/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./get-400-reserved-type/200-response-body.txt ':include :type=code') + + diff --git a/docs/api-endpoints/error/get-400-reserved-type/200-response-body.txt b/docs/api-endpoints/error/get-400-reserved-type/200-response-body.txt new file mode 100644 index 00000000..d7257413 --- /dev/null +++ b/docs/api-endpoints/error/get-400-reserved-type/200-response-body.txt @@ -0,0 +1,15 @@ + _____ _ _ _ _ ____ ___ + | ____|_ __ ___ | |__ ___ _ __ | \ | | _____ ___ _ ___ / \ | _ \_ _| + | _| | '_ ` _ \| '_ \ / _ \ '__| | \| |/ _ \ \/ / | | / __| / _ \ | |_) | | + | |___| | | | | | |_) | __/ | | |\ | __/⟩ ⟨| |_| \__ \ / ___ \| __/| | + |_____|_| |_| |_|_.__/ \___|_| |_| \_|\___/_/\_\\__,_|___/ /_/ \_\_| |___| + + + Error 400 - Reserved Type + + The exception is thrown when a type is reserved. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 400 error code: https://http.dev/400 + diff --git a/docs/api-endpoints/error/get-400-reserved-type/200-response-header.txt b/docs/api-endpoints/error/get-400-reserved-type/200-response-header.txt new file mode 100644 index 00000000..015cf16b --- /dev/null +++ b/docs/api-endpoints/error/get-400-reserved-type/200-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: text/plain; charset=utf-8 +Date: Sat, 16 Sep 2023 14:06:53 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/delete-element-file.md b/docs/api-endpoints/file/delete-element-file.md index 5553b308..acfb20da 100644 --- a/docs/api-endpoints/file/delete-element-file.md +++ b/docs/api-endpoints/file/delete-element-file.md @@ -1,4 +1,24 @@ -# 🚧 DELETE` //file`Delete Element File Endpoint +# DELETE` //file`Delete Element File Endpoint -!> **Currently not implemented.** -This feature is reserved for the version [0.2.0](https://github.com/ember-nexus/api/milestone/1). +Deletes the file associated with an element. + +## Request Example + +```bash +curl \ + -X DELETE + -H "Authorization: Bearer secret-token:PIPeJGUt7c00ENn8a5uDlc" \ + https://api.localhost/74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac/file +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./delete-element-file/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./delete-element-file/200-response-body.json ':include :type=code') diff --git a/docs/api-endpoints/file/delete-element-file/200-response-body.json b/docs/api-endpoints/file/delete-element-file/200-response-body.json new file mode 100644 index 00000000..f91c5190 --- /dev/null +++ b/docs/api-endpoints/file/delete-element-file/200-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/401/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/delete-element-file/200-response-header.txt b/docs/api-endpoints/file/delete-element-file/200-response-header.txt new file mode 100644 index 00000000..baead971 --- /dev/null +++ b/docs/api-endpoints/file/delete-element-file/200-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Fri, 06 Oct 2023 20:22:32 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file.md b/docs/api-endpoints/file/get-element-file.md index cff9779d..8d8ff95b 100644 --- a/docs/api-endpoints/file/get-element-file.md +++ b/docs/api-endpoints/file/get-element-file.md @@ -1,4 +1,23 @@ -# 🚧 GET` //file`Get Element File Endpoint +# GET` //file`Get Element File Endpoint -!> **Currently not implemented.** -This feature is reserved for the version [0.2.0](https://github.com/ember-nexus/api/milestone/1). +Returns the file associated with an element. + +## Request Example + +```bash +curl \ + -H "Authorization: Bearer secret-token:PIPeJGUt7c00ENn8a5uDlc" \ + https://api.localhost/74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac/file +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./get-element-file/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./get-element-file/200-response-body.json ':include :type=code') diff --git a/docs/api-endpoints/file/get-element-file/200-response-body.json b/docs/api-endpoints/file/get-element-file/200-response-body.json new file mode 100644 index 00000000..f91c5190 --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/200-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/401/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/200-response-header.txt b/docs/api-endpoints/file/get-element-file/200-response-header.txt new file mode 100644 index 00000000..baead971 --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/200-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Fri, 06 Oct 2023 20:22:32 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/patch-element-file.md b/docs/api-endpoints/file/patch-element-file.md deleted file mode 100644 index 572d65e2..00000000 --- a/docs/api-endpoints/file/patch-element-file.md +++ /dev/null @@ -1,4 +0,0 @@ -# 🚧 PATCH` //file`Update Element File Endpoint - -!> **Currently not implemented.** -This feature is reserved for the version [0.2.0](https://github.com/ember-nexus/api/milestone/1). diff --git a/docs/api-endpoints/file/post-element-file.md b/docs/api-endpoints/file/post-element-file.md index 9da7dae8..9b4588e3 100644 --- a/docs/api-endpoints/file/post-element-file.md +++ b/docs/api-endpoints/file/post-element-file.md @@ -1,4 +1,26 @@ -# 🚧 POST` //file`Create Element File Endpoint +# POST` //file`Create Element File Endpoint -!> **Currently not implemented.** -This feature is reserved for the version [0.2.0](https://github.com/ember-nexus/api/milestone/1). +Used to upload a smaller file directly to an element, or to create a resumable upload. + +This endpoint requires that the element does not yet have a file associated to it. + +## Request Example + +```bash +curl \ + -X POST + -H "Authorization: Bearer secret-token:PIPeJGUt7c00ENn8a5uDlc" \ + https://api.localhost/74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac/file +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./post-element-file/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./post-element-file/200-response-body.json ':include :type=code') diff --git a/docs/api-endpoints/file/post-element-file/200-response-body.json b/docs/api-endpoints/file/post-element-file/200-response-body.json new file mode 100644 index 00000000..f91c5190 --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/200-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/401/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/200-response-header.txt b/docs/api-endpoints/file/post-element-file/200-response-header.txt new file mode 100644 index 00000000..baead971 --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/200-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Fri, 06 Oct 2023 20:22:32 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file.md b/docs/api-endpoints/file/put-element-file.md index 8017529f..88405bf2 100644 --- a/docs/api-endpoints/file/put-element-file.md +++ b/docs/api-endpoints/file/put-element-file.md @@ -1,4 +1,26 @@ -# 🚧 PUT` //file`Replace Element File Endpoint +# PUT` //file`Replace Element File Endpoint -!> **Currently not implemented.** -This feature is reserved for the version [0.2.0](https://github.com/ember-nexus/api/milestone/1). +Used to upload a smaller file directly to an element, or to create a resumable upload. + +If the element already has a file associated to it, it will get replaced by the new file. + +## Request Example + +```bash +curl \ + -X PUT + -H "Authorization: Bearer secret-token:PIPeJGUt7c00ENn8a5uDlc" \ + https://api.localhost/74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac/file +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./put-element-file/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./put-element-file/200-response-body.json ':include :type=code') diff --git a/docs/api-endpoints/file/put-element-file/200-response-body.json b/docs/api-endpoints/file/put-element-file/200-response-body.json new file mode 100644 index 00000000..f91c5190 --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/200-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/401/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/200-response-header.txt b/docs/api-endpoints/file/put-element-file/200-response-header.txt new file mode 100644 index 00000000..baead971 --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/200-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Fri, 06 Oct 2023 20:22:32 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/system/get-graph-structure/200-response-header.txt b/docs/api-endpoints/system/get-graph-structure/200-response-header.txt index d98fe42a..cb55f9b8 100644 --- a/docs/api-endpoints/system/get-graph-structure/200-response-header.txt +++ b/docs/api-endpoints/system/get-graph-structure/200-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/system/get-graph-structure/403-response-header.txt b/docs/api-endpoints/system/get-graph-structure/403-response-header.txt index ff1216a1..6d5f4750 100644 --- a/docs/api-endpoints/system/get-graph-structure/403-response-header.txt +++ b/docs/api-endpoints/system/get-graph-structure/403-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/api-endpoints/system/get-instance-configuration/200-response-body.json b/docs/api-endpoints/system/get-instance-configuration/200-response-body.json index f71a086b..8e95790c 100644 --- a/docs/api-endpoints/system/get-instance-configuration/200-response-body.json +++ b/docs/api-endpoints/system/get-instance-configuration/200-response-body.json @@ -13,5 +13,11 @@ "expression": { "enabled": true, "maxLength": 10240 + }, + "file": { + "maxFileSizeInBytes": 10737418240, + "uploadExpiresInSecondsAfterFirstRequest": 10800, + "uploadMinChunkSizeInBytes": 5242880, + "uploadMaxChunkSizeInBytes": 105906176 } } \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload.md b/docs/api-endpoints/upload/delete-upload.md new file mode 100644 index 00000000..8bc97b93 --- /dev/null +++ b/docs/api-endpoints/upload/delete-upload.md @@ -0,0 +1,24 @@ +# DELETE` /upload/`Delete Upload Endpoint + +Deletes a resumable upload. + +## Request Example + +```bash +curl \ + -X DELETE + -H "Authorization: Bearer secret-token:PIPeJGUt7c00ENn8a5uDlc" \ + https://api.localhost/upload/74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./delete-upload/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./delete-upload/200-response-body.json ':include :type=code') diff --git a/docs/api-endpoints/upload/delete-upload/200-response-body.json b/docs/api-endpoints/upload/delete-upload/200-response-body.json new file mode 100644 index 00000000..f91c5190 --- /dev/null +++ b/docs/api-endpoints/upload/delete-upload/200-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/401/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." +} \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload/200-response-header.txt b/docs/api-endpoints/upload/delete-upload/200-response-header.txt new file mode 100644 index 00000000..baead971 --- /dev/null +++ b/docs/api-endpoints/upload/delete-upload/200-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Fri, 06 Oct 2023 20:22:32 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/head-upload.md b/docs/api-endpoints/upload/head-upload.md new file mode 100644 index 00000000..29493e90 --- /dev/null +++ b/docs/api-endpoints/upload/head-upload.md @@ -0,0 +1,24 @@ +# GET` /upload/`Head Upload Endpoint + +Queries the current state of the resumable upload. + +## Request Example + +```bash +curl \ + -X HEAD + -H "Authorization: Bearer secret-token:PIPeJGUt7c00ENn8a5uDlc" \ + https://api.localhost/74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac/file +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./head-upload/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./head-upload/200-response-body.json ':include :type=code') diff --git a/docs/api-endpoints/upload/head-upload/200-response-body.json b/docs/api-endpoints/upload/head-upload/200-response-body.json new file mode 100644 index 00000000..f91c5190 --- /dev/null +++ b/docs/api-endpoints/upload/head-upload/200-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/401/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." +} \ No newline at end of file diff --git a/docs/api-endpoints/upload/head-upload/200-response-header.txt b/docs/api-endpoints/upload/head-upload/200-response-header.txt new file mode 100644 index 00000000..baead971 --- /dev/null +++ b/docs/api-endpoints/upload/head-upload/200-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Fri, 06 Oct 2023 20:22:32 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload.md b/docs/api-endpoints/upload/patch-upload.md new file mode 100644 index 00000000..5a6b0dd1 --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload.md @@ -0,0 +1,27 @@ +# PATCH` /upload/`Patch Upload Endpoint + +This endpoint is used to upload a new chunk to the resumable upload. + +The new chunk can be of zero length - acting similar to the `HEAD /upload/` endpoint, but also enabling the user +to complete an upload without transmitting additional chunks. + +## Request Example + +```bash +curl \ + -X PATCH + -H "Authorization: Bearer secret-token:PIPeJGUt7c00ENn8a5uDlc" \ + https://api.localhost/74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac/file +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./patch-upload/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./patch-upload/200-response-body.json ':include :type=code') diff --git a/docs/api-endpoints/upload/patch-upload/200-response-body.json b/docs/api-endpoints/upload/patch-upload/200-response-body.json new file mode 100644 index 00000000..f91c5190 --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/200-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/401/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." +} \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/200-response-header.txt b/docs/api-endpoints/upload/patch-upload/200-response-header.txt new file mode 100644 index 00000000..baead971 --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/200-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Fri, 06 Oct 2023 20:22:32 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/webdav/copy-element.md b/docs/api-endpoints/webdav/copy-element.md deleted file mode 100644 index f5ef0978..00000000 --- a/docs/api-endpoints/webdav/copy-element.md +++ /dev/null @@ -1,4 +0,0 @@ -# 🚧 COPY` /`Copy Element Endpoint - -!> **Currently not implemented.** -This feature is reserved for the version [0.3.0](https://github.com/ember-nexus/api/milestone/5). diff --git a/docs/api-endpoints/webdav/lock-element.md b/docs/api-endpoints/webdav/lock-element.md deleted file mode 100644 index 6b9001f5..00000000 --- a/docs/api-endpoints/webdav/lock-element.md +++ /dev/null @@ -1,4 +0,0 @@ -# 🚧 LOCK` /`Lock Element Endpoint - -!> **Currently not implemented.** -This feature is reserved for the version [0.3.0](https://github.com/ember-nexus/api/milestone/5). diff --git a/docs/api-endpoints/webdav/mkcol-folder.md b/docs/api-endpoints/webdav/mkcol-folder.md deleted file mode 100644 index bb792200..00000000 --- a/docs/api-endpoints/webdav/mkcol-folder.md +++ /dev/null @@ -1,4 +0,0 @@ -# 🚧 MKCOL` /`Create Folder Endpoint - -!> **Currently not implemented.** -This feature is reserved for the version [0.3.0](https://github.com/ember-nexus/api/milestone/5). diff --git a/docs/api-endpoints/webdav/move-element.md b/docs/api-endpoints/webdav/move-element.md deleted file mode 100644 index a24488c3..00000000 --- a/docs/api-endpoints/webdav/move-element.md +++ /dev/null @@ -1,4 +0,0 @@ -# 🚧 MOVE` /`Move Element Endpoint - -!> **Currently not implemented.** -This feature is reserved for the version [0.3.0](https://github.com/ember-nexus/api/milestone/5). diff --git a/docs/api-endpoints/webdav/propfind-element.md b/docs/api-endpoints/webdav/propfind-element.md deleted file mode 100644 index 6e14a0d2..00000000 --- a/docs/api-endpoints/webdav/propfind-element.md +++ /dev/null @@ -1,4 +0,0 @@ -# 🚧 PROPFIND` /`Find Element Property Endpoint - -!> **Currently not implemented.** -This feature is reserved for the version [0.3.0](https://github.com/ember-nexus/api/milestone/5). diff --git a/docs/api-endpoints/webdav/proppatch-element.md b/docs/api-endpoints/webdav/proppatch-element.md deleted file mode 100644 index 557df1ae..00000000 --- a/docs/api-endpoints/webdav/proppatch-element.md +++ /dev/null @@ -1,4 +0,0 @@ -# 🚧 PROPPATCH` /`Change Element Property Endpoint - -!> **Currently not implemented.** -This feature is reserved for the version [0.3.0](https://github.com/ember-nexus/api/milestone/5). diff --git a/docs/api-endpoints/webdav/unlock-element.md b/docs/api-endpoints/webdav/unlock-element.md deleted file mode 100644 index b7ba22b6..00000000 --- a/docs/api-endpoints/webdav/unlock-element.md +++ /dev/null @@ -1,4 +0,0 @@ -# 🚧 UNLOCK` /`Unlock Element Endpoint - -!> **Currently not implemented.** -This feature is reserved for the version [0.3.0](https://github.com/ember-nexus/api/milestone/5). diff --git a/docs/assets/custom.css b/docs/assets/custom.css index 160225d5..50f81491 100755 --- a/docs/assets/custom.css +++ b/docs/assets/custom.css @@ -178,7 +178,8 @@ table th { font-family: 'Fira Code', 'Roboto Mono', Monaco, courier, monospace !important; } -.method-get { +.method-get, +.method-head { color: var(--ember-nexus-text-blue) !important; } diff --git a/docs/commands/assets/backup-create-help.html b/docs/commands/assets/backup-create-help.html index 803884b8..d3875a8e 100644 --- a/docs/commands/assets/backup-create-help.html +++ b/docs/commands/assets/backup-create-help.html @@ -38,28 +38,6 @@
-Description:
-  Creates a new backup.
-
-Usage:
-  backup:create [options] [--] [<name>]
-
-Arguments:
-  name                      Name of the backup, defaults to the current timestamp. [default: "20260214132745"]
-
-Options:
-  -p, --pretty|--no-pretty  Activates pretty print of JSON.
-      --ndjson|--no-ndjson  Saves multiple JSON documents in a few .ndjson files.
-  -h, --help                Display help for the given command. When no command is given display help for the list command
-      --silent              Do not output any message
-  -q, --quiet               Only errors are displayed. All other output is suppressed
-  -V, --version             Display this application version
-      --ansi|--no-ansi      Force (or disable --no-ansi) ANSI output
-  -n, --no-interaction      Do not ask any interactive question
-  -e, --env=ENV             The Environment name. [default: "prod"]
-      --no-debug            Switch off debug mode.
-      --profile             Enables profiling (requires debug).
-  -v|vv|vvv, --verbose      Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
 
\ No newline at end of file diff --git a/docs/commands/assets/backup-create.html b/docs/commands/assets/backup-create.html index 2cb504b4..8a63c3e6 100644 --- a/docs/commands/assets/backup-create.html +++ b/docs/commands/assets/backup-create.html @@ -38,27 +38,30 @@
-
-    ▄
-  ▄▀ ▀▄  Ember Nexus API
-   ▀█▀   v0.1.31, production mode
-
-  Backup Create
-
-┌ Step 1 of 3: Backing up Nodes ────────────────────────────────────────────────
-│ Found 0 nodes.
-└ Successfully backed up 0 nodes.
-
-┌ Step 2 of 3: Backing up Relations ────────────────────────────────────────────
-│ Found 0 relations.
-└ Successfully backed up 0 relations.
-
-┌ Step 3 of 3: Backing up Files ────────────────────────────────────────────────
-│ Currently not implemented.
-└ Successfully backed up 0 files.
-
-▶ Backup finished successfully.
-
+TypeError {#397
+  #message: "App\Command\BackupCreateCommand::__construct(): Argument #5 ($elementService) must be of type App\Service\ElementService, Symfony\Component\DependencyInjection\ParameterBag\ContainerBag given, called in /var/www/html/var/cache/prod/ContainerRnqxWK2/getBackupCreateCommandService.php on line 21"
+  #code: 0
+  #file: "./src/Command/BackupCreateCommand.php"
+  #line: 53
+  trace: {
+    ./src/Command/BackupCreateCommand.php:53 {
+      App\Command\BackupCreateCommand->__construct(ElementManager $elementManager, EntityManager $cypherEntityManager, FilesystemOperator $backupStorage, ElementToRawService $elementToRawService, ElementService $elementService, ParameterBagInterface $bag, FileService $fileService, S3Service $s3Service, FileOperationFactory $fileOperationFactory, Server500LogicExceptionFactory $server500LogicExceptionFactory)
+      › private ElementToRawService $elementToRawService,
+      › private ElementService $elementService,
+      › private ParameterBagInterface $bag,
+    }
+    ./var/cache/prod/ContainerRnqxWK2/getBackupCreateCommandService.php:21 { …}
+    ./var/cache/prod/ContainerRnqxWK2/App_KernelProdContainer.php:130 { …}
+    ./var/cache/prod/ContainerRnqxWK2/getBackupCreateCommand_LazyService.php:21 { …}
+    ./vendor/symfony/console/Command/LazyCommand.php:189 { …}
+    ./vendor/symfony/console/Application.php:352 { …}
+    ./vendor/symfony/framework-bundle/Console/Application.php:77 { …}
+    ./vendor/symfony/console/Application.php:195 { …}
+    ./vendor/symfony/runtime/Runner/Symfony/ConsoleApplicationRunner.php:49 { …}
+    ./vendor/autoload_runtime.php:32 { …}
+    ./bin/console:11 { …}
+  }
+}
 
\ No newline at end of file diff --git a/docs/commands/assets/backup-list.html b/docs/commands/assets/backup-list.html index 291c19ef..9c6aa938 100644 --- a/docs/commands/assets/backup-list.html +++ b/docs/commands/assets/backup-list.html @@ -45,9 +45,8 @@ Backup List - Name Hostname Version Date Nodes Edges Files - test 6a7e89a85f66 0.1.31 2026-02-14 0 0 0 - reference-dataset ember-nexus dev 2023-05-06 282 338 0 + Name Hostname Version Date Nodes Edges Files + reference-dataset ember-nexus dev 2023-05-06 282 338 0 ▶ Command finished successfully. diff --git a/docs/commands/assets/backup-load.html b/docs/commands/assets/backup-load.html index 104ed65c..ad2ed1ab 100644 --- a/docs/commands/assets/backup-load.html +++ b/docs/commands/assets/backup-load.html @@ -52,7 +52,6 @@ └ Loaded 356 relations. ┌ Step 3 of 4: Loading Files ─────────────────────────────────────────────────── -│ Currently not implemented. └ Loaded 0 files. ┌ Step 4 of 4: After Backup Tasks ────────────────────────────────────────────── diff --git a/docs/commands/assets/database-drop.html b/docs/commands/assets/database-drop.html index 28fe34d2..c44fe0e4 100644 --- a/docs/commands/assets/database-drop.html +++ b/docs/commands/assets/database-drop.html @@ -54,8 +54,8 @@ └ Successfully deleted Mongo data. ┌ Task 3 of 6: Object Storage ────────────────────────────────────────────────── -│ Deleting object storage data... -└ Object storage is currently not implemented, nothing to delete. +│ Deleting Object data... +└ Successfully deleted object storage. ┌ Task 4 of 6: Elastic Search ────────────────────────────────────────────────── │ Deleting Elastic data... diff --git a/docs/commands/assets/system-healthcheck.html b/docs/commands/assets/system-healthcheck.html index 91c26f27..71fd88eb 100644 --- a/docs/commands/assets/system-healthcheck.html +++ b/docs/commands/assets/system-healthcheck.html @@ -47,17 +47,18 @@ ┌ Check if databases are online ──────────────────────────────────────────────── │ Neo4j version: 5.25.1 (community) -│ MongoDB version: 8.0.9 +│ MongoDB version: 8.0.20 │ Elasticsearch version: 8.16.1 │ Redis version: 7.9.225 │ RabbitMQ version: 3.13.7 +│ S3 buckets: online └ All databases are online. ┌ Check internal services ────────────────────────────────────────────────────── │ Alpine version: 3.23.3 -│ Caddy version: 2.10.2 -│ FrankenPHP version: 1.11.2 -│ PHP version: 8.5.2 +│ Caddy version: 2.11.2 +│ FrankenPHP version: 1.12.1 +│ PHP version: 8.5.4 └ Internal services are ok. ┌ Check API ──────────────────────────────────────────────────────────────────── diff --git a/docs/commands/assets/token-create.html b/docs/commands/assets/token-create.html index 85dbbcb5..b8eae7a2 100644 --- a/docs/commands/assets/token-create.html +++ b/docs/commands/assets/token-create.html @@ -45,9 +45,9 @@ Token Create - Found user with identifier 'token-create-test@localhost.dev', user's UUID is f87aab53-9266-4826-a55a-3febfc1abb07. + Found user with identifier 'token-create-test@localhost.dev', user's UUID is ef73711d-30cc-4e6a-be32-b69389e5522b. -▶ Successfully created new token: secret-token:JfE0sJM1HORirCumQgl4KP . +▶ Successfully created new token: secret-token:BmTOWN1qv6gaFagt320Ntq . diff --git a/docs/commands/assets/token-revoke.html b/docs/commands/assets/token-revoke.html index 1353bb93..a8c58c65 100644 --- a/docs/commands/assets/token-revoke.html +++ b/docs/commands/assets/token-revoke.html @@ -48,16 +48,16 @@ 51 tokens are affected by revocation. First 10 are shown: Token UUID User UUID User identifier Created Expires on - e3b81351-fe0c-4f8f-ad22-78b6157edde8 7e86b9ec-b1dc-4aed-a627-eb77b265e12c user@frontend.localhost.dev 2026-02-14 13:29:32 - - fdc9fc1f-1436-437b-a46d-358dbbb086d1 e5d6c437-e13c-4fe8-85be-fc12e164eb17 user@ownership-model.general.localhost.dev 2026-02-14 13:29:32 - - b23e672c-a901-4ea4-8dda-74029c46f7d2 df8e3dfd-37e9-45fc-ac23-b2f60be4152c user@pagination.general.localhost.dev 2026-02-14 13:29:32 - - 840fee16-996e-4b1a-af2a-77d77c4aeda7 a70e49dc-ec45-414a-beb2-43e6fa56eb41 user@relation.deleteElement.endpoint.localhost.dev 2026-02-14 13:29:32 - - 374fa7ea-4df7-475c-a6a6-b35ff7b9bae1 1a0fd886-9f62-497f-8d20-bd61a40b49b7 user@node.deleteElement.endpoint.localhost.dev 2026-02-14 13:29:32 - - ca9fa4fe-1fbd-4e8a-a6bf-701d97a7ec12 7935a9d2-e453-4dec-bc4a-243e3ced678a user@node.putElement.endpoint.localhost.dev 2026-02-14 13:29:32 - - 84e32a4f-840b-435a-a064-2e72f19d8b20 53ae199c-6b71-44c1-b10c-fca306b32cbf user@relation.putElement.endpoint.localhost.dev 2026-02-14 13:29:32 - - 5f450171-c535-4365-ac70-a283955d4ff5 4250a83d-707b-4f7a-b7dc-225af430f093 user@node.patchElement.endpoint.localhost.dev 2026-02-14 13:29:32 - - 9543e70d-6552-4409-ab6a-dfaf003382a2 8d70608f-ebca-4077-98d2-12733b7fd08e user@relation.patchElement.endpoint.localhost.dev 2026-02-14 13:29:32 - - 571b30e2-40eb-43f5-a888-a6b7ec6c15fc fdf0509d-812c-4435-a863-a4fd6f17e9c4 user@relation.postIndex.endpoint.localhost.dev 2026-02-14 13:29:32 - + 571b30e2-40eb-43f5-a888-a6b7ec6c15fc fdf0509d-812c-4435-a863-a4fd6f17e9c4 user@relation.postIndex.endpoint.localhost.dev 2026-04-14 21:01:58 - + 6009d74a-1db9-4d1a-bc16-a948706dca58 2274873a-b9a2-4e77-b330-900857b76c1d user@node.postIndex.endpoint.localhost.dev 2026-04-14 21:01:58 - + 84e32a4f-840b-435a-a064-2e72f19d8b20 53ae199c-6b71-44c1-b10c-fca306b32cbf user@relation.putElement.endpoint.localhost.dev 2026-04-14 21:01:58 - + ca9fa4fe-1fbd-4e8a-a6bf-701d97a7ec12 7935a9d2-e453-4dec-bc4a-243e3ced678a user@node.putElement.endpoint.localhost.dev 2026-04-14 21:01:58 - + 840fee16-996e-4b1a-af2a-77d77c4aeda7 a70e49dc-ec45-414a-beb2-43e6fa56eb41 user@relation.deleteElement.endpoint.localhost.dev 2026-04-14 21:01:58 - + 374fa7ea-4df7-475c-a6a6-b35ff7b9bae1 1a0fd886-9f62-497f-8d20-bd61a40b49b7 user@node.deleteElement.endpoint.localhost.dev 2026-04-14 21:01:58 - + 9543e70d-6552-4409-ab6a-dfaf003382a2 8d70608f-ebca-4077-98d2-12733b7fd08e user@relation.patchElement.endpoint.localhost.dev 2026-04-14 21:01:58 - + 5f450171-c535-4365-ac70-a283955d4ff5 4250a83d-707b-4f7a-b7dc-225af430f093 user@node.patchElement.endpoint.localhost.dev 2026-04-14 21:01:58 - + befd839e-5711-414e-8fe3-a8a3225dd603 1eb25cca-9c9e-4ec9-a9be-9044741b2b8c user@deleteToken.user.endpoint.localhost.dev 2026-04-14 21:01:58 - + ed1891bf-d7a8-4e64-ba0d-ebec82ad173e 1eb25cca-9c9e-4ec9-a9be-9044741b2b8c user@deleteToken.user.endpoint.localhost.dev 2026-04-14 21:01:58 - Revoking tokens... diff --git a/docs/commands/assets/user-create.html b/docs/commands/assets/user-create.html index 1abea1b4..a4317a4c 100644 --- a/docs/commands/assets/user-create.html +++ b/docs/commands/assets/user-create.html @@ -45,7 +45,7 @@ User Create -▶ Created user with email 'command-test@localhost.dev' successfully, UUID is 83801c33-ddd0-4fb7-b937-b0bfa5433e56. +▶ Created user with email 'command-test@localhost.dev' successfully, UUID is 2348c6c0-ac64-40ac-9a66-7a47f89df52f. diff --git a/docs/concepts/caching/initial-request-response-header.txt b/docs/concepts/caching/initial-request-response-header.txt index 377c3a3b..0620a4f1 100644 --- a/docs/concepts/caching/initial-request-response-header.txt +++ b/docs/concepts/caching/initial-request-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/concepts/caching/invalid-etag-response-header.txt b/docs/concepts/caching/invalid-etag-response-header.txt index 4aff4231..e0a92bd0 100644 --- a/docs/concepts/caching/invalid-etag-response-header.txt +++ b/docs/concepts/caching/invalid-etag-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/concepts/caching/not-modified-response-header.txt b/docs/concepts/caching/not-modified-response-header.txt index d0abf546..55cbd839 100644 --- a/docs/concepts/caching/not-modified-response-header.txt +++ b/docs/concepts/caching/not-modified-response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/development/best-practices.md b/docs/development/best-practices.md deleted file mode 100644 index df77f43e..00000000 --- a/docs/development/best-practices.md +++ /dev/null @@ -1,66 +0,0 @@ -# Best Practices - -As Ember Nexus internally uses Symfony, it also uses -[Symfony's best practices](https://symfony.com/doc/current/best_practices.html). - -## Language - -The source code of Ember Nexus and responses generated by Ember Nexus API should be only written in **English**. - -There are two reasons for it: - -- Search engines more easily find exceptions returned in a single language, helping with debugging and general - usability. -- As English is the Lingua Franca of the IT industry, it should be the primary language used in this project. - -Documentations and general user guides should always be available in English, and additional translations or regional -adaptations can be created. -It should be noted that Ember Nexus strives towards long-term stability; i.e., it is better not to support language X -than to shortly abandon language X after trial usage. - -When deciding between using the active vs. passive voice, asking the following question helps: - -> Is it an advantage for the reader to know the performer of the action? - -If the answer is Yes, use active voice. If the answer is No, use passive voice. - -See [Technical Writing: Active vs. Passive voice](https://medium.com/@DaphneWatson/technical-writing-active-vs-passive-voice-485dfaa4e498) -for details. - -## Coding Style - -Ember Nexus API tries to implement object-orientated code with patterns where it makes sense. Duplicate code should be -reduced if the code's complexity is similar. -Using easily readable code is better than using the least amount of lines -or highly optimized code. - -Refactoring parts of the source code is always possible as long as code changes are covered by unit tests and existing -feature tests are passed. - -Try to keep the line length below 120 chars. - -## Logging and Exceptions - -Both log messages and exceptions should adhere to a few key principles: - -- Messages should provide context why they are created. -- Messages should provide one specific reason, ideally a unique property or identifier. -- Messages should not contain sensitive data like passwords, hashes, and tokens. -- Messages are sentences, they end with a dot. - -Log messages should be created in the following scenarios: - -- Important events in the lifetime of the API should be documented. This includes startup, shutdown, and backups. -- Security events like creating or updating users and tokens should always be logged. If possible, use the - user's unique identifier for context. -- Administrative actions should be fully logged: - - Interactions with the command line. - - Interactions with the API through users with administrative access. - -Exceptions should be created in the following instances: - -- If input validation returns an error. -- If an action is forbidden, likely due to missing security privileges. -- If the requested data does not exist. -- If there is a logic conflict. -- In other problematic instances. diff --git a/docs/development/long-term-plans.md b/docs/development/long-term-plans.md deleted file mode 100644 index fe098ae6..00000000 --- a/docs/development/long-term-plans.md +++ /dev/null @@ -1,24 +0,0 @@ -# Long-Term Plans - -The Ember Nexus API is not intended to be modified or extended by third parties directly - we do not guarantee that -internal code is not refactored or creates breaking changes in minor version updates. - -If you need to handle custom business logic or general automation, then it is recommended to create a separate -web app, which communicates with Ember Nexus over its API, which is stable and will not have breaking changes in minor -version updates. - -## Version 2 of Ember Nexus API - -While no guarantees can be given, we want to experiment if rewriting Ember Nexus API in Rust provides significant -performance benefits. -This experiment starts after the release of version 1.0.0 and will be covered in separate blog -posts. - -The API endpoints of version 2.0, regardless if the API uses Rust or not, will likely be highly similar. - -## Native Support for HTTP 2 / HTTP 3 - -Neither HTTP 2 nor HTTP 3 will be supported during version 1.x. It is recommended to use a reverse proxy like -[Traefik](https://traefik.io/traefik/). - -Support for version 2.x is likely. diff --git a/docs/example/default-parameters.yaml b/docs/example/default-parameters.yaml index b0e510e2..a1aae4ef 100644 --- a/docs/example/default-parameters.yaml +++ b/docs/example/default-parameters.yaml @@ -75,3 +75,39 @@ ember_nexus: # If true, the original behavior of some user endpoints in versions before 0.1.6 will no longer be supported. Old behavior will be dropped with version 0.2.0. See GitHub issue #280. 280_OldUniqueUserIdentifierDisabled: false + + # Configures behavior related to files. + file: + + # Maximum supported file size in bytes. Note: Upstream services like S3 have limits of their own (5TB). + maxFileSizeInBytes: 10737418240 + + # Number of seconds after which unfinished uploads are expired. + uploadExpiresInSecondsAfterFirstRequest: 10800 + + # Number of digits for the upload chunk counter. Number of max chunks can be limited by upstream services, e.g. AWS can only support up to 10.000 chunks, i.e. a chunk digits length of 4. + uploadChunkDigitsLength: 4 + + # Minimum size in bytes of uploaded chunks. Limited by the S3 provider, e.g. AWS requires that chunks are at least 5 MiB big. + uploadMinChunkSizeInBytes: 5242880 + + # Maximum size in bytes of uploaded chunks. Limited by the S3 provider, Caddy and the PHP configuration. + uploadMaxChunkSizeInBytes: 105906176 + + # Name of the S3 bucket used for storage of files. + S3StorageBucket: api-storage + + # Name of the S3 bucket used for temporary storage of uploads. + S3UploadBucket: api-upload + + # Number of levels (folders) used within the S3 storage bucket. + S3StorageBucketLevels: 3 + + # Number of characters used for each level (folder) in the S3 storage bucket. + S3StorageBucketLevelLength: 2 + + # Number of levels (folders) used within the S3 upload bucket. + S3UploadBucketLevels: 2 + + # Number of characters used for each level (folder) in the S3 upload bucket. + S3UploadBucketLevelLength: 2 diff --git a/docs/open-api/examples/NotFoundProblemExample.json b/docs/open-api/examples/NotFoundProblemExample.json new file mode 100644 index 00000000..a583172d --- /dev/null +++ b/docs/open-api/examples/NotFoundProblemExample.json @@ -0,0 +1,9 @@ +{ + "summary": "Not found", + "value": { + "type": "http://api/error/404/not-found", + "title": "Not Found", + "status": 404, + "detail": "Requested element was not found." + } +} diff --git a/docs/open-api/parameters/If-Match.json b/docs/open-api/parameters/If-Match.json new file mode 100644 index 00000000..69dd8442 --- /dev/null +++ b/docs/open-api/parameters/If-Match.json @@ -0,0 +1,10 @@ +{ + "name": "If-Match", + "in": "header", + "required": false, + "example": "\"blub\"", + "description": "Makes the GET request conditional. See caching for details.", + "schema": { + "type": "string" + } +} diff --git a/docs/open-api/parameters/If-None-Match.json b/docs/open-api/parameters/If-None-Match.json new file mode 100644 index 00000000..327bebee --- /dev/null +++ b/docs/open-api/parameters/If-None-Match.json @@ -0,0 +1,10 @@ +{ + "name": "If-None-Match", + "in": "header", + "required": false, + "x-scalar-ignore": true, + "description": "Makes the GET request conditional. Returns 304 if ETag matches.", + "schema": { + "type": "string" + } +} \ No newline at end of file diff --git a/docs/open-api/paths/Element/GetElement.json b/docs/open-api/paths/Element/GetElement.json new file mode 100644 index 00000000..cce90b9d --- /dev/null +++ b/docs/open-api/paths/Element/GetElement.json @@ -0,0 +1,142 @@ +{ + "path": "/{uuid}", + "method": "get", + "content": { + "summary": "Get Element", + "description": "Retrieves a single element (node or relationship) by UUID.", + "operationId": "getElement", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/If-Match" + }, + { + "$ref": "#/components/parameters/If-None-Match" + } + ], + "responses": { + "200": { + "description": "Element found. Returns either a node or a relationship.", + "headers": { + "ETag": { + "description": "Entity tag for caching purposes.", + "schema": { + "type": "string", + "example": "\"YgbFC4u5EgC\"" + } + }, + "Cache-Control": { + "schema": { + "type": "string", + "example": "no-cache, private" + } + }, + "X-Powered-By": { + "schema": { + "type": "string", + "example": "Ember Nexus API" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Element" + }, + "examples": { + "node": { + "summary": "Node response", + "value": { + "type": "Comment", + "id": "74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac", + "data": { + "created": "2025-09-27T10:39:51+00:00", + "updated": "2025-09-27T10:39:51+00:00", + "name": "Blue", + "content": "Blue is one of the three primary colours in the RYB colour model...\n" + } + } + }, + "relationship": { + "summary": "Relationship response", + "value": { + "type": "OWNS", + "id": "eb5d2879-c5e6-43a6-b8ce-2a1188ca7073", + "start": "56fda20c-b238-4034-b555-1df47c47e17a", + "end": "ce8abdea-2e6c-41d4-b779-87b803f77836", + "data": { + "created": "2025-09-27T10:39:58+00:00", + "updated": "2025-09-27T10:39:58+00:00" + } + } + } + } + } + } + }, + "304": { + "description": "Not modified. Returned when If-None-Match header matches current ETag.", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized. Invalid or expired token.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "Element not found, or user lacks access.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + }, + "examples": { + "notFound": { + "$ref": "#/components/examples/NotFoundProblemExample" + } + } + } + } + }, + "412": { + "description": "Precondition failed. If-Match or If-None-Match condition not met.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "Too many requests. Rate limit exceeded.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } +} diff --git a/docs/open-api/schemas/Element.json b/docs/open-api/schemas/Element.json new file mode 100644 index 00000000..f9362458 --- /dev/null +++ b/docs/open-api/schemas/Element.json @@ -0,0 +1,44 @@ +{ + "type": "object", + "required": [ + "type", + "id", + "data" + ], + "properties": { + "type": { + "type": "string", + "example": "Data" + }, + "id": { + "$ref": "#/components/schemas/ElementId" + }, + "data": { + "type": "object", + "additionalProperties": true, + "example": { + "created": "2025-09-27T10:39:51+00:00", + "updated": "2025-09-27T10:39:51+00:00", + "name": "some name" + } + }, + "file": { + "type": "object", + "additionalProperties": true, + "readOnly": true, + "example": { + "contentLength": 63933, + "extension": "jpg", + "mimeType": "image/jpeg" + } + }, + "start": { + "$ref": "#/components/schemas/ElementId", + "description": "UUID of the start node. Required if end is present." + }, + "end": { + "$ref": "#/components/schemas/ElementId", + "description": "UUID of the end node. Required if start is present." + } + } +} diff --git a/docs/open-api/schemas/ElementId.json b/docs/open-api/schemas/ElementId.json new file mode 100644 index 00000000..d5be0530 --- /dev/null +++ b/docs/open-api/schemas/ElementId.json @@ -0,0 +1,6 @@ +{ + "type": "string", + "format": "uuid", + "example": "00000000-0000-0000-0000-000000000000", + "description": "Global unique identifier for any element" +} diff --git a/docs/open-api/schemas/Problem.json b/docs/open-api/schemas/Problem.json new file mode 100644 index 00000000..ea41edc9 --- /dev/null +++ b/docs/open-api/schemas/Problem.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://spec.openapis.org/oas/3.0/schema/2021-09-28#/definitions/Schema", + "type": "object", + "description": "RFC 7807 Problem Details object.", + "properties": { + "type": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + } + } +} diff --git a/docs/open-api/swagger.json b/docs/open-api/swagger.json new file mode 100644 index 00000000..2619682f --- /dev/null +++ b/docs/open-api/swagger.json @@ -0,0 +1,259 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Ember Nexus API", + "version": "0.1.31" + }, + "components": { + "schemas": { + "ElementId": { + "type": "string", + "format": "uuid", + "example": "00000000-0000-0000-0000-000000000000", + "description": "Global unique identifier for any element" + }, + "Element": { + "type": "object", + "required": [ + "type", + "id", + "data" + ], + "properties": { + "type": { + "type": "string", + "example": "Data" + }, + "id": { + "$ref": "#/components/schemas/ElementId" + }, + "data": { + "type": "object", + "additionalProperties": true, + "example": { + "created": "2025-09-27T10:39:51+00:00", + "updated": "2025-09-27T10:39:51+00:00", + "name": "some name" + } + }, + "file": { + "type": "object", + "additionalProperties": true, + "readOnly": true, + "example": { + "contentLength": 63933, + "extension": "jpg", + "mimeType": "image/jpeg" + } + }, + "start": { + "$ref": "#/components/schemas/ElementId", + "description": "UUID of the start node. Required if end is present." + }, + "end": { + "$ref": "#/components/schemas/ElementId", + "description": "UUID of the end node. Required if start is present." + } + } + }, + "Problem": { + "$schema": "https://spec.openapis.org/oas/3.0/schema/2021-09-28#/definitions/Schema", + "type": "object", + "description": "RFC 7807 Problem Details object.", + "properties": { + "type": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + } + } + } + }, + "examples": { + "NotFoundProblemExample": { + "summary": "Not found", + "value": { + "type": "http://api/error/404/not-found", + "title": "Not Found", + "status": 404, + "detail": "Requested element was not found." + } + } + }, + "parameters": { + "If-Match": { + "name": "If-Match", + "in": "header", + "required": false, + "example": "\"blub\"", + "description": "Makes the GET request conditional. See caching for details.", + "schema": { + "type": "string" + } + }, + "If-None-Match": { + "name": "If-None-Match", + "in": "header", + "required": false, + "x-scalar-ignore": true, + "description": "Makes the GET request conditional. Returns 304 if ETag matches.", + "schema": { + "type": "string" + } + } + } + }, + "paths": { + "/{uuid}": { + "get": { + "summary": "Get Element", + "description": "Retrieves a single element (node or relationship) by UUID.", + "operationId": "getElement", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/If-Match" + }, + { + "$ref": "#/components/parameters/If-None-Match" + } + ], + "responses": { + "200": { + "description": "Element found. Returns either a node or a relationship.", + "headers": { + "ETag": { + "description": "Entity tag for caching purposes.", + "schema": { + "type": "string", + "example": "\"YgbFC4u5EgC\"" + } + }, + "Cache-Control": { + "schema": { + "type": "string", + "example": "no-cache, private" + } + }, + "X-Powered-By": { + "schema": { + "type": "string", + "example": "Ember Nexus API" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Element" + }, + "examples": { + "node": { + "summary": "Node response", + "value": { + "type": "Comment", + "id": "74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac", + "data": { + "created": "2025-09-27T10:39:51+00:00", + "updated": "2025-09-27T10:39:51+00:00", + "name": "Blue", + "content": "Blue is one of the three primary colours in the RYB colour model...\n" + } + } + }, + "relationship": { + "summary": "Relationship response", + "value": { + "type": "OWNS", + "id": "eb5d2879-c5e6-43a6-b8ce-2a1188ca7073", + "start": "56fda20c-b238-4034-b555-1df47c47e17a", + "end": "ce8abdea-2e6c-41d4-b779-87b803f77836", + "data": { + "created": "2025-09-27T10:39:58+00:00", + "updated": "2025-09-27T10:39:58+00:00" + } + } + } + } + } + } + }, + "304": { + "description": "Not modified. Returned when If-None-Match header matches current ETag.", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized. Invalid or expired token.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "Element not found, or user lacks access.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + }, + "examples": { + "notFound": { + "$ref": "#/components/examples/NotFoundProblemExample" + } + } + } + } + }, + "412": { + "description": "Precondition failed. If-Match or If-None-Match condition not met.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "Too many requests. Rate limit exceeded.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "tags": [ + "Element" + ] + } + } + } +} diff --git a/docs/open-api/swagger_old.json b/docs/open-api/swagger_old.json new file mode 100644 index 00000000..eb882beb --- /dev/null +++ b/docs/open-api/swagger_old.json @@ -0,0 +1,261 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Ember Nexus API", + "version": "0.1.31" + }, + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/{uuid}": { + "get": { + "summary": "Get Element", + "description": "Retrieves a single element (node or relationship) by UUID.", + "operationId": "getElement", + "tags": [ + "Element" + ], + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-Match", + "in": "header", + "required": false, + "example": "\"blub\"", + "description": "Makes the GET request conditional. See caching for details.", + "schema": { + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "required": false, + "x-scalar-ignore": true, + "description": "Makes the GET request conditional. Returns 304 if ETag matches.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Element found. Returns either a node or a relationship.", + "headers": { + "ETag": { + "description": "Entity tag for caching purposes.", + "schema": { + "type": "string", + "example": "\"YgbFC4u5EgC\"" + } + }, + "Cache-Control": { + "schema": { + "type": "string", + "example": "no-cache, private" + } + }, + "X-Powered-By": { + "schema": { + "type": "string", + "example": "Ember Nexus API" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Element" + }, + "examples": { + "node": { + "summary": "Node response", + "value": { + "type": "Comment", + "id": "74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac", + "data": { + "created": "2025-09-27T10:39:51+00:00", + "updated": "2025-09-27T10:39:51+00:00", + "name": "Blue", + "content": "Blue is one of the three primary colours in the RYB colour model...\n" + } + } + }, + "relationship": { + "summary": "Relationship response", + "value": { + "type": "OWNS", + "id": "eb5d2879-c5e6-43a6-b8ce-2a1188ca7073", + "start": "56fda20c-b238-4034-b555-1df47c47e17a", + "end": "ce8abdea-2e6c-41d4-b779-87b803f77836", + "data": { + "created": "2025-09-27T10:39:58+00:00", + "updated": "2025-09-27T10:39:58+00:00" + } + } + } + } + } + } + }, + "304": { + "description": "Not modified. Returned when If-None-Match header matches current ETag.", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized. Invalid or expired token.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "Element not found, or user lacks access.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + }, + "examples": { + "notFound": { + "$ref": "#/components/examples/NotFoundProblemExample" + } + } + } + } + }, + "412": { + "description": "Precondition failed. If-Match or If-None-Match condition not met.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "429": { + "description": "Too many requests. Rate limit exceeded.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "ElementId": { + "type": "string", + "format": "uuid", + "example": "00000000-0000-0000-0000-000000000000", + "description": "Global unique identifier for any element" + }, + "Element": { + "type": "object", + "required": [ + "type", + "id", + "data" + ], + "properties": { + "type": { + "type": "string", + "example": "Data" + }, + "id": { + "$ref": "#/components/schemas/ElementId" + }, + "data": { + "type": "object", + "additionalProperties": true, + "example": { + "created": "2025-09-27T10:39:51+00:00", + "updated": "2025-09-27T10:39:51+00:00", + "name": "some name" + } + }, + "file": { + "type": "object", + "additionalProperties": true, + "readOnly": true, + "example": { + "contentLength": 63933, + "extension": "jpg", + "mimeType": "image/jpeg" + } + }, + "start": { + "$ref": "#/components/schemas/ElementId", + "description": "UUID of the start node. Required if end is present." + }, + "end": { + "$ref": "#/components/schemas/ElementId", + "description": "UUID of the end node. Required if start is present." + } + } + }, + "Problem": { + "type": "object", + "description": "RFC 7807 Problem Details object.", + "properties": { + "type": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + } + } + } + }, + "examples": { + "NotFoundProblemExample": { + "summary": "Not found", + "value": { + "type": "http://api/error/404/not-found", + "title": "Not Found", + "status": 404, + "detail": "Requested element was not found." + } + } + } + } +} \ No newline at end of file diff --git a/docs/open-api/swagger_old.yml b/docs/open-api/swagger_old.yml new file mode 100644 index 00000000..e0c8ff24 --- /dev/null +++ b/docs/open-api/swagger_old.yml @@ -0,0 +1,192 @@ +openapi: 3.1.1 +info: + title: Ember Nexus API + version: 0.1.31 + +security: + - bearerAuth: [] + +paths: + /{uuid}: + get: + summary: Get Element + description: Retrieves a single element (node or relationship) by UUID. + operationId: getElement + tags: + - Element + parameters: + - name: uuid + in: path + required: true + schema: + type: string + format: uuid + - name: If-Match + in: header + required: false + example: '"blub"' + description: Makes the GET request conditional. See caching for details. + schema: + type: string + - name: If-None-Match + in: header + required: false + x-scalar-ignore: true + description: Makes the GET request conditional. Returns 304 if ETag matches. + schema: + type: string + + responses: + '200': + description: Element found. Returns either a node or a relationship. + headers: + ETag: + description: Entity tag for caching purposes. + schema: + type: string + example: '"YgbFC4u5EgC"' + Cache-Control: + schema: + type: string + example: no-cache, private + X-Powered-By: + schema: + type: string + example: Ember Nexus API + content: + application/json: + schema: + $ref: '#/components/schemas/Element' + examples: + node: + summary: Node response + value: + type: Comment + id: 74a8fcd9-6cb0-4b0d-8d42-0b6c3c54d1ac + data: + created: '2025-09-27T10:39:51+00:00' + updated: '2025-09-27T10:39:51+00:00' + name: Blue + content: > + Blue is one of the three primary colours in the RYB colour model... + relationship: + summary: Relationship response + value: + type: OWNS + id: eb5d2879-c5e6-43a6-b8ce-2a1188ca7073 + start: 56fda20c-b238-4034-b555-1df47c47e17a + end: ce8abdea-2e6c-41d4-b779-87b803f77836 + data: + created: '2025-09-27T10:39:58+00:00' + updated: '2025-09-27T10:39:58+00:00' + + '304': + description: Not modified. Returned when If-None-Match header matches current ETag. + headers: + ETag: + schema: + type: string + + '401': + description: Unauthorized. Invalid or expired token. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + '404': + description: Element not found, or user lacks access. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + examples: + notFound: + $ref: '#/components/examples/NotFoundProblemExample' + + '412': + description: Precondition failed. If-Match or If-None-Match condition not met. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + '429': + description: Too many requests. Rate limit exceeded. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + +components: + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + + schemas: + + ElementId: + type: string + format: uuid + example: "00000000-0000-0000-0000-000000000000" + description: "Global unique identifier for any element" + + Element: + type: object + required: + - type + - id + - data + properties: + type: + type: string + example: Data + id: + $ref: '#/components/schemas/ElementId' + data: + type: object + additionalProperties: true + example: + created: "2025-09-27T10:39:51+00:00" + updated: "2025-09-27T10:39:51+00:00" + name: "some name" + file: + type: object + additionalProperties: true + readOnly: true + example: + contentLength: 63933 + extension: 'jpg' + mimeType: 'image/jpeg' + start: + $ref: '#/components/schemas/ElementId' + description: UUID of the start node. Required if end is present. + end: + $ref: '#/components/schemas/ElementId' + description: UUID of the end node. Required if start is present. + + Problem: + type: object + description: RFC 7807 Problem Details object. + properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + detail: + type: string + + examples: + + NotFoundProblemExample: + summary: Not found + value: + type: http://api/error/404/not-found + title: Not Found + status: 404 + detail: Requested element was not found. \ No newline at end of file diff --git a/docs/open-api/swagger_template.json b/docs/open-api/swagger_template.json new file mode 100644 index 00000000..604a2d4b --- /dev/null +++ b/docs/open-api/swagger_template.json @@ -0,0 +1,10 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Ember Nexus API", + "version": "0.1.31" + }, + "components": { + "schemas": null + } +} \ No newline at end of file diff --git a/docs/search/example/cypher-path-subset/find-nested-elements/response-header.txt b/docs/search/example/cypher-path-subset/find-nested-elements/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/cypher-path-subset/find-nested-elements/response-header.txt +++ b/docs/search/example/cypher-path-subset/find-nested-elements/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/cypher-path-subset/find-tagged-elements/response-header.txt b/docs/search/example/cypher-path-subset/find-tagged-elements/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/cypher-path-subset/find-tagged-elements/response-header.txt +++ b/docs/search/example/cypher-path-subset/find-tagged-elements/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/cypher-path-subset/parameterized-cypher-search/response-header.txt b/docs/search/example/cypher-path-subset/parameterized-cypher-search/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/cypher-path-subset/parameterized-cypher-search/response-header.txt +++ b/docs/search/example/cypher-path-subset/parameterized-cypher-search/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-header.txt index 80c0aff2..6a9800b9 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/wildcard-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/wildcard-search/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/wildcard-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/wildcard-search/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/element-hydration/explicit-elementids-hydration/response-header.txt b/docs/search/example/element-hydration/explicit-elementids-hydration/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/element-hydration/explicit-elementids-hydration/response-header.txt +++ b/docs/search/example/element-hydration/explicit-elementids-hydration/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/element-hydration/implicit-elements-hydration/response-header.txt b/docs/search/example/element-hydration/implicit-elements-hydration/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/element-hydration/implicit-elements-hydration/response-header.txt +++ b/docs/search/example/element-hydration/implicit-elements-hydration/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/element-hydration/implicit-paths-hydration/response-header.txt b/docs/search/example/element-hydration/implicit-paths-hydration/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/element-hydration/implicit-paths-hydration/response-header.txt +++ b/docs/search/example/element-hydration/implicit-paths-hydration/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/element-hydration/parameterized-elementids-hydration/response-header.txt b/docs/search/example/element-hydration/parameterized-elementids-hydration/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/element-hydration/parameterized-elementids-hydration/response-header.txt +++ b/docs/search/example/element-hydration/parameterized-elementids-hydration/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-header.txt b/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-header.txt +++ b/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-header.txt b/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-header.txt index 34499e40..c3ba5b8f 100644 --- a/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-header.txt +++ b/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-header.txt @@ -1,4 +1,4 @@ -Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, Access-Control-Request-Method +Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location diff --git a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php index ea4faa20..32e2cb1a 100644 --- a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php +++ b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php @@ -17,8 +17,9 @@ class Configuration implements ConfigurationInterface public const int CACHE_ETAG_DEFAULT_UPPER_LIMIT_IN_COLLECTION_ENDPOINTS = 100; public const int EXPRESSION_WARNING_LENGTH = 512; public const int EXPRESSION_MAX_LENGTH = 10 * 1024; - public const int SIZE_OF_5TB_IN_BYTES = 5 * 1024 * 1024 * 1024 * 1024; - public const int SIZE_OF_100MB_IN_BYTES = 100 * 1024 * 1024; + public const int SIZE_OF_10GIB_IN_BYTES = 10 * 1024 * 1024 * 1024; + public const int SIZE_OF_101MIB_IN_BYTES = 101 * 1024 * 1024; + public const int SIZE_OF_5MIB_IN_BYTES = 5 * 1024 * 1024; public function getConfigTreeBuilder(): TreeBuilder { @@ -158,24 +159,29 @@ public function getConfigTreeBuilder(): TreeBuilder ->addDefaultsIfNotSet() ->children() ->integerNode(EmberNexusConfiguration::FILE_MAX_FILE_SIZE_IN_BYTES) - ->info('Maximum supported file size in bytes. By default set to maximum file size of S3 (5TB).') + ->info('Maximum supported file size in bytes. Note: Upstream services like S3 have limits of their own (5TB).') ->min(1) - ->defaultValue(self::SIZE_OF_5TB_IN_BYTES) + ->defaultValue(self::SIZE_OF_10GIB_IN_BYTES) ->end() ->integerNode(EmberNexusConfiguration::FILE_UPLOAD_EXPIRES_IN_SECONDS_AFTER_FIRST_REQUEST) ->info('Number of seconds after which unfinished uploads are expired.') ->min(0) ->defaultValue(self::THREE_HOURS_IN_SECONDS) ->end() - ->integerNode(EmberNexusConfiguration::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES) - ->info('Minimum size in bytes of uploaded chunks.') + ->integerNode(EmberNexusConfiguration::FILE_UPLOAD_CHUNK_DIGITS_LENGTH) + ->info('Number of digits for the upload chunk counter. Number of max chunks can be limited by upstream services, e.g. AWS can only support up to 10.000 chunks, i.e. a chunk digits length of 4.') ->min(1) - ->defaultValue(8) + ->defaultValue(4) + ->end() + ->integerNode(EmberNexusConfiguration::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES) + ->info('Minimum size in bytes of uploaded chunks. Limited by the S3 provider, e.g. AWS requires that chunks are at least 5 MiB big.') + ->min(0) + ->defaultValue(self::SIZE_OF_5MIB_IN_BYTES) ->end() ->integerNode(EmberNexusConfiguration::FILE_UPLOAD_MAX_CHUNK_SIZE_IN_BYTES) - ->info('Maximum size in bytes of uploaded chunks. Limited by the S3 provider and the PHP configuration.') + ->info('Maximum size in bytes of uploaded chunks. Limited by the S3 provider, Caddy and the PHP configuration.') ->min(1) - ->defaultValue(self::SIZE_OF_100MB_IN_BYTES) + ->defaultValue(self::SIZE_OF_101MIB_IN_BYTES) ->end() ->scalarNode(EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET) ->info('Name of the S3 bucket used for storage of files.') @@ -188,7 +194,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->integerNode(EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVELS) ->info('Number of levels (folders) used within the S3 storage bucket.') ->min(1) - ->defaultValue(2) + ->defaultValue(3) ->end() ->integerNode(EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVEL_LENGTH) ->info('Number of characters used for each level (folder) in the S3 storage bucket.') diff --git a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php index 2ce84aad..b48f0cb7 100644 --- a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php +++ b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php @@ -48,6 +48,7 @@ class EmberNexusConfiguration public const string FILE = 'file'; public const string FILE_MAX_FILE_SIZE_IN_BYTES = 'maxFileSizeInBytes'; public const string FILE_UPLOAD_EXPIRES_IN_SECONDS_AFTER_FIRST_REQUEST = 'uploadExpiresInSecondsAfterFirstRequest'; + public const string FILE_UPLOAD_CHUNK_DIGITS_LENGTH = 'uploadChunkDigitsLength'; public const string FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES = 'uploadMinChunkSizeInBytes'; public const string FILE_UPLOAD_MAX_CHUNK_SIZE_IN_BYTES = 'uploadMaxChunkSizeInBytes'; public const string FILE_S3_STORAGE_BUCKET = 'S3StorageBucket'; @@ -78,6 +79,7 @@ class EmberNexusConfiguration private int $fileMaxFileSizeInBytes; private int $fileUploadExpiresInSecondsAfterFirstRequest; + private int $fileUploadChunkDigitsLength; private int $fileUploadMinChunkSizeInBytes; private int $fileUploadMaxChunkSizeInBytes; private string $fileS3StorageBucket; @@ -101,7 +103,7 @@ private static function getValueFromConfig(array $configuration, array $keyParts return $configuration; } - public static function createFromConfiguration(array $configuration): self + public static function createFromConfiguration(array $configuration): static { $emberNexusConfiguration = new self(); @@ -291,6 +293,15 @@ public static function createFromConfiguration(array $configuration): self ); $emberNexusConfiguration->setFileUploadExpiresInSecondsAfterFirstRequest($value); + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_UPLOAD_CHUNK_DIGITS_LENGTH, + ] + ); + $emberNexusConfiguration->setFileUploadChunkDigitsLength($value); + $value = (int) self::getValueFromConfig( $configuration, [ @@ -375,7 +386,7 @@ public function getPageSizeMin(): int return $this->pageSizeMin; } - public function setPageSizeMin(int $pageSizeMin): self + public function setPageSizeMin(int $pageSizeMin): static { $this->pageSizeMin = $pageSizeMin; @@ -387,7 +398,7 @@ public function getPageSizeDefault(): int return $this->pageSizeDefault; } - public function setPageSizeDefault(int $pageSizeDefault): self + public function setPageSizeDefault(int $pageSizeDefault): static { $this->pageSizeDefault = $pageSizeDefault; @@ -399,7 +410,7 @@ public function getPageSizeMax(): int return $this->pageSizeMax; } - public function setPageSizeMax(int $pageSizeMax): self + public function setPageSizeMax(int $pageSizeMax): static { $this->pageSizeMax = $pageSizeMax; @@ -411,7 +422,7 @@ public function isRegisterEnabled(): bool return $this->registerEnabled; } - public function setRegisterEnabled(bool $registerEnabled): self + public function setRegisterEnabled(bool $registerEnabled): static { $this->registerEnabled = $registerEnabled; @@ -423,7 +434,7 @@ public function getRegisterUniqueIdentifier(): string return $this->registerUniqueIdentifier; } - public function setRegisterUniqueIdentifier(string $registerUniqueIdentifier): self + public function setRegisterUniqueIdentifier(string $registerUniqueIdentifier): static { if (0 === strlen($registerUniqueIdentifier)) { throw new Exception('Unique identifier can not be an empty string.'); @@ -441,7 +452,7 @@ public function getRegisterUniqueIdentifierRegex(): string|false return $this->registerUniqueIdentifierRegex; } - public function setRegisterUniqueIdentifierRegex(string|false $registerUniqueIdentifierRegex): self + public function setRegisterUniqueIdentifierRegex(string|false $registerUniqueIdentifierRegex): static { $this->registerUniqueIdentifierRegex = $registerUniqueIdentifierRegex; @@ -453,7 +464,7 @@ public function isExpressionEnabled(): bool return $this->expressionEnabled; } - public function setExpressionEnabled(bool $expressionEnabled): self + public function setExpressionEnabled(bool $expressionEnabled): static { $this->expressionEnabled = $expressionEnabled; @@ -465,7 +476,7 @@ public function getExpressionWarningLength(): int return $this->expressionWarningLength; } - public function setExpressionWarningLength(int $expressionWarningLength): self + public function setExpressionWarningLength(int $expressionWarningLength): static { $this->expressionWarningLength = $expressionWarningLength; @@ -477,7 +488,7 @@ public function getExpressionMaxLength(): int return $this->expressionMaxLength; } - public function setExpressionMaxLength(int $expressionMaxLength): self + public function setExpressionMaxLength(int $expressionMaxLength): static { $this->expressionMaxLength = $expressionMaxLength; @@ -489,7 +500,7 @@ public function isInstanceConfigurationEnabled(): bool return $this->instanceConfigurationEnabled; } - public function setInstanceConfigurationEnabled(bool $instanceConfigurationEnabled): self + public function setInstanceConfigurationEnabled(bool $instanceConfigurationEnabled): static { $this->instanceConfigurationEnabled = $instanceConfigurationEnabled; @@ -501,7 +512,7 @@ public function isInstanceConfigurationShowVersion(): bool return $this->instanceConfigurationShowVersion; } - public function setInstanceConfigurationShowVersion(bool $instanceConfigurationShowVersion): self + public function setInstanceConfigurationShowVersion(bool $instanceConfigurationShowVersion): static { $this->instanceConfigurationShowVersion = $instanceConfigurationShowVersion; @@ -513,7 +524,7 @@ public function getTokenMinLifetimeInSeconds(): int return $this->tokenMinLifetimeInSeconds; } - public function setTokenMinLifetimeInSeconds(int $tokenMinLifetimeInSeconds): self + public function setTokenMinLifetimeInSeconds(int $tokenMinLifetimeInSeconds): static { $this->tokenMinLifetimeInSeconds = $tokenMinLifetimeInSeconds; @@ -525,7 +536,7 @@ public function getTokenDefaultLifetimeInSeconds(): int return $this->tokenDefaultLifetimeInSeconds; } - public function setTokenDefaultLifetimeInSeconds(int $tokenDefaultLifetimeInSeconds): self + public function setTokenDefaultLifetimeInSeconds(int $tokenDefaultLifetimeInSeconds): static { $this->tokenDefaultLifetimeInSeconds = $tokenDefaultLifetimeInSeconds; @@ -537,7 +548,7 @@ public function getTokenMaxLifetimeInSeconds(): false|int return $this->tokenMaxLifetimeInSeconds; } - public function setTokenMaxLifetimeInSeconds(bool|int $tokenMaxLifetimeInSeconds): self + public function setTokenMaxLifetimeInSeconds(bool|int $tokenMaxLifetimeInSeconds): static { $this->tokenMaxLifetimeInSeconds = $tokenMaxLifetimeInSeconds; @@ -549,7 +560,7 @@ public function getTokenDeleteExpiredTokensAutomaticallyInSeconds(): bool|int return $this->tokenDeleteExpiredTokensAutomaticallyInSeconds; } - public function setTokenDeleteExpiredTokensAutomaticallyInSeconds(bool|int $tokenDeleteExpiredTokensAutomaticallyInSeconds): self + public function setTokenDeleteExpiredTokensAutomaticallyInSeconds(bool|int $tokenDeleteExpiredTokensAutomaticallyInSeconds): static { $this->tokenDeleteExpiredTokensAutomaticallyInSeconds = $tokenDeleteExpiredTokensAutomaticallyInSeconds; @@ -561,7 +572,7 @@ public function getCacheEtagSeed(): string return $this->cacheEtagSeed; } - public function setCacheEtagSeed(string $cacheEtagSeed): self + public function setCacheEtagSeed(string $cacheEtagSeed): static { $this->cacheEtagSeed = $cacheEtagSeed; @@ -573,7 +584,7 @@ public function getCacheEtagUpperLimitInCollectionEndpoints(): int return $this->cacheEtagUpperLimitInCollectionEndpoints; } - public function setCacheEtagUpperLimitInCollectionEndpoints(int $cacheEtagUpperLimitInCollectionEndpoints): self + public function setCacheEtagUpperLimitInCollectionEndpoints(int $cacheEtagUpperLimitInCollectionEndpoints): static { $this->cacheEtagUpperLimitInCollectionEndpoints = $cacheEtagUpperLimitInCollectionEndpoints; @@ -585,7 +596,7 @@ public function isFeatureFlag280OldUniqueUserIdentifierDisabled(): bool return $this->featureFlag280OldUniqueUserIdentifierDisabled; } - public function setFeatureFlag280OldUniqueUserIdentifierDisabled(bool $featureFlag280OldUniqueUserIdentifierDisabled): self + public function setFeatureFlag280OldUniqueUserIdentifierDisabled(bool $featureFlag280OldUniqueUserIdentifierDisabled): static { $this->featureFlag280OldUniqueUserIdentifierDisabled = $featureFlag280OldUniqueUserIdentifierDisabled; @@ -597,7 +608,7 @@ public function getFileMaxFileSizeInBytes(): int return $this->fileMaxFileSizeInBytes; } - public function setFileMaxFileSizeInBytes(int $fileMaxFileSizeInBytes): self + public function setFileMaxFileSizeInBytes(int $fileMaxFileSizeInBytes): static { $this->fileMaxFileSizeInBytes = $fileMaxFileSizeInBytes; @@ -609,19 +620,34 @@ public function getFileUploadExpiresInSecondsAfterFirstRequest(): int return $this->fileUploadExpiresInSecondsAfterFirstRequest; } - public function setFileUploadExpiresInSecondsAfterFirstRequest(int $fileUploadExpiresInSecondsAfterFirstRequest): self + public function setFileUploadExpiresInSecondsAfterFirstRequest(int $fileUploadExpiresInSecondsAfterFirstRequest): static { $this->fileUploadExpiresInSecondsAfterFirstRequest = $fileUploadExpiresInSecondsAfterFirstRequest; return $this; } + public function getFileUploadChunkDigitsLength(): int + { + return $this->fileUploadChunkDigitsLength; + } + + public function setFileUploadChunkDigitsLength(int $fileUploadChunkDigitsLength): static + { + if ($fileUploadChunkDigitsLength < 1) { + throw new Exception('Chunk digits can not be less than 1.'); + } + $this->fileUploadChunkDigitsLength = $fileUploadChunkDigitsLength; + + return $this; + } + public function getFileUploadMinChunkSizeInBytes(): int { return $this->fileUploadMinChunkSizeInBytes; } - public function setFileUploadMinChunkSizeInBytes(int $fileUploadMinChunkSizeInBytes): self + public function setFileUploadMinChunkSizeInBytes(int $fileUploadMinChunkSizeInBytes): static { $this->fileUploadMinChunkSizeInBytes = $fileUploadMinChunkSizeInBytes; @@ -633,7 +659,7 @@ public function getFileUploadMaxChunkSizeInBytes(): int return $this->fileUploadMaxChunkSizeInBytes; } - public function setFileUploadMaxChunkSizeInBytes(int $fileUploadMaxChunkSizeInBytes): self + public function setFileUploadMaxChunkSizeInBytes(int $fileUploadMaxChunkSizeInBytes): static { $this->fileUploadMaxChunkSizeInBytes = $fileUploadMaxChunkSizeInBytes; @@ -645,7 +671,7 @@ public function getFileS3StorageBucket(): string return $this->fileS3StorageBucket; } - public function setFileS3StorageBucket(string $fileS3StorageBucket): self + public function setFileS3StorageBucket(string $fileS3StorageBucket): static { $this->fileS3StorageBucket = $fileS3StorageBucket; @@ -657,7 +683,7 @@ public function getFileS3UploadBucket(): string return $this->fileS3UploadBucket; } - public function setFileS3UploadBucket(string $fileS3UploadBucket): self + public function setFileS3UploadBucket(string $fileS3UploadBucket): static { $this->fileS3UploadBucket = $fileS3UploadBucket; @@ -669,7 +695,7 @@ public function getFileS3StorageBucketLevels(): int return $this->fileS3StorageBucketLevels; } - public function setFileS3StorageBucketLevels(int $fileS3StorageBucketLevels): self + public function setFileS3StorageBucketLevels(int $fileS3StorageBucketLevels): static { $this->fileS3StorageBucketLevels = $fileS3StorageBucketLevels; @@ -681,7 +707,7 @@ public function getFileS3StorageBucketLevelLength(): int return $this->fileS3StorageBucketLevelLength; } - public function setFileS3StorageBucketLevelLength(int $fileS3StorageBucketLevelLength): self + public function setFileS3StorageBucketLevelLength(int $fileS3StorageBucketLevelLength): static { $this->fileS3StorageBucketLevelLength = $fileS3StorageBucketLevelLength; @@ -693,7 +719,7 @@ public function getFileS3UploadBucketLevels(): int return $this->fileS3UploadBucketLevels; } - public function setFileS3UploadBucketLevels(int $fileS3UploadBucketLevels): self + public function setFileS3UploadBucketLevels(int $fileS3UploadBucketLevels): static { $this->fileS3UploadBucketLevels = $fileS3UploadBucketLevels; @@ -705,7 +731,7 @@ public function getFileS3UploadBucketLevelLength(): int return $this->fileS3UploadBucketLevelLength; } - public function setFileS3UploadBucketLevelLength(int $fileS3UploadBucketLevelLength): self + public function setFileS3UploadBucketLevelLength(int $fileS3UploadBucketLevelLength): static { $this->fileS3UploadBucketLevelLength = $fileS3UploadBucketLevelLength; diff --git a/src/Attribute/EndpointImplementsTusIo.php b/src/Attribute/EndpointImplementsTusIo.php deleted file mode 100644 index 7a795843..00000000 --- a/src/Attribute/EndpointImplementsTusIo.php +++ /dev/null @@ -1,15 +0,0 @@ -addOption( - 'ndjson', + 'no-files', null, InputOption::VALUE_NEGATABLE, - 'Saves multiple JSON documents in a few .ndjson files.', - false + 'Disable file export.' ); } @@ -85,10 +92,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->backupName = $this->checkBackupName($input->getArgument('name')); $this->prettyPrint = $input->getOption('pretty'); - $this->ndjson = $input->getOption('ndjson'); - if ($this->prettyPrint && $this->ndjson) { - throw new Exception('Pretty print and ndjson are mutually exclusive.'); - } + $this->exportFiles = !$input->getOption('no-files'); $this->io->title('Backup Create'); $this->createBackupFolders(); $this->initCount(); @@ -149,7 +153,7 @@ private function backupNodes(): void foreach ($rawNodeIds->toArray() as $rawNodeId) { $rawNodeIdContent = $rawNodeId->get('n.id'); if (!is_string($rawNodeIdContent)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property n.id as string, not %s.', get_debug_type($rawNodeIdContent))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property n.id as string, not %s.', get_debug_type($rawNodeIdContent))); // @codeCoverageIgnore } $nodeIds[] = Uuid::fromString($rawNodeIdContent); } @@ -204,7 +208,7 @@ private function backupRelations(): void foreach ($rawRelationIds->toArray() as $rawRelationId) { $rawRelationIdContent = $rawRelationId->get('r.id'); if (!is_string($rawRelationIdContent)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.id as string, not %s.', get_debug_type($rawRelationIdContent))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.id as string, not %s.', get_debug_type($rawRelationIdContent))); // @codeCoverageIgnore } $relationIds[] = Uuid::fromString($rawRelationIdContent); } @@ -234,7 +238,69 @@ private function backupRelations(): void private function backupFiles(): void { $this->io->startSection('Step 3 of 3: Backing up Files'); - $this->io->writeln('Currently not implemented.'); + + if (false === $this->exportFiles) { + $this->io->stopSection('File backup skipped.'); + + return; + } + + $rawFileElements = $this->cypherEntityManager->getClient()->runStatement( + Statement::create( + 'OPTIONAL MATCH (n) WHERE n.file '. + 'OPTIONAL MATCH ()-[r]->() WHERE r.file '. + 'WITH coalesce(n, r) AS element '. + 'WHERE element.file '. + 'RETURN element.id' + ) + ); + + $fileElementIds = []; + foreach ($rawFileElements->toArray() as $row) { + $fileElementIds[] = $row->get('element.id'); + } + + $this->fileCount = count($fileElementIds); + + $this->io->writeln(sprintf( + 'Found %d files.', + $this->fileCount + )); + + if (0 === $this->fileCount) { + $this->io->stopSection('File backup skipped.'); + + return; + } + + $progressBar = $this->io->createProgressBarInInteractiveTerminal($this->fileCount); + $progressBar?->display(); + + foreach ($fileElementIds as $rawElementId) { + if (!is_string($rawElementId)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property element.id as string, not %s.', get_debug_type($rawElementId))); // @codeCoverageIgnore + } + $elementId = Uuid::fromString($rawElementId); + $element = $this->elementManager->getElement($elementId); + + if (null === $element) { + $progressBar?->advance(); + continue; + } + + $fileOperation = $this->fileOperationFactory->createFileOperationFromElement($element); + $resource = $this->s3Service->getFileAsResource($fileOperation); + $extension = $this->elementService->getFileNameExtension($element); + + $this->backupStorage->writeStream( + $this->getFilePath($elementId, $extension), + $resource + ); + + $progressBar?->advance(); + } + + $progressBar?->clear(); $this->io->stopSection(sprintf( 'Successfully backed up %d files.', $this->fileCount @@ -243,40 +309,45 @@ private function backupFiles(): void private function getNodePath(UuidInterface $nodeId): string { - $levels = (int) ceil(log($this->nodeCount, 256)); + $levels = max(0, (int) ceil(log($this->nodeCount, 256)) - 1); return sprintf( '%s/node/%s.json', $this->backupName, - $this->storageUtilService->uuidToNestedFolderStructure($nodeId, $levels) + $this->fileService->uuidToNestedFolderStructure($nodeId, $levels) ); } private function getRelationPath(UuidInterface $relationId): string { - $levels = (int) ceil(log($this->relationCount, 256)); + $levels = max(0, (int) ceil(log($this->relationCount, 256)) - 1); return sprintf( '%s/relation/%s.json', $this->backupName, - $this->storageUtilService->uuidToNestedFolderStructure($relationId, $levels) + $this->fileService->uuidToNestedFolderStructure($relationId, $levels) ); } - private function checkBackupName(string $backupName): string + private function getFilePath(UuidInterface $elementId, string $extension): string { - $backupName = trim($backupName); + $levels = max(0, (int) ceil(log($this->fileCount, 256)) - 1); - if ('' === $backupName) { - throw new LogicException("Backup name can not be ''"); - } + return sprintf( + '%s/file/%s.%s', + $this->backupName, + $this->fileService->uuidToNestedFolderStructure($elementId, $levels), + $extension + ); + } - if ('.' === $backupName) { - throw new LogicException("Backup name can not be '.'"); - } + private function checkBackupName(string $backupName): string + { + $backupName = trim($backupName); - if ('..' === $backupName) { - throw new LogicException("Backup name can not be '..'"); + $forbiddenNames = ['', '.', '..']; + if (in_array($backupName, $forbiddenNames)) { + throw new LogicException(sprintf("Backup name can not be '%s'", $backupName)); } if ($this->backupStorage->directoryExists($backupName)) { @@ -288,6 +359,8 @@ private function checkBackupName(string $backupName): string private function createBackupFolders(): void { + $this->io->writeln(sprintf("Creating backup %s in folder ./var/backup\n", $this->backupName)); + $this->backupStorage->createDirectory($this->backupName); $this->backupStorage->createDirectory($this->backupName.'/node'); $this->backupStorage->createDirectory($this->backupName.'/relation'); @@ -300,14 +373,14 @@ private function initCount(): void Statement::create('MATCH (n) RETURN count(n) as count') )->first()->get('count'); if (!is_int($rawNodeCount)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property count as int, not %s.', get_debug_type($rawNodeCount))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property count as int, not %s.', get_debug_type($rawNodeCount))); // @codeCoverageIgnore } $this->nodeCount = $rawNodeCount; $rawRelationCount = $this->cypherEntityManager->getClient()->runStatement( Statement::create('MATCH ()-[r]->() RETURN count(r) as count') )->first()->get('count'); if (!is_int($rawRelationCount)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property count as int, not %s.', get_debug_type($rawRelationCount))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property count as int, not %s.', get_debug_type($rawRelationCount))); // @codeCoverageIgnore } $this->relationCount = $rawRelationCount; } diff --git a/src/Command/BackupLoadCommand.php b/src/Command/BackupLoadCommand.php index de71267e..5247296e 100755 --- a/src/Command/BackupLoadCommand.php +++ b/src/Command/BackupLoadCommand.php @@ -6,10 +6,13 @@ use App\DependencyInjection\DeactivatableTraceableEventDispatcher; use App\EventSystem\EntityManager\Event\ElementUpdateAfterBackupLoadEvent; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; +use App\Factory\Type\S3\UploadFileOperationFactory; +use App\Helper\Regex; use App\Service\AppStateService; use App\Service\ElementManager; use App\Service\RawToElementService; +use App\Service\S3Service; use App\Style\EmberNexusStyle; use App\Type\AppStateType; use Laudis\Neo4j\Databags\Statement; @@ -17,6 +20,8 @@ use LogicException; use Predis\Client; use Ramsey\Uuid\Rfc4122\UuidV4; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -34,15 +39,15 @@ class BackupLoadCommand extends Command { private string $backupName; private int $relationCount = 0; - /** - * @phpstan-ignore-next-line - */ private int $fileCount = 0; private int $nodeCount = 0; private int $pageSize = 250; private EmberNexusStyle $io; + /** + * @SuppressWarnings("PHPMD.ExcessiveParameterList") + */ public function __construct( private ElementManager $elementManager, private CypherEntityManager $cypherEntityManager, @@ -52,7 +57,9 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private AppStateService $appStateService, private ElasticEntityManager $elasticEntityManager, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private S3Service $s3Service, + private UploadFileOperationFactory $uploadFileOperationFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { parent::__construct(); } @@ -105,7 +112,7 @@ private function loadNodes(): void continue; } $data = \Safe\json_decode($this->backupStorage->read($nodeFile->path()), true); - $nodeElement = $this->rawToElementService->rawToElement($data); + $nodeElement = $this->rawToElementService->rawToElement($data, true); unset($data); $this->elementManager->create($nodeElement); ++$pageCount; @@ -142,7 +149,7 @@ private function loadRelations(): void continue; } $data = \Safe\json_decode($this->backupStorage->read($relationFile->path()), true); - $relationElement = $this->rawToElementService->rawToElement($data); + $relationElement = $this->rawToElementService->rawToElement($data, true); unset($data); $this->elementManager->create($relationElement); ++$pageCount; @@ -163,13 +170,65 @@ private function loadRelations(): void )); } + private function parseFilenameAsUuidFromPath(string $path): false|UuidInterface + { + $filename = basename($path); + $parts = explode('.', $filename, 2); + if (2 !== count($parts)) { + return false; + } + $name = $parts[0]; + if (!\Safe\preg_match(Regex::UUID_V4, $name)) { + return false; + } + + return Uuid::fromString($name); + } + private function loadFiles(): void { $this->io->startSection('Step 3 of 4: Loading Files'); - $this->io->writeln('Currently not implemented.'); + $progressBar = $this->io->createProgressBarInInteractiveTerminal($this->fileCount); + $progressBar?->display(); + $files = $this->backupStorage->listContents($this->backupName.'/file/', true); + $pageCount = 0; + $totalCount = 0; + foreach ($files as $file) { + if (!$file->isFile()) { + continue; + } + $path = $file->path(); + $fileId = $this->parseFilenameAsUuidFromPath($path); + if (false === $fileId) { + continue; + } + $element = $this->elementManager->getElement($fileId); + if (null === $element) { + $this->io->warning(sprintf( + 'Found file in backup without corresponding element; can not import file: %s', + $path + )); + continue; + } + + $resource = $this->backupStorage->readStream($path); + $uploadFileOperation = $this->uploadFileOperationFactory->createUploadFileOperationFromElementAndResource($element, $resource); + $this->s3Service->uploadFile($uploadFileOperation); + + ++$pageCount; + if ($pageCount >= $this->pageSize) { + $progressBar?->advance($pageCount); + $totalCount += $pageCount; + $pageCount = 0; + } + } + $this->elementManager->flush(); + $progressBar?->advance($pageCount); + $progressBar?->clear(); + $totalCount += $pageCount; $this->io->stopSection(sprintf( 'Loaded %d files.', - 0 + $totalCount )); } @@ -197,7 +256,7 @@ private function afterBackupTasks(): void foreach ($res as $row) { $rawId = $row->get('n.id'); if (!is_string($rawId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property n.id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property n.id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore } $id = UuidV4::fromString($rawId); $element = $this->elementManager->getNode($id); @@ -225,7 +284,7 @@ private function afterBackupTasks(): void foreach ($res as $row) { $rawId = $row->get('r.id'); if (!is_string($rawId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore } $id = UuidV4::fromString($rawId); $element = $this->elementManager->getRelation($id); diff --git a/src/Command/Cron/DeleteExpiredUploadsCommand.php b/src/Command/Cron/DeleteExpiredUploadsCommand.php new file mode 100755 index 00000000..44c8c66f --- /dev/null +++ b/src/Command/Cron/DeleteExpiredUploadsCommand.php @@ -0,0 +1,55 @@ +io = new EmberNexusStyle($input, $output); + + $this->io->title('Cron'); + + $this->io->writeln('This command is currently a placeholder.'); + $this->io->newLine(); + + // todo: implement command, see https://github.com/ember-nexus/api/issues/441 + + $isCronDisabled = $this->bag->get('isCronDisabled'); + if (!is_bool($isCronDisabled)) { + throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); + } + if ($isCronDisabled) { + $this->io->finalMessage('Cron is disabled; this command terminates early.'); + + return Command::SUCCESS; + } + + $this->io->finalMessage('Finished.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/Cron/ReindexFilesCommand.php b/src/Command/Cron/ReindexFilesCommand.php new file mode 100755 index 00000000..9289f2f5 --- /dev/null +++ b/src/Command/Cron/ReindexFilesCommand.php @@ -0,0 +1,55 @@ +io = new EmberNexusStyle($input, $output); + + $this->io->title('Cron'); + + $this->io->writeln('This command is currently a placeholder.'); + $this->io->newLine(); + + // todo: implement command + + $isCronDisabled = $this->bag->get('isCronDisabled'); + if (!is_bool($isCronDisabled)) { + throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); + } + if ($isCronDisabled) { + $this->io->finalMessage('Cron is disabled; this command terminates early.'); + + return Command::SUCCESS; + } + + $this->io->finalMessage('Finished.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/Cron/UpdateOwnershipCommand.php b/src/Command/Cron/UpdateOwnershipCommand.php new file mode 100755 index 00000000..7cec70c1 --- /dev/null +++ b/src/Command/Cron/UpdateOwnershipCommand.php @@ -0,0 +1,55 @@ +io = new EmberNexusStyle($input, $output); + + $this->io->title('Cron'); + + $this->io->writeln('This command is currently a placeholder.'); + $this->io->newLine(); + + // todo: implement command, see https://github.com/ember-nexus/api/issues/438 + + $isCronDisabled = $this->bag->get('isCronDisabled'); + if (!is_bool($isCronDisabled)) { + throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); + } + if ($isCronDisabled) { + $this->io->finalMessage('Cron is disabled; this command terminates early.'); + + return Command::SUCCESS; + } + + $this->io->finalMessage('Finished.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/DatabaseDropCommand.php b/src/Command/DatabaseDropCommand.php index a7efcdc2..a63f3ab9 100755 --- a/src/Command/DatabaseDropCommand.php +++ b/src/Command/DatabaseDropCommand.php @@ -38,7 +38,7 @@ public function __construct( private Client $redisClient, private AMQPStreamConnection $AMQPStreamConnection, private S3Client $s3Client, - private EmberNexusConfiguration $emberNexusConfiguration + private EmberNexusConfiguration $emberNexusConfiguration, ) { parent::__construct(); } diff --git a/src/Command/HealthcheckCommand.php b/src/Command/HealthcheckCommand.php index 4e97b7bc..ef25fe1a 100755 --- a/src/Command/HealthcheckCommand.php +++ b/src/Command/HealthcheckCommand.php @@ -4,7 +4,7 @@ namespace App\Command; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Style\EmberNexusStyle; use AsyncAws\S3\S3Client; use EmberNexusBundle\Service\EmberNexusConfiguration; @@ -25,6 +25,9 @@ /** * @psalm-suppress PropertyNotSetInConstructor $io + * + * @SuppressWarnings("PHPMD.CyclomaticComplexity") + * @SuppressWarnings("PHPMD.NPathComplexity") */ #[AsCommand(name: 'healthcheck', description: 'Check the health of the API and required databases.')] class HealthcheckCommand extends Command @@ -37,89 +40,73 @@ public function __construct( private ElasticEntityManager $elasticEntityManager, private RedisClient $redisClient, private AMQPStreamConnection $AMQPStreamConnection, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, private S3Client $s3Client, - private EmberNexusConfiguration $emberNexusConfiguration + private EmberNexusConfiguration $emberNexusConfiguration, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { parent::__construct(); } - /** - * @psalm-suppress PossiblyUndefinedArrayOffset - */ - protected function execute(InputInterface $input, OutputInterface $output): int + private function getCypherVersion(): string { - $this->io = new EmberNexusStyle($input, $output); - - $this->io->title('Healthcheck'); - - $this->io->startSection('Check if databases are online'); - - $neo4jInfo = $this->cypherEntityManager->getClient()->runStatement( + $cypherInfo = $this->cypherEntityManager->getClient()->runStatement( Statement::create( 'CALL dbms.components() YIELD versions, edition UNWIND versions AS version RETURN version, edition;' ) ); - $rawVersion = $neo4jInfo->first()->get('version'); + $rawVersion = $cypherInfo->first()->get('version'); if (!is_string($rawVersion)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property version as string, not %s.', get_debug_type($rawVersion))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property version as string, not %s.', get_debug_type($rawVersion))); // @codeCoverageIgnore } - $rawEdition = $neo4jInfo->first()->get('edition'); + $rawEdition = $cypherInfo->first()->get('edition'); if (!is_string($rawEdition)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property edition as string, not %s.', get_debug_type($rawEdition))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property edition as string, not %s.', get_debug_type($rawEdition))); // @codeCoverageIgnore } - $neo4jVersion = sprintf( + + return sprintf( '%s (%s)', $rawVersion, $rawEdition ); + } - $this->io->writeln(sprintf( - 'Neo4j version: %s', - $neo4jVersion - )); - + private function getMongoVersion(): string + { $mongoClient = $this->mongoEntityManager->getClient(); $mongoDatabase = $this->mongoEntityManager->getDatabase(); if (null === $mongoDatabase) { throw new Exception('Mongo database name can not be null.'); } $mongo = $mongoClient->selectDatabase($mongoDatabase)->command(['buildInfo' => 1]); - $mongoVersion = $mongo->toArray()[0]->getArrayCopy()['version']; - $this->io->writeln(sprintf( - 'MongoDB version: %s', - $mongoVersion - )); + return $mongo->toArray()[0]->getArrayCopy()['version']; + } + private function getElasticVersion(): string + { $elasticClient = $this->elasticEntityManager->getClient(); $elastic = $elasticClient->sendRequest(new Request('GET', '/')); + /** * @psalm-suppress PossiblyUndefinedMethod * * @phpstan-ignore-next-line */ - $elasticVersion = $elastic->asArray()['version']['number']; - - $this->io->writeln(sprintf( - 'Elasticsearch version: %s', - $elasticVersion - )); - - $redisVersion = $this->redisClient->info()['Server']['redis_version']; - - $this->io->writeln(sprintf( - 'Redis version: %s', - $redisVersion - )); + return $elastic->asArray()['version']['number']; + } - $rabbitMqVersion = $this->AMQPStreamConnection->getServerProperties()['version'][1]; + private function getRedisVersion(): string + { + return $this->redisClient->info()['Server']['redis_version']; + } - $this->io->writeln(sprintf( - 'RabbitMQ version: %s', - $rabbitMqVersion - )); + private function getRabbitMqVersion(): string + { + return $this->AMQPStreamConnection->getServerProperties()['version'][1]; + } + private function getS3Status(): string + { $isStorageBucketOnline = $this->s3Client->bucketExists(['Bucket' => $this->emberNexusConfiguration->getFileS3StorageBucket()])->isSuccess(); if (!$isStorageBucketOnline) { throw new Exception(sprintf('Unable to connect to bucket %s.', $this->emberNexusConfiguration->getFileS3StorageBucket())); @@ -128,50 +115,76 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$isUploadBucketOnline) { throw new Exception(sprintf('Unable to connect to bucket %s.', $this->emberNexusConfiguration->getFileS3UploadBucket())); } - $this->io->writeln('S3 buckets: online'); - $this->io->stopSection('All databases are online.'); - - $this->io->startSection('Check internal services'); + return 'online'; + } + private function getAlpineVersion(): string + { $alpineVersion = \Safe\shell_exec('cat /etc/os-release | grep -i version') ?? '=unknown'; - $alpineVersion = trim(explode('=', $alpineVersion, 2)[1]); - $this->io->writeln(sprintf( - 'Alpine version: %s', - $alpineVersion - )); + /** + * @psalm-suppress PossiblyUndefinedArrayOffset + */ + return trim(explode('=', $alpineVersion, 2)[1]); + } + private function getCaddyVersion(): string + { $caddyVersion = 'unknown'; $caddyVersionOutput = explode(' ', \Safe\shell_exec('frankenphp -v 2>&1') ?? ''); if (count($caddyVersionOutput) > 1) { $caddyVersion = \Safe\preg_replace('/[^0-9.]/', '', $caddyVersionOutput[0]); } + /** @var string $caddyVersion */ - $this->io->writeln(sprintf( - 'Caddy version: %s', - $caddyVersion - )); + return $caddyVersion; + } + private function getFrankenPhpVersion(): string + { $frankenPhpVersion = 'unknown'; $frankenPhpVersionOutput = getenv('FRANKENPHP_VERSION'); if (is_string($frankenPhpVersionOutput)) { $frankenPhpVersion = \Safe\preg_replace('/[^0-9.]/', '', $frankenPhpVersionOutput); } + /** @var string $frankenPhpVersion */ - $this->io->writeln(sprintf( - 'FrankenPHP version: %s', - $frankenPhpVersion - )); + return $frankenPhpVersion; + } - /** - * @psalm-suppress PossiblyFalseArgument - */ - $this->io->writeln(sprintf( - 'PHP version: %s', - phpversion() - )); + /** + * @psalm-suppress InvalidFalsableReturnType + * @psalm-suppress FalsableReturnStatement + */ + private function getPhpVersion(): string + { + return phpversion(); + } + + /** + * @psalm-suppress PossiblyUndefinedArrayOffset + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->io = new EmberNexusStyle($input, $output); + + $this->io->title('Healthcheck'); + $this->io->startSection('Check if databases are online'); + $this->io->writeln(sprintf('Neo4j version: %s', $this->getCypherVersion())); + $this->io->writeln(sprintf('MongoDB version: %s', $this->getMongoVersion())); + $this->io->writeln(sprintf('Elasticsearch version: %s', $this->getElasticVersion())); + $this->io->writeln(sprintf('Redis version: %s', $this->getRedisVersion())); + $this->io->writeln(sprintf('RabbitMQ version: %s', $this->getRabbitMqVersion())); + $this->io->writeln(sprintf('S3 buckets: %s', $this->getS3Status())); + $this->io->stopSection('All databases are online.'); + + $this->io->startSection('Check internal services'); + $this->io->writeln(sprintf('Alpine version: %s', $this->getAlpineVersion())); + $this->io->writeln(sprintf('Caddy version: %s', $this->getCaddyVersion())); + $this->io->writeln(sprintf('FrankenPHP version: %s', $this->getFrankenPhpVersion())); + $this->io->writeln(sprintf('PHP version: %s', $this->getPhpVersion())); $this->io->stopSection('Internal services are ok.'); $this->io->startSection('Check API'); diff --git a/src/Command/TestCommand.php b/src/Command/TestCommand.php deleted file mode 100644 index 54a8b0ae..00000000 --- a/src/Command/TestCommand.php +++ /dev/null @@ -1,58 +0,0 @@ -io = new EmberNexusStyle($input, $output); - - $this->io->title('Test'); - - // $buckets = $this->s3Client->listBuckets(); - // print_r($buckets->getBuckets()); - // - // $res = $this->s3Client->bucketExists(['Bucket' => 'api-upload2']); - // print_r($res->getState()); - // print_r($res->isSuccess() ? 'true' : 'false'); - - $lock = new FileUploadCheckLock( - Uuid::fromString('119bc6f4-a9cd-4d6a-bbaf-768061542ef9'), - Uuid::fromString('21850a04-ac44-4b14-a64d-6d498e4134a9'), - ); - - $this->lockService->acquireLock($lock); - $this->lockService->releaseLock($lock); - - return Command::SUCCESS; - } -} diff --git a/src/Command/TokenCreateCommand.php b/src/Command/TokenCreateCommand.php index f67de0f1..92d6dffb 100644 --- a/src/Command/TokenCreateCommand.php +++ b/src/Command/TokenCreateCommand.php @@ -4,7 +4,7 @@ namespace App\Command; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Helper\Regex; use App\Security\TokenGenerator; use App\Service\ElementManager; @@ -35,7 +35,7 @@ public function __construct( private CypherEntityManager $cypherEntityManager, private TokenGenerator $tokenGenerator, private EmberNexusConfiguration $emberNexusConfiguration, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { parent::__construct(); } @@ -95,7 +95,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $rawIdentifier = $res->first()->get('id'); if (!is_string($rawIdentifier)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property id as string, not %s.', get_debug_type($rawIdentifier))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property id as string, not %s.', get_debug_type($rawIdentifier))); // @codeCoverageIgnore } $identifier = Uuid::fromString($rawIdentifier); } diff --git a/src/Command/TokenRevokeCommand.php b/src/Command/TokenRevokeCommand.php index e46ac76f..176e4afb 100644 --- a/src/Command/TokenRevokeCommand.php +++ b/src/Command/TokenRevokeCommand.php @@ -4,7 +4,7 @@ namespace App\Command; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Helper\Regex; use App\Security\AuthProvider; use App\Service\ElementManager; @@ -72,7 +72,7 @@ public function __construct( private EmberNexusConfiguration $emberNexusConfiguration, private AuthProvider $authProvider, private LoggerInterface $logger, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { parent::__construct(); } @@ -241,7 +241,7 @@ private function validateInput(InputInterface $input): void $rawUserId = $res[0]['u.id']; if (!is_string($rawUserId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property u.id as string, not %s.', get_debug_type($rawUserId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property u.id as string, not %s.', get_debug_type($rawUserId))); // @codeCoverageIgnore } $this->userId = Uuid::fromString($rawUserId); } @@ -312,27 +312,27 @@ private function getAffectedTokenRevokeEntries(): array foreach ($queryResultLines as $queryResultLine) { $rawTokenId = $queryResultLine['t.id']; if (!is_string($rawTokenId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property t.id as string, not %s.', get_debug_type($rawTokenId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property t.id as string, not %s.', get_debug_type($rawTokenId))); // @codeCoverageIgnore } $rawTokenCreated = $queryResultLine['t.created']; if (!($rawTokenCreated instanceof \Laudis\Neo4j\Types\DateTime) && !($rawTokenCreated instanceof DateTimeZoneId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property t.created as DateTime|DateTimeZoneId, not %s.', get_debug_type($rawTokenCreated))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property t.created as DateTime|DateTimeZoneId, not %s.', get_debug_type($rawTokenCreated))); // @codeCoverageIgnore } $expirationDate = $queryResultLine['t.expirationDate']; if (!($expirationDate instanceof \Laudis\Neo4j\Types\DateTime) && !($expirationDate instanceof DateTimeZoneId) && !is_null($expirationDate)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property t.expirationDate as DateTime|DateTimeZoneId or null, not %s.', get_debug_type($expirationDate))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property t.expirationDate as DateTime|DateTimeZoneId or null, not %s.', get_debug_type($expirationDate))); // @codeCoverageIgnore } $rawUserId = $queryResultLine['u.id']; if (!is_string($rawUserId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property u.id as string, not %s.', get_debug_type($rawUserId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property u.id as string, not %s.', get_debug_type($rawUserId))); // @codeCoverageIgnore } $rawUserUniqueIdentifier = $queryResultLine['userUniqueIdentifier']; if (!is_string($rawUserUniqueIdentifier)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property userUniqueIdentifier as string, not %s.', get_debug_type($rawUserUniqueIdentifier))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property userUniqueIdentifier as string, not %s.', get_debug_type($rawUserUniqueIdentifier))); // @codeCoverageIgnore } $rawTokenHash = $queryResultLine['t.hash']; if (!is_string($rawTokenHash)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property t.hash as string, not %s.', get_debug_type($rawTokenHash))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property t.hash as string, not %s.', get_debug_type($rawTokenHash))); // @codeCoverageIgnore } $result[] = new TokenRevokeEntry( Uuid::fromString($rawTokenId), diff --git a/src/Console/EmberNexusOutputWrapper.php b/src/Console/EmberNexusOutputWrapper.php index 2e8f5719..3f26492f 100644 --- a/src/Console/EmberNexusOutputWrapper.php +++ b/src/Console/EmberNexusOutputWrapper.php @@ -49,6 +49,11 @@ public function isQuiet(): bool return $this->output->isQuiet(); } + public function isSilent(): bool + { + return $this->output->isSilent(); + } + public function isVerbose(): bool { return $this->output->isVerbose(); diff --git a/src/Contract/EtagCapableResponseInterface.php b/src/Contract/EtagCapableResponseInterface.php new file mode 100755 index 00000000..9859f832 --- /dev/null +++ b/src/Contract/EtagCapableResponseInterface.php @@ -0,0 +1,12 @@ + + */ + public function getUploadKeys(): array; + + public function getStorageBucket(): string; + + public function getPreviousStorageKey(): ?string; + + public function getStorageKey(): string; +} diff --git a/src/Contract/S3/UploadFileChunkOperationInterface.php b/src/Contract/S3/UploadFileChunkOperationInterface.php new file mode 100755 index 00000000..a6dcbd8d --- /dev/null +++ b/src/Contract/S3/UploadFileChunkOperationInterface.php @@ -0,0 +1,21 @@ +client404NotFoundExceptionFactory->createFromTemplate(); } - $element = $this->elementManager->getElement($elementId); - if (null === $element) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } + $element = $this->elementManager->getElementOrFail($elementId); $this->elementManager->delete($element); $this->elementManager->flush(); diff --git a/src/Controller/Element/GetChildrenController.php b/src/Controller/Element/GetChildrenController.php index 73a91867..144f88b1 100644 --- a/src/Controller/Element/GetChildrenController.php +++ b/src/Controller/Element/GetChildrenController.php @@ -6,7 +6,7 @@ use App\Attribute\EndpointSupportsEtag; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; @@ -33,7 +33,7 @@ public function __construct( private AuthProvider $authProvider, private AccessChecker $accessChecker, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -131,21 +131,21 @@ public function getChildren(string $id): Response if (count($res) > 0) { $totalCount = $res->first()->get('totalCount'); if (!is_int($totalCount)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property totalCount as int, not %s.', get_debug_type($totalCount))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property totalCount as int, not %s.', get_debug_type($totalCount))); // @codeCoverageIgnore } foreach ($res as $resultSet) { $rawChild = $resultSet->get('child'); if (!is_string($rawChild)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property child as string, not %s.', get_debug_type($rawChild))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property child as string, not %s.', get_debug_type($rawChild))); // @codeCoverageIgnore } $nodeIds[] = UuidV4::fromString($rawChild); $rawRelations = $resultSet->get('r'); if (!($rawRelations instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r as CypherList, not %s.', get_debug_type($rawRelations))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r as CypherList, not %s.', get_debug_type($rawRelations))); // @codeCoverageIgnore } foreach ($rawRelations as $relationId) { if (!is_string($relationId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.item as string, not %s.', get_debug_type($relationId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.item as string, not %s.', get_debug_type($relationId))); // @codeCoverageIgnore } $relationIds[] = UuidV4::fromString($relationId); } diff --git a/src/Controller/Element/GetIndexController.php b/src/Controller/Element/GetIndexController.php index 9214b479..27b3c0ef 100644 --- a/src/Controller/Element/GetIndexController.php +++ b/src/Controller/Element/GetIndexController.php @@ -5,7 +5,7 @@ namespace App\Controller\Element; use App\Attribute\EndpointSupportsEtag; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\AuthProvider; use App\Service\CollectionService; use App\Type\EtagType; @@ -23,7 +23,7 @@ public function __construct( private CypherEntityManager $cypherEntityManager, private AuthProvider $authProvider, private CollectionService $collectionService, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -58,11 +58,11 @@ public function getIndex(): Response if (count($res) > 0) { $totalCount = $res->first()->get('totalCount'); if (!is_int($totalCount)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property totalCount as int, not %s.', get_debug_type($totalCount))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property totalCount as int, not %s.', get_debug_type($totalCount))); // @codeCoverageIgnore } $rawElementIds = $res->first()->get('elementIds'); if (!($rawElementIds instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property elementIds as CypherList, not %s.', get_debug_type($rawElementIds))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property elementIds as CypherList, not %s.', get_debug_type($rawElementIds))); // @codeCoverageIgnore } foreach ($rawElementIds as $elementId) { $nodeIds[] = UuidV4::fromString($elementId); diff --git a/src/Controller/Element/GetParentsController.php b/src/Controller/Element/GetParentsController.php index 3f89886b..6e89e10e 100644 --- a/src/Controller/Element/GetParentsController.php +++ b/src/Controller/Element/GetParentsController.php @@ -6,7 +6,7 @@ use App\Attribute\EndpointSupportsEtag; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; @@ -33,7 +33,7 @@ public function __construct( private AuthProvider $authProvider, private AccessChecker $accessChecker, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -131,21 +131,21 @@ public function getParents(string $id): Response if (count($res) > 0) { $totalCount = $res->first()->get('totalCount'); if (!is_int($totalCount)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property totalCount as int, not %s.', get_debug_type($totalCount))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property totalCount as int, not %s.', get_debug_type($totalCount))); // @codeCoverageIgnore } foreach ($res as $resultSet) { $rawParent = $resultSet->get('parent'); if (!is_string($rawParent)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property parent as string, not %s.', get_debug_type($rawParent))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property parent as string, not %s.', get_debug_type($rawParent))); // @codeCoverageIgnore } $nodeIds[] = UuidV4::fromString($rawParent); $rawRelations = $resultSet->get('r'); if (!($rawRelations instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r as CypherList, not %s.', get_debug_type($rawRelations))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r as CypherList, not %s.', get_debug_type($rawRelations))); // @codeCoverageIgnore } foreach ($rawRelations as $relationId) { if (!is_string($relationId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.item as string, not %s.', get_debug_type($relationId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.item as string, not %s.', get_debug_type($relationId))); // @codeCoverageIgnore } $relationIds[] = UuidV4::fromString($relationId); } diff --git a/src/Controller/Element/GetRelatedController.php b/src/Controller/Element/GetRelatedController.php index f34e7e14..a8baa4bd 100644 --- a/src/Controller/Element/GetRelatedController.php +++ b/src/Controller/Element/GetRelatedController.php @@ -6,7 +6,7 @@ use App\Attribute\EndpointSupportsEtag; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; @@ -33,7 +33,7 @@ public function __construct( private AuthProvider $authProvider, private AccessChecker $accessChecker, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -131,21 +131,21 @@ public function getRelated(string $id): Response if (count($res) > 0) { $totalCount = $res->first()->get('totalCount'); if (!is_int($totalCount)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property totalCount as int, not %s.', get_debug_type($totalCount))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property totalCount as int, not %s.', get_debug_type($totalCount))); // @codeCoverageIgnore } foreach ($res as $resultSet) { $rawOuter = $resultSet->get('outer'); if (!is_string($rawOuter)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property outer as string, not %s.', get_debug_type($rawOuter))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property outer as string, not %s.', get_debug_type($rawOuter))); // @codeCoverageIgnore } $nodeIds[] = UuidV4::fromString($rawOuter); $rawRelations = $resultSet->get('r'); if (!($rawRelations instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r as CypherList, not %s.', get_debug_type($rawRelations))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r as CypherList, not %s.', get_debug_type($rawRelations))); // @codeCoverageIgnore } foreach ($rawRelations as $relationId) { if (!is_string($relationId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.item as string, not %s.', get_debug_type($relationId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.item as string, not %s.', get_debug_type($relationId))); // @codeCoverageIgnore } $relationIds[] = UuidV4::fromString($relationId); } diff --git a/src/Controller/Element/PatchElementController.php b/src/Controller/Element/PatchElementController.php index 3d81aa4b..9b0ccc0d 100644 --- a/src/Controller/Element/PatchElementController.php +++ b/src/Controller/Element/PatchElementController.php @@ -7,13 +7,13 @@ use App\Attribute\EndpointSupportsEtag; use App\Factory\Exception\Client404NotFoundExceptionFactory; use App\Helper\Regex; -use App\Response\NoContentResponse; use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\ElementManager; use App\Service\UpdateElementFromRawDataService; use App\Type\AccessType; use App\Type\EtagType; +use App\Type\Response\NoContentResponse; use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -49,10 +49,7 @@ public function patchElement(string $id, Request $request): Response throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } - $element = $this->elementManager->getElement($elementId); - if (null === $element) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } + $element = $this->elementManager->getElementOrFail($elementId); /** * @var array $rawData diff --git a/src/Controller/Element/PostElementController.php b/src/Controller/Element/PostElementController.php index 1aeb7d26..14c02f0d 100644 --- a/src/Controller/Element/PostElementController.php +++ b/src/Controller/Element/PostElementController.php @@ -8,7 +8,6 @@ use App\Factory\Exception\Client400MissingPropertyExceptionFactory; use App\Factory\Exception\Client404NotFoundExceptionFactory; use App\Helper\Regex; -use App\Response\CreatedResponse; use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\CreateElementFromRawDataService; @@ -16,6 +15,7 @@ use App\Type\AccessType; use App\Type\ElementType; use App\Type\RelationElement; +use App\Type\Response\CreatedResponse; use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -68,7 +68,7 @@ public function postElement(string $id, Request $request): Response } if (array_key_exists('end', $body)) { // owns-relation can only target nodes - throw $this->client400BadContentExceptionFactory->createFromTemplate('start', 'non-existent', 'existent'); + throw $this->client400BadContentExceptionFactory->createFromTemplate('end', 'non-existent', 'existent'); } if (array_key_exists('id', $body)) { diff --git a/src/Controller/Element/PostIndexController.php b/src/Controller/Element/PostIndexController.php index 4010dbaa..c669995b 100644 --- a/src/Controller/Element/PostIndexController.php +++ b/src/Controller/Element/PostIndexController.php @@ -9,13 +9,13 @@ use App\Factory\Exception\Client400MissingPropertyExceptionFactory; use App\Factory\Exception\Client403ForbiddenExceptionFactory; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Response\CreatedResponse; use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\CreateElementFromRawDataService; use App\Service\ElementManager; use App\Type\AccessType; use App\Type\RelationElement; +use App\Type\Response\CreatedResponse; use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -79,10 +79,7 @@ public function postIndex(Request $request): Response $startId = null; if (array_key_exists('start', $body)) { $startId = UuidV4::fromString($body['start']); - $startElement = $this->elementManager->getElement($startId); - if (null === $startElement) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } + $startElement = $this->elementManager->getElementOrFail($startId); if (!$this->accessChecker->hasAccessToElement($userId, $startId, AccessType::CREATE)) { throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } @@ -94,10 +91,7 @@ public function postIndex(Request $request): Response $endId = null; if (array_key_exists('end', $body)) { $endId = UuidV4::fromString($body['end']); - $endElement = $this->elementManager->getElement($endId); - if (null === $endElement) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } + $endElement = $this->elementManager->getElementOrFail($endId); if (!$this->accessChecker->hasAccessToElement($userId, $endId, AccessType::READ)) { throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } diff --git a/src/Controller/Element/PutElementController.php b/src/Controller/Element/PutElementController.php index 095968d2..9d68f883 100644 --- a/src/Controller/Element/PutElementController.php +++ b/src/Controller/Element/PutElementController.php @@ -7,7 +7,6 @@ use App\Attribute\EndpointSupportsEtag; use App\Factory\Exception\Client404NotFoundExceptionFactory; use App\Helper\Regex; -use App\Response\NoContentResponse; use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\ElementManager; @@ -15,6 +14,7 @@ use App\Service\UpdateElementFromRawDataService; use App\Type\AccessType; use App\Type\EtagType; +use App\Type\Response\NoContentResponse; use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -51,10 +51,7 @@ public function putElement(string $id, Request $request): Response throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } - $element = $this->elementManager->getElement($elementId); - if (null === $element) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } + $element = $this->elementManager->getElementOrFail($elementId); /** * @var array $rawData diff --git a/src/Controller/Error/ExceptionDetailController.php b/src/Controller/Error/ExceptionDetailController.php index 67f6c30b..567a2cae 100644 --- a/src/Controller/Error/ExceptionDetailController.php +++ b/src/Controller/Error/ExceptionDetailController.php @@ -5,7 +5,7 @@ namespace App\Controller\Error; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Response\TextResponse; +use App\Type\Response\TextResponse; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; diff --git a/src/Controller/Error/assets/error-400-reserved-type.txt b/src/Controller/Error/assets/error-400-reserved-type.txt new file mode 100644 index 00000000..917dd717 --- /dev/null +++ b/src/Controller/Error/assets/error-400-reserved-type.txt @@ -0,0 +1,8 @@ + Error 400 - Reserved Type + + The exception is thrown when a reserved type is used. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 400 error code: https://http.dev/400 + diff --git a/src/Controller/Error/assets/error-409-conflict.txt b/src/Controller/Error/assets/error-409-conflict.txt new file mode 100644 index 00000000..49e5a4c4 --- /dev/null +++ b/src/Controller/Error/assets/error-409-conflict.txt @@ -0,0 +1,9 @@ + Error 409 - Conflict + + The HTTP 409 Conflict client error response status code indicates a request conflict with the current state of the + target resource. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 409 error code: https://http.dev/409 + diff --git a/src/Controller/Error/assets/error-410-gone.txt b/src/Controller/Error/assets/error-410-gone.txt new file mode 100644 index 00000000..b8baf5fa --- /dev/null +++ b/src/Controller/Error/assets/error-410-gone.txt @@ -0,0 +1,9 @@ + Error 410 - Gone + + The HTTP 410 Gone client error response status code indicates that the target resource is no longer available at the + origin server and that this condition is likely to be permanent. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 410 error code: https://http.dev/410 + diff --git a/src/Controller/Error/assets/error-412-tus-io-precondition-failed.txt b/src/Controller/Error/assets/error-412-tus-io-precondition-failed.txt deleted file mode 100644 index e1845f66..00000000 --- a/src/Controller/Error/assets/error-412-tus-io-precondition-failed.txt +++ /dev/null @@ -1,11 +0,0 @@ - Error 412 - Tus.io Precondition Failed - - For tus.io endpoints, the tus.io standard requires the HTTP header 'Tus-Resumable' to be set to a valid tus.io version. - The only version supported by the Ember Nexus API is '1.0.0'. - - - - Link to the documentation: https://ember-nexus.github.io/api - - Link to http.dev's explanation of the 412 error code: https://http.dev/412 - diff --git a/src/Controller/File/DeleteElementFileController.php b/src/Controller/File/DeleteElementFileController.php index 91f6a52e..e12fd80d 100644 --- a/src/Controller/File/DeleteElementFileController.php +++ b/src/Controller/File/DeleteElementFileController.php @@ -4,12 +4,23 @@ namespace App\Controller\File; -use App\Factory\Exception\Server501NotImplementedExceptionFactory; +use App\Attribute\EndpointSupportsEtag; +use App\EventSystem\ElementFileDelete\Event\ElementFileDeleteEvent; +use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Factory\Type\S3\FileOperationFactory; use App\Helper\Regex; +use App\Security\AccessChecker; +use App\Security\AuthProvider; +use App\Service\ElementManager; +use App\Service\S3Service; +use App\Type\AccessType; +use App\Type\EtagType; +use App\Type\Response\NoContentResponse; +use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * @SuppressWarnings("PHPMD.UnusedFormalParameter") @@ -17,7 +28,13 @@ class DeleteElementFileController extends AbstractController { public function __construct( - private Server501NotImplementedExceptionFactory $server501NotImplementedExceptionFactory, + private AuthProvider $authProvider, + private AccessChecker $accessChecker, + private S3Service $s3Service, + private ElementManager $elementManager, + private EventDispatcherInterface $eventDispatcher, + private FileOperationFactory $fileOperationFactory, + private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -29,8 +46,26 @@ public function __construct( ], methods: ['DELETE'] )] - public function deleteElementFile(string $id, Request $request): Response + #[EndpointSupportsEtag(EtagType::FILE)] + public function deleteElementFile(string $id): Response { - throw $this->server501NotImplementedExceptionFactory->createFromTemplate(); + $elementId = UuidV4::fromString($id); + $userId = $this->authProvider->getUserId(); + + if (!$this->accessChecker->hasAccessToElement($userId, $elementId, AccessType::UPDATE)) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + $element = $this->elementManager->getElementOrFail($elementId); + $deleteFileOperation = $this->fileOperationFactory->createFileOperationFromElement($element); + $this->s3Service->deleteFile($deleteFileOperation); + + $element->addProperty('file', null); + $this->elementManager->merge($element); + $this->elementManager->flush(); + + $this->eventDispatcher->dispatch(new ElementFileDeleteEvent($elementId)); + + return new NoContentResponse(); } } diff --git a/src/Controller/File/GetElementFileController.php b/src/Controller/File/GetElementFileController.php index b9fe948d..ff6f9ec8 100644 --- a/src/Controller/File/GetElementFileController.php +++ b/src/Controller/File/GetElementFileController.php @@ -4,29 +4,33 @@ namespace App\Controller\File; +use App\Attribute\EndpointSupportsEtag; use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Factory\Type\S3\FileOperationFactory; use App\Helper\Regex; -use App\Response\BinaryStreamResponse; use App\Security\AccessChecker; use App\Security\AuthProvider; -use App\Service\StorageUtilService; +use App\Service\ElementManager; +use App\Service\ElementService; +use App\Service\FileService; +use App\Service\S3Service; use App\Type\AccessType; -use AsyncAws\S3\S3Client; -use EmberNexusBundle\Service\EmberNexusConfiguration; +use App\Type\EtagType; +use App\Type\Response\BinaryStreamResponse; use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\StreamedResponse; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Attribute\Route; class GetElementFileController extends AbstractController { public function __construct( private AuthProvider $authProvider, private AccessChecker $accessChecker, - private S3Client $s3Client, - private EmberNexusConfiguration $emberNexusConfiguration, - private StorageUtilService $storageUtilService, + private ElementManager $elementManager, + private ElementService $elementService, + private FileService $fileService, + private FileOperationFactory $fileOperationFactory, + private S3Service $s3Service, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -39,7 +43,8 @@ public function __construct( ], methods: ['GET'] )] - public function getElementFile(string $id): Response + #[EndpointSupportsEtag(EtagType::FILE)] + public function getElementFile(string $id): BinaryStreamResponse { $elementId = UuidV4::fromString($id); $userId = $this->authProvider->getUserId(); @@ -48,39 +53,20 @@ public function getElementFile(string $id): Response throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } - $objectConfig = [ - 'Bucket' => $this->emberNexusConfiguration->getFileS3StorageBucket(), - 'Key' => $this->storageUtilService->getStorageBucketKey($elementId), - ]; - $status = $this->s3Client->objectExists($objectConfig); + $element = $this->elementManager->getElementOrFail($elementId); - if (!$status->isSuccess()) { + $fileName = $this->elementService->getFileName($element); + $fileNameFallback = $this->fileService->getAsciiSafeFileName($fileName); + + $fileOperation = $this->fileOperationFactory->createFileOperationFromElement($element); + + $doesFileExist = $this->s3Service->existsFile($fileOperation); + if (false === $doesFileExist) { throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } - $object = $this->s3Client->getObject($objectConfig); - - return new BinaryStreamResponse($object); + $object = $this->s3Service->getFile($fileOperation); - // $stream = $object->getBody()->getContentAsResource(); - // - // $response = new StreamedResponse(); - // $response->headers->set('Content-Length', (string) ($object->getContentLength() ?? 0)); - // $response->headers->set('Content-Type', 'application/octet-stream'); - // - // $response->setCallback(function () use ($stream): void { - // while (!feof($stream)) { - // $buffer = fread($stream, StorageUtilService::STREAM_CHUNK_SIZE); - // if (false === $buffer || 0 === strlen($buffer)) { - // break; - // } - // echo $buffer; - // // ob_flush(); - // flush(); - // } - // fclose($stream); - // }); - // - // return $response; + return new BinaryStreamResponse($object, $fileName, $fileNameFallback); } } diff --git a/src/Controller/File/GetTusUploadController.php b/src/Controller/File/GetTusUploadController.php deleted file mode 100644 index 4264c96b..00000000 --- a/src/Controller/File/GetTusUploadController.php +++ /dev/null @@ -1,44 +0,0 @@ - Regex::UUID_V4_CONTROLLER, - ], - methods: ['GET'] - )] - public function getTusUpload(string $id): Response - { - $uploadId = UuidV4::fromString($id); - - $response = new JsonResponse( - [ - 'upload-id' => $uploadId, - ], - Response::HTTP_OK, - [ - 'Location' => sprintf('/tus-upload/%s', $uploadId), - ] - ); - - return $response; - } -} diff --git a/src/Controller/File/PatchElementFileController.php b/src/Controller/File/PatchElementFileController.php deleted file mode 100644 index f2f1bcf4..00000000 --- a/src/Controller/File/PatchElementFileController.php +++ /dev/null @@ -1,36 +0,0 @@ - Regex::UUID_V4_CONTROLLER, - ], - methods: ['PATCH'] - )] - public function patchElementFile(string $id, Request $request): Response - { - throw $this->server501NotImplementedExceptionFactory->createFromTemplate(); - } -} diff --git a/src/Controller/File/PostElementFileController.php b/src/Controller/File/PostElementFileController.php index 9fd00255..0b55f4dc 100644 --- a/src/Controller/File/PostElementFileController.php +++ b/src/Controller/File/PostElementFileController.php @@ -4,43 +4,35 @@ namespace App\Controller\File; -use App\Attribute\EndpointImplementsTusIo; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Client409ConflictExceptionFactory; use App\Helper\Regex; -use App\Response\NoContentResponse; use App\Security\AccessChecker; use App\Security\AuthProvider; -use App\Service\LockService; +use App\Service\ElementManager; +use App\Service\UploadCreationService; use App\Type\AccessType; -use App\Type\Lock\FileUploadCheckLock; -use Exception; -use Laudis\Neo4j\Databags\Statement; -use Laudis\Neo4j\Types\CypherMap; -use Laudis\Neo4j\Types\Node; use Ramsey\Uuid\Rfc4122\UuidV4; -use Ramsey\Uuid\Uuid; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; /** + * @todo add support for etags + * * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ class PostElementFileController extends AbstractController { public function __construct( - private Server501NotImplementedExceptionFactory $server501NotImplementedExceptionFactory, private AuthProvider $authProvider, private AccessChecker $accessChecker, - private LockService $lockService, - private CypherEntityManager $cypherEntityManager, + private UploadCreationService $uploadCreationService, + private ElementManager $elementManager, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory) - { + private Client409ConflictExceptionFactory $client409ConflictExceptionFactory, + ) { } #[Route( @@ -51,75 +43,21 @@ public function __construct( ], methods: ['POST'] )] - #[EndpointImplementsTusIo] public function postElementFile(string $id, Request $request): Response { $elementId = UuidV4::fromString($id); $userId = $this->authProvider->getUserId(); - // note: update is used because files are optional parts of already existing elements if (!$this->accessChecker->hasAccessToElement($userId, $elementId, AccessType::UPDATE)) { throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } - $lock = new FileUploadCheckLock($elementId, $userId); - $this->lockService->acquireLock($lock); - - $cypherClient = $this->cypherEntityManager->getClient(); - $currentlyHappeningUploads = $cypherClient->runStatement(Statement::create( - "MATCH (upload:FileUpload {uploadForElement: \$elementId})\n". - "WHERE upload.status <> 'finished'\n". - "RETURN upload\n". - "ORDER BY upload.id", - [ - 'elementId' => $elementId->toString() - ] - )); - - $canCreateNewUpload = true; - foreach ($currentlyHappeningUploads as $currentlyHappeningUploadsRow) { - /** - * @var $currentlyHappeningUploadsRow CypherMap - */ - $upload = $currentlyHappeningUploadsRow->get('upload'); - /** - * @var $upload Node - */ - $uploadProperties = $upload->getProperties(); - - - if (!$uploadProperties->hasKey('id')) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to handle file upload with attribute \'id\' missing.'); - } - $uploadId = Uuid::fromString($uploadProperties->get('id')); - - - if (!$uploadProperties->hasKey('variant')) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf( - "Unable to handle upload '%s' with missing attribute 'variant'.", - $uploadId->toString() - )); - } - $variant = $uploadProperties->get('variant'); - - if ($variant !== 'tus.io') { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf( - "Unable to handle upload '%s' of unknown variant '%s'.", - $uploadId->toString(), - $variant - )); - } - - + $element = $this->elementManager->getElementOrFail($elementId); + $properties = $element->getProperties(); + if (array_key_exists('file', $properties)) { + throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf("Element with id '%s' already has an associated file; can not create new file. Delete existing file first or replace it with PUT.", $element->getId()?->toString() ?? 'missing element id')); } -// $nodeIds = []; -// foreach ($res as $resultSet) { -// $nodeIds[] = UuidV4::fromString($resultSet->get('element.id')); -// } - - - $this->lockService->releaseLock($lock); - return new NoContentResponse(); + return $this->uploadCreationService->handleUploadCreationFromRequest($elementId, $request); } } diff --git a/src/Controller/File/PutElementFileController.php b/src/Controller/File/PutElementFileController.php index 35a0d841..d30da8a2 100644 --- a/src/Controller/File/PutElementFileController.php +++ b/src/Controller/File/PutElementFileController.php @@ -4,8 +4,15 @@ namespace App\Controller\File; -use App\Factory\Exception\Server501NotImplementedExceptionFactory; +use App\Attribute\EndpointSupportsEtag; +use App\Factory\Exception\Client404NotFoundExceptionFactory; use App\Helper\Regex; +use App\Security\AccessChecker; +use App\Security\AuthProvider; +use App\Service\UploadCreationService; +use App\Type\AccessType; +use App\Type\EtagType; +use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -17,7 +24,10 @@ class PutElementFileController extends AbstractController { public function __construct( - private Server501NotImplementedExceptionFactory $server501NotImplementedExceptionFactory, + private AuthProvider $authProvider, + private AccessChecker $accessChecker, + private UploadCreationService $uploadCreationService, + private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -29,8 +39,16 @@ public function __construct( ], methods: ['PUT'] )] + #[EndpointSupportsEtag(EtagType::FILE)] public function putElementFile(string $id, Request $request): Response { - throw $this->server501NotImplementedExceptionFactory->createFromTemplate(); + $elementId = UuidV4::fromString($id); + $userId = $this->authProvider->getUserId(); + + if (!$this->accessChecker->hasAccessToElement($userId, $elementId, AccessType::UPDATE)) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + return $this->uploadCreationService->handleUploadCreationFromRequest($elementId, $request); } } diff --git a/src/Controller/Search/PostSearchController.php b/src/Controller/Search/PostSearchController.php index 0cbb0a92..625152e8 100644 --- a/src/Controller/Search/PostSearchController.php +++ b/src/Controller/Search/PostSearchController.php @@ -9,7 +9,7 @@ use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client400MissingPropertyExceptionFactory; use App\Factory\Exception\Server500InternalServerErrorExceptionFactory; -use App\Response\JsonResponse; +use App\Type\Response\JsonResponse; use App\Type\SearchStepType; use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; diff --git a/src/Controller/System/GetGraphStructureController.php b/src/Controller/System/GetGraphStructureController.php index 9492489e..b37b1a18 100644 --- a/src/Controller/System/GetGraphStructureController.php +++ b/src/Controller/System/GetGraphStructureController.php @@ -5,8 +5,8 @@ namespace App\Controller\System; use App\Factory\Exception\Client403ForbiddenExceptionFactory; -use App\Response\JsonResponse; use App\Service\GraphStructureService; +use App\Type\Response\JsonResponse; use EmberNexusBundle\Service\EmberNexusConfiguration; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; diff --git a/src/Controller/System/GetInstanceConfigurationController.php b/src/Controller/System/GetInstanceConfigurationController.php index d01a6094..122db87e 100644 --- a/src/Controller/System/GetInstanceConfigurationController.php +++ b/src/Controller/System/GetInstanceConfigurationController.php @@ -5,7 +5,7 @@ namespace App\Controller\System; use App\Factory\Exception\Client403ForbiddenExceptionFactory; -use App\Response\JsonResponse; +use App\Type\Response\JsonResponse; use EmberNexusBundle\Service\EmberNexusConfiguration; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -48,6 +48,12 @@ public function getInstanceConfiguration(): Response 'enabled' => $this->emberNexusConfiguration->isExpressionEnabled(), 'maxLength' => $this->emberNexusConfiguration->getExpressionMaxLength(), ], + 'file' => [ + 'maxFileSizeInBytes' => $this->emberNexusConfiguration->getFileMaxFileSizeInBytes(), + 'uploadExpiresInSecondsAfterFirstRequest' => $this->emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest(), + 'uploadMinChunkSizeInBytes' => $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes(), + 'uploadMaxChunkSizeInBytes' => $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(), + ], ]; if ($this->emberNexusConfiguration->isInstanceConfigurationShowVersion()) { diff --git a/src/Controller/System/GetWellKnownSecurityTxtController.php b/src/Controller/System/GetWellKnownSecurityTxtController.php index 832c7a81..07ee401a 100644 --- a/src/Controller/System/GetWellKnownSecurityTxtController.php +++ b/src/Controller/System/GetWellKnownSecurityTxtController.php @@ -5,7 +5,7 @@ namespace App\Controller\System; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Response\TextResponse; +use App\Type\Response\TextResponse; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; diff --git a/src/Controller/Upload/DeleteUploadController.php b/src/Controller/Upload/DeleteUploadController.php new file mode 100644 index 00000000..4860ea16 --- /dev/null +++ b/src/Controller/Upload/DeleteUploadController.php @@ -0,0 +1,73 @@ + Regex::UUID_V4_CONTROLLER, + ], + methods: ['DELETE'] + )] + public function deleteUpload(string $id): NoContentResponse + { + $uploadElement = $this->elementManager->getElementOrFail(UuidV4::fromString($id)); + try { + $upload = $this->uploadFactory->createUploadFromElement($uploadElement); + } catch (Exception $e) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + if ($upload->getUploadOwner()->toString() !== $this->authProvider->getUserId()->toString()) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + $this->logger->info( + 'Deleting upload element from database and S3.', + [ + 'uploadId' => $upload->getId()->toString(), + 'elementId' => $upload->getUploadTarget()->toString(), + ] + ); + + for ($i = 0; $i <= $upload->getAlreadyUploadedChunks(); ++$i) { + $deleteChunkOperation = $this->fileOperationFactory->createFileOperationFromUpload($upload, $i); + $this->s3Service->deleteFile($deleteChunkOperation); + } + + $this->elementManager->delete($uploadElement); + $this->elementManager->flush(); + + return new NoContentResponse(); + } +} diff --git a/src/Controller/Upload/HeadUploadController.php b/src/Controller/Upload/HeadUploadController.php new file mode 100644 index 00000000..4e9f887e --- /dev/null +++ b/src/Controller/Upload/HeadUploadController.php @@ -0,0 +1,53 @@ + Regex::UUID_V4_CONTROLLER, + ], + methods: ['HEAD'] + )] + public function headUpload(string $id): Response + { + $uploadElement = $this->elementManager->getElementOrFail(UuidV4::fromString($id)); + try { + $upload = $this->uploadFactory->createUploadFromElement($uploadElement); + } catch (Exception $e) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + if ($upload->getUploadOwner()->toString() !== $this->authProvider->getUserId()->toString()) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + return $this->noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload); + } +} diff --git a/src/Controller/Upload/PatchUploadController.php b/src/Controller/Upload/PatchUploadController.php new file mode 100644 index 00000000..dc604204 --- /dev/null +++ b/src/Controller/Upload/PatchUploadController.php @@ -0,0 +1,143 @@ + Regex::UUID_V4_CONTROLLER, + ], + methods: ['PATCH'] + )] + public function patchUpload(string $id, Request $request): Response + { + $uploadElement = $this->elementManager->getElementOrFail(UuidV4::fromString($id)); + try { + $upload = $this->uploadFactory->createUploadFromElement($uploadElement); + } catch (Exception $e) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + $userId = $this->authProvider->getUserId(); + if ($upload->getUploadOwner()->toString() !== $userId->toString()) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + // verify that user has still update access to upload target + if (!$this->accessChecker->hasAccessToElement($userId, $upload->getUploadTarget(), AccessType::UPDATE)) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + if ($upload->getExpires() < new DateTime()) { + throw $this->client410GoneExceptionFactory->createFromTemplate(); + } + + $partialUploadRequest = $this->partialUploadRequestFactory->createPartialUploadRequestFromRequest($request); + + if ($partialUploadRequest->getUploadOffset() !== $upload->getUploadOffset()) { + throw $this->client409ConflictExceptionFactory->createFromDetail('Offset from request does not match offset of resource.', additionalDetails: ['expected-offset' => $upload->getUploadOffset(), 'provided-offset' => $partialUploadRequest->getUploadOffset()]); + } + + $uploadFileChunkOperation = $this->uploadFileChunkOperationFactory->createUploadFileChunkOperationFromPartialUploadRequest($partialUploadRequest, $upload); + $chunkLength = $this->s3Service->uploadFileChunk($uploadFileChunkOperation); + + if (null !== $upload->getUploadLength()) { + if ($upload->getUploadLength() < $upload->getUploadOffset() + $chunkLength) { + throw $this->client409ConflictExceptionFactory->createFromDetail('Already uploaded data exceeds defined upload length.'); + } + } + + $upload = $this->uploadFactory->addNewChunkToUpload($upload, $chunkLength); + $this->uploadService->mergeUploadElement($upload); + + if ($partialUploadRequest->isUploadComplete()) { + // the final chunk was successfully uploaded -> we can create the file + $upload = $this->uploadFactory->markUploadAsComplete($upload); + $this->uploadService->mergeUploadElement($upload); + $this->createFile($upload); + } + + $this->elementManager->flush(); + + return $this->noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload); + } + + public function createFile(UploadInterface $upload): Response + { + $mergeFileChunksOperation = $this->mergeFileChunksOperationFactory->createMergeFileOperationFromUpload($upload); + $mergedContentLength = $this->s3Service->mergeFileChunks($mergeFileChunksOperation); + + $element = $this->elementManager->getElementOrFail($upload->getUploadTarget()); + + $element->addProperty('file', [ + 'contentLength' => $mergedContentLength, + 'extension' => $upload->getExtension(), + ]); + $this->elementManager->merge($element); + $this->elementManager->flush(); + + $this->s3Service->deleteFileChunks($mergeFileChunksOperation); + $this->uploadService->deleteUpload($upload); + $this->elementManager->flush(); + + $this->eventDispatcher->dispatch(new ElementFileReplaceEvent($upload->getUploadTarget())); + + return new JsonResponse([ + 'upload' => 'complete', + ]); + } +} diff --git a/src/Controller/User/DeleteTokenController.php b/src/Controller/User/DeleteTokenController.php index 730074ac..db8f6ca9 100644 --- a/src/Controller/User/DeleteTokenController.php +++ b/src/Controller/User/DeleteTokenController.php @@ -5,10 +5,9 @@ namespace App\Controller\User; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; -use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Response\NoContentResponse; use App\Security\AuthProvider; use App\Service\ElementManager; +use App\Type\Response\NoContentResponse; use LogicException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -20,7 +19,6 @@ public function __construct( private ElementManager $elementManager, private AuthProvider $authProvider, private Client401UnauthorizedExceptionFactory $client401UnauthorizedExceptionFactory, - private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -45,10 +43,7 @@ public function deleteToken(): Response throw new LogicException('Token must be provided.'); } - $tokenElement = $this->elementManager->getElement($tokenId); - if (null === $tokenElement) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } + $tokenElement = $this->elementManager->getElementOrFail($tokenId); $this->elementManager->delete($tokenElement); $this->elementManager->flush(); diff --git a/src/Controller/User/GetTokenController.php b/src/Controller/User/GetTokenController.php index 69a53185..acabed6e 100644 --- a/src/Controller/User/GetTokenController.php +++ b/src/Controller/User/GetTokenController.php @@ -5,7 +5,7 @@ namespace App\Controller\User; use App\Factory\Exception\Client403ForbiddenExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\AuthProvider; use App\Service\ElementResponseService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -18,7 +18,7 @@ public function __construct( private ElementResponseService $elementResponseService, private AuthProvider $authProvider, private Client403ForbiddenExceptionFactory $client403ForbiddenExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -36,7 +36,7 @@ public function getToken(): Response $tokenId = $this->authProvider->getTokenId(); if (!$tokenId) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Token uuid should not be null.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Token uuid should not be null.'); } return $this->elementResponseService->buildElementResponseFromId($tokenId); diff --git a/src/Controller/User/PostChangePasswordController.php b/src/Controller/User/PostChangePasswordController.php index 0702b93d..aa4057f2 100644 --- a/src/Controller/User/PostChangePasswordController.php +++ b/src/Controller/User/PostChangePasswordController.php @@ -6,9 +6,9 @@ use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; -use App\Response\NoContentResponse; use App\Service\RequestUtilService; use App\Service\SecurityUtilService; +use App\Type\Response\NoContentResponse; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; diff --git a/src/Controller/User/PostRegisterController.php b/src/Controller/User/PostRegisterController.php index aa059330..35c16291 100644 --- a/src/Controller/User/PostRegisterController.php +++ b/src/Controller/User/PostRegisterController.php @@ -6,12 +6,12 @@ use App\Factory\Exception\Client400ReservedIdentifierExceptionFactory; use App\Factory\Exception\Client403ForbiddenExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; -use App\Response\CreatedResponse; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\UserPasswordHasher; use App\Service\CreateElementFromRawDataService; use App\Service\ElementManager; use App\Service\RequestUtilService; +use App\Type\Response\CreatedResponse; use EmberNexusBundle\Service\EmberNexusConfiguration; use Laudis\Neo4j\Databags\Statement; use Ramsey\Uuid\Rfc4122\UuidV4; @@ -38,7 +38,7 @@ public function __construct( private CreateElementFromRawDataService $createElementFromRawDataService, private Client400ReservedIdentifierExceptionFactory $client400ReservedIdentifierExceptionFactory, private Client403ForbiddenExceptionFactory $client403ForbiddenExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -71,7 +71,8 @@ public function postRegister(Request $request): Response $userNode = $this->createElementFromRawDataService->createElementFromRawData( $userId, 'User', - rawData: $rawData + rawData: $rawData, + skipReservedTypeCheck: true ); $userNode->addProperty($uniqueIdentifier, $uniqueUserIdentifier); $userNode->addProperty('_passwordHash', $this->userPasswordHasher->hashPassword($password)); @@ -98,7 +99,7 @@ private function checkForDuplicateUniqueUserIdentifier(string $uniqueUserIdentif )); $rawCount = $res->first()->get('count'); if (!is_int($rawCount)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property count as int, not %s.', get_debug_type($rawCount))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property count as int, not %s.', get_debug_type($rawCount))); // @codeCoverageIgnore } if ($rawCount > 0) { throw $this->client400ReservedIdentifierExceptionFactory->createFromTemplate($uniqueUserIdentifier); diff --git a/src/Controller/User/PostTokenController.php b/src/Controller/User/PostTokenController.php index 3720471c..e5fa7a3e 100644 --- a/src/Controller/User/PostTokenController.php +++ b/src/Controller/User/PostTokenController.php @@ -7,10 +7,10 @@ use App\Exception\Client400BadContentException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; -use App\Response\JsonResponse; use App\Security\TokenGenerator; use App\Service\RequestUtilService; use App\Service\SecurityUtilService; +use App\Type\Response\JsonResponse; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; diff --git a/src/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListener.php b/src/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListener.php index 24102216..aab7ca93 100644 --- a/src/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListener.php +++ b/src/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListener.php @@ -6,9 +6,8 @@ use App\Attribute\EndpointSupportsEtag; use App\Factory\Exception\Client412PreconditionFailedExceptionFactory; -use App\Response\NotModifiedResponse; use App\Service\EtagService; -use App\Type\Etag; +use App\Type\Response\NotModifiedResponse; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ControllerEvent; diff --git a/src/EventSystem/Controller/EventListener/TusIoControllerEventListener.php b/src/EventSystem/Controller/EventListener/TusIoControllerEventListener.php deleted file mode 100644 index 2120ff8f..00000000 --- a/src/EventSystem/Controller/EventListener/TusIoControllerEventListener.php +++ /dev/null @@ -1,37 +0,0 @@ -getAttributes(EndpointImplementsTusIo::class); - if (0 === count($attributes)) { - return; - } - - $this->tusIoService->setIsTusIoEndpoint(true); - - if (!$event->getRequest()->headers->has('Tus-Resumable')) { - throw $this->client412TusResumablePreconditionFailedExceptionFactory->createFromTemplate(); - } - - if ('1.0.0' !== $event->getRequest()->headers->get('Tus-Resumable')) { - throw $this->client412TusResumablePreconditionFailedExceptionFactory->createFromTemplate(); - } - } -} diff --git a/src/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListener.php b/src/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListener.php index 4a379689..6c96017a 100644 --- a/src/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListener.php +++ b/src/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListener.php @@ -12,6 +12,8 @@ use Laudis\Neo4j\Types\DateTimeZoneId as LaudisDateTimeZoneId; use Laudis\Neo4j\Types\LocalDateTime as LaudisLocalDateTime; use Laudis\Neo4j\Types\LocalTime as LaudisLocalTime; +use MongoDB\Model\BSONArray; +use MongoDB\Model\BSONDocument; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; class GenericPropertyElementDefragmentizeEventListener @@ -45,9 +47,15 @@ private function handleEvent(NodeElementDefragmentizeEvent|RelationElementDefrag } else { $element = $event->getRelationElement(); } + $documentProperties = []; if ($documentFragment) { $documentProperties = $documentFragment->getProperties(); $documentProperties = ReservedPropertyNameHelper::removeReservedPropertyNamesFromArray($documentProperties); + foreach ($documentProperties as $key => $value) { + if (($value instanceof BSONArray) || ($value instanceof BSONDocument)) { + $documentProperties[$key] = $value->getArrayCopy(); + } + } $element->addProperties($documentProperties); } $cypherProperties = $cypherFragment->getProperties(); @@ -68,6 +76,12 @@ private function handleEvent(NodeElementDefragmentizeEvent|RelationElementDefrag if ($value instanceof LaudisLocalTime) { $cypherProperties[$key] = $value->toArray(); } + // remove placeholder values from neo4j for non-scalar properties + if (is_bool($value)) { + if (true === $value && array_key_exists($key, $documentProperties)) { + unset($cypherProperties[$key]); + } + } } $element->addProperties($cypherProperties); foreach ($element->getProperties() as $key => $value) { diff --git a/src/EventSystem/ElementDefragmentize/EventListener/RelationElementDefragmentizeEventListener.php b/src/EventSystem/ElementDefragmentize/EventListener/RelationElementDefragmentizeEventListener.php index 992b07a6..d56f7c14 100644 --- a/src/EventSystem/ElementDefragmentize/EventListener/RelationElementDefragmentizeEventListener.php +++ b/src/EventSystem/ElementDefragmentize/EventListener/RelationElementDefragmentizeEventListener.php @@ -5,14 +5,14 @@ namespace App\EventSystem\ElementDefragmentize\EventListener; use App\EventSystem\ElementDefragmentize\Event\RelationElementDefragmentizeEvent; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; class RelationElementDefragmentizeEventListener { public function __construct( - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -23,27 +23,27 @@ public function onRelationElementDefragmentizeEvent(RelationElementDefragmentize $identifier = $cypherFragment->getProperty('id'); if (null === $identifier) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires cypher fragment to contain valid UUID.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires cypher fragment to contain valid UUID.'); } $identifier = UuidV4::fromString($identifier); $start = $cypherFragment->getStartNode(); if (null === $start) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires cypher fragment to contain valid start node.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires cypher fragment to contain valid start node.'); } $start = $start->getProperty('id'); if (null === $start) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires start node to have valid UUID.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires start node to have valid UUID.'); } $start = UuidV4::fromString($start); $end = $cypherFragment->getEndNode(); if (null === $end) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires cypher fragment to contain valid end node.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires cypher fragment to contain valid end node.'); } $end = $end->getProperty('id'); if (null === $end) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires end node to have valid UUID.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Relation element defragmentize event listener requires end node to have valid UUID.'); } $end = UuidV4::fromString($end); diff --git a/src/EventSystem/ElementFileDelete/Event/ElementFileDeleteEvent.php b/src/EventSystem/ElementFileDelete/Event/ElementFileDeleteEvent.php new file mode 100644 index 00000000..d6124b3d --- /dev/null +++ b/src/EventSystem/ElementFileDelete/Event/ElementFileDeleteEvent.php @@ -0,0 +1,24 @@ +elementId; + } +} diff --git a/src/EventSystem/ElementFileDelete/EventListener/ElasticsearchFileDeleteEventListener.php b/src/EventSystem/ElementFileDelete/EventListener/ElasticsearchFileDeleteEventListener.php new file mode 100644 index 00000000..0f6c1ce5 --- /dev/null +++ b/src/EventSystem/ElementFileDelete/EventListener/ElasticsearchFileDeleteEventListener.php @@ -0,0 +1,29 @@ +queueService->publishEvent( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + [ + 'elementId' => $event->getElementId()->toString(), + ] + ); + } +} diff --git a/src/EventSystem/ElementFileReplace/Event/ElementFileReplaceEvent.php b/src/EventSystem/ElementFileReplace/Event/ElementFileReplaceEvent.php new file mode 100644 index 00000000..6e338c2b --- /dev/null +++ b/src/EventSystem/ElementFileReplace/Event/ElementFileReplaceEvent.php @@ -0,0 +1,24 @@ +elementId; + } +} diff --git a/src/EventSystem/ElementFileReplace/EventListener/ElasticsearchFileReplaceEventListener.php b/src/EventSystem/ElementFileReplace/EventListener/ElasticsearchFileReplaceEventListener.php new file mode 100644 index 00000000..51124f89 --- /dev/null +++ b/src/EventSystem/ElementFileReplace/EventListener/ElasticsearchFileReplaceEventListener.php @@ -0,0 +1,29 @@ +queueService->publishEvent( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + [ + 'elementId' => $event->getElementId()->toString(), + ] + ); + } +} diff --git a/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php b/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php index 4522e4ac..da378e73 100644 --- a/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php +++ b/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php @@ -52,6 +52,7 @@ private function handleEvent(NodeElementFragmentizeEvent|RelationElementFragment foreach ($element->getProperties() as $name => $value) { $elasticFragment->addProperty($name, $value); if (is_array($value)) { + $cypherFragment->addProperty($name, true); $mongoFragment->addProperty($name, $value); continue; } @@ -86,6 +87,7 @@ private function handleEvent(NodeElementFragmentizeEvent|RelationElementFragment continue; } if (is_object($value)) { + $cypherFragment->addProperty($name, true); $mongoFragment->addProperty($name, $value); continue; } diff --git a/src/EventSystem/ElementFragmentize/EventListener/NodeElementFragmentizeEventListener.php b/src/EventSystem/ElementFragmentize/EventListener/NodeElementFragmentizeEventListener.php index 4de61157..d10c8adb 100644 --- a/src/EventSystem/ElementFragmentize/EventListener/NodeElementFragmentizeEventListener.php +++ b/src/EventSystem/ElementFragmentize/EventListener/NodeElementFragmentizeEventListener.php @@ -5,13 +5,13 @@ namespace App\EventSystem\ElementFragmentize\EventListener; use App\EventSystem\ElementFragmentize\Event\NodeElementFragmentizeEvent; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; class NodeElementFragmentizeEventListener { public function __construct( - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -22,13 +22,13 @@ public function onNodeElementFragmentizeEvent(NodeElementFragmentizeEvent $event $nodeElementIdentifier = $nodeElement->getId(); if (null === $nodeElementIdentifier) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Node element fragmentize event listener requires node to contain valid UUID.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Node element fragmentize event listener requires node to contain valid UUID.'); } $nodeElementIdentifier = $nodeElementIdentifier->toString(); $nodeLabel = $nodeElement->getLabel(); if (null === $nodeLabel) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Node element fragmentize event listener requires node to contain valid label.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Node element fragmentize event listener requires node to contain valid label.'); } /** diff --git a/src/EventSystem/ElementFragmentize/EventListener/RelationElementFragmentizeEventListener.php b/src/EventSystem/ElementFragmentize/EventListener/RelationElementFragmentizeEventListener.php index c9bb7dfe..3ef9ed40 100644 --- a/src/EventSystem/ElementFragmentize/EventListener/RelationElementFragmentizeEventListener.php +++ b/src/EventSystem/ElementFragmentize/EventListener/RelationElementFragmentizeEventListener.php @@ -5,14 +5,14 @@ namespace App\EventSystem\ElementFragmentize\EventListener; use App\EventSystem\ElementFragmentize\Event\RelationElementFragmentizeEvent; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Syndesi\CypherDataStructures\Type\Node; class RelationElementFragmentizeEventListener { public function __construct( - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -23,24 +23,24 @@ public function onRelationElementFragmentizeEvent(RelationElementFragmentizeEven $relationId = $relationElement->getId(); if (null === $relationId) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Relation element fragmentize event listener requires relation to contain valid UUID.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Relation element fragmentize event listener requires relation to contain valid UUID.'); } $relationId = $relationId->toString(); $relationType = $relationElement->getType(); if (null === $relationType) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Relation element fragmentize event listener requires relation to contain valid type.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Relation element fragmentize event listener requires relation to contain valid type.'); } $startId = $relationElement->getStart(); if (null === $startId) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Relation element fragmentize event listener requires relation to have valid start UUID.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Relation element fragmentize event listener requires relation to have valid start UUID.'); } $startId = $startId->toString(); $endId = $relationElement->getEnd(); if (null === $endId) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Relation element fragmentize event listener requires relation to have valid end UUID.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Relation element fragmentize event listener requires relation to have valid end UUID.'); } $endId = $endId->toString(); diff --git a/src/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListener.php b/src/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListener.php index ddec14c5..0c2e3e48 100644 --- a/src/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListener.php +++ b/src/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListener.php @@ -7,7 +7,7 @@ use App\EventSystem\EntityManager\Event\ElementPostCreateEvent; use App\EventSystem\EntityManager\Event\ElementPostMergeEvent; use App\EventSystem\EntityManager\Event\ElementPreDeleteEvent; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Factory\Type\RedisKeyFactory; use App\Type\RedisKey; use Exception; @@ -24,7 +24,7 @@ public function __construct( private Client $redisClient, private CypherEntityManager $cypherEntityManager, private RedisKeyFactory $redisKeyTypeFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -86,41 +86,41 @@ private function handleEvent(ElementPostCreateEvent|ElementPostMergeEvent|Elemen if (1 === count($result)) { $rawChildrenList = $result[0]['childrenList']; if (!($rawChildrenList instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property childrenList as CypherList, not %s.', get_debug_type($rawChildrenList))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property childrenList as CypherList, not %s.', get_debug_type($rawChildrenList))); // @codeCoverageIgnore } foreach ($rawChildrenList as $childId) { if (!is_string($childId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property childrenList.item as string, not %s.', get_debug_type($childId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property childrenList.item as string, not %s.', get_debug_type($childId))); // @codeCoverageIgnore } $redisEtagKeysToExpire[] = $this->redisKeyTypeFactory->getEtagParentsCollectionRedisKey(Uuid::fromString($childId)); } $rawParentsList = $result[0]['parentsList']; if (!($rawParentsList instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property parentsList as CypherList, not %s.', get_debug_type($rawParentsList))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property parentsList as CypherList, not %s.', get_debug_type($rawParentsList))); // @codeCoverageIgnore } foreach ($rawParentsList as $parentId) { if (!is_string($parentId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property parentsList.item as string, not %s.', get_debug_type($parentId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property parentsList.item as string, not %s.', get_debug_type($parentId))); // @codeCoverageIgnore } $redisEtagKeysToExpire[] = $this->redisKeyTypeFactory->getEtagChildrenCollectionRedisKey(Uuid::fromString($parentId)); } $rawRelatedList = $result[0]['relatedList']; if (!($rawRelatedList instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property relatedList as CypherList, not %s.', get_debug_type($rawRelatedList))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property relatedList as CypherList, not %s.', get_debug_type($rawRelatedList))); // @codeCoverageIgnore } foreach ($rawRelatedList as $centerId) { if (!is_string($centerId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property relatedList.item as string, not %s.', get_debug_type($centerId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property relatedList.item as string, not %s.', get_debug_type($centerId))); // @codeCoverageIgnore } $redisEtagKeysToExpire[] = $this->redisKeyTypeFactory->getEtagRelatedCollectionRedisKey(Uuid::fromString($centerId)); } $rawIndexList = $result[0]['indexList']; if (!($rawIndexList instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property indexList as CypherList, not %s.', get_debug_type($rawIndexList))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property indexList as CypherList, not %s.', get_debug_type($rawIndexList))); // @codeCoverageIgnore } foreach ($rawIndexList as $userId) { if (!is_string($userId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property indexList.item as string, not %s.', get_debug_type($userId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property indexList.item as string, not %s.', get_debug_type($userId))); // @codeCoverageIgnore } $redisEtagKeysToExpire[] = $this->redisKeyTypeFactory->getEtagIndexCollectionRedisKey(Uuid::fromString($userId)); } @@ -137,11 +137,11 @@ private function handleEvent(ElementPostCreateEvent|ElementPostMergeEvent|Elemen if (1 === count($result)) { $rawStartId = $result[0]['start.id']; if (!is_string($rawStartId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property start.id as string, not %s.', get_debug_type($rawStartId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property start.id as string, not %s.', get_debug_type($rawStartId))); // @codeCoverageIgnore } $rawEndId = $result[0]['end.id']; if (!is_string($rawEndId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property end.id as string, not %s.', get_debug_type($rawEndId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property end.id as string, not %s.', get_debug_type($rawEndId))); // @codeCoverageIgnore } $redisEtagKeysToExpire[] = $this->redisKeyTypeFactory->getEtagRelatedCollectionRedisKey(Uuid::fromString($rawStartId)); $redisEtagKeysToExpire[] = $this->redisKeyTypeFactory->getEtagRelatedCollectionRedisKey(Uuid::fromString($rawEndId)); diff --git a/src/EventSystem/EntityManager/EventListener/OwnershipChangeEventListener.php b/src/EventSystem/EntityManager/EventListener/OwnershipChangeEventListener.php index 7ebba3a4..d14d91a1 100644 --- a/src/EventSystem/EntityManager/EventListener/OwnershipChangeEventListener.php +++ b/src/EventSystem/EntityManager/EventListener/OwnershipChangeEventListener.php @@ -8,13 +8,10 @@ use App\EventSystem\EntityManager\Event\ElementPostCreateEvent; use App\EventSystem\EntityManager\Event\ElementPostDeleteEvent; use App\EventSystem\EntityManager\Event\ElementPostMergeEvent; +use App\Service\QueueService; use App\Type\RabbitMQQueueType; -use PhpAmqpLib\Connection\AMQPStreamConnection; -use PhpAmqpLib\Message\AMQPMessage; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; -use function Safe\json_encode; - class OwnershipChangeEventListener { /** @@ -31,7 +28,7 @@ class OwnershipChangeEventListener ]; public function __construct( - private AMQPStreamConnection $AMQPStreamConnection, + private QueueService $queueService, ) { } @@ -74,14 +71,11 @@ public function handleOwnershipChange(RelationElementInterface $element): void if (!$elementId) { return; } - $channel = $this->AMQPStreamConnection->channel(); - $queue = RabbitMQQueueType::REBUILD_SEARCH_DOCUMENT_QUEUE->value; - $channel->queue_declare($queue, false, false, false, false); - $jsonMessage = json_encode([ - 'element' => $elementId->toString(), - ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $message = new AMQPMessage($jsonMessage); - $channel->basic_publish($message, '', $queue); - $channel->close(); + $this->queueService->publishEvent( + RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE, + [ + 'element' => $elementId->toString(), + ] + ); } } diff --git a/src/EventSystem/Etag/Event/FileEtagEvent.php b/src/EventSystem/Etag/Event/FileEtagEvent.php new file mode 100644 index 00000000..a89dc78d --- /dev/null +++ b/src/EventSystem/Etag/Event/FileEtagEvent.php @@ -0,0 +1,39 @@ +etag; + } + + public function setEtag(?Etag $etag): static + { + $this->etag = $etag; + + return $this; + } + + public function getElementId(): UuidInterface + { + return $this->elementId; + } +} diff --git a/src/EventSystem/Etag/EventListener/LiveFileEtagEventListener.php b/src/EventSystem/Etag/EventListener/LiveFileEtagEventListener.php new file mode 100644 index 00000000..bcc1ad86 --- /dev/null +++ b/src/EventSystem/Etag/EventListener/LiveFileEtagEventListener.php @@ -0,0 +1,49 @@ +etagCalculatorService->calculateFileEtag($event->getElementId()); + $redisKey = $this->redisKeyTypeFactory->getEtagFileRedisKey($event->getElementId()); + + $this->logger->debug( + 'Trying to persist Etag for file in Redis.', + [ + 'elementId' => $event->getElementId()->toString(), + 'redisKey' => (string) $redisKey, + 'etag' => $etag, + ] + ); + + if ($etag) { + $redisValue = $etag; + $this->redisClient->set((string) $redisKey, $redisValue, 'EX', self::REDIS_FILE_TTL_IN_SECONDS); + } + + $event->setEtag($etag); + $event->stopPropagation(); + } +} diff --git a/src/EventSystem/Etag/EventListener/RedisChildrenCollectionEtagEventListener.php b/src/EventSystem/Etag/EventListener/RedisChildrenCollectionEtagEventListener.php index 6b9a2d8b..917d0d39 100644 --- a/src/EventSystem/Etag/EventListener/RedisChildrenCollectionEtagEventListener.php +++ b/src/EventSystem/Etag/EventListener/RedisChildrenCollectionEtagEventListener.php @@ -21,7 +21,7 @@ public function __construct( ) { } - #[AsEventListener] + #[AsEventListener(priority: 10)] public function onChildrenCollectionEtagEvent(ChildrenCollectionEtagEvent $event): void { $redisKey = $this->redisKeyTypeFactory->getEtagChildrenCollectionRedisKey($event->getParentId()); diff --git a/src/EventSystem/Etag/EventListener/RedisElementEtagEventListener.php b/src/EventSystem/Etag/EventListener/RedisElementEtagEventListener.php index dd0bf404..81e6c1a5 100644 --- a/src/EventSystem/Etag/EventListener/RedisElementEtagEventListener.php +++ b/src/EventSystem/Etag/EventListener/RedisElementEtagEventListener.php @@ -21,7 +21,7 @@ public function __construct( ) { } - #[AsEventListener] + #[AsEventListener(priority: 10)] public function onElementEtagEvent(ElementEtagEvent $event): void { $redisKey = $this->redisKeyTypeFactory->getEtagElementRedisKey($event->getElementId()); diff --git a/src/EventSystem/Etag/EventListener/RedisFileEtagEventListener.php b/src/EventSystem/Etag/EventListener/RedisFileEtagEventListener.php new file mode 100644 index 00000000..df916da9 --- /dev/null +++ b/src/EventSystem/Etag/EventListener/RedisFileEtagEventListener.php @@ -0,0 +1,62 @@ +redisKeyTypeFactory->getEtagFileRedisKey($event->getElementId()); + $this->logger->debug( + 'Trying to find Etag for file in Redis.', + [ + 'elementId' => $event->getElementId()->toString(), + 'redisKey' => (string) $redisKey, + ] + ); + $rawEtag = $this->redisClient->get((string) $redisKey); + if (null === $rawEtag) { + $this->logger->debug( + 'Unable to find Etag for file in Redis.', + [ + 'elementId' => $event->getElementId()->toString(), + 'redisKey' => (string) $redisKey, + ] + ); + + return; + } + $etag = new Etag($rawEtag); + if ((string) $etag === RedisValueType::NULL->value) { + $etag = null; + } + $this->logger->debug( + 'Found Etag for file in Redis.', + [ + 'elementId' => $event->getElementId()->toString(), + 'redisKey' => (string) $redisKey, + 'etag' => $etag, + ] + ); + $event->setEtag($etag); + $event->stopPropagation(); + } +} diff --git a/src/EventSystem/Etag/EventListener/RedisIndexCollectionEtagEventListener.php b/src/EventSystem/Etag/EventListener/RedisIndexCollectionEtagEventListener.php index 37e6e4a4..e29ed1e3 100644 --- a/src/EventSystem/Etag/EventListener/RedisIndexCollectionEtagEventListener.php +++ b/src/EventSystem/Etag/EventListener/RedisIndexCollectionEtagEventListener.php @@ -21,7 +21,7 @@ public function __construct( ) { } - #[AsEventListener] + #[AsEventListener(priority: 10)] public function onIndexCollectionEtagEvent(IndexCollectionEtagEvent $event): void { $redisKey = $this->redisKeyTypeFactory->getEtagIndexCollectionRedisKey($event->getUserId()); diff --git a/src/EventSystem/Etag/EventListener/RedisParentsCollectionEtagEventListener.php b/src/EventSystem/Etag/EventListener/RedisParentsCollectionEtagEventListener.php index 0e381693..46ff4328 100644 --- a/src/EventSystem/Etag/EventListener/RedisParentsCollectionEtagEventListener.php +++ b/src/EventSystem/Etag/EventListener/RedisParentsCollectionEtagEventListener.php @@ -21,7 +21,7 @@ public function __construct( ) { } - #[AsEventListener] + #[AsEventListener(priority: 10)] public function onParentsCollectionEtagEvent(ParentsCollectionEtagEvent $event): void { $redisKey = $this->redisKeyTypeFactory->getEtagParentsCollectionRedisKey($event->getChildId()); diff --git a/src/EventSystem/Etag/EventListener/RedisRelatedCollectionEtagEventListener.php b/src/EventSystem/Etag/EventListener/RedisRelatedCollectionEtagEventListener.php index ca262625..d0e4b4a1 100644 --- a/src/EventSystem/Etag/EventListener/RedisRelatedCollectionEtagEventListener.php +++ b/src/EventSystem/Etag/EventListener/RedisRelatedCollectionEtagEventListener.php @@ -21,7 +21,7 @@ public function __construct( ) { } - #[AsEventListener] + #[AsEventListener(priority: 10)] public function onRelatedCollectionEtagEvent(RelatedCollectionEtagEvent $event): void { $redisKey = $this->redisKeyTypeFactory->getEtagRelatedCollectionRedisKey($event->getCenterId()); diff --git a/src/EventSystem/Exception/EventListener/ExceptionEventListener.php b/src/EventSystem/Exception/EventListener/ExceptionEventListener.php index c21d9063..c76c6a53 100644 --- a/src/EventSystem/Exception/EventListener/ExceptionEventListener.php +++ b/src/EventSystem/Exception/EventListener/ExceptionEventListener.php @@ -6,7 +6,7 @@ use App\Exception\ProblemJsonException; use App\Factory\Exception\Server500InternalServerErrorExceptionFactory; -use App\Response\ProblemJsonResponse; +use App\Type\Response\ProblemJsonResponse; use Exception; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; @@ -57,6 +57,7 @@ public function onKernelException(ExceptionEvent $event): void 'status' => $extendedException->getStatus(), 'instance' => $instanceLink, 'detail' => $extendedException->getDetail(), + ...$extendedException->getAdditionalProperties(), ]; if (null === $instanceLink) { diff --git a/src/EventSystem/NormalizedValueToRawValue/EventListener/MongoDBNormalizedValueToRawValueEventListener.php b/src/EventSystem/NormalizedValueToRawValue/EventListener/MongoDBNormalizedValueToRawValueEventListener.php index aee92e76..d7c7000d 100644 --- a/src/EventSystem/NormalizedValueToRawValue/EventListener/MongoDBNormalizedValueToRawValueEventListener.php +++ b/src/EventSystem/NormalizedValueToRawValue/EventListener/MongoDBNormalizedValueToRawValueEventListener.php @@ -6,6 +6,7 @@ use App\EventSystem\NormalizedValueToRawValue\Event\NormalizedValueToRawValueEvent; use MongoDB\Model\BSONArray; +use MongoDB\Model\BSONDocument; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; class MongoDBNormalizedValueToRawValueEventListener @@ -14,7 +15,7 @@ class MongoDBNormalizedValueToRawValueEventListener public function onNormalizedValueToRawValueEvent(NormalizedValueToRawValueEvent $event): void { $normalizedValue = $event->getNormalizedValue(); - if (!($normalizedValue instanceof BSONArray)) { + if (!($normalizedValue instanceof BSONArray) && !($normalizedValue instanceof BSONDocument)) { return; } $event->setRawValue($normalizedValue->getArrayCopy()); diff --git a/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php b/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php index 12e734f2..1bb15368 100644 --- a/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php +++ b/src/EventSystem/Request/EventListener/ApiKeyCheckOnKernelRequestEventListener.php @@ -5,7 +5,7 @@ namespace App\EventSystem\Request\EventListener; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\AuthProvider; use App\Security\TokenGenerator; use App\Type\TokenStateType; @@ -32,7 +32,7 @@ public function __construct( private RedisClient $redisClient, private AuthProvider $authProvider, private Client401UnauthorizedExceptionFactory $client401UnauthorizedExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -85,12 +85,12 @@ private function getUserIdAndTokenIdObjectFromTokenFromCypher(string $token): Us $rawUserId = $res->first()->get('user.id'); if (!is_string($rawUserId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property user.id as string, not %s.', get_debug_type($rawUserId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property user.id as string, not %s.', get_debug_type($rawUserId))); // @codeCoverageIgnore } $userId = Uuid::fromString($rawUserId); $rawTokenId = $res->first()->get('token.id'); if (!is_string($rawTokenId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property token.id as string, not %s.', get_debug_type($rawTokenId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property token.id as string, not %s.', get_debug_type($rawTokenId))); // @codeCoverageIgnore } $tokenId = Uuid::fromString($rawTokenId); @@ -99,7 +99,7 @@ private function getUserIdAndTokenIdObjectFromTokenFromCypher(string $token): Us $rawTokenExpiration = $res->first()->get('token.expirationDate'); if (null !== $rawTokenExpiration) { if (!($rawTokenExpiration instanceof LaudisDateTime) && !($rawTokenExpiration instanceof DateTimeZoneId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property token.expirationDate as LaudisDateTime|DateTimeZoneId, not %s.', get_debug_type($rawTokenExpiration))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property token.expirationDate as LaudisDateTime|DateTimeZoneId, not %s.', get_debug_type($rawTokenExpiration))); // @codeCoverageIgnore } $secondsUntilTokenExpires = $rawTokenExpiration->toDateTime()->getTimestamp() - (new DateTime())->getTimestamp(); $tokenLifetimeInRedis = min($tokenLifetimeInRedis, $secondsUntilTokenExpires); diff --git a/src/EventSystem/Response/EventListener/EtagResponseEventListener.php b/src/EventSystem/Response/EventListener/EtagResponseEventListener.php index f4dd8b17..a36d5704 100644 --- a/src/EventSystem/Response/EventListener/EtagResponseEventListener.php +++ b/src/EventSystem/Response/EventListener/EtagResponseEventListener.php @@ -4,9 +4,7 @@ namespace App\EventSystem\Response\EventListener; -use App\Response\CollectionResponse; -use App\Response\ElementResponse; -use App\Response\NotModifiedResponse; +use App\Contract\EtagCapableResponseInterface; use App\Service\EtagService; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ResponseEvent; @@ -22,11 +20,7 @@ public function __construct( public function onKernelResponse(ResponseEvent $event): void { $response = $event->getResponse(); - if (!( - $response instanceof CollectionResponse - || $response instanceof ElementResponse - || $response instanceof NotModifiedResponse - )) { + if (!($response instanceof EtagCapableResponseInterface)) { return; } $etag = $this->etagService->getCurrentRequestEtag(); diff --git a/src/EventSystem/Response/EventListener/TusIoResponseEventListener.php b/src/EventSystem/Response/EventListener/TusIoResponseEventListener.php deleted file mode 100644 index c9423daa..00000000 --- a/src/EventSystem/Response/EventListener/TusIoResponseEventListener.php +++ /dev/null @@ -1,25 +0,0 @@ -tusIoService->isTusIoEndpoint()) { - return; - } - header('Tus-Resumable: 1.0.0'); - header('Tus-Extension: creation,creation-defer-length,creation-with-upload,expiration,checksum,checksum-trailer,termination,concatenation'); - } -} diff --git a/src/EventSystem/Response/config.yaml b/src/EventSystem/Response/config.yaml deleted file mode 100644 index e69de29b..00000000 diff --git a/src/EventSystem/SearchStep/EventListener/CypherPathSearchStepEventListener.php b/src/EventSystem/SearchStep/EventListener/CypherPathSearchStepEventListener.php index b7e5eae6..91980716 100644 --- a/src/EventSystem/SearchStep/EventListener/CypherPathSearchStepEventListener.php +++ b/src/EventSystem/SearchStep/EventListener/CypherPathSearchStepEventListener.php @@ -7,7 +7,7 @@ use App\Antlr\CypherPathSubsetGrammar; use App\EventSystem\SearchStep\Event\SearchStepEvent; use App\Factory\Exception\Client400BadContentExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Type\SearchStepType; @@ -34,7 +34,7 @@ public function __construct( private AuthProvider $authProvider, private Stopwatch $stopwatch, private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -52,11 +52,11 @@ private function parseRawPathFromRow(CypherMap $row): array * @phpstan-ignore-next-line greater.alwaysTrue */ if (!($rawNode instanceof Node)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property path.nodes as Node, not %s.', get_debug_type($rawNode))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property path.nodes as Node, not %s.', get_debug_type($rawNode))); // @codeCoverageIgnore } $rawId = $rawNode->getProperty('id'); if (!is_string($rawId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property path.node.id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property path.node.id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore } $nodeIds[] = UuidV4::fromString($rawId); } @@ -65,11 +65,11 @@ private function parseRawPathFromRow(CypherMap $row): array * @phpstan-ignore-next-line greater.alwaysTrue */ if (!($rawRelation instanceof UnboundRelationship)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property path.relationships as UnboundRelationship, not %s.', get_debug_type($rawRelation))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property path.relationships as UnboundRelationship, not %s.', get_debug_type($rawRelation))); // @codeCoverageIgnore } $rawId = $rawRelation->getProperty('id'); if (!is_string($rawId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property path.relationships.id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property path.relationships.id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore } $relationIds[] = UuidV4::fromString($rawId); } diff --git a/src/Exception/Client400ReservedTypeException.php b/src/Exception/Client400ReservedTypeException.php new file mode 100644 index 00000000..591f7130 --- /dev/null +++ b/src/Exception/Client400ReservedTypeException.php @@ -0,0 +1,21 @@ + $additionalDetails + */ + public function __construct( + string $type, + string $title = 'Conflict', + int $status = 409, + string $detail = 'Operation can not be performed due to some sort of conflict.', + ?string $instance = null, + ?Throwable $previous = null, + array $additionalDetails = [], + ) { + parent::__construct($type, $title, $status, $detail, $instance, $previous, $additionalDetails); + } +} diff --git a/src/Exception/Client410GoneException.php b/src/Exception/Client410GoneException.php new file mode 100644 index 00000000..e08114eb --- /dev/null +++ b/src/Exception/Client410GoneException.php @@ -0,0 +1,21 @@ + + */ + protected array $additionalProperties; /** - * @param string $type A URI reference [RFC3986] that identifies the - * @param string $title A short, human-readable summary of the problem - * @param int $status The HTTP status code ([RFC7231], Section 6) - * @param string $detail A human-readable explanation specific to this - * @param string|null $instance A URI reference that identifies the specific - * @param Throwable|null $previous the previous throwable used for the exception chaining + * @param string $type A URI reference [RFC3986] that identifies the + * @param string $title A short, human-readable summary of the problem + * @param int $status The HTTP status code ([RFC7231], Section 6) + * @param string $detail A human-readable explanation specific to this + * @param string|null $instance A URI reference that identifies the specific + * @param Throwable|null $previous the previous throwable used for the exception chaining + * @param array $additionalProperties Array of additional properties which will be added to the resulting Problem JSON response */ public function __construct( string $type, @@ -31,6 +36,7 @@ public function __construct( string $detail, ?string $instance = null, ?Throwable $previous = null, + array $additionalProperties = [], ) { parent::__construct('', 0, $previous); $this->type = $type; @@ -38,6 +44,7 @@ public function __construct( $this->status = $status; $this->detail = $detail; $this->instance = $instance; + $this->additionalProperties = $additionalProperties; } public function getType(): string @@ -64,4 +71,12 @@ public function getInstance(): ?string { return $this->instance; } + + /** + * @return array + */ + public function getAdditionalProperties(): array + { + return $this->additionalProperties; + } } diff --git a/src/Factory/Exception/Client400IncompleteMutualDependencyExceptionFactory.php b/src/Factory/Exception/Client400IncompleteMutualDependencyExceptionFactory.php index 7ba3ebeb..0d1ab151 100644 --- a/src/Factory/Exception/Client400IncompleteMutualDependencyExceptionFactory.php +++ b/src/Factory/Exception/Client400IncompleteMutualDependencyExceptionFactory.php @@ -11,7 +11,7 @@ class Client400IncompleteMutualDependencyExceptionFactory { public function __construct( private UrlGeneratorInterface $urlGenerator, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -25,7 +25,7 @@ public function __construct( public function createFromTemplate(array $properties, array $setProperties, array $missingProperties): Client400IncompleteMutualDependencyException { if (count($properties) < 2) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Mutual dependency requires at least two properties.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Mutual dependency requires at least two properties.'); } $message = sprintf( @@ -50,7 +50,7 @@ public function createFromTemplate(array $properties, array $setProperties, arra } if (0 === count($missingProperties)) { - throw $this->server500LogicExceptionFactory->createFromTemplate('At least one missing property is required.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('At least one missing property is required.'); } elseif (1 === count($missingProperties)) { $message .= sprintf( " property '%s' is missing.", diff --git a/src/Factory/Exception/Client400ReservedTypeExceptionFactory.php b/src/Factory/Exception/Client400ReservedTypeExceptionFactory.php new file mode 100644 index 00000000..4a30216f --- /dev/null +++ b/src/Factory/Exception/Client400ReservedTypeExceptionFactory.php @@ -0,0 +1,34 @@ +urlGenerator->generate( + 'exception-detail', + [ + 'code' => '400', + 'name' => 'reserved-type', + ], + UrlGeneratorInterface::ABSOLUTE_URL + ), + detail: sprintf( + "The requested type '%s' is reserved and can not be used.", + $type + ) + ); + } +} diff --git a/src/Factory/Exception/Client409ConflictExceptionFactory.php b/src/Factory/Exception/Client409ConflictExceptionFactory.php new file mode 100644 index 00000000..e06d6995 --- /dev/null +++ b/src/Factory/Exception/Client409ConflictExceptionFactory.php @@ -0,0 +1,35 @@ + $additionalDetails + */ + public function createFromDetail(string $detail, array $additionalDetails = []): Client409ConflictException + { + return new Client409ConflictException( + $this->urlGenerator->generate( + 'exception-detail', + [ + 'code' => '409', + 'name' => 'conflict', + ], + UrlGeneratorInterface::ABSOLUTE_URL + ), + detail: $detail, + additionalDetails: $additionalDetails + ); + } +} diff --git a/src/Factory/Exception/Client410GoneExceptionFactory.php b/src/Factory/Exception/Client410GoneExceptionFactory.php new file mode 100644 index 00000000..8d891030 --- /dev/null +++ b/src/Factory/Exception/Client410GoneExceptionFactory.php @@ -0,0 +1,30 @@ +urlGenerator->generate( + 'exception-detail', + [ + 'code' => '410', + 'name' => 'gone', + ], + UrlGeneratorInterface::ABSOLUTE_URL + ) + ); + } +} diff --git a/src/Factory/Exception/Client412PreconditionFailedExceptionFactory.php b/src/Factory/Exception/Client412PreconditionFailedExceptionFactory.php index 27206c23..baea6693 100644 --- a/src/Factory/Exception/Client412PreconditionFailedExceptionFactory.php +++ b/src/Factory/Exception/Client412PreconditionFailedExceptionFactory.php @@ -14,7 +14,7 @@ public function __construct( ) { } - public function createFromTemplate(?string $detail = 'Precondition does not match.'): Client412PreconditionFailedException + public function createFromTemplate(string $detail = 'Precondition does not match.'): Client412PreconditionFailedException { return new Client412PreconditionFailedException( $this->urlGenerator->generate( @@ -25,7 +25,7 @@ public function createFromTemplate(?string $detail = 'Precondition does not matc ], UrlGeneratorInterface::ABSOLUTE_URL ), - $detail + detail: $detail ); } } diff --git a/src/Factory/Exception/Client412TusResumablePreconditionFailedExceptionFactory.php b/src/Factory/Exception/Client412TusResumablePreconditionFailedExceptionFactory.php deleted file mode 100644 index d86f91e2..00000000 --- a/src/Factory/Exception/Client412TusResumablePreconditionFailedExceptionFactory.php +++ /dev/null @@ -1,31 +0,0 @@ -urlGenerator->generate( - 'exception-detail', - [ - 'code' => '412', - 'name' => 'tus-io-precondition-failed', - ], - UrlGeneratorInterface::ABSOLUTE_URL - ), - detail: "Tus.io endpoints require the HTTP header 'Tus-Resumable' to be set to a valid version. Supported versions are '1.0.0'." - ); - } -} diff --git a/src/Factory/Exception/Server500LogicExceptionFactory.php b/src/Factory/Exception/Server500LogicErrorExceptionFactory.php similarity index 85% rename from src/Factory/Exception/Server500LogicExceptionFactory.php rename to src/Factory/Exception/Server500LogicErrorExceptionFactory.php index 6f4e289d..8ce7ba8f 100644 --- a/src/Factory/Exception/Server500LogicExceptionFactory.php +++ b/src/Factory/Exception/Server500LogicErrorExceptionFactory.php @@ -8,8 +8,9 @@ use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Throwable; -class Server500LogicExceptionFactory +class Server500LogicErrorExceptionFactory { public function __construct( private UrlGeneratorInterface $urlGenerator, @@ -23,7 +24,7 @@ public function __construct( * * @param mixed[] $context */ - public function createFromTemplate(string $developmentDetail, array $context = []): Server500LogicErrorException + public function createFromTemplate(string $developmentDetail, array $context = [], ?Throwable $previous = null): Server500LogicErrorException { $this->logger->error($developmentDetail, $context); $message = 'Internal server error, see log.'; @@ -40,7 +41,8 @@ public function createFromTemplate(string $developmentDetail, array $context = [ ], UrlGeneratorInterface::ABSOLUTE_URL ), - detail: $message + detail: $message, + previous: $previous ); } } diff --git a/src/Factory/RabbitMQFactory.php b/src/Factory/RabbitMQFactory.php index 18437ec5..d291bc5c 100644 --- a/src/Factory/RabbitMQFactory.php +++ b/src/Factory/RabbitMQFactory.php @@ -4,52 +4,52 @@ namespace App\Factory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use PhpAmqpLib\Connection\AMQPStreamConnection; use function Safe\parse_url; +/** + * @codeCoverageIgnore + */ class RabbitMQFactory { public function __construct( private string $rabbitMQAuth, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } - /** - * @codeCoverageIgnore - */ public function createRabbitMQ(): AMQPStreamConnection { $parsed = parse_url($this->rabbitMQAuth); if (!is_array($parsed)) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to correctly parse RabbitMQ DSN.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to correctly parse RabbitMQ DSN.'); } if (!array_key_exists('user', $parsed)) { - throw $this->server500LogicExceptionFactory->createFromTemplate('RabbitMQ DSN requires user.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('RabbitMQ DSN requires user.'); } $user = $parsed['user']; if (!$user) { - throw $this->server500LogicExceptionFactory->createFromTemplate('RabbitMQ DSN requires user.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('RabbitMQ DSN requires user.'); } if (!array_key_exists('pass', $parsed)) { - throw $this->server500LogicExceptionFactory->createFromTemplate('RabbitMQ DSN requires password.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('RabbitMQ DSN requires password.'); } $pass = $parsed['pass']; if (!$pass) { - throw $this->server500LogicExceptionFactory->createFromTemplate('RabbitMQ DSN requires password.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('RabbitMQ DSN requires password.'); } if (!array_key_exists('host', $parsed)) { - throw $this->server500LogicExceptionFactory->createFromTemplate('RabbitMQ DSN requires host.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('RabbitMQ DSN requires host.'); } $host = $parsed['host']; if (!$host) { - throw $this->server500LogicExceptionFactory->createFromTemplate('RabbitMQ DSN requires host.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('RabbitMQ DSN requires host.'); } return new AMQPStreamConnection( diff --git a/src/Factory/S3ClientFactory.php b/src/Factory/S3ClientFactory.php index ce43cb3c..19463c2a 100644 --- a/src/Factory/S3ClientFactory.php +++ b/src/Factory/S3ClientFactory.php @@ -7,12 +7,15 @@ use AsyncAws\Core\Configuration; use AsyncAws\S3\S3Client; +/** + * @codeCoverageIgnore + */ class S3ClientFactory { public function __construct( private string $s3Endpoint, private string $s3AccessKeyId, - private string $s3SecretAccessKey + private string $s3SecretAccessKey, ) { } @@ -20,7 +23,7 @@ public function createS3Client(): S3Client { $configuration = Configuration::create([ Configuration::OPTION_ENDPOINT => $this->s3Endpoint, - Configuration::OPTION_PATH_STYLE_ENDPOINT => true, + Configuration::OPTION_PATH_STYLE_ENDPOINT => 'true', Configuration::OPTION_ACCESS_KEY_ID => $this->s3AccessKeyId, Configuration::OPTION_SECRET_ACCESS_KEY => $this->s3SecretAccessKey, ]); diff --git a/src/Factory/Type/RedisKeyFactory.php b/src/Factory/Type/RedisKeyFactory.php index ee5bee08..e0cc061c 100644 --- a/src/Factory/Type/RedisKeyFactory.php +++ b/src/Factory/Type/RedisKeyFactory.php @@ -68,4 +68,12 @@ public function getEtagIndexCollectionRedisKey(UuidInterface $userId): RedisKey $userId->toString() ); } + + public function getEtagFileRedisKey(UuidInterface $elementId): RedisKey + { + return new RedisKey( + RedisPrefixType::ETAG_FILE, + $elementId->toString() + ); + } } diff --git a/src/Factory/Type/Request/PartialUploadRequestFactory.php b/src/Factory/Type/Request/PartialUploadRequestFactory.php new file mode 100644 index 00000000..8e4a1c3c --- /dev/null +++ b/src/Factory/Type/Request/PartialUploadRequestFactory.php @@ -0,0 +1,37 @@ +headers; + + $contentType = $this->headerParseService->getContentTypeFromHeaders($headers, 'application/partial-upload'); + $uploadOffset = $this->headerParseService->getUploadOffsetFromHeaders($headers); + $isUploadComplete = $this->headerParseService->isUploadCompleteFromHeaders($headers); + $contentLength = $this->headerParseService->getContentLengthFromHeaders($headers); + $content = $request->getContent(true); + + return new PartialUploadRequest( + $content, + $contentType, + $uploadOffset, + $isUploadComplete, + $contentLength + ); + } +} diff --git a/src/Factory/Type/Request/ResumableUploadRequestFactory.php b/src/Factory/Type/Request/ResumableUploadRequestFactory.php new file mode 100644 index 00000000..8cdfcbc0 --- /dev/null +++ b/src/Factory/Type/Request/ResumableUploadRequestFactory.php @@ -0,0 +1,45 @@ +headers; + + $isUploadComplete = $this->headerParseService->isUploadCompleteFromHeaders($headers); + $uploadLength = $this->headerParseService->getUploadLengthFromHeaders($headers); + $contentLength = $this->headerParseService->getContentLengthFromHeaders($headers); + $extension = $this->headerParseService->getExtensionFromHeaders($headers); + $content = $request->getContent(true); + + if (null !== $uploadLength && null !== $contentLength && $uploadLength !== $contentLength && true === $isUploadComplete) { + throw $this->client400BadContentExceptionFactory->createFromDetail("Inconsistent length values provided in headers 'Content-Length' and 'Upload-Length'."); + } + + return new ResumableUploadRequest( + $elementId, + $content, + $isUploadComplete, + $uploadLength, + $contentLength, + $extension + ); + } +} diff --git a/src/Factory/Type/Response/NoContentResponseFactory.php b/src/Factory/Type/Response/NoContentResponseFactory.php new file mode 100644 index 00000000..8cbd3b16 --- /dev/null +++ b/src/Factory/Type/Response/NoContentResponseFactory.php @@ -0,0 +1,51 @@ +headers; + + $headers->set('Upload-Complete', sprintf('?%s', $upload->isUploadComplete() ? '1' : '0')); + $headers->set('Upload-Offset', sprintf('%d', $upload->getUploadOffset())); + + if (null !== $location) { + $headers->set('Location', $location); + } + + $uploadLength = $upload->getUploadLength(); + if (null !== $uploadLength) { + $headers->set('Upload-Length', sprintf('%d', $uploadLength)); + } + + $headers->set( + 'Upload-Limit', + sprintf( + 'max-age=%d, max-size=%d, min-append-size=%d, max-append-size=%d', + $this->emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest(), + $this->emberNexusConfiguration->getFileMaxFileSizeInBytes(), + $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes(), + $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(), + ) + ); + $headers->set('Expires', $upload->getExpires()->setTimezone(new DateTimeZone('UTC'))->format('D, d M Y H:i:s \\G\\M\\T')); + $headers->set('Cache-Control', 'no-store, private'); + + return $response; + } +} diff --git a/src/Factory/Type/S3/FileOperationFactory.php b/src/Factory/Type/S3/FileOperationFactory.php new file mode 100644 index 00000000..da800fdc --- /dev/null +++ b/src/Factory/Type/S3/FileOperationFactory.php @@ -0,0 +1,48 @@ +emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($upload->getId(), $chunk) + ); + } + + public function createFileOperationFromElement(NodeElementInterface|RelationElementInterface $element): FileOperationInterface + { + $elementId = $element->getId(); + if (null === $elementId) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Expected elementId to be not null.'); + } + $extension = $this->elementService->getFileNameExtension($element); + + return new FileOperation( + $this->emberNexusConfiguration->getFileS3StorageBucket(), + $this->fileService->getStorageBucketKey($elementId, $extension) + ); + } +} diff --git a/src/Factory/Type/S3/MergeFileChunksOperationFactory.php b/src/Factory/Type/S3/MergeFileChunksOperationFactory.php new file mode 100644 index 00000000..29172dd9 --- /dev/null +++ b/src/Factory/Type/S3/MergeFileChunksOperationFactory.php @@ -0,0 +1,56 @@ +isUploadComplete()) { + throw $this->client400BadContentExceptionFactory->createFromDetail("'MergeFileChunksOperation' requires 'Upload' to be complete."); + } + + $uploadKeys = []; + $uploadId = $upload->getId(); + $alreadyUploadedChunks = $upload->getAlreadyUploadedChunks(); + for ($i = 0; $i < $alreadyUploadedChunks; ++$i) { + $uploadKeys[] = $this->fileService->getUploadBucketKey($uploadId, $i + 1); + } + + $element = $this->elementManager->getElementOrFail($upload->getUploadTarget()); + + $previousStorageKey = null; + if ($element->hasProperty('file')) { + $previousExtension = $this->elementService->getFileNameExtension($element); + $previousStorageKey = $this->fileService->getStorageBucketKey($upload->getUploadTarget(), $previousExtension); + } + + return new MergeFileChunksOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $uploadKeys, + $this->emberNexusConfiguration->getFileS3StorageBucket(), + $previousStorageKey, + $this->fileService->getStorageBucketKey($upload->getUploadTarget(), $upload->getExtension()) + ); + } +} diff --git a/src/Factory/Type/S3/UploadFileChunkOperationFactory.php b/src/Factory/Type/S3/UploadFileChunkOperationFactory.php new file mode 100644 index 00000000..9f84c3f7 --- /dev/null +++ b/src/Factory/Type/S3/UploadFileChunkOperationFactory.php @@ -0,0 +1,63 @@ +isUploadComplete()) { + throw $this->client400BadContentExceptionFactory->createFromDetail("'UploadFileChunkOperation' requires 'ResumableUploadRequest' to contain partial content, i.e. be a chunked upload request."); + } + + return new UploadFileChunkOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($uploadId, 1), + $resumableUploadRequest->getContent(), + $resumableUploadRequest->getContentLength(), + 'application/octet-stream' + ); + } + + public function createUploadFileChunkOperationFromPartialUploadRequest(PartialUploadRequestInterface $partialUploadRequest, UploadInterface $upload): UploadFileChunkOperationInterface + { + return new UploadFileChunkOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($upload->getId(), $upload->getAlreadyUploadedChunks() + 1), + $partialUploadRequest->getContent(), + $partialUploadRequest->getContentLength(), + 'application/octet-stream' + ); + } + + public function createUploadFileChunkOperationFromUploadFileOperation(UploadFileOperationInterface $uploadFileOperation): UploadFileChunkOperationInterface + { + return new UploadFileChunkOperation( + $uploadFileOperation->getUploadBucket(), + $uploadFileOperation->getUploadKey(), + $uploadFileOperation->getContent(), + $uploadFileOperation->getContentLength(), + $uploadFileOperation->getMimeType() + ); + } +} diff --git a/src/Factory/Type/S3/UploadFileOperationFactory.php b/src/Factory/Type/S3/UploadFileOperationFactory.php new file mode 100644 index 00000000..ee86a8a2 --- /dev/null +++ b/src/Factory/Type/S3/UploadFileOperationFactory.php @@ -0,0 +1,85 @@ +isUploadComplete()) { + throw $this->client400BadContentExceptionFactory->createFromDetail("'UploadFileOperation' requires 'ResumableUploadRequest' to contain the whole content, i.e. be a non-chunked upload request."); + } + + $elementId = $resumableUploadRequest->getElementId(); + $element = $this->elementManager->getElementOrFail($elementId); + + $previousStorageKey = null; + if ($element->hasProperty('file')) { + $previousExtension = $this->elementService->getFileNameExtension($element); + $previousStorageKey = $this->fileService->getStorageBucketKey($elementId, $previousExtension); + } + + $resource = $resumableUploadRequest->getContent(); + + return new UploadFileOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($elementId, 0), + $this->emberNexusConfiguration->getFileS3StorageBucket(), + $previousStorageKey, + $this->fileService->getStorageBucketKey($elementId, $resumableUploadRequest->getExtension()), + $resource, + $resumableUploadRequest->getContentLength(), + $this->mimeTypeService->getMimeTypeFromResource($resource) + ); + } + + /** + * @param resource $resource + */ + public function createUploadFileOperationFromElementAndResource(NodeElementInterface|RelationElementInterface $element, $resource): UploadFileOperationInterface + { + $elementId = $element->getId(); + if (null === $elementId) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Expected element.id to not be null.'); + } + + $extension = $this->elementService->getFileNameExtension($element); + + return new UploadFileOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($elementId, 0), + $this->emberNexusConfiguration->getFileS3StorageBucket(), + null, + $this->fileService->getStorageBucketKey($elementId, $extension), + $resource, + null, + $this->mimeTypeService->getMimeTypeFromResource($resource) + ); + } +} diff --git a/src/Factory/Type/UploadFactory.php b/src/Factory/Type/UploadFactory.php new file mode 100644 index 00000000..8cadc871 --- /dev/null +++ b/src/Factory/Type/UploadFactory.php @@ -0,0 +1,81 @@ +client400BadContentExceptionFactory->createFromDetail('Upload element must be a node, not a relation.'); + } + + $label = $element->getLabel(); + if ('Upload' !== $label) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Can not cast element of type %s to upload.', $label ?? '')); + } + $id = $element->getId(); + if (null === $id) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects element id to not be null.'); + } + + $properties = $element->getProperties(); + + return new Upload( + $id, + $this->propertyParseService->getUploadLengthFromProperties($properties), + $this->propertyParseService->getUploadOffsetFromProperties($properties), + $this->propertyParseService->getIsUploadCompleteFromProperties($properties), + $this->propertyParseService->getUploadTargetFromProperties($properties), + $this->propertyParseService->getAlreadyUploadedChunksFromProperties($properties), + $this->propertyParseService->getUploadOwnerFromProperties($properties), + $this->propertyParseService->getExtensionFromProperties($properties), + $this->propertyParseService->getExpiresFromProperties($properties) + ); + } + + public function markUploadAsComplete(UploadInterface $upload): UploadInterface + { + return new Upload( + $upload->getId(), + $upload->getUploadLength(), + $upload->getUploadOffset(), + true, + $upload->getUploadTarget(), + $upload->getAlreadyUploadedChunks(), + $upload->getUploadOwner(), + $upload->getExtension(), + $upload->getExpires() + ); + } + + public function addNewChunkToUpload(UploadInterface $upload, int $chunkLength): UploadInterface + { + return new Upload( + $upload->getId(), + $upload->getUploadLength(), + $upload->getUploadOffset() + $chunkLength, + $upload->isUploadComplete(), + $upload->getUploadTarget(), + $upload->getAlreadyUploadedChunks() + 1, + $upload->getUploadOwner(), + $upload->getExtension(), + $upload->getExpires() + ); + } +} diff --git a/src/Factory/UploadObjectFactory.php b/src/Factory/UploadObjectFactory.php deleted file mode 100644 index e69de29b..00000000 diff --git a/src/Helper/Neo4jClientHelper.php b/src/Helper/Neo4jClientHelper.php index 1cee2441..3bb52a41 100755 --- a/src/Helper/Neo4jClientHelper.php +++ b/src/Helper/Neo4jClientHelper.php @@ -4,7 +4,7 @@ namespace App\Helper; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Types\Node as LaudisNode; use Laudis\Neo4j\Types\Relationship as LaudisRelationship; @@ -16,7 +16,7 @@ class Neo4jClientHelper { public function __construct( private CypherEntityManager $cypherEntityManager, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -55,7 +55,7 @@ public function getRelationFromLaudisRelation( ); $startNode = $res->first()->get('startNode'); if (!($startNode instanceof LaudisNode)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property startNode as Node, not %s.', get_debug_type($startNode))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property startNode as Node, not %s.', get_debug_type($startNode))); // @codeCoverageIgnore } $res = $this->cypherEntityManager->getClient()->runStatement( Statement::create( @@ -67,7 +67,7 @@ public function getRelationFromLaudisRelation( ); $endNode = $res->first()->get('endNode'); if (!($endNode instanceof LaudisNode)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property endNode as Node, not %s.', get_debug_type($endNode))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property endNode as Node, not %s.', get_debug_type($endNode))); // @codeCoverageIgnore } } $startNode = $this->getNodeFromLaudisNode($startNode); diff --git a/src/Response/BinaryStreamResponse.php b/src/Response/BinaryStreamResponse.php deleted file mode 100644 index 581f9f35..00000000 --- a/src/Response/BinaryStreamResponse.php +++ /dev/null @@ -1,41 +0,0 @@ -getBody()->getContentAsResource(); - - $this->headers->set('Content-Length', (string) ($object->getContentLength() ?? 0)); - $this->headers->set('Content-Type', 'application/octet-stream'); - - $this->setCallback(function () use ($stream): void { - while (!feof($stream)) { - $buffer = fread($stream, StorageUtilService::STREAM_CHUNK_SIZE); - if (false === $buffer || 0 === strlen($buffer)) { - break; - } - echo $buffer; - // todo is this comment safe to remove? - // ob_flush(); - flush(); - } - fclose($stream); - }); - } - - public function setEtagFromEtagInstance(Etag $etag): static - { - return parent::setEtag((string) $etag); - } -} diff --git a/src/Response/CollectionResponse.php b/src/Response/CollectionResponse.php deleted file mode 100755 index 585e80b7..00000000 --- a/src/Response/CollectionResponse.php +++ /dev/null @@ -1,9 +0,0 @@ -get('group.id'); if (!is_string($rawGroupId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property group.id as string, not %s.', get_debug_type($rawGroupId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property group.id as string, not %s.', get_debug_type($rawGroupId))); // @codeCoverageIgnore } $groupIds[] = UuidV4::fromString($rawGroupId); } @@ -500,7 +500,7 @@ public function getDirectGroupsWithAccessToNode(UuidInterface $elementId, Access foreach ($res as $row) { $rawGroupId = $row->get('group.id'); if (!is_string($rawGroupId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property group.id as string, not %s.', get_debug_type($rawGroupId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property group.id as string, not %s.', get_debug_type($rawGroupId))); // @codeCoverageIgnore } $groups[] = UuidV4::fromString($rawGroupId); } @@ -612,7 +612,7 @@ public function getDirectGroupsWithAccessToRelation(UuidInterface $elementId, Ac foreach ($res as $row) { $rawGroup = $row->get('group'); if (!is_string($rawGroup)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property group as string, not %s.', get_debug_type($rawGroup))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property group as string, not %s.', get_debug_type($rawGroup))); // @codeCoverageIgnore } $groups[] = UuidV4::fromString($rawGroup); } @@ -706,7 +706,7 @@ public function getDirectUsersWithAccessToNode(UuidInterface $elementId, AccessT foreach ($res as $row) { $rawUserId = $row->get('user.id'); if (!is_string($rawUserId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property user.id as string, not %s.', get_debug_type($rawUserId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property user.id as string, not %s.', get_debug_type($rawUserId))); // @codeCoverageIgnore } $users[] = UuidV4::fromString($rawUserId); } @@ -854,7 +854,7 @@ public function getDirectUsersWithAccessToRelation(UuidInterface $elementId, Acc foreach ($res as $row) { $rawUser = $row->get('user'); if (!is_string($rawUser)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property user as string, not %s.', get_debug_type($rawUser))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property user as string, not %s.', get_debug_type($rawUser))); // @codeCoverageIgnore } $users[] = UuidV4::fromString($rawUser); } @@ -900,7 +900,7 @@ public function checkUserAccessToMultipleElements(UuidInterface $userId, array $ foreach ($res as $row) { $rawId = $row->get('id'); if (!is_string($rawId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property id as string, not %s.', get_debug_type($rawId))); // @codeCoverageIgnore } $id = UuidV4::fromString($rawId); @@ -909,7 +909,7 @@ public function checkUserAccessToMultipleElements(UuidInterface $userId, array $ if (is_string($rawStartId)) { $startId = UuidV4::fromString($rawStartId); } elseif (!is_null($rawStartId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property startId as string or null, not %s.', get_debug_type($rawStartId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property startId as string or null, not %s.', get_debug_type($rawStartId))); // @codeCoverageIgnore } $rawEndId = $row->get('endId'); @@ -917,7 +917,7 @@ public function checkUserAccessToMultipleElements(UuidInterface $userId, array $ if (is_string($rawEndId)) { $endId = UuidV4::fromString($rawEndId); } elseif (!is_null($rawEndId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property endId as string or null, not %s.', get_debug_type($rawEndId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property endId as string or null, not %s.', get_debug_type($rawEndId))); // @codeCoverageIgnore } $data[] = [ @@ -998,7 +998,7 @@ public function checkUserAccessToMultipleElements(UuidInterface $userId, array $ foreach ($res as $row) { $rawElementId = $row->get('elementId'); if (!is_string($rawElementId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property elementId as string, not %s.', get_debug_type($rawElementId))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property elementId as string, not %s.', get_debug_type($rawElementId))); // @codeCoverageIgnore } $nodeIdsWithAccess[] = UuidV4::fromString($rawElementId); } diff --git a/src/Security/AuthProvider.php b/src/Security/AuthProvider.php index 5c67cddd..9a7e5ade 100755 --- a/src/Security/AuthProvider.php +++ b/src/Security/AuthProvider.php @@ -4,7 +4,7 @@ namespace App\Security; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Type\RedisPrefixType; use Ramsey\Uuid\Rfc4122\UuidV4; use Ramsey\Uuid\UuidInterface; @@ -20,11 +20,11 @@ class AuthProvider public function __construct( private ParameterBagInterface $bag, private TokenGenerator $tokenGenerator, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { $anonymousUserId = $this->bag->get('anonymousUserUUID'); if (!is_string($anonymousUserId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate('anonymousUserUUID must be set to a valid UUID'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('anonymousUserUUID must be set to a valid UUID'); } $this->userId = UuidV4::fromString($anonymousUserId); $this->isAnonymous = true; diff --git a/src/Security/TokenGenerator.php b/src/Security/TokenGenerator.php index 7c1215e6..cb96c970 100755 --- a/src/Security/TokenGenerator.php +++ b/src/Security/TokenGenerator.php @@ -4,7 +4,7 @@ namespace App\Security; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Service\ElementManager; use App\Type\NodeElement; use App\Type\RelationElement; @@ -26,7 +26,7 @@ public function __construct( private ElementManager $elementManager, private CypherEntityManager $cypherEntityManager, private EmberNexusConfiguration $emberNexusConfiguration, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { $this->encoder = new Base58(); } @@ -55,7 +55,7 @@ public function createNewToken(UuidInterface $userId, array $data = [], ?int $li * @phpstan-ignore-next-line greater.alwaysTrue */ if ($res->count() > 0 && 2 == $i) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to generate new token hash without collision. Please repeat your last call at a later time.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to generate new token hash without collision. Please repeat your last call at a later time.'); } } diff --git a/src/Service/CollectionService.php b/src/Service/CollectionService.php index 3dafc129..0e259feb 100755 --- a/src/Service/CollectionService.php +++ b/src/Service/CollectionService.php @@ -5,8 +5,8 @@ namespace App\Service; use App\Factory\Exception\Server500InternalServerErrorExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; -use App\Response\CollectionResponse; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; +use App\Type\Response\CollectionResponse; use EmberNexusBundle\Service\EmberNexusConfiguration; use Ramsey\Uuid\UuidInterface; use Symfony\Component\HttpFoundation\InputBag; @@ -24,7 +24,7 @@ public function __construct( private ElementToRawService $elementToRawService, private EmberNexusConfiguration $emberNexusConfiguration, private Server500InternalServerErrorExceptionFactory $server500InternalServerErrorExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -78,7 +78,7 @@ public function getPageLink(?int $page = null): string { $currentRequest = $this->requestStack->getCurrentRequest(); if (null === $currentRequest) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Current request can not be null.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Current request can not be null.'); } $basePath = $currentRequest->getPathInfo(); diff --git a/src/Service/CreateElementFromRawDataService.php b/src/Service/CreateElementFromRawDataService.php index 511431f0..4c4d5948 100644 --- a/src/Service/CreateElementFromRawDataService.php +++ b/src/Service/CreateElementFromRawDataService.php @@ -10,6 +10,7 @@ use App\EventSystem\RawValueToNormalizedValue\Event\RawValueToNormalizedValueEvent; use App\Factory\Exception\Client400IncompleteMutualDependencyExceptionFactory; use App\Factory\Exception\Client400ReservedIdentifierExceptionFactory; +use App\Factory\Exception\Client400ReservedTypeExceptionFactory; use App\Type\NodeElement; use App\Type\RelationElement; use Ramsey\Uuid\UuidInterface; @@ -17,8 +18,11 @@ class CreateElementFromRawDataService { + public const array RESERVED_TYPES = ['User', 'Token', 'Upload']; + public function __construct( private Client400ReservedIdentifierExceptionFactory $client400ReservedIdentifierExceptionFactory, + private Client400ReservedTypeExceptionFactory $client400ReservedTypeExceptionFactory, private Client400IncompleteMutualDependencyExceptionFactory $client400IncompleteMutualDependencyExceptionFactory, private ElementManager $elementManager, private EventDispatcherInterface $eventDispatcher, @@ -27,6 +31,9 @@ public function __construct( /** * @param array $rawData + * + * @SuppressWarnings("PHPMD.CyclomaticComplexity") + * @SuppressWarnings("PHPMD.NPathComplexity") */ public function createElementFromRawData( UuidInterface $elementId, @@ -34,6 +41,7 @@ public function createElementFromRawData( ?UuidInterface $startNodeId = null, ?UuidInterface $endNodeId = null, array $rawData = [], + bool $skipReservedTypeCheck = false, ): NodeElementInterface|RelationElementInterface { if (null !== $startNodeId && null === $endNodeId) { throw $this->client400IncompleteMutualDependencyExceptionFactory->createFromTemplate(['start', 'end'], ['start'], ['end']); @@ -55,6 +63,10 @@ public function createElementFromRawData( $normalizedData[$rawPropertyName] = $rawValueToNormalizedValueEvent->getNormalizedValue(); } + if (in_array($type, self::RESERVED_TYPES) && !$skipReservedTypeCheck) { + throw $this->client400ReservedTypeExceptionFactory->createFromTemplate($type); + } + $elementPropertyChangeEvent = new ElementPropertyChangeEvent($type, null, $normalizedData); $this->eventDispatcher->dispatch($elementPropertyChangeEvent); $verifiedData = $elementPropertyChangeEvent->getChangedProperties(); diff --git a/src/Service/ElementManager.php b/src/Service/ElementManager.php index a21b818a..70a47c52 100755 --- a/src/Service/ElementManager.php +++ b/src/Service/ElementManager.php @@ -12,7 +12,8 @@ use App\EventSystem\EntityManager\Event\ElementPreCreateEvent; use App\EventSystem\EntityManager\Event\ElementPreDeleteEvent; use App\EventSystem\EntityManager\Event\ElementPreMergeEvent; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Helper\Neo4jClientHelper; use Laudis\Neo4j\Databags\Statement; use Laudis\Neo4j\Types\Node; @@ -47,7 +48,8 @@ public function __construct( private ElementDefragmentizeService $elementDefragmentizeService, private Neo4jClientHelper $neo4jClientHelper, private EventDispatcherInterface $eventDispatcher, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -139,6 +141,16 @@ public function getElement(UuidInterface $id): NodeElementInterface|RelationElem return null; } + public function getElementOrFail(UuidInterface $id): NodeElementInterface|RelationElementInterface + { + $element = $this->getElement($id); + if (null === $element) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + return $element; + } + public function getNode(UuidInterface $id): ?NodeElementInterface { $res = $this->cypherEntityManager->getClient()->runStatement( @@ -152,7 +164,7 @@ public function getNode(UuidInterface $id): ?NodeElementInterface try { $rawNode = $res->first()->get('node'); if (!($rawNode instanceof Node)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property node as Node, not %s.', get_debug_type($rawNode))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property node as Node, not %s.', get_debug_type($rawNode))); // @codeCoverageIgnore } $cypherFragment = $this->neo4jClientHelper->getNodeFromLaudisNode($rawNode); } catch (OutOfBoundsException $e) { @@ -182,15 +194,15 @@ public function getRelation(UuidInterface $id): ?RelationElementInterface try { $rawRelation = $res->first()->get('relation'); if (!($rawRelation instanceof Relationship)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property relation as Relationship, not %s.', get_debug_type($rawRelation))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property relation as Relationship, not %s.', get_debug_type($rawRelation))); // @codeCoverageIgnore } $rawStartNode = $res->first()->get('startNode'); if (!($rawStartNode instanceof Node)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property startNode as Node, not %s.', get_debug_type($rawStartNode))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property startNode as Node, not %s.', get_debug_type($rawStartNode))); // @codeCoverageIgnore } $rawEndNode = $res->first()->get('endNode'); if (!($rawEndNode instanceof Node)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property endNode as Node, not %s.', get_debug_type($rawEndNode))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property endNode as Node, not %s.', get_debug_type($rawEndNode))); // @codeCoverageIgnore } $cypherFragment = $this->neo4jClientHelper->getRelationFromLaudisRelation( $rawRelation, @@ -202,7 +214,7 @@ public function getRelation(UuidInterface $id): ?RelationElementInterface } $type = $cypherFragment->getType(); if (null === $type) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to get relationship type.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to get relationship type.'); } $documentFragment = $this->mongoEntityManager->getOneByIdentifier($type, $id->toString()); $fileFragment = null; diff --git a/src/Service/ElementResponseService.php b/src/Service/ElementResponseService.php index dae6ca53..8c0db633 100755 --- a/src/Service/ElementResponseService.php +++ b/src/Service/ElementResponseService.php @@ -4,8 +4,8 @@ namespace App\Service; -use App\Factory\Exception\Server500LogicExceptionFactory; -use App\Response\ElementResponse; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; +use App\Type\Response\ElementResponse; use Ramsey\Uuid\UuidInterface; class ElementResponseService @@ -13,7 +13,7 @@ class ElementResponseService public function __construct( private ElementManager $elementManager, private ElementToRawService $elementToRawService, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -22,7 +22,7 @@ public function buildElementResponseFromId( ): ElementResponse { $element = $this->elementManager->getElement($id); if (null === $element) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf("Unable to find element with the id '%s'.", $id->toString())); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Unable to find element with the id '%s'.", $id->toString())); } $rawData = $this->elementToRawService->elementToRaw($element); diff --git a/src/Service/ElementService.php b/src/Service/ElementService.php new file mode 100644 index 00000000..657365bd --- /dev/null +++ b/src/Service/ElementService.php @@ -0,0 +1,68 @@ +getId(); + if (null === $elementId) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Expected element.id to not be null.'); + } + + return $elementId; + } + + public function getFileName(NodeElementInterface|RelationElementInterface $element): string + { + $base = $this->getFileNameBase($element); + $extension = $this->getFileNameExtension($element); + + return $this->fileService->buildFileNameFromParts($base, $extension); + } + + protected function getFileNameBase( + NodeElementInterface|RelationElementInterface $element, + ): string { + $elementId = $this->getElementId($element); + $fallbackName = $elementId->toString(); + + // try to use the content of the 'name' property as the file's name + if (!$element->hasProperty('name')) { + return $fallbackName; + } + $nameProperty = $element->getProperty('name'); + if (!is_string($nameProperty)) { + return $fallbackName; + } + $nameProperty = $this->fileService->removeReservedCharactersFromFileName($nameProperty); + if (0 === strlen($nameProperty)) { + return $fallbackName; + } + + return $nameProperty; + } + + public function getFileNameExtension( + NodeElementInterface|RelationElementInterface $element, + ): string { + $parsedFileProperty = $this->filePropertyService->parseFilePropertyFromElement($element); + + return $parsedFileProperty?->getExtension() ?? FileService::DEFAULT_EXTENSION; + } +} diff --git a/src/Service/ElementToRawService.php b/src/Service/ElementToRawService.php index 2f0f7b84..6efe9931 100755 --- a/src/Service/ElementToRawService.php +++ b/src/Service/ElementToRawService.php @@ -28,6 +28,7 @@ public function elementToRaw(NodeElementInterface|RelationElementInterface $elem 'start' => null, 'end' => null, 'data' => [], + 'file' => null, ]; if ($element instanceof NodeElementInterface) { @@ -51,6 +52,13 @@ public function elementToRaw(NodeElementInterface|RelationElementInterface $elem $rawData['data'][$normalizedPropertyName] = $normalizedValueToRawValueEvent->getRawValue(); } + if (array_key_exists('file', $rawData['data'])) { + $rawData['file'] = $rawData['data']['file']; + unset($rawData['data']['file']); + } else { + unset($rawData['file']); + } + return $rawData; } } diff --git a/src/Service/EtagCalculatorService.php b/src/Service/EtagCalculatorService.php index 3ffeff93..95e8deed 100644 --- a/src/Service/EtagCalculatorService.php +++ b/src/Service/EtagCalculatorService.php @@ -4,10 +4,12 @@ namespace App\Service; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; +use App\Factory\Type\S3\FileOperationFactory; use App\Helper\DateTimeHelper; use App\Type\Etag; use App\Type\EtagCalculator; +use AsyncAws\Core\Exception\Http\ClientException; use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; use Laudis\Neo4j\Databags\Statement; @@ -22,8 +24,11 @@ class EtagCalculatorService public function __construct( private EmberNexusConfiguration $emberNexusConfiguration, private CypherEntityManager $cypherEntityManager, + private ElementManager $elementManager, + private S3Service $s3Service, + private FileOperationFactory $fileOperationFactory, private LoggerInterface $logger, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -119,7 +124,7 @@ public function calculateChildrenCollectionEtag(UuidInterface $parentId): ?Etag $etagCalculator->addUuid($parentId); $rawSortedTuples = $result[0]['sortedTuples']; if (!($rawSortedTuples instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property sortedTuples as array, not %s.', get_debug_type($rawSortedTuples))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property sortedTuples as array, not %s.', get_debug_type($rawSortedTuples))); // @codeCoverageIgnore } foreach ($rawSortedTuples as $idUpdatedPair) { $etagCalculator->addUuid(Uuid::fromString($idUpdatedPair[0])); @@ -191,7 +196,7 @@ public function calculateParentsCollectionEtag(UuidInterface $childId): ?Etag $etagCalculator->addUuid($childId); $rawSortedTuples = $result[0]['sortedTuples']; if (!($rawSortedTuples instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property sortedTuples as array, not %s.', get_debug_type($rawSortedTuples))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property sortedTuples as array, not %s.', get_debug_type($rawSortedTuples))); // @codeCoverageIgnore } foreach ($rawSortedTuples as $idUpdatedPair) { $etagCalculator->addUuid(Uuid::fromString($idUpdatedPair[0])); @@ -262,7 +267,7 @@ public function calculateRelatedCollectionEtag(UuidInterface $centerId): ?Etag $etagCalculator->addUuid($centerId); $rawSortedTuples = $result[0]['sortedTuples']; if (!($rawSortedTuples instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property sortedTuples as array, not %s.', get_debug_type($rawSortedTuples))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property sortedTuples as array, not %s.', get_debug_type($rawSortedTuples))); // @codeCoverageIgnore } foreach ($rawSortedTuples as $idUpdatedPair) { $etagCalculator->addUuid(Uuid::fromString($idUpdatedPair[0])); @@ -333,7 +338,7 @@ public function calculateIndexCollectionEtag(UuidInterface $userId): ?Etag $etagCalculator->addUuid($userId); $rawSortedTuples = $result[0]['sortedTuples']; if (!($rawSortedTuples instanceof CypherList)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property sortedTuples as array, not %s.', get_debug_type($rawSortedTuples))); // @codeCoverageIgnore + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property sortedTuples as array, not %s.', get_debug_type($rawSortedTuples))); // @codeCoverageIgnore } foreach ($rawSortedTuples as $idUpdatedPair) { $etagCalculator->addUuid(Uuid::fromString($idUpdatedPair[0])); @@ -352,4 +357,50 @@ public function calculateIndexCollectionEtag(UuidInterface $userId): ?Etag return $etag; } + + public function calculateFileEtag(UuidInterface $elementId): ?Etag + { + $this->logger->debug( + 'Calculating Etag for file.', + [ + 'elementId' => $elementId->toString(), + ] + ); + + $element = $this->elementManager->getElementOrFail($elementId); + $fileOperation = $this->fileOperationFactory->createFileOperationFromElement($element); + try { + $fileEtag = $this->s3Service->getEtag($fileOperation); + } catch (ClientException $exception) { + $this->logger->error(sprintf( + 'Unable to calculate Etag for file of element %s.', + (string) $elementId + )); + + return null; + } + + $fileProperties = $element->getProperty('file'); + $fileProperties = \Safe\json_encode($fileProperties); + + $name = $element->getProperty('name'); + $name = \Safe\json_encode($name); + + $etagCalculator = new EtagCalculator($this->emberNexusConfiguration->getCacheEtagSeed()); + $etagCalculator->addUuid($elementId); + $etagCalculator->addString($fileEtag); + $etagCalculator->addString($fileProperties); + $etagCalculator->addString($name); + $etag = $etagCalculator->getEtag(); + + $this->logger->debug( + 'Calculated Etag for file.', + [ + 'elementId' => $elementId->toString(), + 'etag' => $etag, + ] + ); + + return $etag; + } } diff --git a/src/Service/EtagService.php b/src/Service/EtagService.php index 29458f09..285e7b5f 100644 --- a/src/Service/EtagService.php +++ b/src/Service/EtagService.php @@ -6,6 +6,7 @@ use App\EventSystem\Etag\Event\ChildrenCollectionEtagEvent; use App\EventSystem\Etag\Event\ElementEtagEvent; +use App\EventSystem\Etag\Event\FileEtagEvent; use App\EventSystem\Etag\Event\IndexCollectionEtagEvent; use App\EventSystem\Etag\Event\ParentsCollectionEtagEvent; use App\EventSystem\Etag\Event\RelatedCollectionEtagEvent; @@ -49,6 +50,9 @@ public function setCurrentRequestEtagFromRequestAndEtagType(Request $request, Et case EtagType::RELATED_COLLECTION: $event = new RelatedCollectionEtagEvent($requestId); break; + case EtagType::FILE: + $event = new FileEtagEvent($requestId); + break; } } diff --git a/src/Service/FilePropertyService.php b/src/Service/FilePropertyService.php new file mode 100644 index 00000000..d06e7d51 --- /dev/null +++ b/src/Service/FilePropertyService.php @@ -0,0 +1,57 @@ +hasProperty('file')) { + return null; + } + + $rawFileProperties = $element->getProperty('file'); + if (!is_array($rawFileProperties)) { + // todo: make sure that the property 'file' is restricted, i.e. users can not directly change these properties + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Expected property 'file' of element %s to be of type array, got %s.", $element->getId()?->toString() ?? 'null', get_debug_type($rawFileProperties))); + } + + $parsedFileProperty->setExtension($this->parseExtensionPropertyFromRawFileProperties($rawFileProperties, $element->getId())); + + return $parsedFileProperty; + } + + /** + * @param array $rawFileProperties + */ + protected function parseExtensionPropertyFromRawFileProperties(array $rawFileProperties, ?UuidInterface $elementId): string + { + if (!array_key_exists('extension', $rawFileProperties)) { + return FileService::DEFAULT_EXTENSION; + } + $extensionProperty = $rawFileProperties['extension']; + if (!is_string($extensionProperty)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Expected property 'file.extension' of element %s to be of type string, got %s.", $elementId?->toString() ?? 'null', get_debug_type($extensionProperty))); + } + if (0 === strlen($extensionProperty)) { + return FileService::DEFAULT_EXTENSION; + } + + return $extensionProperty; + } +} diff --git a/src/Service/FileService.php b/src/Service/FileService.php new file mode 100644 index 00000000..076b9ee6 --- /dev/null +++ b/src/Service/FileService.php @@ -0,0 +1,124 @@ +stringService->getAsciiSafeString($fileName); + $fileName = $this->removeReservedCharactersFromFileName($fileName); + + $parts = explode('.', $fileName, 2); + if (2 === count($parts)) { + $baseName = $parts[0]; + $extension = trim(substr($parts[1], 0, self::MAX_EXTENSION_LENGTH)); + + return sprintf( + '%s.%s', + trim(substr($baseName, 0, self::MAX_FILENAME_LENGTH - strlen($extension) - 1)), + $extension + ); + } + + return substr($fileName, 0, self::MAX_FILENAME_LENGTH); + } + + public function removeReservedCharactersFromFileName(string $fileName): string + { + return trim(str_replace( + ['"', '*', '/', ':', '<', '>', '?', '\\', '|'], + '', + $fileName + )); + } + + public function buildFileNameFromParts(string $name, string $extension): string + { + /** @psalm-suppress PossiblyInvalidArgument */ + $extension = substr(\Safe\preg_replace('/\s+/', '', $extension), 0, self::MAX_EXTENSION_LENGTH); + $name = trim(substr(trim($name), 0, self::MAX_FILENAME_LENGTH - strlen($extension) - 1)); + + return sprintf('%s.%s', $name, $extension); + } + + public function getStorageBucketKey(UuidInterface $id, string $extension): string + { + return sprintf( + '%s.%s', + $this->uuidToNestedFolderStructure( + $id, + $this->emberNexusConfiguration->getFileS3StorageBucketLevels(), + $this->emberNexusConfiguration->getFileS3StorageBucketLevelLength(), + ), + $extension + ); + } + + public function getUploadBucketKey(UuidInterface $id, int $chunkIndex): string + { + $digits = $this->emberNexusConfiguration->getFileUploadChunkDigitsLength(); + if ($chunkIndex < 0) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Chunk index can not be less than 0.'); + } + $maxIndex = (10 ** $digits) - 1; + if ($chunkIndex > $maxIndex) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Chunk index can not be longer than %d digits, i.e. bigger than %d.', $digits, $maxIndex)); + } + + return sprintf( + '%s-%s.%s', + $this->uuidToNestedFolderStructure( + $id, + $this->emberNexusConfiguration->getFileS3UploadBucketLevels(), + $this->emberNexusConfiguration->getFileS3UploadBucketLevelLength(), + ), + str_pad((string) $chunkIndex, $digits, '0', STR_PAD_LEFT), + self::UPLOAD_EXTENSION + ); + } + + public function uuidToNestedFolderStructure(UuidInterface $uuid, int $levels = 0, int $levelLength = 2): string + { + $uuidAsHexString = $uuid->getHex()->toString(); + if ($levels < 0) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to generate nested folder structure from uuid with negative level argument.', ['levels' => $levels]); + } + if ($levelLength < 1) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to generate nested folder structure from uuid with level length less than 1.', ['levelLength' => $levelLength]); + } + if ($levels * $levelLength >= strlen($uuidAsHexString)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to generate nested folder structure as long as product of levels and level length exceeds length of uuid without dashes.', ['levels' => $levels, 'levelLength' => $levelLength, 'product' => $levels * $levelLength, 'limit' => strlen($uuidAsHexString) - 1]); + } + + $parts = []; + for ($i = 0; $i < $levels; ++$i) { + $parts[] = substr($uuidAsHexString, $levelLength * $i, $levelLength); + } + + return sprintf( + '%s%s%s', + implode('/', $parts), + $levels > 0 ? '/' : '', + $uuid->toString() + ); + } +} diff --git a/src/Service/HeaderParseService.php b/src/Service/HeaderParseService.php new file mode 100644 index 00000000..36fbefad --- /dev/null +++ b/src/Service/HeaderParseService.php @@ -0,0 +1,121 @@ +get('Content-Type'); + if (null === $contentType) { + throw $this->client400BadContentExceptionFactory->createFromDetail("Endpoint requires the header 'content-type' to be present."); + } + $contentType = trim(strtolower(explode(';', $contentType)[0])); + if ('' === $contentType) { + throw $this->client400BadContentExceptionFactory->createFromDetail("Header 'Content-Type' must contain a valid MIME type, got an empty value."); + } + if (null !== $expectedContentType) { + $expectedContentType = strtolower($expectedContentType); + if ($expectedContentType !== $contentType) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Expected content type '%s', got '%s'.", $expectedContentType, $contentType)); + } + } + + return $contentType; + } + + public function getExtensionFromHeaders(HeaderBag $headers): string + { + $contentDisposition = $headers->get('Content-Disposition'); + if (null === $contentDisposition) { + return FileService::DEFAULT_EXTENSION; + } + try { + $fileName = $this->contentDispositionWrapper->parseContentDisposition($contentDisposition); + } catch (Throwable $exception) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not parse 'Content-Disposition' header: %s", $exception->getMessage())); + } + if (null === $fileName) { + return FileService::DEFAULT_EXTENSION; + } + $fileName = basename($fileName); + $fileName = $this->fileService->removeReservedCharactersFromFileName($fileName); + $extension = pathinfo($fileName, PATHINFO_EXTENSION); + if (empty($extension)) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not parse a file extension from the filename in 'Content-Disposition': '%s'.", $fileName)); + } + + return $extension; + } + + public function getUploadOffsetFromHeaders(HeaderBag $headers): int + { + $uploadOffset = $headers->get('Upload-Offset'); + if (null === $uploadOffset) { + throw $this->client400BadContentExceptionFactory->createFromDetail("Header 'Upload-Offset' is required."); + } + if (!ctype_digit($uploadOffset)) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Header 'Upload-Offset' requires a non-negative integer as its value, got '%s'.", $uploadOffset)); + } + + return (int) $uploadOffset; + } + + public function isUploadCompleteFromHeaders(HeaderBag $headers): ?bool + { + $possibleValues = [ + '?0' => false, + '?1' => true, + ]; + $uploadCompleteHeader = $headers->get('Upload-Complete'); + + if (null === $uploadCompleteHeader) { + return null; + } + if (!array_key_exists($uploadCompleteHeader, $possibleValues)) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Header 'Upload-Complete' must contain a boolean value, either '?0' or '?1', got '%s'.", $uploadCompleteHeader)); + } + + return $possibleValues[$uploadCompleteHeader]; + } + + public function getContentLengthFromHeaders(HeaderBag $headers): ?int + { + $contentLength = $headers->get('Content-Length'); + if (null === $contentLength) { + return null; + } + if (!ctype_digit($contentLength)) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Header 'Content-Length' requires a non-negative integer as its value, got '%s'.", $contentLength)); + } + + return (int) $contentLength; + } + + public function getUploadLengthFromHeaders(HeaderBag $headers): ?int + { + $uploadLength = $headers->get('Upload-Length'); + if (null === $uploadLength) { + return null; + } + if (!ctype_digit($uploadLength)) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Header 'Upload-Length' requires a non-negative integer as its value, got '%s'.", $uploadLength)); + } + + return (int) $uploadLength; + } +} diff --git a/src/Service/LockService.php b/src/Service/LockService.php deleted file mode 100644 index f007f2fc..00000000 --- a/src/Service/LockService.php +++ /dev/null @@ -1,48 +0,0 @@ -redisClient->set($lock->getLockKey(), $lock->getLockValue(), 'EX', $lock->getLockTimeoutInSeconds(), 'NX'); - - if ($status instanceof Status) { - if ('OK' === $status->getPayload()) { - $lock->setLocked(true); - - return $lock; - } - } - - throw $this->client412PreconditionFailedExceptionFactory->createFromTemplate('Unable to acquire lock. Please retry your request at a later time.'); - } - - public function releaseLock(LockInterface $lock): LockInterface - { - if (!$lock->isLocked()) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Releasing lock of unlocked lock is not possible.'); - } - $this->redisClient->del($lock->getLockKey()); - - $lock->setLocked(false); - - return $lock; - } -} diff --git a/src/Service/MimeTypeService.php b/src/Service/MimeTypeService.php new file mode 100644 index 00000000..e2b36e8d --- /dev/null +++ b/src/Service/MimeTypeService.php @@ -0,0 +1,26 @@ +buffer($bytes); + + return false === $mimeType ? self::DEFAULT_MIME_TYPE : $mimeType; + } +} diff --git a/src/Service/PropertyParseService.php b/src/Service/PropertyParseService.php new file mode 100644 index 00000000..26e1402a --- /dev/null +++ b/src/Service/PropertyParseService.php @@ -0,0 +1,162 @@ +client400BadContentExceptionFactory->createFromDetail('Upload expects property uploadLength to be either int or to be absent.'); + } + } + + return $uploadLength; + } + + /** + * @param mixed[] $properties + */ + public function getUploadOffsetFromProperties(mixed $properties): int + { + if (!array_key_exists('uploadOffset', $properties)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property uploadOffset to be present.'); + } + $uploadOffset = $properties['uploadOffset']; + if (!is_int($uploadOffset)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property uploadOffset to be int.'); + } + if ($uploadOffset < 0) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property uploadOffset to be positive int.'); + } + + return $uploadOffset; + } + + /** + * @param mixed[] $properties + */ + public function getIsUploadCompleteFromProperties(mixed $properties): bool + { + if (!array_key_exists('uploadComplete', $properties)) { + return false; + } + $uploadComplete = $properties['uploadComplete']; + if (!is_bool($uploadComplete)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property uploadComplete to be bool.'); + } + + return $uploadComplete; + } + + /** + * @param mixed[] $properties + */ + public function getUploadTargetFromProperties(mixed $properties): UuidInterface + { + if (!array_key_exists('uploadTarget', $properties)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property uploadTarget to be present.'); + } + $uploadTarget = $properties['uploadTarget']; + if (is_string($uploadTarget)) { + $uploadTarget = Uuid::fromString($uploadTarget); + } + if (!($uploadTarget instanceof UuidInterface)) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Upload expects property uploadTarget to be a uuid, got %s.', get_debug_type($uploadTarget))); + } + + return $uploadTarget; + } + + /** + * @param mixed[] $properties + */ + public function getAlreadyUploadedChunksFromProperties(mixed $properties): int + { + if (!array_key_exists('alreadyUploadedChunks', $properties)) { + return 0; + } + $alreadyUploadedChunks = $properties['alreadyUploadedChunks']; + if (!is_int($alreadyUploadedChunks)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property alreadyUploadedChunks to be int.'); + } + if ($alreadyUploadedChunks < 0) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property alreadyUploadedChunks to be positive int.'); + } + + return $alreadyUploadedChunks; + } + + /** + * @param mixed[] $properties + */ + public function getUploadOwnerFromProperties(mixed $properties): UuidInterface + { + if (!array_key_exists('uploadOwner', $properties)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property uploadOwner to be present.'); + } + $uploadOwner = $properties['uploadOwner']; + if (is_string($uploadOwner)) { + $uploadOwner = Uuid::fromString($uploadOwner); + } + if (!($uploadOwner instanceof UuidInterface)) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Upload expects property uploadOwner to be a uuid, got %s.', get_debug_type($uploadOwner))); + } + + return $uploadOwner; + } + + /** + * @param mixed[] $properties + */ + public function getExtensionFromProperties(mixed $properties): string + { + if (!array_key_exists('extension', $properties)) { + return FileService::DEFAULT_EXTENSION; + } + $extension = $properties['extension']; + if (!is_string($extension)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property extension to be string.'); + } + + return $extension; + } + + /** + * @param mixed[] $properties + */ + public function getExpiresFromProperties(mixed $properties): DateTime + { + if (!array_key_exists('expires', $properties)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property expires to be present.'); + } + $expires = $properties['expires']; + if ($expires instanceof DateTimeImmutable || $expires instanceof \DateTimeImmutable) { + $expires = DateTime::createFromImmutable($expires); + } + if (!($expires instanceof DateTime)) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Upload expects property expires to be a DateTime, got %s.', get_debug_type($expires))); + } + + return $expires; + } +} diff --git a/src/Service/QueueService.php b/src/Service/QueueService.php new file mode 100644 index 00000000..6a695161 --- /dev/null +++ b/src/Service/QueueService.php @@ -0,0 +1,28 @@ +AMQPStreamConnection->channel(); + $queue = $queueType->value; + $channel->queue_declare($queue, false, false, false, false); + $jsonMessage = \Safe\json_encode($eventData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $message = new AMQPMessage($jsonMessage); + $channel->basic_publish($message, '', $queue); + $channel->close(); + } +} diff --git a/src/Service/RawToElementService.php b/src/Service/RawToElementService.php index 30f1c92c..45404309 100755 --- a/src/Service/RawToElementService.php +++ b/src/Service/RawToElementService.php @@ -13,6 +13,10 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Ramsey\Uuid\Uuid; +/** + * @SuppressWarnings("PHPMD.CyclomaticComplexity") + * @SuppressWarnings("PHPMD.NPathComplexity") + */ class RawToElementService { public function __construct( @@ -22,9 +26,11 @@ public function __construct( } /** + * todo: make sure that $supportFile is never true in API calls. add feature tests. + * * @param array $rawData */ - public function rawToElement(array $rawData): NodeElementInterface|RelationElementInterface + public function rawToElement(array $rawData, bool $supportFile = false): NodeElementInterface|RelationElementInterface { if (!array_key_exists('type', $rawData)) { throw $this->client400MissingPropertyExceptionFactory->createFromTemplate('type', 'valid type'); @@ -60,6 +66,10 @@ public function rawToElement(array $rawData): NodeElementInterface|RelationEleme $normalizedProperties[$rawPropertyName] = $rawValueToNormalizedValueEvent->getNormalizedValue(); } + if (true === $supportFile && array_key_exists('file', $rawData)) { + $normalizedProperties['file'] = $rawData['file']; + } + if (null !== $start && null !== $end) { $element = new RelationElement(); $element->setStart($start); diff --git a/src/Service/RequestUtilService.php b/src/Service/RequestUtilService.php index cf8bc77a..f3a079ea 100644 --- a/src/Service/RequestUtilService.php +++ b/src/Service/RequestUtilService.php @@ -8,12 +8,8 @@ use App\Exception\Client400MissingPropertyException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client400MissingPropertyExceptionFactory; -use App\Type\TusIoExtensionType; -use App\Type\TusIoHeaderType; use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; class RequestUtilService { @@ -157,32 +153,4 @@ public function getStringFromBody(string $key, array $body): string return $value; } - - public function validateTusResumableHttpHeader(Request $request): void - { - // The Tus-Resumable header MUST be included in every request and response except for OPTIONS requests. - if (Request::METHOD_OPTIONS === $request->getMethod()) { - return; - } - if ($request->headers->has(TusIoHeaderType::TUS_RESUMABLE->value)) { - } - } - - public function setCommonTusHeaders(Response $response): void - { - $response->headers->set(TusIoHeaderType::TUS_VERSION->value, '1.0.0'); - $response->headers->set(TusIoHeaderType::TUS_RESUMABLE->value, '1.0.0'); - - $supportedExtensions = [ - TusIoExtensionType::CREATION_WITH_UPLOAD->value, - TusIoExtensionType::CREATION_DEFER_LENGTH->value, - TusIoExtensionType::EXPIRATION->value, - TusIoExtensionType::CHECKSUM->value, - TusIoExtensionType::TERMINATION->value, - ]; - $response->headers->set(TusIoHeaderType::TUS_EXTENSION->value, implode(',', $supportedExtensions)); - - // 5 TB, max limit of S3 - $response->headers->set(TusIoHeaderType::TUS_MAX_SIZE->value, (string) (5 * 1024 * 1024 * 1024 * 1024)); - } } diff --git a/src/Service/S3Service.php b/src/Service/S3Service.php new file mode 100644 index 00000000..44a2712f --- /dev/null +++ b/src/Service/S3Service.php @@ -0,0 +1,334 @@ +s3Client->createMultipartUpload([ + 'Bucket' => $mergeFileChunksOperation->getStorageBucket(), + 'Key' => $mergeFileChunksOperation->getStorageKey(), + 'ContentType' => $this->getMimeTypeFromMergeFileChunksOperation($mergeFileChunksOperation), + ]); + + $multipartUploadId = $createResult->getUploadId(); + + if (null === $multipartUploadId) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to create multipart upload.'); + } + + return $multipartUploadId; + } + + /** + * @internal + * + * @return array + */ + public function createUploadPartsFromMergeFileChunksOperation(MergeFileChunksOperationInterface $mergeFileChunksOperation, string $multipartUploadId): array + { + $parts = []; + foreach ($mergeFileChunksOperation->getUploadKeys() as $i => $uploadKey) { + $copyResult = $this->s3Client->uploadPartCopy([ + 'Bucket' => $mergeFileChunksOperation->getStorageBucket(), + 'Key' => $mergeFileChunksOperation->getStorageKey(), + 'UploadId' => $multipartUploadId, + 'PartNumber' => $i + 1, + 'CopySource' => sprintf('%s/%s', $mergeFileChunksOperation->getUploadBucket(), $uploadKey), + ]); + + $copyPartResult = $copyResult->getCopyPartResult(); + if (null === $copyPartResult) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to read copy part result.'); + } + + $etag = $copyPartResult->getETag(); + if (null === $etag) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to read etag of copy part result.'); + } + + $parts[] = [ + 'PartNumber' => $i + 1, + 'ETag' => $etag, + ]; + } + + return $parts; + } + + public function mergeFileChunks(MergeFileChunksOperationInterface $mergeFileChunksOperation): int + { + $multipartUploadId = $this->createMultipartUploadFromMergeFileChunksOperation($mergeFileChunksOperation); + try { + $parts = $this->createUploadPartsFromMergeFileChunksOperation($mergeFileChunksOperation, $multipartUploadId); + $this->s3Client->completeMultipartUpload([ + 'Bucket' => $mergeFileChunksOperation->getStorageBucket(), + 'Key' => $mergeFileChunksOperation->getStorageKey(), + 'UploadId' => $multipartUploadId, + 'MultipartUpload' => [ + 'Parts' => $parts, + ], + ]); + } catch (Throwable $e) { + $this->s3Client->abortMultipartUpload([ + 'Bucket' => $mergeFileChunksOperation->getStorageBucket(), + 'Key' => $mergeFileChunksOperation->getStorageKey(), + 'UploadId' => $multipartUploadId, + ]); + + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Caught exception '%s' during multipart upload.", $e->getMessage()), previous: $e); + } + + $mergedContentLength = $this->getContentLength(new FileOperation( + $mergeFileChunksOperation->getStorageBucket(), + $mergeFileChunksOperation->getStorageKey() + )); + + $previousStorageKey = $mergeFileChunksOperation->getPreviousStorageKey(); + if (null !== $previousStorageKey && $previousStorageKey !== $mergeFileChunksOperation->getStorageKey()) { + $this->deleteFile(new FileOperation( + $mergeFileChunksOperation->getStorageBucket(), + $previousStorageKey + )); + } + + return $mergedContentLength; + } + + public function deleteFileChunks(MergeFileChunksOperationInterface $mergeFileChunksOperation): void + { + foreach ($mergeFileChunksOperation->getUploadKeys() as $uploadKey) { + $this->deleteFile(new FileOperation( + $mergeFileChunksOperation->getUploadBucket(), + $uploadKey + )); + } + } + + /** + * @return int length of the uploaded chunk + */ + public function uploadFileChunk(UploadFileChunkOperationInterface $uploadFileChunkOperation): int + { + $this->s3Client->putObject([ + 'Bucket' => $uploadFileChunkOperation->getUploadBucket(), + 'Key' => $uploadFileChunkOperation->getUploadKey(), + 'Body' => $uploadFileChunkOperation->getContent(), + 'ContentType' => $uploadFileChunkOperation->getMimeType(), + ]); + + $uploadContentLength = $this->getContentLength(new FileOperation( + $uploadFileChunkOperation->getUploadBucket(), + $uploadFileChunkOperation->getUploadKey() + )); + + $providedContentLength = $uploadFileChunkOperation->getContentLength(); + if (null !== $providedContentLength) { + if ($providedContentLength !== $uploadContentLength) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Inconsistent length values between provided content-length (%d) and actual content length (%d) detected.', $providedContentLength, $uploadContentLength)); + } + } + + return $uploadContentLength; + } + + /** + * todo: optimize upload for larger files using multipart-upload?, handled by https://github.com/ember-nexus/api/issues/452. + */ + public function uploadFile(UploadFileOperationInterface $uploadFileOperation): int + { + // intermediate upload to "upload bucket" + $uploadFileChunkOperation = $this->uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation($uploadFileOperation); + $contentLength = $this->uploadFileChunk($uploadFileChunkOperation); + + // transfer uploaded element to "storage bucket" + $copyResult = $this->s3Client->copyObject([ + 'Bucket' => $uploadFileOperation->getStorageBucket(), + 'Key' => $uploadFileOperation->getStorageKey(), + 'CopySource' => sprintf( + '%s/%s', + $uploadFileOperation->getUploadBucket(), + $uploadFileOperation->getUploadKey() + ), + 'ContentType' => $uploadFileOperation->getMimeType(), + 'MetadataDirective' => 'REPLACE', + ]); + + try { + $this->s3ClientWrapper->resolveCopyObjectOutput($copyResult); + $previousStorageKey = $uploadFileOperation->getPreviousStorageKey(); + if (null !== $previousStorageKey && $previousStorageKey !== $uploadFileOperation->getStorageKey()) { + // delete previous uploaded element, if available + $this->deleteFile(new FileOperation( + $uploadFileOperation->getStorageBucket(), + $previousStorageKey + )); + } + + // clean up upload bucket + $this->deleteFile(new FileOperation( + $uploadFileOperation->getUploadBucket(), + $uploadFileOperation->getUploadKey() + )); + } catch (Throwable $e) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Upload failed: %s', $e->getMessage()), previous: $e); + } + + return $contentLength; + } + + public function deleteFile(FileOperationInterface $fileOperation): void + { + $objectConfig = [ + 'Bucket' => $fileOperation->getBucket(), + 'Key' => $fileOperation->getKey(), + ]; + + $objectExistsWaiter = $this->s3Client->objectExists($objectConfig); + if (!$this->s3ClientWrapper->getIsSuccessFromObjectExistsWaiter($objectExistsWaiter)) { + return; + } + + $this->s3Client->deleteObject($objectConfig); + $objectExistsWaiter = $this->s3Client->objectExists($objectConfig); + if ($this->s3ClientWrapper->getIsSuccessFromObjectExistsWaiter($objectExistsWaiter)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to delete file.'); + } + } + + public function existsFile(FileOperationInterface $fileOperation): bool + { + $objectConfig = [ + 'Bucket' => $fileOperation->getBucket(), + 'Key' => $fileOperation->getKey(), + ]; + $objectExistsWaiter = $this->s3Client->objectExists($objectConfig); + + return $this->s3ClientWrapper->getIsSuccessFromObjectExistsWaiter($objectExistsWaiter); + } + + public function getFile(FileOperationInterface $fileOperation): GetObjectOutput + { + $objectConfig = [ + 'Bucket' => $fileOperation->getBucket(), + 'Key' => $fileOperation->getKey(), + ]; + + return $this->s3Client->getObject($objectConfig); + } + + /** + * @return resource + */ + public function getFileAsResource(FileOperationInterface $fileOperation) + { + $objectConfig = [ + 'Bucket' => $fileOperation->getBucket(), + 'Key' => $fileOperation->getKey(), + ]; + + $object = $this->s3Client->getObject($objectConfig); + + return $object->getBody()->getContentAsResource(); + } + + /** + * @return resource + */ + public function getFileRangeAsResource(FileOperationInterface $fileOperation, int $maxContentLength) + { + $contentLength = $this->getContentLength($fileOperation); + $result = $this->s3Client->getObject([ + 'Bucket' => $fileOperation->getBucket(), + 'Key' => $fileOperation->getKey(), + 'Range' => sprintf('bytes=0-%d', min($contentLength, $maxContentLength)), + ]); + + return $result->getBody()->getContentAsResource(); + } + + public function getContentLength(FileOperationInterface $fileOperation): int + { + $headResult = $this->s3Client->headObject([ + 'Bucket' => $fileOperation->getBucket(), + 'Key' => $fileOperation->getKey(), + ]); + $contentLength = $headResult->getContentLength(); + + if (null === $contentLength) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to read content length of file.'); + } + + return $contentLength; + } + + public function getEtag(FileOperationInterface $fileOperation): string + { + $headResult = $this->s3Client->headObject([ + 'Bucket' => $fileOperation->getBucket(), + 'Key' => $fileOperation->getKey(), + ]); + + $etag = $headResult->getETag(); + + if (null === $etag) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to retrieve file.'); + } + + return $etag; + } + + public function getMimeTypeFromFile(FileOperationInterface $fileOperation): string + { + $resource = $this->getFileRangeAsResource($fileOperation, MimeTypeService::NECESSARY_BYTES_FOR_MIME_TYPE_DETECTION); + + return $this->mimeTypeService->getMimeTypeFromResource($resource); + } + + /** + * Returns the MimeType of a chunked merge file operation. + * Assumes that the first chunk a) exists and is b) sufficiently long to correctly determine the MimeType. This is + * currently the case, as S3's minimum chunk length is 5MB - sufficient for MimeType detection. + */ + public function getMimeTypeFromMergeFileChunksOperation(MergeFileChunksOperationInterface $mergeFileChunksOperation): string + { + $uploadKeys = $mergeFileChunksOperation->getUploadKeys(); + if (0 === count($uploadKeys)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Creating a single file from multiple uploaded chunks requires at least one chunk to be present, got none.'); + } + + return $this->getMimeTypeFromFile(new FileOperation( + $mergeFileChunksOperation->getUploadBucket(), + $uploadKeys[0] + )); + } +} diff --git a/src/Service/SecurityUtilService.php b/src/Service/SecurityUtilService.php index 1f8404c7..742655ba 100644 --- a/src/Service/SecurityUtilService.php +++ b/src/Service/SecurityUtilService.php @@ -12,7 +12,7 @@ use App\Factory\Exception\Client400MissingPropertyExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; use App\Factory\Exception\Client403ForbiddenExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\UserPasswordHasher; use EmberNexusBundle\Service\EmberNexusConfiguration; use Laudis\Neo4j\Databags\Statement; @@ -33,7 +33,7 @@ public function __construct( private Client400MissingPropertyExceptionFactory $client400MissingPropertyExceptionFactory, private Client401UnauthorizedExceptionFactory $client401UnauthorizedExceptionFactory, private Client403ForbiddenExceptionFactory $client403ForbiddenExceptionFactory, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -70,7 +70,7 @@ public function validateUserIsNotAnonymousUser(UuidInterface $userId): void { $anonymousUserId = $this->bag->get('anonymousUserUUID'); if (!is_string($anonymousUserId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Environment variable "ANONYMOUS_USER_UUID" must be set to a valid UUID.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Environment variable "ANONYMOUS_USER_UUID" must be set to a valid UUID.'); } $anonymousUserId = UuidV4::fromString($anonymousUserId); if ($userId->equals($anonymousUserId)) { @@ -99,7 +99,7 @@ public function findUserByUniqueUserIdentifier(string $uniqueUserIdentifier): No } $rawUserId = $res->first()->get('id'); if (!is_string($rawUserId)) { - throw $this->server500LogicExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property id as string, not %s.', get_debug_type($rawUserId))); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property id as string, not %s.', get_debug_type($rawUserId))); } $userId = Uuid::fromString($rawUserId); $element = $this->elementManager->getElement($userId); @@ -107,7 +107,7 @@ public function findUserByUniqueUserIdentifier(string $uniqueUserIdentifier): No throw $this->client401UnauthorizedExceptionFactory->createFromTemplate(); } if ($element instanceof RelationElementInterface) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Impossible situation, found relation by looking for user node.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Impossible situation, found relation by looking for user node.'); } return $element; diff --git a/src/Service/StorageUtilService.php b/src/Service/StorageUtilService.php deleted file mode 100644 index 50c04b19..00000000 --- a/src/Service/StorageUtilService.php +++ /dev/null @@ -1,71 +0,0 @@ -uuidToNestedFolderStructure( - $id, - $this->emberNexusConfiguration->getFileS3StorageBucketLevels(), - $this->emberNexusConfiguration->getFileS3StorageBucketLevelLength(), - ) - ); - } - - public function getUploadBucketKey(UuidInterface $id): string - { - return sprintf( - '%s.bin', - $this->uuidToNestedFolderStructure( - $id, - $this->emberNexusConfiguration->getFileS3UploadBucketLevels(), - $this->emberNexusConfiguration->getFileS3UploadBucketLevelLength(), - ) - ); - } - - public function uuidToNestedFolderStructure(UuidInterface $uuid, int $levels = 0, int $levelLength = 2): string - { - $uuidAsHexString = $uuid->getHex()->toString(); - if ($levels < 0) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to generate nested folder structure from uuid with negative level argument.', ['levels' => $levels]); - } - if ($levelLength < 1) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to generate nested folder structure from uuid with level length less than 1.', ['levelLength' => $levelLength]); - } - if ($levels * $levelLength >= strlen($uuidAsHexString)) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Unable to generate nested folder structure as long as product of levels and level length exceeds length of uuid without dashes.', ['levels' => $levels, 'levelLength' => $levelLength, 'product' => $levels * $levelLength, 'limit' => strlen($uuidAsHexString) - 1]); - } - - $parts = []; - for ($i = 0; $i < $levels; ++$i) { - $parts[] = substr($uuidAsHexString, $levelLength * $i, $levelLength); - } - - return sprintf( - '%s%s%s', - implode('/', $parts), - $levels > 0 ? '/' : '', - $uuid->toString() - ); - } -} diff --git a/src/Service/StringService.php b/src/Service/StringService.php new file mode 100644 index 00000000..cafebc0a --- /dev/null +++ b/src/Service/StringService.php @@ -0,0 +1,73 @@ +server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected mb_convert_encoding to return string, got %s.', get_debug_type($string))); // @codeCoverageIgnore + } + + return $string; + } + + public function removeNonAsciiCharactersFromString(string $string): string + { + $string = \Safe\preg_replace('/[^\x20-\x7E]/', '', $string); + /** + * @phpstan-ignore-next-line + */ + if (!is_string($string)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected preg_replace to return string, got %s.', get_debug_type($string))); // @codeCoverageIgnore + } + + return $string; + } + + public function transliterateEmojis(string $string): string + { + $transliteratedString = EmojiTransliterator::create('en')->transliterate($string); + + return is_string($transliteratedString) ? $transliteratedString : $string; // @codeCoverageIgnore + } + + public function transliterateNonLatinCharacters(string $string): string + { + $transliterator = Transliterator::create( + 'Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; Latin-ASCII' + ); + if (null === $transliterator) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to create Transliterator-object; is ext-intl installed?'); // @codeCoverageIgnore + } + $string = $transliterator->transliterate($string); + if (!is_string($string)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected transliterate to return string, got %s.', get_debug_type($string))); // @codeCoverageIgnore + } + + return $string; + } + + public function getAsciiSafeString(string $string): string + { + $string = $this->transliterateEmojis($string); + $string = $this->enforceUtf8Encoding($string); + $string = $this->transliterateNonLatinCharacters($string); + $string = $this->removeNonAsciiCharactersFromString($string); + + return trim($string); + } +} diff --git a/src/Service/TusIoService.php b/src/Service/TusIoService.php deleted file mode 100644 index 16f51316..00000000 --- a/src/Service/TusIoService.php +++ /dev/null @@ -1,26 +0,0 @@ -isTusIoEndpoint; - } - - public function setIsTusIoEndpoint(bool $isTusIoEndpoint): self - { - $this->isTusIoEndpoint = $isTusIoEndpoint; - - return $this; - } -} diff --git a/src/Service/UpdateElementFromRawDataService.php b/src/Service/UpdateElementFromRawDataService.php index 84b85ac8..f0865435 100644 --- a/src/Service/UpdateElementFromRawDataService.php +++ b/src/Service/UpdateElementFromRawDataService.php @@ -8,14 +8,14 @@ use App\Contract\RelationElementInterface; use App\EventSystem\ElementPropertyChange\Event\ElementPropertyChangeEvent; use App\EventSystem\RawValueToNormalizedValue\Event\RawValueToNormalizedValueEvent; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class UpdateElementFromRawDataService { public function __construct( private EventDispatcherInterface $eventDispatcher, - private Server500LogicExceptionFactory $server500LogicExceptionFactory, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { } @@ -45,7 +45,7 @@ public function updateElementFromRawData( $typeOrLabel = $element->getType(); } if (null === $typeOrLabel) { - throw $this->server500LogicExceptionFactory->createFromTemplate('Type or label should not be null here.'); + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Type or label should not be null here.'); } $elementPropertyChangeEvent = new ElementPropertyChangeEvent($typeOrLabel, $element, $normalizedData); diff --git a/src/Service/UploadCreationService.php b/src/Service/UploadCreationService.php new file mode 100755 index 00000000..1bd76fa9 --- /dev/null +++ b/src/Service/UploadCreationService.php @@ -0,0 +1,133 @@ +elementManager->getElementOrFail($elementId); + + $resumableUploadRequest = $this->resumableUploadRequestFactory->createResumableUploadRequestFromRequest($request, $elementId); + + if (false === $resumableUploadRequest->isUploadComplete()) { + return $this->createNewResumableUpload($resumableUploadRequest); + } + + return $this->setOrReplaceElementFileDirectly($element, $resumableUploadRequest); + } + + private function setOrReplaceElementFileDirectly( + NodeElementInterface|RelationElementInterface $element, + ResumableUploadRequestInterface $resumableUploadRequest, + ): Response { + $uploadFileOperation = $this->uploadFileOperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest); + $this->s3Service->uploadFile($uploadFileOperation); + + $this->eventDispatcher->dispatch(new ElementFileReplaceEvent($resumableUploadRequest->getElementId())); + + $element->addProperty('file', [ + 'contentLength' => $uploadFileOperation->getContentLength(), + 'extension' => $resumableUploadRequest->getExtension(), + 'mimeType' => $uploadFileOperation->getMimeType(), + ]); + $this->elementManager->merge($element); + $this->elementManager->flush(); + + return new CreatedResponse(); + } + + private function createNewResumableUpload(ResumableUploadRequestInterface $resumableUploadRequest): Response + { + $uploadId = Uuid::uuid4(); + + $uploadOffset = 0; + $alreadyUploadedChunks = 0; + if (0 !== $resumableUploadRequest->getContentLength()) { + $uploadFileChunkOperation = $this->uploadFileChunkOperationFactory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId); + $uploadOffset = $this->s3Service->uploadFileChunk($uploadFileChunkOperation); + $alreadyUploadedChunks = 1; + if ($uploadOffset < $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()) { + /** + * file chunk has to be bigger than length, unless: + * - it is the last file chunk, which can contain data of arbitrary length (max limit still applies) + * - it is of zero length -> no actual content / client just asks for upload limits & starts upload process + */ + if (false === $resumableUploadRequest->isUploadComplete()) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at least %d bytes long, got %d.', $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes(), $uploadOffset)); + } + } + if ($uploadOffset > $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at most %d bytes long, got %d.', $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(), $uploadOffset)); + } + } + + $expires = (new DateTime())->add(new DateInterval(sprintf('PT%sS', $this->emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest()))); + $upload = new Upload( + $uploadId, + $resumableUploadRequest->getUploadLength(), + $uploadOffset, + $resumableUploadRequest->isUploadComplete() ?? false, + $resumableUploadRequest->getElementId(), + $alreadyUploadedChunks, + $this->authProvider->getUserId(), + $resumableUploadRequest->getExtension(), + $expires + ); + + $this->uploadService->mergeUploadElement($upload); + $this->elementManager->flush(); + + $location = $this->urlGenerator->generate( + 'head-upload', + [ + 'id' => $uploadId->toString(), + ] + ); + + return $this->noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload, $location); + } +} diff --git a/src/Service/UploadService.php b/src/Service/UploadService.php new file mode 100755 index 00000000..622d2d18 --- /dev/null +++ b/src/Service/UploadService.php @@ -0,0 +1,57 @@ +elementManager->getElement($upload->getId()); + if (null !== $element) { + if (!($element instanceof NodeElementInterface)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected upload element to be a node, received %s.', get_debug_type($element))); + } + if ('Upload' !== $element->getLabel()) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Expected upload element to be of type 'Upload', not '%s'.", $element->getLabel() ?? 'null')); + } + } else { + $element = (new NodeElement()) + ->setId($upload->getId()) + ->setLabel('Upload'); + } + + $element->addProperty('uploadLength', $upload->getUploadLength()); + $element->addProperty('uploadOffset', $upload->getUploadOffset()); + $element->addProperty('uploadComplete', $upload->isUploadComplete()); + $element->addProperty('uploadTarget', $upload->getUploadTarget()->toString()); + $element->addProperty('alreadyUploadedChunks', $upload->getAlreadyUploadedChunks()); + $element->addProperty('uploadOwner', $upload->getUploadOwner()->toString()); + $element->addProperty('extension', $upload->getExtension()); + $element->addProperty('expires', $upload->getExpires()); + + $this->elementManager->merge($element); + } + + public function deleteUpload(UploadInterface $upload): void + { + $element = $this->elementManager->getElementOrFail($upload->getId()); + + $this->elementManager->delete($element); + } +} diff --git a/src/Style/EmberNexusStyle.php b/src/Style/EmberNexusStyle.php index 19d6de13..4f198c97 100755 --- a/src/Style/EmberNexusStyle.php +++ b/src/Style/EmberNexusStyle.php @@ -46,32 +46,36 @@ public function __construct( parent::__construct($input, $output); } + private function getVersion(): ?string + { + $version = getenv('VERSION'); + if (!is_string($version)) { + return null; + } + /** @psalm-suppress PossiblyInvalidArgument */ + $version = trim(preg_replace('/[^0-9.]/', '', $version)); + if ('' === $version) { + return null; + } + + return 'v'.$version; + } + public function title(string $message): void { - $versionString = 'development version'; $appMode = 'production'; if ('prod' !== getenv('APP_ENV')) { $appMode = 'development'; } - $envVersion = getenv('VERSION'); - if (is_string($envVersion)) { - if ($envVersion) { - $version = preg_replace('/[^0-9.]/', '', $envVersion); - // @phpstan-ignore-next-line - if (is_array($version)) { - $version = implode('', $version); - } - $versionString = 'v'.$version; - } - } + $version = $this->getVersion(); $this->newLine(); $this->writeln(sprintf( " ▄ \n". " ▄▀ ▀▄ Ember Nexus API\n". - " ▀█▀ %s, %s mode\n". + " ▀█▀ %s%s mode\n". "\n". " %s\n", - $versionString, + null !== $version ? sprintf('%s, ', $version) : '', $appMode, $message )); @@ -81,7 +85,7 @@ public function title(string $message): void public function finalMessage(array|string $message): void { if ($this->isInSection) { - throw new Exception('Function success() should only be called at end of command, not within sections.'); + throw new Exception('Function finalMessage() should only be called at end of command, not within sections.'); } if (is_string($message)) { $message = [$message]; diff --git a/src/Type/EtagCalculator.php b/src/Type/EtagCalculator.php index 06e69175..553cf828 100644 --- a/src/Type/EtagCalculator.php +++ b/src/Type/EtagCalculator.php @@ -52,6 +52,16 @@ public function addUuid(UuidInterface $id): static return $this; } + public function addString(string $data): static + { + if ($this->finalEtag) { + throw new LogicException('Etag is already finalized, no new data can be added.'); + } + hash_update($this->hashContext, $data); + + return $this; + } + public function getEtag(): Etag { if ($this->finalEtag) { diff --git a/src/Type/EtagType.php b/src/Type/EtagType.php index 2a4323bf..1e755339 100644 --- a/src/Type/EtagType.php +++ b/src/Type/EtagType.php @@ -11,4 +11,5 @@ enum EtagType: string case CHILDREN_COLLECTION = 'children'; case INDEX_COLLECTION = 'index'; case RELATED_COLLECTION = 'related'; + case FILE = 'file'; } diff --git a/src/Type/FileProperty.php b/src/Type/FileProperty.php new file mode 100644 index 00000000..c3d341b1 --- /dev/null +++ b/src/Type/FileProperty.php @@ -0,0 +1,39 @@ + + */ + public function jsonSerialize(): array + { + return [ + 'extension' => $this->getExtension(), + ]; + } + + public function getExtension(): string + { + return $this->extension; + } + + public function setExtension(string $extension): static + { + $this->extension = $extension; + + return $this; + } +} diff --git a/src/Type/Lock/FileUploadCheckLock.php b/src/Type/Lock/FileUploadCheckLock.php deleted file mode 100644 index 7803710f..00000000 --- a/src/Type/Lock/FileUploadCheckLock.php +++ /dev/null @@ -1,71 +0,0 @@ -elementId; - } - - public function setElementId(UuidInterface $elementId): self - { - $this->elementId = $elementId; - - return $this; - } - - public function getUserId(): UuidInterface - { - return $this->userId; - } - - public function setUserId(UuidInterface $userId): self - { - $this->userId = $userId; - - return $this; - } - - public function getLockKey(): string - { - return sprintf('%s%s', RedisPrefixType::LOCK_FILE_UPLOAD_CHECK->value, $this->elementId); - } - - public function getLockValue(): string - { - return $this->userId->toString(); - } - - public function getLockTimeoutInSeconds(): ?int - { - return 30; - } - - public function isLocked(): bool - { - return $this->isLocked; - } - - public function setLocked(bool $locked): LockInterface - { - $this->isLocked = $locked; - - return $this; - } -} diff --git a/src/Type/RabbitMQQueueType.php b/src/Type/RabbitMQQueueType.php index e4ae3450..6fe3a7d8 100644 --- a/src/Type/RabbitMQQueueType.php +++ b/src/Type/RabbitMQQueueType.php @@ -6,5 +6,6 @@ enum RabbitMQQueueType: string { - case REBUILD_SEARCH_DOCUMENT_QUEUE = 'REBUILD_SEARCH_DOCUMENT'; + case ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE = 'ELASTICSEARCH_UPDATE_OWNERSHIP'; + case ELASTICSEARCH_REINDEX_FILE_QUEUE = 'ELASTICSEARCH_REINDEX_FILE'; } diff --git a/src/Type/RedisPrefixType.php b/src/Type/RedisPrefixType.php index 69deb7ea..a9120713 100644 --- a/src/Type/RedisPrefixType.php +++ b/src/Type/RedisPrefixType.php @@ -14,5 +14,5 @@ enum RedisPrefixType: string case ETAG_PARENTS_COLLECTION = 'etag:parents:'; case ETAG_RELATED_COLLECTION = 'etag:related:'; case ETAG_INDEX_COLLECTION = 'etag:index:'; - case LOCK_FILE_UPLOAD_CHECK = 'lock:file-upload-check:'; + case ETAG_FILE = 'etag:file:'; } diff --git a/src/Type/Request/PartialUploadRequest.php b/src/Type/Request/PartialUploadRequest.php new file mode 100644 index 00000000..5b2bebab --- /dev/null +++ b/src/Type/Request/PartialUploadRequest.php @@ -0,0 +1,50 @@ +content; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getUploadOffset(): int + { + return $this->uploadOffset; + } + + public function isUploadComplete(): ?bool + { + return $this->isUploadComplete; + } + + public function getContentLength(): ?int + { + return $this->contentLength; + } +} diff --git a/src/Type/Request/ResumableUploadRequest.php b/src/Type/Request/ResumableUploadRequest.php new file mode 100644 index 00000000..9507da3c --- /dev/null +++ b/src/Type/Request/ResumableUploadRequest.php @@ -0,0 +1,58 @@ +elementId; + } + + /** + * @return resource + */ + public function getContent() + { + return $this->content; + } + + public function isUploadComplete(): ?bool + { + return $this->isUploadComplete; + } + + public function getUploadLength(): ?int + { + return $this->uploadLength; + } + + public function getContentLength(): ?int + { + return $this->contentLength; + } + + public function getExtension(): string + { + return $this->extension; + } +} diff --git a/src/Type/Response/BinaryStreamResponse.php b/src/Type/Response/BinaryStreamResponse.php new file mode 100644 index 00000000..0df088dc --- /dev/null +++ b/src/Type/Response/BinaryStreamResponse.php @@ -0,0 +1,52 @@ +content = ''; + $stream = $object->getBody()->getContentAsResource(); + + $this->headers->set('Content-Length', (string) ($object->getContentLength() ?? 0)); + + // todo: use file's actual mime type, if available. otherwise fall back to current mime type? + $this->headers->set('Content-Type', 'application/octet-stream'); + + $disposition = $this->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $fileName, + $fileNameFallback + ); + $this->headers->set('Content-Disposition', $disposition); + + $this->setCallback(function () use ($stream): void { + while (!feof($stream)) { + $buffer = \Safe\fread($stream, self::STREAM_CHUNK_SIZE); + if (0 === strlen($buffer)) { + break; + } + echo $buffer; + flush(); + } + \Safe\fclose($stream); + }); + } + + public function setEtagFromEtagInstance(Etag $etag): static + { + return parent::setEtag((string) $etag); + } +} diff --git a/src/Type/Response/CollectionResponse.php b/src/Type/Response/CollectionResponse.php new file mode 100755 index 00000000..3847f889 --- /dev/null +++ b/src/Type/Response/CollectionResponse.php @@ -0,0 +1,16 @@ + $headers */ public function __construct( - ?string $location = '', + ?string $location = null, int $status = self::HTTP_CREATED, array $headers = [], ) { $this->charset = 'UTF-8'; - $headers['Location'] = $location; + if (null !== $location) { + $headers['Location'] = $location; + } parent::__construct(null, $status, $headers); } } diff --git a/src/Type/Response/ElementResponse.php b/src/Type/Response/ElementResponse.php new file mode 100755 index 00000000..8ecfff12 --- /dev/null +++ b/src/Type/Response/ElementResponse.php @@ -0,0 +1,16 @@ + $headers diff --git a/src/Response/ProblemJsonResponse.php b/src/Type/Response/ProblemJsonResponse.php similarity index 94% rename from src/Response/ProblemJsonResponse.php rename to src/Type/Response/ProblemJsonResponse.php index 56a34bd5..57b66840 100755 --- a/src/Response/ProblemJsonResponse.php +++ b/src/Type/Response/ProblemJsonResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Response; +namespace App\Type\Response; class ProblemJsonResponse extends JsonResponse { diff --git a/src/Response/TextResponse.php b/src/Type/Response/TextResponse.php similarity index 94% rename from src/Response/TextResponse.php rename to src/Type/Response/TextResponse.php index 76f61909..984370d8 100644 --- a/src/Response/TextResponse.php +++ b/src/Type/Response/TextResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Response; +namespace App\Type\Response; use Symfony\Component\HttpFoundation\Response; diff --git a/src/Type/S3/FileOperation.php b/src/Type/S3/FileOperation.php new file mode 100644 index 00000000..fa8a6640 --- /dev/null +++ b/src/Type/S3/FileOperation.php @@ -0,0 +1,26 @@ +bucket; + } + + public function getKey(): string + { + return $this->key; + } +} diff --git a/src/Type/S3/MergeFileChunksOperation.php b/src/Type/S3/MergeFileChunksOperation.php new file mode 100644 index 00000000..6f7a2c8c --- /dev/null +++ b/src/Type/S3/MergeFileChunksOperation.php @@ -0,0 +1,50 @@ + $uploadKeys + */ + public function __construct( + private string $uploadBucket, + private array $uploadKeys, + private string $storageBucket, + private ?string $previousStorageKey, + private string $storageKey, + ) { + } + + public function getUploadBucket(): string + { + return $this->uploadBucket; + } + + /** + * @return list + */ + public function getUploadKeys(): array + { + return $this->uploadKeys; + } + + public function getStorageBucket(): string + { + return $this->storageBucket; + } + + public function getPreviousStorageKey(): ?string + { + return $this->previousStorageKey; + } + + public function getStorageKey(): string + { + return $this->storageKey; + } +} diff --git a/src/Type/S3/UploadFileChunkOperation.php b/src/Type/S3/UploadFileChunkOperation.php new file mode 100644 index 00000000..103f0f55 --- /dev/null +++ b/src/Type/S3/UploadFileChunkOperation.php @@ -0,0 +1,50 @@ +uploadBucket; + } + + public function getUploadKey(): string + { + return $this->uploadKey; + } + + /** + * @return resource + */ + public function getContent(): mixed + { + return $this->content; + } + + public function getContentLength(): ?int + { + return $this->contentLength; + } + + public function getMimeType(): string + { + return $this->mimeType; + } +} diff --git a/src/Type/S3/UploadFileOperation.php b/src/Type/S3/UploadFileOperation.php new file mode 100644 index 00000000..8e2a1322 --- /dev/null +++ b/src/Type/S3/UploadFileOperation.php @@ -0,0 +1,68 @@ +uploadBucket; + } + + public function getUploadKey(): string + { + return $this->uploadKey; + } + + public function getStorageBucket(): string + { + return $this->storageBucket; + } + + public function getPreviousStorageKey(): ?string + { + return $this->previousStorageKey; + } + + public function getStorageKey(): string + { + return $this->storageKey; + } + + /** + * @return resource + */ + public function getContent(): mixed + { + return $this->content; + } + + public function getContentLength(): ?int + { + return $this->contentLength; + } + + public function getMimeType(): string + { + return $this->mimeType; + } +} diff --git a/src/Type/TusIoExtensionType.php b/src/Type/TusIoExtensionType.php deleted file mode 100644 index 5c0bbc2c..00000000 --- a/src/Type/TusIoExtensionType.php +++ /dev/null @@ -1,16 +0,0 @@ -variant; - } - - public function setVariant(UploadVariantType $variant): self - { - $this->variant = $variant; - - return $this; - } - - public function getVariantVersion(): UploadVariantVersionType - { - return $this->variantVersion; - } - - public function setVariantVersion(UploadVariantVersionType $variantVersion): self - { - $this->variantVersion = $variantVersion; - - return $this; - } - - public function getConcat(): ?UploadConcatType - { - return $this->concat; + public function __construct( + private UuidInterface $id, + private ?int $uploadLength, + private int $uploadOffset, + private bool $uploadComplete, + private UuidInterface $uploadTarget, + private int $alreadyUploadedChunks, + private UuidInterface $uploadOwner, + private string $extension, + private DateTime $expires, + ) { } - public function setConcat(?UploadConcatType $concat): self + public function getId(): UuidInterface { - $this->concat = $concat; - - return $this; + return $this->id; } public function getUploadLength(): ?int @@ -64,70 +36,38 @@ public function getUploadLength(): ?int return $this->uploadLength; } - public function setUploadLength(?int $uploadLength): self - { - $this->uploadLength = $uploadLength; - - return $this; - } - - public function isUploadLengthDeferred(): bool - { - return $this->isUploadLengthDeferred; - } - - public function setIsUploadLengthDeferred(bool $isUploadLengthDeferred): self - { - $this->isUploadLengthDeferred = $isUploadLengthDeferred; - - return $this; - } - public function getUploadOffset(): int { return $this->uploadOffset; } - public function setUploadOffset(int $uploadOffset): self + public function isUploadComplete(): bool { - $this->uploadOffset = $uploadOffset; - - return $this; + return $this->uploadComplete; } - public function getCreated(): DateTime + public function getUploadTarget(): UuidInterface { - return $this->created; + return $this->uploadTarget; } - public function setCreated(DateTime $created): self + public function getAlreadyUploadedChunks(): int { - $this->created = $created; - - return $this; + return $this->alreadyUploadedChunks; } - public function getUpdated(): ?DateTime + public function getUploadOwner(): UuidInterface { - return $this->updated; + return $this->uploadOwner; } - public function setUpdated(?DateTime $updated): self + public function getExtension(): string { - $this->updated = $updated; - - return $this; + return $this->extension; } - public function getExpires(): ?DateTime + public function getExpires(): DateTime { return $this->expires; } - - public function setExpires(?DateTime $expires): self - { - $this->expires = $expires; - - return $this; - } } diff --git a/src/Type/UploadConcatType.php b/src/Type/UploadConcatType.php deleted file mode 100644 index 2c663ce2..00000000 --- a/src/Type/UploadConcatType.php +++ /dev/null @@ -1,11 +0,0 @@ -getFilename(); + } +} diff --git a/src/Wrapper/S3ClientWrapper.php b/src/Wrapper/S3ClientWrapper.php new file mode 100644 index 00000000..e5b99f30 --- /dev/null +++ b/src/Wrapper/S3ClientWrapper.php @@ -0,0 +1,27 @@ +isSuccess(); + } + + public function resolveCopyObjectOutput(CopyObjectOutput $copyObjectOutput): bool + { + return $copyObjectOutput->resolve(); + } +} diff --git a/taskfile.yml b/taskfile.yml index c7d7dc63..6ae30ba7 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -17,3 +17,7 @@ tasks: dev:cli: cmds: - docker compose -f ./tools/docker-compose.yml exec api /bin/bash + + generate:swagger: + cmds: + - ./tools/generate-swagger.sh \ No newline at end of file diff --git a/tests/.php-cs-fixer.dist.php b/tests/.php-cs-fixer.dist.php index 096026fd..3173a4b5 100644 --- a/tests/.php-cs-fixer.dist.php +++ b/tests/.php-cs-fixer.dist.php @@ -7,6 +7,7 @@ ; return (new PhpCsFixer\Config()) + ->setUnsupportedPhpVersionAllowed(true) ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) ->setUnsupportedPhpVersionAllowed(true) ->setRules([ diff --git a/tests/ExampleGenerationCommand/Backup/BackupCreateTest.php b/tests/ExampleGenerationCommand/Backup/BackupCreateTest.php index 45508aac..2e06f763 100644 --- a/tests/ExampleGenerationCommand/Backup/BackupCreateTest.php +++ b/tests/ExampleGenerationCommand/Backup/BackupCreateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\Backup; +namespace App\Tests\ExampleGenerationCommand\Backup; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php b/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php index be542a50..81f75e03 100644 --- a/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php +++ b/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\Backup; +namespace App\Tests\ExampleGenerationCommand\Backup; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/Backup/BackupListTest.php b/tests/ExampleGenerationCommand/Backup/BackupListTest.php index 2491d972..95be995a 100644 --- a/tests/ExampleGenerationCommand/Backup/BackupListTest.php +++ b/tests/ExampleGenerationCommand/Backup/BackupListTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\Backup; +namespace App\Tests\ExampleGenerationCommand\Backup; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/Backup/BackupLoadTest.php b/tests/ExampleGenerationCommand/Backup/BackupLoadTest.php index 092c9c28..8da8f677 100644 --- a/tests/ExampleGenerationCommand/Backup/BackupLoadTest.php +++ b/tests/ExampleGenerationCommand/Backup/BackupLoadTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\Backup; +namespace App\Tests\ExampleGenerationCommand\Backup; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/BaseCommandTestCase.php b/tests/ExampleGenerationCommand/BaseCommandTestCase.php index 8f0c1d1a..a26a691b 100644 --- a/tests/ExampleGenerationCommand/BaseCommandTestCase.php +++ b/tests/ExampleGenerationCommand/BaseCommandTestCase.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand; +namespace App\Tests\ExampleGenerationCommand; use Exception; use PHPUnit\Framework\TestCase; diff --git a/tests/ExampleGenerationCommand/Cache/CacheClearEtagTest.php b/tests/ExampleGenerationCommand/Cache/CacheClearEtagTest.php index d5a55a30..dfb46e00 100644 --- a/tests/ExampleGenerationCommand/Cache/CacheClearEtagTest.php +++ b/tests/ExampleGenerationCommand/Cache/CacheClearEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\Cache; +namespace App\Tests\ExampleGenerationCommand\Cache; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/Database/DatabaseDropTest.php b/tests/ExampleGenerationCommand/Database/DatabaseDropTest.php index 9aedc63c..1a00dab8 100644 --- a/tests/ExampleGenerationCommand/Database/DatabaseDropTest.php +++ b/tests/ExampleGenerationCommand/Database/DatabaseDropTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\Database; +namespace App\Tests\ExampleGenerationCommand\Database; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/Other/ConfigDumpReferenceEmberNexusTest.php b/tests/ExampleGenerationCommand/Other/ConfigDumpReferenceEmberNexusTest.php index 03acf2ad..43079560 100644 --- a/tests/ExampleGenerationCommand/Other/ConfigDumpReferenceEmberNexusTest.php +++ b/tests/ExampleGenerationCommand/Other/ConfigDumpReferenceEmberNexusTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\Other; +namespace App\Tests\ExampleGenerationCommand\Other; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/System/SystemHealthcheckTest.php b/tests/ExampleGenerationCommand/System/SystemHealthcheckTest.php index e63666cb..8130dd59 100644 --- a/tests/ExampleGenerationCommand/System/SystemHealthcheckTest.php +++ b/tests/ExampleGenerationCommand/System/SystemHealthcheckTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\System; +namespace App\Tests\ExampleGenerationCommand\System; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/Token/TokenCreateTest.php b/tests/ExampleGenerationCommand/Token/TokenCreateTest.php index ea2c6ed0..cb4fcbe5 100644 --- a/tests/ExampleGenerationCommand/Token/TokenCreateTest.php +++ b/tests/ExampleGenerationCommand/Token/TokenCreateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\Token; +namespace App\Tests\ExampleGenerationCommand\Token; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php b/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php index f3dc26e4..1bd6044e 100644 --- a/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php +++ b/tests/ExampleGenerationCommand/Token/TokenRevokeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\Token; +namespace App\Tests\ExampleGenerationCommand\Token; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/User/UserCreateTest.php b/tests/ExampleGenerationCommand/User/UserCreateTest.php index 7a38edb7..00319462 100644 --- a/tests/ExampleGenerationCommand/User/UserCreateTest.php +++ b/tests/ExampleGenerationCommand/User/UserCreateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationCommand\User; +namespace App\Tests\ExampleGenerationCommand\User; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; diff --git a/tests/ExampleGenerationCommand/docker-compose.yml b/tests/ExampleGenerationCommand/docker-compose.yml index 1750be69..f50cadf0 100644 --- a/tests/ExampleGenerationCommand/docker-compose.yml +++ b/tests/ExampleGenerationCommand/docker-compose.yml @@ -53,3 +53,37 @@ services: environment: - RABBITMQ_DEFAULT_USER=user - RABBITMQ_DEFAULT_PASS=password + + minio: + container_name: minio + image: quay.io/minio/minio:latest + expose: + - "9000" + - "9001" + environment: + - MINIO_ROOT_USER=ROOTNAME + - MINIO_ROOT_PASSWORD=CHANGEME123 + command: server /data --console-address ":9001" + + minio-init: + image: quay.io/minio/mc:latest + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set minio http://minio:9000 ROOTNAME CHANGEME123; do + echo 'Waiting for MinIO...'; + sleep 1; + done; + + mc mb --ignore-existing minio/api-storage; + mc mb --ignore-existing minio/api-upload; + + mc anonymous set none minio/api-storage; + mc anonymous set none minio/api-upload; + + mc admin user add minio accessKey secretKey || true; + mc admin policy attach minio readwrite --user accessKey; + + echo 'MinIO buckets and user configured'; + " diff --git a/tests/ExampleGenerationController/BaseRequestTestCase.php b/tests/ExampleGenerationController/BaseRequestTestCase.php index 0885274e..21df66c0 100644 --- a/tests/ExampleGenerationController/BaseRequestTestCase.php +++ b/tests/ExampleGenerationController/BaseRequestTestCase.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController; +namespace App\Tests\ExampleGenerationController; use Psr\Http\Message\ResponseInterface; diff --git a/tests/ExampleGenerationController/Element/DeleteElementTest.php b/tests/ExampleGenerationController/Element/DeleteElementTest.php index e35eda82..eb250531 100644 --- a/tests/ExampleGenerationController/Element/DeleteElementTest.php +++ b/tests/ExampleGenerationController/Element/DeleteElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Element/GetChildrenTest.php b/tests/ExampleGenerationController/Element/GetChildrenTest.php index c6dc0128..7914cfd5 100644 --- a/tests/ExampleGenerationController/Element/GetChildrenTest.php +++ b/tests/ExampleGenerationController/Element/GetChildrenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Element/GetElementTest.php b/tests/ExampleGenerationController/Element/GetElementTest.php index 03088dcc..7ad30f82 100644 --- a/tests/ExampleGenerationController/Element/GetElementTest.php +++ b/tests/ExampleGenerationController/Element/GetElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Element/GetIndexTest.php b/tests/ExampleGenerationController/Element/GetIndexTest.php index a968c2df..8d864b08 100644 --- a/tests/ExampleGenerationController/Element/GetIndexTest.php +++ b/tests/ExampleGenerationController/Element/GetIndexTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Element/GetParentsTest.php b/tests/ExampleGenerationController/Element/GetParentsTest.php index 7c418313..936fddc1 100644 --- a/tests/ExampleGenerationController/Element/GetParentsTest.php +++ b/tests/ExampleGenerationController/Element/GetParentsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Element/GetRelatedTest.php b/tests/ExampleGenerationController/Element/GetRelatedTest.php index c9c90152..166a5219 100644 --- a/tests/ExampleGenerationController/Element/GetRelatedTest.php +++ b/tests/ExampleGenerationController/Element/GetRelatedTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Element/PatchElementTest.php b/tests/ExampleGenerationController/Element/PatchElementTest.php index a166e7b3..96e4842a 100644 --- a/tests/ExampleGenerationController/Element/PatchElementTest.php +++ b/tests/ExampleGenerationController/Element/PatchElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Element/PostElementTest.php b/tests/ExampleGenerationController/Element/PostElementTest.php index 65550198..bc7e1533 100644 --- a/tests/ExampleGenerationController/Element/PostElementTest.php +++ b/tests/ExampleGenerationController/Element/PostElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Element/PostIndexTest.php b/tests/ExampleGenerationController/Element/PostIndexTest.php index 0727c9c9..66e29a17 100644 --- a/tests/ExampleGenerationController/Element/PostIndexTest.php +++ b/tests/ExampleGenerationController/Element/PostIndexTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Element/PutElementTest.php b/tests/ExampleGenerationController/Element/PutElementTest.php index faece49b..3d21de84 100644 --- a/tests/ExampleGenerationController/Element/PutElementTest.php +++ b/tests/ExampleGenerationController/Element/PutElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Element; +namespace App\Tests\ExampleGenerationController\Element; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get400BadContentTest.php b/tests/ExampleGenerationController/Error/Get400BadContentTest.php index 5c24ce00..cd3c4522 100644 --- a/tests/ExampleGenerationController/Error/Get400BadContentTest.php +++ b/tests/ExampleGenerationController/Error/Get400BadContentTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get400ForbiddenPropertyTest.php b/tests/ExampleGenerationController/Error/Get400ForbiddenPropertyTest.php index 9108b929..49ebe358 100644 --- a/tests/ExampleGenerationController/Error/Get400ForbiddenPropertyTest.php +++ b/tests/ExampleGenerationController/Error/Get400ForbiddenPropertyTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get400IncompleteMutualDependencyTest.php b/tests/ExampleGenerationController/Error/Get400IncompleteMutualDependencyTest.php index 0ebb61f9..26c45830 100644 --- a/tests/ExampleGenerationController/Error/Get400IncompleteMutualDependencyTest.php +++ b/tests/ExampleGenerationController/Error/Get400IncompleteMutualDependencyTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get400MissingPropertyTest.php b/tests/ExampleGenerationController/Error/Get400MissingPropertyTest.php index a4b1d0e7..0163ce50 100644 --- a/tests/ExampleGenerationController/Error/Get400MissingPropertyTest.php +++ b/tests/ExampleGenerationController/Error/Get400MissingPropertyTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get400ReservedIdentifierTest.php b/tests/ExampleGenerationController/Error/Get400ReservedIdentifierTest.php index 8d4d73cc..bb3a2a5a 100644 --- a/tests/ExampleGenerationController/Error/Get400ReservedIdentifierTest.php +++ b/tests/ExampleGenerationController/Error/Get400ReservedIdentifierTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get401UnauthorizedTest.php b/tests/ExampleGenerationController/Error/Get401UnauthorizedTest.php index 307db492..8e868ffc 100644 --- a/tests/ExampleGenerationController/Error/Get401UnauthorizedTest.php +++ b/tests/ExampleGenerationController/Error/Get401UnauthorizedTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get403ForbiddenTest.php b/tests/ExampleGenerationController/Error/Get403ForbiddenTest.php index ba64204a..491ea5d5 100644 --- a/tests/ExampleGenerationController/Error/Get403ForbiddenTest.php +++ b/tests/ExampleGenerationController/Error/Get403ForbiddenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get404NotFoundTest.php b/tests/ExampleGenerationController/Error/Get404NotFoundTest.php index fd4e64ac..f8df1ca4 100644 --- a/tests/ExampleGenerationController/Error/Get404NotFoundTest.php +++ b/tests/ExampleGenerationController/Error/Get404NotFoundTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get412PreconditionFailedTest.php b/tests/ExampleGenerationController/Error/Get412PreconditionFailedTest.php index 78a661eb..a3ad2a14 100644 --- a/tests/ExampleGenerationController/Error/Get412PreconditionFailedTest.php +++ b/tests/ExampleGenerationController/Error/Get412PreconditionFailedTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get500InternalServerErrorTest.php b/tests/ExampleGenerationController/Error/Get500InternalServerErrorTest.php index 49eeff96..cec18f79 100644 --- a/tests/ExampleGenerationController/Error/Get500InternalServerErrorTest.php +++ b/tests/ExampleGenerationController/Error/Get500InternalServerErrorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Error/Get501NotImplementedTest.php b/tests/ExampleGenerationController/Error/Get501NotImplementedTest.php index 4f788be9..aa1784e5 100644 --- a/tests/ExampleGenerationController/Error/Get501NotImplementedTest.php +++ b/tests/ExampleGenerationController/Error/Get501NotImplementedTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Error; +namespace App\Tests\ExampleGenerationController\Error; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/CypherPathSubset/FindNestedElementsTest.php b/tests/ExampleGenerationController/Search/CypherPathSubset/FindNestedElementsTest.php index 8d780b0f..47660605 100644 --- a/tests/ExampleGenerationController/Search/CypherPathSubset/FindNestedElementsTest.php +++ b/tests/ExampleGenerationController/Search/CypherPathSubset/FindNestedElementsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\CypherPathSubset; +namespace App\Tests\ExampleGenerationController\Search\CypherPathSubset; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/CypherPathSubset/FindTaggedElementsTest.php b/tests/ExampleGenerationController/Search/CypherPathSubset/FindTaggedElementsTest.php index 225bf298..5dbc2eb6 100644 --- a/tests/ExampleGenerationController/Search/CypherPathSubset/FindTaggedElementsTest.php +++ b/tests/ExampleGenerationController/Search/CypherPathSubset/FindTaggedElementsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\CypherPathSubset; +namespace App\Tests\ExampleGenerationController\Search\CypherPathSubset; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/AdvancedFullTextSearchTest.php b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/AdvancedFullTextSearchTest.php index aa60aa7f..0599825e 100644 --- a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/AdvancedFullTextSearchTest.php +++ b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/AdvancedFullTextSearchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; +namespace App\Tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/KeywordSearchTest.php b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/KeywordSearchTest.php index df669e77..7683fcce 100644 --- a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/KeywordSearchTest.php +++ b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/KeywordSearchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; +namespace App\Tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/ParameterizedElasticsearchQueryDSLMixinSearchTest.php b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/ParameterizedElasticsearchQueryDSLMixinSearchTest.php index fc1d3a84..660bd778 100644 --- a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/ParameterizedElasticsearchQueryDSLMixinSearchTest.php +++ b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/ParameterizedElasticsearchQueryDSLMixinSearchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; +namespace App\Tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/QueryStringSearchTest.php b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/QueryStringSearchTest.php index 720b7eb1..d2517d35 100644 --- a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/QueryStringSearchTest.php +++ b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/QueryStringSearchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; +namespace App\Tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/SimpleFullTextSearchTest.php b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/SimpleFullTextSearchTest.php index 00f16348..98b99306 100644 --- a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/SimpleFullTextSearchTest.php +++ b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/SimpleFullTextSearchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; +namespace App\Tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/TextPrefixSearchTest.php b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/TextPrefixSearchTest.php index 4115e5b7..826625b4 100644 --- a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/TextPrefixSearchTest.php +++ b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/TextPrefixSearchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; +namespace App\Tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/WildcardSearchTest.php b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/WildcardSearchTest.php index d42be619..e824dd69 100644 --- a/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/WildcardSearchTest.php +++ b/tests/ExampleGenerationController/Search/ElasticsearchQueryDslMixin/WildcardSearchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; +namespace App\Tests\ExampleGenerationController\Search\ElasticsearchQueryDslMixin; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElementHydration/ExplicitElementIdsHydrationTest.php b/tests/ExampleGenerationController/Search/ElementHydration/ExplicitElementIdsHydrationTest.php index b7616d39..08f63135 100644 --- a/tests/ExampleGenerationController/Search/ElementHydration/ExplicitElementIdsHydrationTest.php +++ b/tests/ExampleGenerationController/Search/ElementHydration/ExplicitElementIdsHydrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElementHydration; +namespace App\Tests\ExampleGenerationController\Search\ElementHydration; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElementHydration/ImplicitElementsHydrationTest.php b/tests/ExampleGenerationController/Search/ElementHydration/ImplicitElementsHydrationTest.php index f14a9eae..757c8501 100644 --- a/tests/ExampleGenerationController/Search/ElementHydration/ImplicitElementsHydrationTest.php +++ b/tests/ExampleGenerationController/Search/ElementHydration/ImplicitElementsHydrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElementHydration; +namespace App\Tests\ExampleGenerationController\Search\ElementHydration; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElementHydration/ImplicitPathsHydrationTest.php b/tests/ExampleGenerationController/Search/ElementHydration/ImplicitPathsHydrationTest.php index f1fbb07d..f98d6fc6 100644 --- a/tests/ExampleGenerationController/Search/ElementHydration/ImplicitPathsHydrationTest.php +++ b/tests/ExampleGenerationController/Search/ElementHydration/ImplicitPathsHydrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElementHydration; +namespace App\Tests\ExampleGenerationController\Search\ElementHydration; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/ElementHydration/ParameterizedElementIdsHydrationTest.php b/tests/ExampleGenerationController/Search/ElementHydration/ParameterizedElementIdsHydrationTest.php index e7603a14..4fd78905 100644 --- a/tests/ExampleGenerationController/Search/ElementHydration/ParameterizedElementIdsHydrationTest.php +++ b/tests/ExampleGenerationController/Search/ElementHydration/ParameterizedElementIdsHydrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\ElementHydration; +namespace App\Tests\ExampleGenerationController\Search\ElementHydration; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/Polyglot/CypherPathEsQueryDslMixinElementHydrationTest.php b/tests/ExampleGenerationController/Search/Polyglot/CypherPathEsQueryDslMixinElementHydrationTest.php index 18d2b5ad..48a4d5f0 100644 --- a/tests/ExampleGenerationController/Search/Polyglot/CypherPathEsQueryDslMixinElementHydrationTest.php +++ b/tests/ExampleGenerationController/Search/Polyglot/CypherPathEsQueryDslMixinElementHydrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\Polyglot; +namespace App\Tests\ExampleGenerationController\Search\Polyglot; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/Search/Polyglot/EsQueryDslMixinCypherPathElementHydrationTest.php b/tests/ExampleGenerationController/Search/Polyglot/EsQueryDslMixinCypherPathElementHydrationTest.php index b311c9f9..a355ac9b 100644 --- a/tests/ExampleGenerationController/Search/Polyglot/EsQueryDslMixinCypherPathElementHydrationTest.php +++ b/tests/ExampleGenerationController/Search/Polyglot/EsQueryDslMixinCypherPathElementHydrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\Search\Polyglot; +namespace App\Tests\ExampleGenerationController\Search\Polyglot; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/System/AGetInstanceConfigurationTest.php b/tests/ExampleGenerationController/System/AGetInstanceConfigurationTest.php index a508bc37..f583e69c 100644 --- a/tests/ExampleGenerationController/System/AGetInstanceConfigurationTest.php +++ b/tests/ExampleGenerationController/System/AGetInstanceConfigurationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\System; +namespace App\Tests\ExampleGenerationController\System; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/System/GetGraphStructureTest.php b/tests/ExampleGenerationController/System/GetGraphStructureTest.php index ee368ad7..7397e0c8 100644 --- a/tests/ExampleGenerationController/System/GetGraphStructureTest.php +++ b/tests/ExampleGenerationController/System/GetGraphStructureTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\System; +namespace App\Tests\ExampleGenerationController\System; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/System/GetWellKnownSecurityTxtTest.php b/tests/ExampleGenerationController/System/GetWellKnownSecurityTxtTest.php index 2b526369..82e8592a 100644 --- a/tests/ExampleGenerationController/System/GetWellKnownSecurityTxtTest.php +++ b/tests/ExampleGenerationController/System/GetWellKnownSecurityTxtTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\System; +namespace App\Tests\ExampleGenerationController\System; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/User/DeleteTokenTest.php b/tests/ExampleGenerationController/User/DeleteTokenTest.php index 50b03fc8..96de9a26 100644 --- a/tests/ExampleGenerationController/User/DeleteTokenTest.php +++ b/tests/ExampleGenerationController/User/DeleteTokenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\User; +namespace App\Tests\ExampleGenerationController\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/User/GetMeTest.php b/tests/ExampleGenerationController/User/GetMeTest.php index e628fee7..b108bdad 100644 --- a/tests/ExampleGenerationController/User/GetMeTest.php +++ b/tests/ExampleGenerationController/User/GetMeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\User; +namespace App\Tests\ExampleGenerationController\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/User/GetTokenTest.php b/tests/ExampleGenerationController/User/GetTokenTest.php index 41d81078..4bcc8f55 100644 --- a/tests/ExampleGenerationController/User/GetTokenTest.php +++ b/tests/ExampleGenerationController/User/GetTokenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\User; +namespace App\Tests\ExampleGenerationController\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/User/PostChangePasswordOldTest.php b/tests/ExampleGenerationController/User/PostChangePasswordOldTest.php index bf1483e9..6c344278 100644 --- a/tests/ExampleGenerationController/User/PostChangePasswordOldTest.php +++ b/tests/ExampleGenerationController/User/PostChangePasswordOldTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\User; +namespace App\Tests\ExampleGenerationController\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/ExampleGenerationController/User/PostChangePasswordTest.php b/tests/ExampleGenerationController/User/PostChangePasswordTest.php index 8e6b2b3b..67f806b1 100644 --- a/tests/ExampleGenerationController/User/PostChangePasswordTest.php +++ b/tests/ExampleGenerationController/User/PostChangePasswordTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\User; +namespace App\Tests\ExampleGenerationController\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/User/PostRegisterOldTest.php b/tests/ExampleGenerationController/User/PostRegisterOldTest.php index 8fedd909..8827ed27 100644 --- a/tests/ExampleGenerationController/User/PostRegisterOldTest.php +++ b/tests/ExampleGenerationController/User/PostRegisterOldTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\User; +namespace App\Tests\ExampleGenerationController\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/ExampleGenerationController/User/PostRegisterTest.php b/tests/ExampleGenerationController/User/PostRegisterTest.php index 6c1f2955..fdaba420 100644 --- a/tests/ExampleGenerationController/User/PostRegisterTest.php +++ b/tests/ExampleGenerationController/User/PostRegisterTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\User; +namespace App\Tests\ExampleGenerationController\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/User/PostTokenOldTest.php b/tests/ExampleGenerationController/User/PostTokenOldTest.php index afa4436b..d5726ac2 100644 --- a/tests/ExampleGenerationController/User/PostTokenOldTest.php +++ b/tests/ExampleGenerationController/User/PostTokenOldTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\User; +namespace App\Tests\ExampleGenerationController\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/ExampleGenerationController/User/PostTokenTest.php b/tests/ExampleGenerationController/User/PostTokenTest.php index 92c00686..05e6955e 100644 --- a/tests/ExampleGenerationController/User/PostTokenTest.php +++ b/tests/ExampleGenerationController/User/PostTokenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationController\User; +namespace App\Tests\ExampleGenerationController\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationController/docker-compose.yml b/tests/ExampleGenerationController/docker-compose.yml index 7164d784..531f529b 100644 --- a/tests/ExampleGenerationController/docker-compose.yml +++ b/tests/ExampleGenerationController/docker-compose.yml @@ -62,3 +62,37 @@ services: environment: - RABBITMQ_DEFAULT_USER=user - RABBITMQ_DEFAULT_PASS=password + + minio: + container_name: minio + image: quay.io/minio/minio:latest + expose: + - "9000" + - "9001" + environment: + - MINIO_ROOT_USER=ROOTNAME + - MINIO_ROOT_PASSWORD=CHANGEME123 + command: server /data --console-address ":9001" + + minio-init: + image: quay.io/minio/mc:latest + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set minio http://minio:9000 ROOTNAME CHANGEME123; do + echo 'Waiting for MinIO...'; + sleep 1; + done; + + mc mb --ignore-existing minio/api-storage; + mc mb --ignore-existing minio/api-upload; + + mc anonymous set none minio/api-storage; + mc anonymous set none minio/api-upload; + + mc admin user add minio accessKey secretKey || true; + mc admin policy attach minio readwrite --user accessKey; + + echo 'MinIO buckets and user configured'; + " diff --git a/tests/ExampleGenerationControllerWithDifferentConfiguration/System/GetGraphStructureTest.php b/tests/ExampleGenerationControllerWithDifferentConfiguration/System/GetGraphStructureTest.php index 513698b6..ae34b965 100644 --- a/tests/ExampleGenerationControllerWithDifferentConfiguration/System/GetGraphStructureTest.php +++ b/tests/ExampleGenerationControllerWithDifferentConfiguration/System/GetGraphStructureTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationControllerWithDifferentConfiguration\System; +namespace App\Tests\ExampleGenerationControllerWithDifferentConfiguration\System; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationControllerWithDifferentConfiguration/System/GetInstanceConfigurationTest.php b/tests/ExampleGenerationControllerWithDifferentConfiguration/System/GetInstanceConfigurationTest.php index b133c0ad..e18e5f44 100644 --- a/tests/ExampleGenerationControllerWithDifferentConfiguration/System/GetInstanceConfigurationTest.php +++ b/tests/ExampleGenerationControllerWithDifferentConfiguration/System/GetInstanceConfigurationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationControllerWithDifferentConfiguration\System; +namespace App\Tests\ExampleGenerationControllerWithDifferentConfiguration\System; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/ExampleGenerationControllerWithDifferentConfiguration/User/PostRegisterTest.php b/tests/ExampleGenerationControllerWithDifferentConfiguration/User/PostRegisterTest.php index ce69bd70..00b31080 100644 --- a/tests/ExampleGenerationControllerWithDifferentConfiguration/User/PostRegisterTest.php +++ b/tests/ExampleGenerationControllerWithDifferentConfiguration/User/PostRegisterTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\ExampleGenerationControllerWithDifferentConfiguration\User; +namespace App\Tests\ExampleGenerationControllerWithDifferentConfiguration\User; use App\Tests\ExampleGenerationController\BaseRequestTestCase; diff --git a/tests/FeatureTests/Asset/.gitignore b/tests/FeatureTests/Asset/.gitignore new file mode 100644 index 00000000..2211df63 --- /dev/null +++ b/tests/FeatureTests/Asset/.gitignore @@ -0,0 +1 @@ +*.txt diff --git a/tests/FeatureTests/Asset/cherry-blossoms.jpg b/tests/FeatureTests/Asset/cherry-blossoms.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5e800c378d0a5efb1bb741e1336786a3faedd68c GIT binary patch literal 63933 zcmb4KRZv_(n;qQU-F0yH;4rwm1_=arCpf{~9R_z82p$5#Cg?zr!DX-j0RjX9NjCph z?ZfWdo`?I;U)R@N-F5q(bH3Yu*Z*z;lmO_cXy|CD=;&zZ7#Qf7*o4^FSXkJk_yo9w z6r_}t6r|+j)O2hN)HE!#uooC0i|T%7+&f`oyAfsKhxf{jhW zNli}8`Txe>Q2-Gpaxuy&3KBB_nFtAm2}GV;HMe&Hb$`#jcsbq~}^ zH08o_>UL}Zi4td*Ftwt6K9kCH*=-y#*jAEJBLZ9+9o_2=6je4KPy5$9Sp*MRwN^55K4dOW0+^5MY z;y$HaHk*H3Gv!k(5a?U;(R?f{wsLfY#CRATo#6kX0XfcJjdwMrwh^%AE>HZ%=p1Ry zdx%7+&Yq|-%6=*8mVA)mJ`q_rw$hZQk`duM{|m6&W^o9}vSf4K0okDiqE}!spypbo z!Zg2L{07&u&W8{5SXjs#rY;$!i!yHuuF8~U7p46n){he>^{>OE$3s<482Nw)0 zAobjXxb9>L)MZDFX_aklapHpE%P_~v8jx#V5oypRm$0Y~SA1ir(SASZ$XR6gyZ&t# zD5yt;x!1hcQv9t)@trYeX>4@{zUrt&R*KW(k59u2o9=V>gsXw6zYUx~t;i&)Am0ykTKaZf6EDS3| z86F$I510HEHWy>Q7k_r+H|IYiBD5wF@`s`4e%#3Up4rD0 zW8XP+Qo-rcG#zpZzH-YFev{GQAbFj&ZR=+wDfxn41Zy2OT+WU8-lHp+I+xgA!shr3 zoUJ6B@T7gGQ*Cy=Rsm__)6wN<7^paXknlGQ>}+BZ#jz&g_)WG1AeA{L#*>Dy$hMeI1dzgvOQD_vnqY0af)YHkfJ@64v_`rz|0 z#l3V>eWuSAR*pr$@FuN)uV9;4YiPLBiq4v-@97T~j{_*lc8}ebuVrOH1?_hYvKD{# zGYpJ4EKMspeW&=bX^r6wjQCfCr<0V=V=(^%8co5}gE47HYo|~kz~tZ*uY^`B3P9dc zozv&rEg?if)bs8=7E-`;i(hsUZD26`LTf;091m7mxt9|6Lq&W8l$Fp1vry2Y^2Kdf zz$7l?vjOq)m;^1hb2)2;rPV+&ayuA&_j@*$csgPX)J=jHV0F=B6F@Cr|D2m%@obe8 ztM{KNOTz0m=<_&YmBXu822@>o005kp0I3`~{J59I@)htwjL!?(8){%dP6qazOYD|{ z(!IKzzB86NK^`t$ftg>TfS2poo=Fh(>t*F;%&zwAXt5LmLr6PSG;M8!&0gFSmmOob zjP;Yo?C%sS{}RYh`x+Oz5|JF#wO4IGjS?OyM=aF!zCS7!oO<&cIyVk7w+shYccmU{ z;rYL?D5Z5EyWYg|)^*xl-8fj%?iGZtU0 z#G;Tp-s+MXb5IXH5`BU`OAQST4Pw#T*w~m&6%-WY3nKm@HC+BEAx8%c83v`&M6`VS z!CE5U@QZDCcvHQK|4rnF)83hHfn3SG;ZM$p6+(vh@1P!-V-LP~K*fqmSB!l@GLHjn}7Wbr{fFp2?~kplubZW#EI&&X~L z>JtPZ&=w5rIpU}Rpd>$lhAsnKURz&|lpdx~S%93J*zLs6>aWq|&&Rj-7vMy3DBU}) zrpo^Rn2rS3mDJ{8Zz zi&~X8a*pm})hGz*N%u7y{8n7;-&767la?}wl)v@>)sUwL^CH;t_!&uKZ%cQuZLBt3 zugDYMi{vYp6AR6lUd794y5ABfnDkxbo??2f=-5x5Aw7T`CT%<+?VaZOQN+fiMd-|z z`|1L>rS3{5wPznT)Z35~wJVLvPuLkV`u_qxfD-M025XEZC((NFbAH6$@hm^@$Sxoo zyX(*>DlG5=NSTm~9Y2C9wrva<1&7#|=%G7E`^E?KvR{W7Ak;YBNu>{>IDx}Fp(^n@ z3Le&7ew~2;<|cUaxrF0p4H~%F1#WdKn`7?z=8UFlEB_ZY)sOHNFVM*AlZvw-)};(-}vQa&1M{zAn#_9GTV+eZ*UP2c6E*-r>bI6517S&mQLq94mPl>a^@K z2bUNfI?T7kT5L(=D=Bz*l+Vu8Wb8*{$itfVdd;@h#6DTf7B+qLw6dO)Qdw5qziw|1 zH7exy?}**F=-7~zhh%CzuYHvj(9K|M$NBLWu(GcEc{aGlq$C5e`q&YSt-XT(P#^!b z0`)SC)@|CT{R*`qQ6Kj9gUzHSUZU}obD*?u6`*H#V2G>?Y5j-KSWwM9$JjDX+T@ku z8!5o!ZxbYt(}_}jm0))nhQnMwnfq3LU$5DtOp_JmWxXV*sBVlyuB->hlr% zB`9?T0zY`O&fwddBDM+rG+wZN-s6NGfp2Ph%ywWKfV*^7))Hj;Wn|yA<>i|fP`rpM z>*THY+%U@L)Heg(CN*FP;bJ#ss}#_9>C>}*`J`s`gM@+|BNHeklB|x$dbT?GQ64pK zNq|MTZTusa>va@0juHz1^o1%iM?yi>*^Fy4hL4qF$kk#dY3qu_tk05Ky`}>~Q}DYPXFm% zYvbOqa;7{{JHUo7W({|mL#o^D7z_K4fSGKX z%_g^Dh?nB(`Jr{dO@T>2cAI%Nx@hfg zST0#Awys?mXS~EHOd@?U#&K*(o5u-U*++;yTtO=t_m#VmrMg`d4F4n#-sZ|TMJS8Z zAsxQ*tawzqS1NE$8H^*sqazbbtG5c9&k4SX!U%d$asW;)dy5~w^`E7dh)vVOoE_(v zdh13&+lQ>!n=Y1+=HX|(H(l?g?G||wqBlK}D%1chO%q0#%u4qg{&07Y%u84Lv)Fu; zHYaUiet>$3two?`&D|`PD>!LIbTG*$g+14YRv-7-3>*wTAB8^N$TRo_4!l@+L@QYK z5+cXe(6SqGX>M8=zLv96sXId|g$)gpzGXtt#Xw0Fi+M!}QQQP|17S(5%(8s9S%oR( z5sPJl2WzY!{f81VxG3Rdu&htkR89jjtQG7}^5M}e3Ow7nFtNbg^1z*ppT@VE!;Dgj z;kBc9vU@pB48%e|gQVka0YVy*m2Eepxch7}T%19a5u?CTp9k#gMjtC#1t+Wh@_h~5 zF50yC32|EH-hLFW#uT#a998ur4>z40`_T+6P3!MK4CIdpAP^-h?7~C7<@F#lc^{ER zJwKs!8d-#}Zd~&Lzd2GKU4YHowLDl1AlwM^i~A ze)LPdeMHnSrDTH)#$P}K%C@c$1xOwbb&$Zlm1QhcZ6$C26OQ#+WgRp*%7q4p_g>k$ zZ=MXg;VA69`sSQGw60k}bKHEfot%N|JX0db)#-K+erO*-({$i+=6rNUFda5XGr5Q` zbFwgskfu17N+s%Z>EnmlYTJ*~phOK+Vn0qT4dYFnm`ZMTuinsUPGs^VsTA^=_* zW@}fy*N*0>q(2b7ZGG%xB%U^S7;%7aTI1ba%~6Y4hd)hn)*nZ`DSS5U%?_uo$j4o` zg5|Hv)2bkqi`2urkeT;WC2VMX*Tz2iLv39VKfo$uqq$VzCnb^?NsR@*I}-<1q93SC zY$X~XuAb8)cnM`h-d0(3uWB~sCX?l%c$~9xoj3|7y9F0kF=iU%kv*=57%^6P5S>Kf zs!XHL{uhALqoA4HNtU$eD}9Bn6A&jJW9_zLnzx|lMH^cN9f`=%7*nuk6Bcs~nS1cJ zx8*ib=_D`$NcG(46|GRkG)F<*)Lq279)D$~aUUS8@j z*llFms(%S(x^w5Np<=nh-YB}bT(XrIgZ|?dE`nWy#b|9hAeW1S+@+cvnKIXXLJS~zI1ujXMB}psJxYAAGF`OMQIXn(l$G;y{0Fdt z?$pd6cv<$CnLbA@zNe_2IQ`MQ@gG`g_kU?-q(<7&N1OBSm=X}~whE-M$>i0$7sB<+ zFWFDTvLR_qsSEyxG)fA>C{PRrls{` zNq}UJ+vgpYwR&leZakO@Wpe2ffbV5SN+dnd)Pgz$-@%Y2;L#Jv69$m9 z@vc#<@FbL=ePg{^$IierAA|3{s$=if zbzm@=7=18f?JlGW@YBmJ>icy+Bm5#D%#1S(tF-u^mblvf;p)za>Tq51I*mNDZw^1N zP2Aq4L(R2E>HY3d81Nzt4^1(d`Dqx@9o@3>Ryq#TGjO-Ti|CY_L?G8*YJ8+SED29AGsQB_F%MF z5J*IRSjHaN(!@nxH11_UGi{JDDiJ1wpLR?NA+LyOXfVa?a#J=5zdctE+iP~>M8CwrDA5y(*WI^=VSJ<>;N( z(P2;y5o_^&#Dls+@E)s>RJiUn1wRLyXzW~t>@VT755kPv!rU2ocd;naD4L27BDgVf ztH~;CA$L8g-!yb;C@Nvc-VNL3s|@{L_g%X;w_q}4KQfv--y|Jd`MOPGiwt5rXKkbk z7Ko%Z&CbNa)q#NB2Ir_yFCUt9uH`ke7oVSoAGx(fFE*=w&33759TTgGbo@3H{GkEj z{swGQe(2Be=D6)D+Tab3>%qE=LOmJGO&twmk<+{_$XXp>Ls6mUe2$xx=~3Y47>K2{ z_-1V2A<|NEPba(Bx9#0v1NEVwNwq2;x52(c)G6+NGRljsW8(=0L1AQ_9&x2N(N3ge z&_>UNfm8>3aTc-4^)u-8j9^O`tamcR1I;=WxhuLCIdD9Fl3X+TsM)2S&qx57JFiU^ zf@7R)=hE{Y-Z|m%HQqS+W{cP2aGHm&FH|QbJa7?vjE~HUcpZBT3(1Jk<+>pAYfTU` zp|e5_QwS?3d#FsqkyK1edE^Qq)W6r$Hl=4q$P2nqIw0=0+lmobv0;ohlmAk!_~-Dp z(Yzqia6Nki%X=P=oPo(o-oH`6dIDI8sb;{qpE0QqFG!n-^3U3#@+tMvYc_1=Onsep zyZ?`stdcR08-wZ~T!Cm4Gqe5+^q7S!nw%W8E8?OdFCXfa=P}hKK~R}ys!gb(eh(6y zB-528d&#JUVN8w1v0icCG*b@N{ae1Qm0mkAuk>8m!$cXZcQld&S6L?f2AMbPRcwC; ztXA(jLADWVzj3Y<>k7^$&usT$y8>98Aad#x{-nvV4zIM`SaCUdBdW<(MgBQ(@cn*i zr-IgCEeFj_M(#Sv!J)c?$g$YdcP{X|nXo7^h(!CMSzA^nC$dQmv0H1qQp`QQsm!5B z;Z<)pU%E+&v1kY3wUddv|9qRvEc>dK7^Pg&qy{SsFREpIi3WhgD}6y|s(5ZGRUcuaZ;kBanzFC~kqdc5 zE)7*!GlFO|&PtW$Bb)vbyZK$J{tlr=Sr=5kd?qZqQ9 zQ%N={H)U*~$xQs*M%G>O#ziMJ%vH)+m};p%jWND_F35Rxsx{%4Z?YF}b9-Ws-pp4{zZlk3uw z@mkd4&^@YIAmi6{oA-t%wO(~Y_hg1ii3^x=$_1;TWXot0rVq(o@d=AlXI{gq>o9Ok z`ilK4R4-F+U&=x@)6w# z@jRA_wDbTri&R$|~?;98CgX}_KBew40hCvzQUQhfPw>syuCrD&mAa5~>H{l$uaEj;5J zeh$vo6E(ossGv@~8|_zpS%HVYBXJ0)@Yw4ROx3CVR|lkiU!Sb_J96FL_XvE`ssU(k z>bYq*dIY^q)>0RVXV3OG0s+fbDln8}M+tI=zIRlf8Ng<46Qhpk_ub~;*57w>4J-X3Z=*V5 z%x;Z3dxev7bi2 zvZ(GgmoU**cNv7Y*%*CMQpr(Q9%f_8u~fTSEhw8gs(7QJ{sU@#We^5~$tq+dg2i1y zQ3n1EzH)GRb4ne1lgtVS(98}oiHw0iP$lrq1yFP)8ouN-Xwo2+xcxR;C#$s7y_Qz2 z#n-$mE30lzgGpI|cip6r#Ak%eun(eT9%dsJ->Q&2`F5}P6y7sA&FJ3pnYfs=#izcD zf^24++_iFsRIJ!p7rLO91rN~@=0N!N@gBJiczU_@TeQ&b!^K7%67$V&daB3gcu}s5 z4m@Z}w<~nd`lDBPZ50^3agK*s8h#(Txh~(1!^H-JpL|MbOG9m92BJwT2`xWG z(*DJ5JM-favZV1-KLDl2;$mlBLybfH8*XqDS|DNi(c+KX~BryhJdJHn4=5SSDD>oIc z9T3ZNuaGt^Ar=7zzHD<<>TlUydRzrQ^pWm6}eJn+nlHLdo{0_bFse+q1u0PvEY z;cGC_QmB+pN^D^IDoXnof^kdS{Ee%yH*`ygcDI_G-fxJtblco&b8pb@+uaUI9O0$N z=!-?OGz$E|Hn1}_s#G7*QFHs}Di-lxErKUqMHJg95zi`RhJ~_e{65$)3OzLA*NRFp zy~#qzpLriF4g)Y$0qjva-OU7i<{Qy?##A9f%GxBgb93WI14InUu`N9VY+^f2QyE($ zilK+_bJn<7>+rQJ8xJ1?s+zN$d*?KZR_Hf*k>4vFIrmq`fBpjQjm@o{<_CPU59%B& zze^GRzECm^*~giZXXJ9(QS)1hJ!vaGqr9!g;_Gb{RLO$o4|Aw@q>P0U zs)KROl1%#6k7Ldcv@`8+*~a6re6X$|L=I;AS_fwPfm|dDxHhV2+i2#mbXd!HU)H7e z+4yx%G$nvCbCXFGU6=oWiTasXXUS(Fcq_Tf*OiZp)J?P`fIJ|9qFjrUMb_40bx z61+X*0o4U`-QP-)JhreVcY31(LKx#8&5HG(YjQia_+^Hem~9*Zcnt)+^$T4r%*`g+ z<`Q+z>Dtj(m%;aXCh-i z##->?9sLE|y|}&PcNVaHyt#`^$(Q=*q+4n=`rWBEFMm5;;p^)Xtg>)v>n)HVXYX&_z~=%>Nxvixjku%ykUyjwxpXtt12R}o6eAsN z#*xlS3euo21zHGG?QD>kF*E2ZUQukFS5NC-z_LK1XySHwXHJD4&31ch1opm|%$w|- zqfqt;oO%J3jCYuReH3QWs#myiXqH*Lvfh@zsP#8?-{i6j&96m&7fTk&;>CHrfeUz> zXLLi{`X&IpGylVe#7?9+Py)v4O)qaEcql?6ts)r3$)S#)Y}IfEeln-;m&HIEoJ$Rq z2}zR?uW}fdT-n@%mLtQ zqeCL3r!fOTmroLRi(5kkOgjPM+|F|fmpx2rv>gPtD_4tTL?m*E;0hLUCAlD^FI-9f z$RlT;V@R7bG`Pk3gpiR(#u=l!eoHARt|sCuC?@ODk~eDx5^1Q+m<$sRf|5{sFx(r0 z!W)Km(Hv6=7mvW*jsQZUo#oBI=3At$0f{4%&%1*n%_kmx^~a0g4<31&m>REjQfrSh zG#0z}d4)yCnuMH#gn+|rEbo6}n@ajza!y7CtK^$5PST4;8NW}o0DTho!g>T>Aski9 zQ*_D1_uqtqEr6z(7;h`j1-H+fql%Khyicrv+LU4seVO_|$LbiX42z8!FdbO5hDIzA z&1X8s<#8YZIi#M{WwHZX*`uCWcab<0wC7P&ipPw8Gx(3&^U`uX&=aZS$EAizS$n<; zihE}4DoLR+)eJ7t$jQ*A*3`tKCws|si%Y#RJ`>8y0s+GmHQpFE%hY{sa{r*u2@!yN zSJ%>aihvuKS}1U3e(V> z^nrI(l~4-PAcQlrIyLJ1;9i81^Ju&R)Tvm~Q5JILo3$lpsZy}7cdM=g7A)p8PRFEy z$o-Saji!6z@gXR3i-V6RL|?Q-1k>12<=a)^YH#~x$^98CKT?0!n9SAm0Af3tPXew7 zrvDXNE;=jYH!U@5@yF@d;e;$Q?35mM0IjM94i_Ofg_9WOPhA8@;;76$Zv0 zjFPt}$FourK?=H8#O;weo^*2>>SMgY@0k7XgDVszQO&_6TP0jh zm_3rT1%4l@8#zrrefgw5zw&JH1jbED58HAfH^hwcu&xAxcz$HKNiT{OSEvp2 z@a~{bAs?-Pk_K1`r}fRxOQ*2Fp8g!HSj@CJ*78XVn&>!#Ggzq6bM{VOncVZBF(fyYfQT=iMz2 zQxNYWEgJEqd$Z4r%i-O7TUpx<1Okd`Iz|~hY>=S5-y*FY-i_m5tW&sRZFIL@8;<8P z&0NA;ejK(oV`KhQ>devi2l@6^QqDHdlg^MbW8Jqya=>Ul4Ow@+-{Z6H0Iv0Y_o^p6ft6CPWJXgd%d5X209cdrK%bDlEiFYXJiPoQ0m8k6QMk>KSa zWsHzTWxTbH;yu74yi*Fna-DL4b>|HjZ>UC_7dFg>T#8i!0^b$+^Z$1I|Wy zvcuo9ySt(@Ddu#1u(#1e8JlCLh9mF=AvPrY=@PE|%)%K^l$REJbG+;r1vPKhhJ@X? z8m6EgygBW{yQ;IA1Psz3Xvb6%Hs(W%;09y{dwo*I68js`a3%LeV6^R3E91ZIx1i7* z!tibvv{B)CAZhAtwJ*i)p;m?clEgwHSrIj9zCqw)l_P{*Ja55MUb!8COVpRuse(+; zWbO}!?V0FJTa8fzriZTu2*iw(46X{otA$iHc~r?p*=pa4M^t@`*A@Z_Z6HjhmC(P| znJ*bhE-?NxM$poo6X}|9L#77i)R?~CwlL);D+mZZ-C~G88gK9t7^Z8!{SlwR9i^hG zB?E6QmMsHr<)1dqSdI{PF4>;O?uV?8_2~)$uTz;j$E<{%Nc(~g)iE%FG}V22#-b!pig+g%6H9iBTQOt`3!cMGgGW| zyeL%=F2NS>4@t_iCV_%AwT0(`lHk+!T4#IY<=Xu7Lh)^IkA#pjvnFIH=If3Lm8+C- zw~~f-d@HF7+?l8&aZ^Vn)q{=X)4GB;R^6DD9I3$J6_VAVL(F!qrkZiQ=8ZNhVv1#K zWst^-QJP?ItPZd4wM-BN4)!JvMH&#@F3K}!eH{{PSRadV)?w`Gdd<=VYaB~~q+mbe z>KPq$1+Y!h$k)2zjA9p4Nh{hp>LG$AO248lJChO>@iws^t>u0;m(e%AWjz`sKvO5P zCH-lsE%+1RA*4VXcSX8&$y82Sq^wNM**Oe2ZO4GYX?dvFsxg|5N3xWQ)uwHU_Wp?V z{VYasvRMgR;E#btv-N2lLo_=d3JT;c9$o8HMmx+E zY=hk0jQXaMEUI}rh9T;Y=vK8l3axQ9(zRJFc6?!J07*9SY%IC zLeRp9aMF!t5XRDxT54_f<106^xaoU*@Z;O%D~d0_b~Ov?ok9&V_x87@(rGEy%hJz| z&__m-__e3XDU@<(z0$@~tTtgfHfUe|0TtQb)IIBUjaSC`MdYF?>t2uV36s9Q*< z>TE_m%9%(B>ezURllct2akuDccuje6-O^b@PTaRgUF(DrOlp+PDf@o2)+>YZw%I41 zu=OTA>zW|b2NNvGxU^_W86gL%AqaB!c{lkR)pZ+o!@?W(+t4zI?9r$kyKQDyWpx3ne>XYe_&0oMp$*+=g8M`&5WfmS8D%x+arn)9yaeUbVy);ju2hMxyW(})v z72$J)DfrEZ9Su5OiGhC>P;-F3X9q9M=G1A9%(YRRHuPWO9kJGx&(x03-TwM)n~`#v zSitfj-QauS;G0KzJ`Tr*zSXr7|L+@%waluTgTR>}x+NQf&m<3Vvv|fETKwpFLl=V} zO)8uv_4)!;hX#AmOEI1%tN}Y8=9o<E6td%e-Z?hb2HuqSYC%R{$F582l`F z>q>b{_wNe{_S0ea(_WN9s5R|B^qnU+PhBO*tG)-ehaZ__^xEAMK41euuv?|rz!6!P zRHK&*hi1gxPd#E051*Cb&dt+btOgh&Ea+!Od%8R5Ux#$?!KW!wRBm-5fg*f;0HRTs zxRxbjs+J|w970~h050nF8CEV^b;nt?l_%T!$;@m{wCl)^*%zfVc(kLu>`*Dr_oNt=`)J~)$+sCS@w6P_V z^bM5$4CAP0@=`|bQw^Taa2>Uafq{4;YG8?V2jjS;p*QS~mreU1rb*B|U&Q2{WPuwf zdz;&R(zlyR=)FiHhu}m!Mfa6je4aWhxikRwy2S&VN!~(G%81mE?IZSpsyVE?BrrD^ zZX$$68d&70L4;S&)XHU$gMJi`eQj9|l_}lyGneaw zXVpA$?KhVeT4KMeOjwPS$0I2c1RivIV)r#WkSoxa#107@T{`2h)8QPsJJKqRE8jTZ ztDptzj6cL502KYB@YoC!sD)rB?KTGZ#Y2???_LkEL4akv?+hZA_GjRgBpcce8X)X$ zEj9FdYPZ&A3CE7^K3^B0Rfq(Y1I%^UO;r#iI4MWGj zBr?Wzm|jQ{E?6qEXze&3gvK6yrdzTMj`OXMa9b(+Mm6QZeZ@=g5J zY;Fy1_WAvWcrOe?6!B&<`XokHoOF;;=Rst0d^|qp9m9G1fvaD8c8F$&utFMM8{oQZ z@>%|Prs+X@3S0fwvND5J5xf06vdtcPMpuyJepXg^yy2FXU55W1vkEvp+|%uV1Jd@2 zX`)0VyvepfYSmODmg?q!w$Lif#?tB24glrgE5yrL2bBt!!7&vd@KV_-e&CKg^9+7` zFt=@%D|9RTQ?pDZ?`dZ7uiS9!^&DU4f_vq&HpWh_Kp_6C8xs%h$(317YA?d*ez1gXep6D0oy z%$pX?Ozt1MZam54&$#_cm9UpYk9-Nt_y~!Zle4tF0k+pmW$4UIL^@Cb8@F2=^Gt1N z!BTHD#wI@H5FSigSi(ZD$arG*z5OVHX)90{E4ju$mFHi2KA zwb}$p92+R-fW)I1m=UoX?qr=PL=QH&m1R8G1a`F@$OavOEekfO`Xl7UehN+_l{uHd zH_mDc14j%#mEHWwr6yk*qOMH>UH$?-13x7BlLL`>q<|`mziK+mk)(`j^Y%g2@2ea2 z8Y!PC6whPS;bHw27c!Z9cA64;5I7`Ao#iP5P`|I|%?dd{af&uEDAkr2Im2jpM<)5+lH zAodQ(P_l9~@52}PJGXq@DC%3${A|(0iAjSMrig@z5F|V1^wV|XW1hN+#CIp5=|=+x zj4@#h>@9y&zlwUIKt4kZt3OHyTBbq#~9VdZyl1+tH0 zhiYhN>=<*?(c&Y77FCgK z_*z&y{Wo4St^Jl_-KTdUI7$gi3PH*#r=IM9uUP6TtbqKYvstdJPsZlPkUv_bO)Ui% zl~d~aqvi(A$s}7<=LVeh>G}WE#95JvG$R2w3oE^0$>%P=OU&=C3fZzNB?58=9nN?k zK!v5qs^T@GrM)9Upk=+iy$?V zG|e5ErL~GM>43ar!W+hz#vk(4rR5$5O<(fIG`Kjc&$US}Ev5sU*L+t4sQ6Yb8-K6% z^(T(=OTdo>o@2_@>lvWwg3n{AII4;DE@tE~`fFcq3&J-MfqHIMtVit2 zTwBR zH>}>Z*iDa@K?82z<&upmdqW5u+3^ouHyN{pCvzxMzviL$E@xreDeP=k-x_ zAi4)Q@W1nNJ&2I~IAkppMju%>KGFGBN6{sd60Wnqqxj?T)e7PxzHw@s_(rpvD^)^_ zD(;K9`bAk>?jCS0QKZ-A{krEbOt0(}EB~0=#+dk2DRXHH@xK5z2V8AO>k*{ETZ3WA ze3)7eG<>+lZUBy1_NvO25~k*~o2%ZBx*f;GwSasY&OCjC>zI7?Q(0fGlkw^W&1Q;z zicoZ#vtMnsnIpU%9V20K!N0xpPfECIhV}prweeElq2tkOW7WTao0`WF#jf#^3iFS+V)v!;Gy4K6GBol>T#Wqmz}fy&xs;-Bo8*d8iV4 z5XyL&cm8PI={CJLfc1%=Ye`!M);d=x3J$Tvs}Au(UX?zlN+gF!_RF^V$bG?Ey&VS8 zsZd!=s>nGGN*ib6pO~paBc~yprC7i&a;j24uHG1RhPb1=vtsq3B^{KBy8!6TLcZX` zP#gurMSdT0Zffk4)x8qfFZR*U71CYN!!C(6M1#R1Qv5!R`$5{Zp{!$2 z;t8Ia)JluAtl_x;6X4bfSun~G!`=EQ(i!Ar$ zzpX<+N7D*iR2)ps(8oKg)VbRXdRyq1(k7W2rGndn=`aip9MGGjDp)%!7jeu zWJfr^(%rw`FiZWA#gcaI1^TEKdgzUi_Q}$eF{C|I#;xj5NLih;gvfkqk;J|7xZ=@l zWy+^WL{4U>ahInkHm_3_Q! zcfAj$OE-rXaYz?2{p~l7A+O@7ztPo?@$f#!GZI>gBP8)qAe0pfY|s#4<0aA0z}!k^~JzX0P$ z*+|b z#Q1V}tVu6^=eGixLP=ZKFPpe*2QEDBE4>>#TLiEeBQ$9rpcLh#3YXq&DCD!UTMhS| z5#i!vG96Jh$VT z6~JwKfBx14$Zk7OQnk;(&!?#Cm-N*f!FUoQzR-33M{VY1PN=Jcq+D<7n@BC+Cvdmd zKyq#>`Bkp=C1Iq8+5)tsZ5C_Il9TN#s36K<8Cyz0pr+Lu{_VXCqv+>G$vd%F1t}{G zZel2L*PL>#<1}~9U|4W%gVtk;y+&(i&8amq5$e|Fs8;-AxtRIy(n&sRM)$WiAFb+}2;Q=74!h zJ3y>WI<+fG)aRUyOW7D}!m(!ZK(c~Nn?uNy0W|!(Y_DSNj9;-f9HNSwk1~_MKKR<>lj|4Z!yDzZd-b4MBOJV9w4A#F}VzxX1k>_)h zpCB!*AE9~EdKoxPULK@E6b!7X=>?{ox)Ep`-UVR|Z9o-5oqMriPC6&;HUr9#46O+c z`t#2ACoMI$5aX0l3O&%|@{O#40tcFz0te3G!4XmQ6$acR9o7`1{NmNKO;#05jQfSMLsUyIz=$WSRUj5B znN;joj5g{oH?GqH!y9KJDmRvJTZ(76f?S2ACt)O@+pJvoxOp0NolD;|p3)@^bp*GC zMq#<8J9kUM^2ax6LAD~m^;J|?FxMNL_H7Zg zxCPDAkPs0|ny44xLj-R=jZ@+?x5FGpwzJ9&%|1QmskueNpIm6U+_CC6dTb5Pq=F0% zw|(zdaT2@Px9W4Xi*Y>rW{17a8v1u z`DGi&5`U%Ryov88jJ_pv)c4G}4iGzVkj#b*(Ie-}sG1k3;#W3(MBz zV$j*jg&Q?A_w_wXr7S3c(!|{jQY%ce9^p9HaHN)nQ)3q(Qo+Prba-ysMXxjH(2qPV zr?0>ktz#CssT^NGsT&1rKG(WP#ZN!GDf~GMHuIcglOlKc(YcYu`d zD{A|wunZ?y_DbW#9WI9oD~i7-pO;zd{{WDxUO9mKNtIeceLQmSgda=?X-_!%c!Yc_ z%IsQtXY%CrG8{@rs9{B)is&ZtG<}LYjlBL>K~$+!v5dMtS=1oN={fI-?xHju?hsp? z^5)sP_gnt}s=7Nmo&kFR84&{Jk;(X9F&5XByF_zqt=5$|cJxya%iKFj0IxnN`WdqC7Iw`mm`DVjS>!7$X^e7$4FCw&m6Qa< zHMfv0KZlxP0{6WxzCK?U*pIX69UrKSymq!v{m^LZopPToJl%!!{+ra?CqA3N4ItmT z*mPc^G8ySePpS#4@)y7HK3e2TDfUXIJq^5hP$I^UDqTk|984MVSRL*aRE%>D#~jjmukV0S;39=+ zq3d!s43@d0eVZzq7ItIw`Db|pxag9N^&I7s08YyY+NowVJx>qx^GN2v){^5JsNL|E z-Xo?b_0n_Z2%H=oEu`!67I!x}=hSJkTz0#kIAMF5O_RTsO+3W%ydRzNTYeHUlzMqV zw5fS3z=s=Na2>Tv&Ph+Ph?jx?0CW=V>2=^AC^yL zaAY#$VGZWNP9Q^A&UX!0{2M=GZyGy-g-e^n&cR+oDo_gbXK^aS!UUh_>zzYX~gJ?Bcs40Eei zaAArXOxi-}pJzvFTXnTb)p}W?Kwm(X0@mw=Q=I^FO^@M4+tX?CeC%gJ<6$RzTQ0YT zKH@^g<+Q~u6YlYT>NC$K{M@*B2tm%?*MS6TsqAb_py1-U(@D(Nt>CDpBP}H~JgbMXEh_@S^1_m602>OcD<6|fM>`d6Ok$S8ij2Nq zINb$Hn+;x;uPuXWgE@m~DHfj0^PAdK*)M9IQ)<DvueJ0&iPi}k$1}D zv21aruyuH5XNcF8U6BK}wbJ4ID*T*EwY)SxkjTu(H6c68d0ITk00&DypB)mVYXRk@ zbduU#!?>wP(h6gzLF?r)p5=440{7_erw>)i1??r4zQ@wOio$|6TtJcVr}PI%gBt=j zHKVwV>23Wm2A5wPDP8+Hsp~2+I!-<_KxAzn!eQ`7S^+fhP>A2cc4Q_P#vK5Kkqk2r zy?~#>Zdm+;tc@r!k|Q5a$1VMhw5~@?#(AQ_X@)(`D6RrgZPSp}HW|L7&{){rBzmo^ zqh8m@<_=oEnttXMKZByGpY--1W8Vt^dD=(h+RynZFFlyn#M1Wn07b1dSv@)?!}^D{ zuCYN>=3+Y#qFDxn^(I6n$lthSJe(waRvVs|Yxi;o>S!{q~L zpI2vY5&VQK2>#a+6P<09hCe8HR58i-N9ZfiFw2+lkn3~y$Jnj|Lt=Bv$n`nQv$%K( zp?#Tl#fLjn3Jo6HP2*y|QZ+#~ly3mG?uPHe`IpwcCH*(489O^+o9<~mU$XmVFHedf z9PO1!_1{I0taND@)&neGd}MQe%ZujlE`Bz?{(!5q;lcH_-s8wi$^IOrjs!WTi1tPf zpgVY9A7u9>fJ?6NilX$y9%?|959PaGX zNm_oB)T&W4O%}Vkx8-7Gkk!_LvBDC~*h8tHZ}6|f%J-pPM(-m!OpcA6jUWU_9P_%e ze1CP9=(D>QAVHSS&n$4()sIieZd)Y)hc&bT3%M9w&UB%%(}*38H+o*LAIqedeu{Cl zx@O~{J0r(ERJlPIe_dRYF?+u`7_Jc**;z`9{!7*-=}&nP~5JY)Z=d>`-P0^KS^T!If0p# z({d(}l7Vr``|SLC{{YQtV(KoEU-A1x{{YGz{_2D3{P&zmW3t;7+Ew|K_&RIRDXLF2 z`L<#I01)q;q~#!rwJDrQHd68v;e3PIuOq1CfOMkgS{0J*h0;YPv}3N=u$GXNVBk<+ zz3D9U%#Sn*UcTLmLuO^uI}ys`Umpw3ym{<6r2yRA3in_t4)?zcOazPTG&aUuNkoiykIN8$(*(0af(8 z24Lc`jc5jw(3*Di7k?iYjFYFV)Z@s~1sf~NBiH_>#rk^$%QL&>I%6-AyrXiH>@u75 zH3Q398Wp|{E2bgG_NxA$0iI^G4K0p7CuB`%3boN^UqZ0L2AVXD`hMe}ta0&u!fq^4 z-E^kdjg|0W&;*3^PyiEx0pSs`SA{xah)ED5g-z74$g%>{J~dyP6|m_>jC}X3#5){F zSRSn%xx{#1O6#obYwdjFs*~$DcuU6Kj=L$E4betpZ4r@oa*Ebc^dW-*wVWa~h|FiW zQ|wkjNC;}-YCme>Zoesp7p2EBG1;}}H%T1mL?mzbN=(fwIT`Sjm8+iMB}DArl5Z4M zr0nG*BRO0=urgp;>O^7M3$#?4c%{n58)LStF`la9HbQy_Q3<&EdLYcv)9D>N6Ni8dXK(jaK1HjOB_&_n>Aq4JcK z+@nnW5RsYx04ah7Vyp5GDT$={NKX42KzT)WVdYTfj#cJP?NnvAl&t8JJ(?=jM1b1A z%8$5}TKGQg1t!X9EMdK?Tz2r5$Ae=^&IqcsiV?8zH}Ic?rC7?O#Cl9hyB&zxPvf)Q zSHj_H%N@f=r=l_VA_RD($cRz|a)1#G!nsbcn_%XQZk0xCV`|!2K?PN2I1qN5O{RCK zGeu_eqx*`}=MX_z?9wc*enwGtQb`udYoo;TogZUKH{n6wxoUoff;V*rIpXNrUU&-g0&F+MS&(;FxxNx z08r+V1!&jG{KJns*aO(&4|RJBR((6I=3r*CCl+_MK|p&8@KRSjc_%$DzrX#3aWC2W z97to%cW)2ce+Jz1e(U2nf7G0|%Nz3XV~{saECr=~L+L-L`gd7qc09q7*5Y}m5BjdD z<%{VuRV^(n{-9_Wt^BI&R}#?Q3d66zxr}ehtb4=|5_wh46?BPpeAYvXSkw0qvU`!_ z@<~R@nb}zsH??`wI&5*VvP;jhJA--`>D?{QXM4t#yja15iloP`*0;5_K>_o)&;cu8 z*3LNImsuHcqBMyNCBn!|i2FjqwtNrzsn8qJ`u_mx*v`ysn|%T2$XNjK+AD?n2kYz{ zey`By#BdIj4(QL2;J-WMG4M?2WwyDbw1OAT{{X7|f5(1lbzlDgOGENnFvkFDxKbxt zK#GHVuYSrzki2HGluaEVxuozDY|ZV&FtiX4l$%^$LtIB_L#%eM&eEwHgA8Vn0 z%`6>59$6#BaAW%!ZgZ}rcd_^6s;rky({mk_feA6R{oSU;dlCNXyA%9;mgBe*=_F__ z28b7G;qc@_93XpNR@=L;ack4~{8z}l*U?AFlEyN6qY$Tmy6QE zpv#9M!sFZkUYA0_KyypNYF>;$yq5sqb=P-R)Y}+n`CO6OJBuE>A16AGo2}y7yg^A~qWyH_tO8VD58`0KVn)CqZXU>Fjgc{vj4k zd021hpVQ9F${CY0{5JmE9Q?1Y(lDc>!UXAGmaJ5JEFJ8vzSl#mw zWIwfolD>cSSJydNd4?{RA&VJ@_YPdLy-%p+bv%(abfxbCg0CmYnAE+9HT54^pRH#c ziHU0ln%m)dRXdwymF4Hvx_Zohv-)2cIG2^KjxA^^AEz3~F$?4&qui%+`pis(3}bHh zUW1oah&%RQK?mEyW+*K1vY0)3Sj9*$tK)Z9Cd> zc5NnGwdZn94u&ErxXyw~Be6-Q8c71sr`O=Fcx+5S7@xdW#pjfLD=U3-03?GPy>~lbo6;dW?fd)*Cqj@ zRNTFCx^rvF%#6}Jt~)gQru?W!CW-mL4pMm;g0@vNvWfD5`Ck11P<-H0F`1Kgsd8W3 zQ_h9&{m3$7(D_iSswpU|?fbY|Kcn&+2T+W>(6vzbK=7Er!^@MpTxiK`LfrC0K>kmk zgeOzdjdXs1!;U#x3920X6#qP~@uVq_kqgaEshPmht$uW$re z2ZD^(Zox{#8IaESTpZtp;$IozGHRBiG+Dzr6bdZBNIRLQ1y>yV-zm7IzbK2CXL|nt zQGCSU;kZYZc<$1%=s4K}1+2_}eB<{fGVo!NcI5hF)o=NcTOkFnVEcz7+%8!7?Q+*G zJ#VG_&;H6g*;0Y!;j!_#lQFN1=(WPZUp)G!=ypoi<#bcuTXYw(`2PT++x=CYlNU~G z-99rTiJ}0y{uLG+nLQ?0@-tjXn!B3b0!~hOe2pB%}?F-YdVGRN>i z9(>vfgahYwyW*GJ{-gSL1FgsFmU?rM{{Xnbv-$U5HtT;(Pt%?|h6pfHKP$OXrKypp*tmd# zjSev+6Tm>5IgQX3Ner z@m?(WLnYTc@{CJ+MH~1~>i9DBy!o-F6j}3PJ)Ap zA0co&59KX(f=TbK5|^3v<$7mql!WuwIbUrfg$GT3P@enpyCyJJ!MyI1 z{{T;Pxq81;hZ-P$<8C#;_f^jOoB-J?=s!&LUY+!g0hUG#uB|A)O{Yxu1goXJ0cW;- zLmCWErurADEk_hgvc5OEg8gk^ca>OZC60dZ%1;zpcn385|Fy-86eD zf&c{B2aXf+b0oYV&FLn#n{KTQT9XB|kOBDeqToaOS;!TB6uU)P2Y%eHM!<3^1e0Lb z3&@cZ*z>Y$Ru_`{G5Wq@;fow?Izo;sAx*z|7MY2oOX(sojZx^~6r{rVAKb!09?6E# z2y&1@cC?l35~#@iXPiFaYvtL~7s^N~@9J)Tu#MbJ2_r|G!#rxnkksi$^jHnTII_#hGPJzxyn7-QE^s8*)r`Y znGnm6%hsVonNPa+tx00#0`(@?N1H#&GiUQ^s7daK*=cV!V$@HrSB&8KRf1Dosd~Cd z?TdrYm)$P+=c8w*?0C{px(JoHM9H%*9Y){_yf4H{Xz89)JC!IA@c z3(}G-xDO2oQse+RRnldO(MZpk&e3ei78B)bm0vbh-qn2Bcfm?Yk!O^y%BdfdBFRg- zLP}HBUNVG^he$bc8=Qt@`9pMbG|^_DHtMaPK%y*f7|_24#%)`u5hvYzK!av z;-_Qz_z1Y)VQ&n_Md}af`R$Y%-LJHC&Y_L<7fxgkm$lWpG*`^AWX+H1?1P1kgmLlS z-_$;4_c`6hfB{u>-mQ_-vb*6Wt-Amo7M7_vwW*S%_sM%dLiGNYFmam597{*KBbnjt zrH=Yr>$ql(k>xl8?zb4-Lkl&a$0H5=uDF?*N<9#C_p+llY;1WL$H)WTXarH}Mn;JX zomc7DGBUqg-1j-PKs!Q}ls&JEYSehwE2{jNpIQ9>0CJ7aEEk6$AGOQUv(tBTcGLce zlZwZF-c%gNFi{p2w*atCLRt%$G;~M0jxdKV7h}%R+~3$w{E@p$388H~gtksG{{Y;F zl-*G26Nq*W&uvnv>(~P)5=K?E!}}`?dApBnhMat&&Rf_){zA#E!jq}#@+@dra^&gx z&McDu0K<^~0LZ1fuSc5}PraP%7VICDqWaUN`k>`v>S*_+UD7(^bMbu%>(+~q?+A-AIhzh8Ogw zfAp8vGGjvAPOwYiueJANe0~?rJyyB>gSb1+xm|0kU=W%)>y7F&g#DkQusW_5b|{}7 zX1Si%vH1vjJxEtp za%X?2eIRuVA(;zDuL%3AlHdC;mqdR0JdH29*n3>p3uzQVUnlyrr5=;jUE0&=OZTw& zrQ!bos{C#L0KGr*JonLDGyoi^$o}a2Pa#FFJ>y(#uSmd2Iu^VP&tU`)Wp;`_3M-{^ zn`D#xpgeE2#`nr7XzlhznIPC%4~k$k1GMkL_fyY)FQE7=xIDM*_CNJVyHEcBpUUog z<&qqDqoCO2?r;aiF3*oyNj5Y*WBC~pOu+YD`Lrg!z_dvTpl-zksj%Q$C?(eW9F{}2 zD$t9aVNLH2B88O~16xUNSW+P3M<&p&k19?LXf{w}b9@krThW+2hZ^Tfs(0C>=3KPR zAMC3f@J;f%xMK;ndB&7WwfSCg*j`}na9sck2&3LP_eTvjZtwBJ@NIU0EY<%2(HX4- zI`sPEn^LMV(^MV}i{Nq!?RQt&?$+(Y%7`v!+``_`q7NtXi0IGwY>YS(JDdTsreYwF zOT(N;;*^OEAM!V~;MWGUj`{;c8`|uTX^pNuy~LX*WYPvW*MRXrkz3mG8;^h7>JiGzZvGJDeI?r>jFM>W5lGB!rBU?>OBz5{ zvp$$r2UC+pbMpErBzk9Aek}d2ciU^s@$~1FX8&#z5V+xlsVP={iO{T%-QT?1YerAPTA*28*$~Of({TG&H$e zRa|&&Ebt@^Bi(6mC#qQtXLgXTu_mB4yEtA7(X++L(tWvfIay46svM>cX1QHT#qlrX z%hxG6vi?;&^DJ_gc2Oskwa67Ev$8vnD~*!;s-{dzYAFg&EgXBfbGXtITuPgHO4!K~ z?kC-FlBG+vqLg$kAv;RSN^(Z~C1h@jR?H^yZ2`i;q|}YC2rd)~L0AW9Y@oU3^osG^ zSDO`}C<4;&C~k<8t`(ek_KwV7gz8&16oLbMCgFsdR;|Ma!aR6@R+T0Vf-vJ>gzR|4 zrs9H#(y_(Dl2*9K8E|hTcvn)s#C;Vb68tH*3*lU{0mUMEAb3u=(Y%UbhydY9xWIT; zu81JHwd|Y4AnPk+$F2gk>2V2uo$Anm_kFLgf&T#MEhw;``f7%Busin86Pf=10QEtj zQ|PGZn2yWF>R4@gZ+sr8XyL`C)?$2rjV+%_?&-a5Cp%q|c0c4D6J_`R-U+zQh`G7LeP3^G|l2(w<{h|Z6ol3Bg087G^w48V|R39kAy)Z zh^L*RkWhJpC9)4|y_Hu|&2)I~7j9_f6C}#OT+?IOWje=J^dxg%{DPS!x^?hFr_h6X z{{VhekxF!RwUq1Eg`N8BGh6%G5qimra%abIWP@aTs!YD1W`xHl?gEOHKc;$kQ23?g8I$`-043=jF3IzY)YZnj)+_@)bNXPZ4ut4(u!HU(+9_;GQFsn-MO*w$!Tp z@_xg}=Y|`lq?^z)vwB$0j~jJwV|ITado1kHQ;Qzb*AIpFj5an9*0r?3zzV18f21(F zyB$0|xcQ&)f%d!dKinzud~IEOJPNhF9}mdIY2fWwNfYC7zrBEeA!<7JNX_XJ`gu_n z6Tv%czCrS-BrPO|Hi`cLRp#j zG)=M+A5?Tf);de$Xdb&h_|MI8{Hh!4Rc3@ z-!&ZZ*J+TZ<)5DbbW=Vzf*#XE6*ssLO|75xL!f?(HN8(6l?3Lbp`iZ&`>Z469ihW( z<~~=(d_0azbk?G6``>!k$|*gqBgd4yAYJI6Whn_F?_L)xTFimSclMv`ndx$NX0({w zARZnpe<@cE4{sjISb)1Ice}N>{B3}>|0?*9O(DiHwY7~J2yn`eQ3NAkK}=LYxpy`sS!SHg=% zY$IFiIgCB6H)YMLr5f(JDXsgg9_rHbzSxNbecx+CxdBD39j_;_Py@HSYa336w8UQ0 z*7mzb_rHb4$l7poNo$VJCjJ8V*y7^rA8m^N0CY0r-Pjyz_-F%O>tVE;?V-PSb5EZi zh2*oOG1zOX;2Q;bGUIF~=~)^2c>oTuQ`pmRs->99T78kBeJ`MmOR`NDt(RP`K3gd! zNv>Cns+vy&%>MwjXZoA;Riu(zRYrcnQ04SeQI_hC)0Sp;lH#=;O_mDF<*>EoeCN!U zniM&e0lI89=S@q)He%T_pjJTI){qp=+%upbz4^i&ogjg-?xoR+#67>U?L= z8EXkmvuPL(f^Ck{^`Z154POg{FE!WYPQgq7E@}*s>x9#WluJ^xVOK1C=_e}+x|SjA zlA10^2`WNT%+7R?nWQV3n)K@cDW^vBi5pwIDXf8WI zQciM-a)XMGa$GP@Mp|HIJ z29tHIF0MW=wj*G>LqdV(kx1I`X4TY~;N#&9&Dl+Qe{Rs;&&4WtnOjouKgl0oW zgz$xT(oB{ZS+NfR6ho5cR1(O@!MC-8_kTV=gv4;U?hR33leN1UR zM*;UW#+_=rH>l_I%sI{kMp=mM^xQ3@S6Aw7z%j-mGfyG=TfhNm zI%X{I`gNU0sj>0QFVv1G?2lt!6tHok&ucQ`$9HDH5)ZwAn%=f%(KkfxXuD~SpGF!h@gS+xS^ZgUq zZS}BNf6o~0KZGW?^*tr=SKkY)eh>Z?h0`mM5ZYQEpDt9vku;KYwfG$Ze8&P>Lsq|{ z{W}g7IJ6gg(ix_7kwNs=(QFJ4$zU!%3fy7zXfa~}k$?xnvBqOq+5s9zYLg_FqFzj{ zm5gAZ0!_oY+$$NQ4&b4(5)Rf1Vs3^z6{cS)wg`-|G&mBb;&IiO+HN2-Rfm9bX%h#Rd07Q)?pE_D@s>D$Tvp)&CpU#Gr`hFnP9G(ziQ zmj3{z^mbt4wm6wUdn@cb7BGgi00MZz(AuSc6ulmAH_EO~nV%834mPD2EeE*$%F0;7 zY@{cVk+IHo29G5abMBRWR}&vM74c=n*&{w3{{Wn?i~U8?BZoF&(xfxM!?wGT?_TR$ z^}p2bnn^IQo!c1G+&ug&-$r>eZAY3W*NgUFkBzmg$jPU6`5dLDjGA`Rr+%u zsIbWx^m0GqZwK-gAF1GFbjkfZxX1^Gjf40r%jV#&KYoLlRyD`G*&ckUF~ByQJfThY zy2VjCDkarty3fMndV#nw>Mi7al?R>ZeC|4yj(>lI) zOUd={vR@3IXy13i2b8m%9^bhC0IC=k>jRDLJlmJk4(~_TIxp1SH|pM#k<&WM;>5}< zquxVU?`Jfg2=-qn>-|1XlhkFzo0dCH8x9@_K0zBx5NHx_;O^AJojg8_jxM25l5e)W z4~1@iZ_f!*?R}ahC)c1%eGPJo1?eFR{_@x9z^9n|ZPk8Pn&%kUa$-xnLC{>srbyQj zU>&Xp6a4$HN4Kz1BJcT1GDEpE9c|I}KpQ0RR4048+ih?s+%CRGHHFSc7;qfU16O5N zEZhK7| z?)Lfj_E2v?MZu+_tGxZ(=zb9z(~#q4qypUHc?12_dzeN2%mZ{j;7R`gQU0sVW1Ae0 z0ibqlL{4*iIpF&*E~O}J!}QMzY<(|Dt4o(rUWqKy#yNDN`F>JDY8h6IxbrKw1GITJ*l23jrZr zGo!j_{>EthF8(W0Gm|$HNEV9MZCz#bhhxY018S01mN<`P=yCEUO`wf`l`#7}r31Ih zBOa;bE)F#LQv%QZPl_QzMhZs^QDj7`GGozK`AigPROGXt3RNaW z7DG7-wr0^+Sq$Y>s{lXa{q zv5dq!)rbNlWdcVV9s;O;RL6fSy0bvY$tviql&CpfjDLP2OA54yaTXmcJUjm9j1C~KdC?y2*8py}oY2Sy?` z1Rw%i>=kpm+-!xf^!sGG?I!Q$&|H(n`cR%4`ZN+Xz5f8>P;v4Rqr~-E3wAygA58jR zJ>6zZsWYaSQF~(c_*Xq6CTuK+dbKQJ-*YOQN4WW2F;DFY=rp}1QH>+mHqqQga*Y>B zjr|$fxZL*lSL2YjSO*^~8M5O!vPADA{zX&txJOOvIXx;t{{YiuG|i3=75--b0OzuB zC!;+rkx7LF(KL3wpzHlpeYN3N{VvBqbGme&yB1^H{{Y3?1zniF7GQlbIV3F_DM!mC z@~uudcZI=(-bw(}Ry4i!%4R5DYq;q&L@|3MvNI@sqJk+lZT=FFM+mnYRrwi&*k{@j zxp`g3Wme5OqI?8iO4c;XW!^3!$ou3r4}~Z6ymr64(rG^Qv0qF5Nt2cJGqG7Ke@xJO zntUQ}Vpb9_&iKQE8+LhJp*$q$aaSR8z2w!1(H<}frMf_w@H(VunFNb^gW`wDVP zLehEtOB)h0=_CaB4K`Lws`Z&?ad&x+KMe+!NGe-2y-TS>10CE!eSXkZ1L{7tB$2tX zzwbH#aNql+daqKMxYIiwguk@cl1k3~IX+%yG>^_>nJ0dOg=tQ?l-iHBbaC+F)*+FK z^5X5AwWD^2GrD|XvLUIWPUBZKL7xDZJM4}`8Gnpy%ea1#M{_UUtY z_EJc)Q8T+f?&kAqa=0~>f6A?PL*U6SY5QHs_e@PAoWOYmTBD3$0W;{IF0`D^HDwk{ zzT>ZC7qIr(!ozHlV@XLaR7=Z-8B#m zjpSeKI~q}I9LD!{4eT4zF2g22Xr^q2*CZ&@)+!?#X|zv|Ewi-CizXzrA5?%yc^e+% z{%8ygY;AMe=Qthx-@DI03%~-`RldM>^+_5wlbNIt3k3iLf&ssMa-k?Ov5~o@zveVQ zvg5!6&}jbv{C*dwH!GUarjFiq!YEeAGJprQz1{^-{FTB0V_d)*Xo2^Cs^D{h1a{dR z5EotBNA4fotSGBNet8=wLz`C-#6uj=DDm>B<#~%Qyj_B?vF2;o91lKMR$@m)^om}o z58HHmg7Ub`jwxYqIzKDQ()9<&PrFR*l$5ej1g0PZ5yw|`?P0VrFjofVYoCdt~q>J#YgW;%r6_bwlj z`V|zpNAD?nQpkUE?$Fx2Dq8fq3EZRyk17pP28uJsp&EmbuKHmDM%tvuJ69Mxdq7H?-79d zj}eUyAOOB&)qcs{UhC;!SI2uoGH6l-kuk+8ik~D$IeCjfl-}!W(p_-%9c+DnoOc6kjO*Qqo8j z6$;Vmruj%uCp%M>`$|fFR`L;`GCOKe$XetnF7&vFpqC4;g`^D@dkm8kH;=w!q1Q7#t;Cg2x>M z&Q@UH6dY`j=Zh$67qmg$BoUyJpQbuD2QtA6Qw!KyEQL45$Bj{25xm+yZPoEIU7L?2 z5yVBGP`mdJk?y9+@-bc8-Q2_A@Ku~$4>~`o@?9*<{R5g`7`twQ*Vrl*PTk}j!^{{YiJxKo;1 zcyK;xXfe89R)?nkr>RRE-Twe{CXKOzG@r6?Kg`kK`=(O7wXvM}>y%n_U#WVg57aU;JW?>40ezbi z{{a5Wan~|q>irTftI^-zk%IpKST?E6uiVl<3pv#BaXO4I%;>_{muDmsKDyk zJvS@$cw`v>?V1;U*<0*>RVI8yOJ82i>vJMKYozr|Z~p*`)gkmAeVjJi`Q$Azb3oeo zmRHqrMUO5P9Y>Dozg3FY9l;4~_0DAYZDtNkt(D+C#jZ9lz((mx)>r(TlGOs2DrQgh|Gz|p1oucduU^=Cya$ZWY1LP)sfcY7W2U-VyH`cvwdzM{i2MhtVa zQo?y@{{V6FlZ%#b>8JkW`93v5-)Pj!KDJeUOzt)Z2*eULmywF>k`jA~J5c~TM}mth zD4`7%HdPVm{W}i}JH9Ab!zrgY4=Yl}xrdWWCp`Kgz7!puktAj}OzI-JODbVxtU|Qw zvs*7whaIQ6;JAO39jxbD`jxC@Ni#8GajSc%l}7m-*O2mmDwD7D7&^12_QV4zxc zUxd81YIYW}=Zbf;MLttr%ZbI|+wQ%82?+r%B$9t9I6z~@+-^NKhL4aFO$w-dz%Gu! zL7BdsjBo>gX-kUERt4>0FWIa=EMJaxY9}63dRE)Ns1nZ!#}OFn|4KaQ^`QL;co&A_EKm z0C6M13(X*DP4ZaPeit?J`MrL@TuJ%>Zug`q8Uv2BcV#pr^#QR+6W=4ow3A6xIR~qt zDE@7BsDsG&LG^9KSU&fpSjNBt>yWguQ6AHE+|Yf`l)hw5$c~r^b4H2t{{Upes)d3) z=|Fw9Yjr@{bzO{~Vi@i%u6ste?wT@v_6>j#{Wf~t&Ihs92p$R)9mYe-H4FTTrbc11 zT~s($ICzbdr03;HDQLzl3u!OR_T~!$1U`0q&gYY@4TjJTWF=W9OW`;Pdv4 zf2mgCl}Y&{XJv7X<@MM_9fPVu%3y>Rlk-by^;5Ci{{W}R$U|p#94>p?`(?I`{zX#6 z=RC`j_Z|X8#^;mA=%d9NDg)&v+s~m z9u;5g{7zaJlK?j#xEdrw9%_Py;JqDlOC3vxkX#PB^M;o`$QvRPd>R9fH{jX^bt_YQYcUO3;qX?^K(&yK5<*m9hy|{bwS( zg{*#D#K>2f^1p~M#zXtOqBG?u6ZLkQW3;^G$A?CS28P!=wxf2UT&k7|*xV1NE3TW6 zBbkN5_OpzA6znr_k3dZO@6o+olH;^DRi&z*G&UmV74SZT*WZNJxBQ`KGrqD9#UMVN zOP=e;(9M>=S9M07`f$D%&GPaKq$bdFy1bb$adWH_?yKKDos~`};)QH!($VF3O}a?B zTT%5Dmmr#3Gb3YZn6f@q5SouD@lC>cq$;@QrL7tZ^~SYN1WZEG;MLxFh0K`~zUi@^!9jHKtVG(@SQitdSfTIaB1Mm_rs7~| zPol9yiRCRUeVnV3G_#*jwLzDJ8FocgSd~}Mx-9j z{{X5^Os2aRM&ky>4Yd4(%3k;Aq&F{7m&EHCc#_8#RxXDnx{EFjA6`e#yHD_y%=(#) z&-F57j2RuNSShbRBf(7lLE79`ONt|rru|HL5BP5!+g<)s`Cm5b7}0eYzgyCtWbNDF zfBxe?>ONCb!@eeY7SXEurHz&okpU&H{oTBRvfV!|lIKSp?Jw^&-VG2(l84i}+=4e^ zmGfBDO*v-ROl^gVi9)o@gU~ z-w~n?jeGtQIh}d&eNF!Wr#2w==oJQFgbET8*HgwVOxg(zWxfkO9 z02JDL{{X>n3|*F4qqK<|$X$wipnrwJ;#=v-&w`P00R2D$zjGw*94_s^W|u+1!onQw z?up~!RnOF$gNXU%1hu{bCrawjb!=lUId^C@LGV!gljY;RiQ>@q3;VIyJ+z_Jo81vh z!;sv3d0y+LSv0IVL{IAdDVc7K(MKir1IvfM;b(KcFW8mWXUzVggtg|C0C5~G_tiZy z(T5(;YtOpDOemG){F%zqlJy?$E3W&Rf*rI@@k^bp8$dYK=|}vd*rp_Hw~>GHN*6h{ z6kLs@gZ|nBYp-xImXV|3Lg~66%HymJaH7@$icN}JlVQQk4YWpBBCfotdd+N<;Awi# zK2>MY3-_S=sCJKK!uoFL_jx=1SG(M7hiwhr&X9udyWJ2V_^=$zs4@LQ46h9CBTchb zme0h=>3P{0Swps1NpHdV{1tEXlqVc32mTu3um(d#hwIQzAA z@bP(ZxBMw4Tj|K!?x}*;*Z%7X2mb()clxnAX8M!TV`xVcTl||p?x=p7%WHb3BV{GO z;2kCU#32>z z8Y})~VmePxld1JwYzR-?E57%C!rn>#%T@hK%rfzXpTviJ|ve8z?UMZr}ZPeCv3lPH$A+sXd`qP4;4aAGtX@0ZJu`zJx| z*&qdI;Qs*f3wh`~5yg)Xjs^xadwE%JseYp))WpDk_>xe6(ZBUeeM8sG;rQ52A}${w z<=+G2lrp( zwV0MmdEWjL{ZAvg#42Kb^0;{#rdC!K5IsE+vH4XnJ76ma9i`7wHjaBJ7jKo&Y$3>K zl_$`2@qYP5ifZzcaq_+0r5bu<%U{-0X}UswOP%E)9yYt&q|-Ky8A|Y&rGvD(3SP`H z>g{{gK(*j+g|(Prt~QyC$yoeaI~Ai%gRx1%Ws)lP5{}n@3%8A!bV;QbNP*v^DJ9bC zOkEE7ELu`$XsDTQ$`hY!KEvb<;>zO!nc;c5DqbN>_iw`JZpxS%3%$EX#B|OyYLVh@ z(Aqu`nWWe{4#ugd2U3taTL5;f@ViUarN~vyX4xNoTYB zBALZ)4bKkYHl`+vJ-R3*yV_{5xp+ZkBS6tNGQdN8XJVC?>B$@}{+r=r6t0VWAsElH zyml_QKz=tUfT<1An;1fQ*!$slLtYjDC>UJe0>0_^)<%+;SiQ~!1*hqlylGMvgl7W` zP}07`!~hK_;XnWyR`~IN1%-7u5uTln1KXvr>FsMH3o(dlK(zRubV9wAB%KVuDXh|* z89~KWsYWQS<;=`+{wbvfOvl7zX2&QFh8-A@w5qc) z#BQ`qE~y;yxsB7CqiL+RdaTmRvwCGrZlggt6_5%@v$9G*>a6jZ_JmT=o58$I=<*Z6 z-J*h16}0Lp$FVaZ_C{8+c*`Ydb5DY+WrHISJM_obgZ}`s{{SC?tlAd{YY0$r7^R}8tC48T)^-A+TGr3oTV!N~%*_zUK}OR2|_4}OW{*ZjhjqFCb;TrclxVX*GYJh3<#Zr|Wri{^sK|m-~JbeQy>x zqd53f;U1~ZvVKD6Z|xS;T;_dE7g~tuM(->KkNT{qQE_~&XePN-av3q^x-5`?qO-)w zZf9#`A+ruV2PoHloarR`HGf3)X?jmmf<`zr15UBX@)zg^J+j2fVsLXFJDNw3zck~y z(Yd;!3j6Q${#WFEGXvkS9Ok$2{{ZgQ{xfYf$LC&LYX1P87P8xR3PhK|DVhOb{`PzP zt9c_1y;2h~!2~Xba;MQeOs3>T+{3Rw2s8c1CV@za#_Mh`N!9x9ae}Wz4dmu7E zGjL&avQHdMZcCa0kPVPH2}U?b#|LQe;p|nTUEJ_VNOc&!DTMLSW3=EFSiJ;{P4(gJ zKfRh%xgAOtbq3-+$JXNOUxjHK{Tz_{`yMEio_|Qfc4H=FEo|0b+&q-Lzmpf^&-n_( z`#UTV;uHF(Mh4%& z9!gFs{GYp=e!KP-y1|~Gjr!cTV%3RHX&)X}&|=1Hc=5v+0gPy19{^BDbPkH%oYOS4 za-`9-rVn7QeOTGr9%!Bwb6%WWmE`&|zfpA7x*|(=OXHtWV2o^s7K#MyFSzyWfX1F( zV}ZI~Hu{$qET%ZLQ63aI;f&?l81bWJYy?Aa;+5q2p`pgJWX`_FfUI9^EXBoAdN?b2D}@VVG1 zJUZ;vBbX~8XW)glkySbqGW9o=)$zq>!`OjREA3CizZu2Z-_VpMP`P z=!)2mcc z`(yiIZ|{(e4}tQx7?ERi+-AcF4QTh}$K0eDlNK&JV8mx@8=>>_`ANwvlACc{wen^? zcFg71zeT1`QWsH+RkKd{jkuIOU&@#uw?T1obKU!8B~1Dkm5y_073AAubNC#uB%dtl z59WTNBATiCl--RxPfwHsIa!{umMPoP`nK@%5-EDNZ)rPO4x`j)ou~TFRhMr|7u07Z z$7_d|pE^4D<3r?i5?#tldEEa1DK@&~+9)8E$y|<(g$V$ew`mC*MOw(p1y{~Cgt2J@ ziPTfjy;lts%q|O2BGxxp3(po1B6Wa|dR+Dp57GHh;G;Zyeh^P83@0p)E4S^+;anb4 zfFL_v2WkOG0#Lw{wd-CCC3~>@Z4VHPM&Lk42jwXhgm#*xctIErq(LQUrP_71+g(rw zAJj|DCiJ-H4|KGqin?T*4c@xfT39>#BDDVibimNoq|Q)-zp|Ji5T-KEg&^BdX9$4# zM+&BR3R7X>44rVK*C5#Gh6j671q2>BL~hdQn8^aZt@Vf9TSCci zrt9#weUC5b#Qj5&{{W@Pe@xHuUUam7Na5$_^zk-8?Q6I$)&3E2{+y4l<})I4TZ^-smp@gisq zjmE#?b*q<_fo`A`Hg8piIej>XbBU|)6%zg&{#RWDbP+Ipkc%Er-7t(E;>P?^1m3Ky zu`L7al#>cd?(XmW6xhDZ)qRtjI2|#d{L)qPzC%RN_)&CHH2uvZ_*?3O1O6kRygE#hI0iIUhgW zeDW-ZLy8Y;fEp!oa`JcL(dlqEG1q0bMDgHanBfg=wzcfB*^lm#&o1pA>70zWJNFXm z<7%TT^%FYFX`{egwD;Isb=b7?wdb|$w*LUC8zBJtk!_=O?2ItoCDr_dY*?KPg1{}k zgXI-Scul-#pz3n9;j&Yi^W*tnqke(38`jgODhNQOuw4R$k++|WFaaq_-r)IX+= z>OBt|tbI1`QIkX->{q(p$9WAtcke}EapuqHneB57xY;nf<*sQ~S6olDEjMe$zenoV zG`TA&bYPLS^Y5a+srdPgW-ni;XW(Q3_PhT8kn#CnOl)kPCdm`qByJYhG}t5Hu9ZsG zq8-b|QA}Rz?gr9tfSrz3l1xBH30ZMSIuwCLNTO*a-L56B_C)kcfe+Xz+>9|Os_k&# zrCBSWf2Wi*bk~j+(C(Fzu*cmDn)1S^x_{Jd%IX6OX!|8)mOZB0_WuB}TF#}4W{0{z z+}*a8Zz33*JMtMD8zX>goEt=hl&#Wo@qPaQxYCsfeMvV{#?Qo;3BCCJz(@soR=-a5 z7C-AApNS(K+~-&|ccWkG5Hlt9u)BfVMU_9KB#utK(w<9Lj`G`lKJRt{ee;E|z(+A(*d9<79E zf)+bJ7{@6zxcNBqv@u=j_V19Q=6M=MKHMlCRB##z;0>s_10Kp}v=K-zjTBNDXQ9Pw96~5aQ38=Gl|{Hhu>`>Vy4Ncy2B@;_pja&>@GWbWB)W=CHMZ zn?6p;#Qy+PB#%AVk>g0$xCdApt{1D};lYB#{rf&yn<&s9yNXvG7Y+FM?R5VDzV5w~ zSbZusNF{i=INyb2V&c~ywS)3g;UXIT?{JLBnR~nc0Gc@z+t9BH zxG$k8B>EN(P4x4 zIZu-O7i00tf3rJ}j_j#BTd2qGcl#^&SR;k8MmtzM1;}D#F@~@J2LVVDbOo;?o0;3| z(O)2)ox4#c$fS6&CXu#BX!%UXhzHNfOB_{3?T84AkFZu4cqRPZd!{0VjoB7cB2Ljk zc5!@ZUD1L=jT}H|61<>h+Vbm4S4bLtHg2VDmE2C&dmxt+X}pOG+)}a)TJ!$^Be$ngoE17MLCP-*7OuM+L%sGAbDmKPkR>`y zkd`dxecuStJ85YNwdmxcD5k;;wu$_0G}lQ(k*SKql-au25*I73|6nlI$$h;PxRy2JjlKggg*M3^SK z$Am_?0lXv!w~$cl9;P0XYsxEJPnEpKOgDX^vuSpMc}HQO zhUn>xH5G!;V=Euoi+? zfE7szq0*<038gWHkFqRLeiKiO1GVNf1h@uHHX{Hj3=jhMdLSg{e`F^c{H{6I!ebH= z@)q`B4TaAs?0hn6!n4N0<4VsT3cM#!GAPCfBZN6H0URw!#1+Da=?AiB2xMfA0cqhm zg9Di0D6K1SLz}`RJu~hU)T@ggpjBXQlFMkdcGv@l2pAYewWWWJi}s5PrFtUSPH@Y- zCa^IJ9taBZJVET3W+-Uk0BKjB)ER*rOU8;mE<|}z2e-mMFuZZtZ&xVdE4 z*jmx~Sv^{Xf2ngAj~*696F_T406ZwVP{v!1((=d~`;8t}cd7NpoR{N5MYm2q#bx{@ zFzMn6*E+L_F+}gU^IjEf8;;K@8E?JP6SKXhFsEdqns%s`9SS^GMa($`@d0}Ym`jM| zRbty@Eh_gNpJ+=!rH2|0lTosTkfj_~vb{6IMFgbu1m3{{+RdI+rGIF%%B`MrW|Jgg zx6BFI(i8f24n%S`Tv#M*f}R~1J>Q)w@gtm582LSq0RGjVVp#+-ftp09E$({mr(NCq_v-QG6VA?G}%ba(;2n z{+{*RR~LWj?vaQ80Ji43zp=EdK91@xk)!l;wdCu%G8gFiqCQ2X-2+J)q^sh>IwR(_ z?opHJ&TrD6O5?|z;{O0dQS29G#F`4bsr1f;_0|KXF@KKVx7KTWWMQrkxLV%3(IV?T zIrwuu>|=;`b2$Vmu95VVy%QbZ`hIk>NaJVM0-QVzC9ll+RO0-3{{T8m&R*Mpvpyg7 zN6=kUp%=>=4o)E0mNB;f0P-FbK9u^`t9>;nY%&>fukM0FZT|qVUwV_NO$XW3a30aY ze0ynFpQ?V7`g^T0^WaK4erehJ2xE`P@%agBfy>@DQg6v+BUSXL*S#aJ#>RIyA1sZ- zA?NUXtrXc2`0Zboy2nV)=$RlqXdkvn-EBXDv>#3X08l!IPwa<0oSbp5xc$xi6{nx- z(W{5;A9$L0`*;Wrh76V#x~QzzMeF!oYBBmL!IHNdwBh_K*kYGQidNM7LgoOU8g7~k zoBlR0k>#sD)+39z*T-;lPt$<#wQ|fNjENj8&6V^20Ic#qLc@~JtYO`d7t=E_42>;#@A(6To9f*) z%?<=8Tosm?^DKx<9qfQcwzOSO7%{Fc(Hc~fN2?2tryd!hZVxJBeiTacTrB}kIzJ%^ z^+hyIwJjhnO3{QS?sLHc!t^-Ohlk&8^|fsVDC{@=)Px`(g{CwPhMU!MWQnbn{mufr zh(H0-zq#hze6EHH(w&BdcH3Q(_Dn(m2g($hBGjEV-z1^6uaN5PCh18;;?dG(J+_+E zOe_NO42G46>A+WOzp}hZn@CNJE*D9-$bkFWY2hyRtdq*3|7TTgWGANAHwgW@UDcW zRmm4VP&){5S0)dHH)C&v*iE4b>{4ns5T)o+=w$^yNYh|uKGE85X)&Z`JKb>dahPNU z){80(a7SW_mSLP`TC;(JNi0yUv72TY`LuYkxJ)(*MJ8c(x8X4iPI1y= z3}W{UD~wDE14@$(fzewQF$t(~jetV9iwEH|78b^pR~R%`3mA&hyMhX6(k?C@u9Lop zXjhehE98kIJYXwnhia|KZ~&~sg-qZCv2Y0454RMXiBfDh5WR3Y1i5%Rf*N4$JBy6)*%!!usq*J%8wokx_8OaT4O(OE1RG81F(xr$iwQSE;y5#+izcE~i2 z=Q2TUt4uKaTGtIEx|SG{7Yb01AjIk?N!HBS8(KpR=*>ghhzVe-kzIyo(|- zNsYj;sxG^VCym9wxNIQddquc^SAgP_OB(JI8w7>G=$)z_pPE@Cp^*Kdop zmV9ONa~g750pAN*)5mmeE9Druj(1j!Uafssr%Kl1G}z%Q?C0j3!P#6RYC=hD=?^0- zz0?YWctIirCATGC2XXgDo>s6Zdi{}tIVgwF7h}4qjU=J|pRoPz+BfSS0tAwGl20WJ z50e;XSE49vCYg}QTlOEj*;BpYysEsMx5p>=WQ1@kbk}-i!q+_kl;ysn?q&;I;S zllT_BmDJrJ?T8k9l!138$A0A;8EPqd{{Tnna$~$R7i4?r{{Rc-eM{(wGx1FLE;#do zf5U(6KlEQ%%y4lQ?RzSjTGqJsq`oPyXz1hQqg%5?kzpQANhsJTvf&U zLoMX1+BTTqP)F53bQvsj`7Cyi_EgyzIT=5xnn$ug%LG|2b^ENpK>A1O4yeZD^xQXy zPwjipU_T<(^P~MC^(Gu9Y^=OPEr0D77azeYJe(2oDsPf|LoQj5w0zoGpVP8j$|fi1 zwNzw0L)96O!{inAoUiHpeO00Pvd4=d4O<^KRlF`UEcb$6Eo_V-2?ONW?;zJ5Ac zy_sL4exz?i`jlnJf52<|pI`8}QD31k`ivP+M?5cVjC(_v@w*3t7r}G?0Hxh>0O#V# zkCZB@q_nGFq<{YP=z*9|=~852uz}k0KEZqKH~xv`we08RndJSYtv=M*B#?mVY z@YpG>CExx>OEI03n**Qy(vHYq3oyf zNehjfbHEfls5P&Uk>NQGCvdkW;l>xC;S6+9P6}h<*x@}EKW!(Wg{%WhlMlP$T6nP9 z2tT)v^j`f#fd}W;T!AtV9H70S9mFU&Yt5y?iHflK}#C17Y2n)O3MB;R9z&`^AU zc}Po`1??#&aQ8<6g_M~9c~NdaCzV4!VZKyg%Ovu$LwFibTS?TY-5zm}T z%l`l{S>C99ScnFSw#c)ht1_T@7Su|@NcTfviUP?WXY#p|k=Ze8xK;CHJ0Hnac^y%& zd27$z;H!J=~?wO&BV@d?jn`yZ*t9z$M8yS25&`lX+zYg+FM;n_LvWSse0kEeZ1puh=P@@Gg+ZV7X& z`sYs1_5R>=Rg>~vRNhq=MaFbcGofHCS8r15#|k)0+ZS`5{{ZrUIKSm>9lO{+ke7>+ zc^1f=kLV9|A8>~GC>k_;TAZbV>?EcQK2P^lZv{wNNekyKEjsd}EH`8XcH2QD=|(c? zW%T3wo$=uf9Ikv_?(OX*$3nSQ7@ZP0fPQ62dGyZ%@~rXk*#YjL`F-m_7DlQiR6PoVcE)e++JWtlZXblPeRHz8 z>Wd`VM^M;0$w=e-sC5j7{*q5flKRqDy`<=#6qxSPFJW!aIVk!bW_zcVok6G4?ldm+ zuA~u01lYx(`2$4|Ix}Id1uK_|*Vkur2|+COH%*40t7M3p_?jHf4)zroh1W08w2Pb9gVeiHZ-@FL0DTt(~@%nppmz~>I*z&=(dGpI`jW8)e&w7*B=H!~&k z%J(_(?0X^M-V||*O>}Ws045m-Z{>J606Zy&RodxNkp_9JqK7m^+X&4dqcx+mX@$b5 z#&1D%GlcY!jg*;UIR3ss$s-;P{=1!J;_X>jriopd^S4bti8?4OdGj+&`bO& z186am3raz|P+>-H$VCnjtq81QX-$TRNYOTn%ol7e77O$iQw{uYOGASv z8&!!Bnv6$XD|~u1v)FQQuCb)hHo7GMvtSKa6jpds8#bANwnmV_C?+f%D{Oc;(grg)R`{`v zBZ33;HNy08(N^QjV%GSV;tJ$ zSukMJ_6n@cZi;+KVJ($Kr4f;o>Wt-Ov)e?ipHYtQOEJ_T=GP}Il}MnG)Pnb;WVf`G zjE>sp^1$KONQCF90o}s$p(PI{&4A*PrDbkfHF&uD!I;k%>YVo7aB*AV5d(;#D)PbDZF@1gnV2Q0JP|Y zFe2GEUqm8jXhvv4cW_gT`=!xFW6~*-hbdDa4N1+FS_J@L`=&Oe2yoh}W=)-xM`Zvs zRt^p1MH85KwNo$*CbkrvGvqW1@QuZ%xfI7;8Q#0;n4Ct4UpMNxMUo!Za97bjs$gb2 z$UCL+zOgK#XI0W$w7s6E#hpW0Ip%1MpSUcxY~G$vcr<*XiDWK4pp>RWGR-9hI?uwD z+Gy%fH;CbrJYb(I>)%L0i;V*vAs1J*^UtP!oXebtG9rJ4Z`b1LJsJS_hc&(u$0mNw z9RC0&(_Sf`Xv56MZ~=}V-8b3l5?pk?J^ecBbM+j?vR9hD?R|C3leBh^3#utv@<)%z zvEwA@Tke*YBHlI>jB=iBMa_^6l;A?L0u((*4OvXNq>ZJd9lj7_q;l_+wpQAR9qfZK zu$8gVP1LfRfr}*XYn;XlA){$dh1MbK5J@Brb6VGS{oYlkOihulYrrich$EF;+-UJw z1}trjt^WWkMFOS!d3)%x?9lx?3~^0Ar5I^@Y`LxWK2@}!5l2KMzLRq@F|q~y&2S`n zsw%@KI_G~NDlU=FZ}t?F*dUAiD*0Lf)u*1?;Gg9H#7&zPmWM)&jq^LO4o~(9@+h(4 ziLt`n`6J2>CIAq+X$cpgp2dZX7u*m;`721o(SL5gDX;<4nm6T0vLY$3Z*mjhm78nB zz7r8SCOe+jG_zh6PF5Lh_8a(CjF%=bOWRFeC6j*k5PibfGHn@&n&zLlaDm&t7LOwh zg`_ps54cqMIIfJ?;Uh(+w1imw<3tNa{V8Z4aIci1bJ}%wrp&jr{lda2pgkElympcJ zU2{Wp@JRQhHfP39-9VzhIx+^w^R(dXi@QMWk)W{oSx&psSq$eQAma%;?B%ObCS(Dm zSaM2ym>j}8sCfrqpi(Kz(;L-s@|lR-j69a)z|-BmJ*zu6t(_C&B=>`6_EwjxVo2Az zPM;>-%zhlQj?I22Q;pL90EI7iYuMpee#$(v_fQ0y34uTD^73aL2 zDYLoff7v!-)`rmg2sVHMLl(A&w@_=$ z6wpqug`AJ4I5pDBC~~K1zL004f_R&-58M*Pyr#04MpK+PQ2eN4UdPc#f|hu*yrjcR$eRSa{#OgdpgG%7FI;MXGvD%v$t+idk8vol zy!Q_&lQtGH6a1#42{;dTg!F2XhC@*V%LlSvBASg}LUgqPR_OdD>-j^Gx-_U_u3@r} zpGwmqvO6wCRjjov!O-ypzLhIxds-elyv_p^zZ5aib z={Y3dE3(UBB`f@-W1)vgDD78d$Sq0onkhN$#QddWY34i=u2Z=DgoJUMRv677r4_Q< z5NIoWVWm@xrEQLQSVlbG+Mxdcj#Qc~DY7o-ZDCljh?AuDGb)i82m^#z1{G}rl5kr7gKphg$LwuLzE$T0K+F_Cfl*nMQG$U-KuQ%U8K3_qGmrn zIwexhDa%iv2{%DeWaGNdis0l}#}Q-b)u-Vh!<{4~h0R=wXGUnGPXJoJ2E1J>4vIZF zfIEZx(z3f_Zq@WTA&M7SN z=aK>pWRc3Uz=%1(ou&riUHSR0`qX%=WHaXRqW`Gkx}URP8ZRD5?;Z-g?G~aiN`Z{ zNgdb1`Xew~U$iD+#%n7}E;(Ib*Yc^X5a~S|8xA1Y<7oaC3;NfL8PVGIfH@25&20-l z7sbD=eR?SXz=(q2XrsxB8P6Mbk@NompgMuX%V{RIzVm_A1o+H*NH!P1Iz%jFO?yoh z^bezYuiWfszkUHz;J+2(%lwF5(eybp90Fb0?gwcZB{j-j@)Pp9JcubQ-P9U+0SM%= z_qtVYH}Hh}1C%_9DJ>l+@KVysO0P1w72jXoBV(i%ce=Ef4kw)=dccLlM}mPw`oP^E zExkLcM>E_+4;_+x1Lyc#!`mbw>^<#p1Q2-(=T2vGx%z*t90rU`yLKrw*WTm*0R4aD znpqxL^8Jx}kpOeP(c8V+jCkSFwX`bJ#CHn^xwoeW0uouSbP*TEKH=md(FDnLIoT6K zNaKJNG`&(m_Qyaaave18w-Rj!)9wA*OM@&OZe)%(drraB9U0rKsv__mq)6W@jWML5 zq47-BL)E*7X=pd@oNi=JXzySjg_^}|;EHG;DfYG9zj@jzisuU{axtDj{i%6>O7WuF zN=Xnc+lVO_F5EO`&?k3AvmC70PCp-;d zK2mQD1n!la@yqnqxQ{M(lnG!A*J!igB*Bv^-g%;H@RoUd{{U-EdzEt^Pm*U_huSBXf;6=1g7ezd;M(_IEk61{ zQU#5zAvvI(Bla&g5>NZA0e??u(J@caH^M6-AbCY0TMGms;*BWh;5GQ}Ye$VEKnO=K z)l#&jf*6tX`td^_Pl#kH_LLQzBTn;6>R z5;&8v$6}Lshf1eo8>&Gt7Mr54-39Q3{A=+@sTHj%;kK&?CaOY`0^QOU+sY~7Hj9X| zeo>p1@~ImwA_-F#)kxaC*qGul#1CFu@}Wb;SAl7Qh!@iLt6UVMvr+@6+1ff$BFM{Qlo=Rg2S_;X?kdg*?zFaO z8^W4=9^iDVXX%8UwQWC495`G!K=KkkG{o{gd6CkI2klkT^n=}ftLd`z;D5@Oe@t(d zsaFDHDrN@{rn#;fD?x>XTV26jx2Ax2Ph;WG*hV;%`Wdlqp9yUO6~t|!!jxFeXhw8& z(@UWUE8ZIAb<(C`0?1kXmhVY;vY!KiY2gz&@<-4^g;+y;l(U^btp`(NJ68>mO514# zY$D~aVa($BTsZ={OTH%*H$v}p4S}S|qskxZkiSmz%9zHkRt_R#A;nFyxUM!J7f1{i zO$C5V5>O?Pt#uQ$9GsxgLwP{Njq^Xf_O=+biiifO7rufEK>~uEXPkTtH4mjv6@HVkm&{id#ivXt|D*K-y*6|a^+1J?5&DmL0tMI=CzG z{>RUHUPGdgvP-3)!%EQs+gSJN!90Q1S1@mUZRX^lpdMnbe~J z!?r=lR<4O$x>^_E^Fc2r5%xfJT2l8pLGM;l;t{}0KOeMgf1(;X zVzG6+&W_UjHi;PG3!!$yzv=l}JRhpCdWJiqlzy8miu9y55B~rXvRzte@uDtw14Px* zMbMKIGGZW>)7s&}S-;t>{{Vqs9M`jKWh5HXQP(5LB&zO<*5K+r(Yx%)X#1Fa{Doxa zQMRMI=$DQ>V-VvsbG2guYXGLTRYdYmoJmG}&n(scP(Wz7c4I&xtOAn%0IDx_xu%kE zHpR$E37=K4fkMh;D8Q6F{lK&@KO#QR)&ag!*DgaxZ42;=77uNDjsW?z=p7?G@oRx8_2J8O=D@HDU|O`hyEnolQgyTcq*?c#qZY$2S9g#331s<%>L(S=}usj zEpaDn3{C9S1(*7#9z2X^M=N`QT6Q(n_gyLuxBe^3S~L>TZa0j^#ezLHv8~|nwA~Il zAPkHX+w4+s!PzT+)m~xKUBaJ5uJpJL>}aZ9$qvZIb1VZ%c0Tz0BS#??5!0Qc37H2{ z>HS%cE-!-k_F5c%p(B7UAzn!fG9oQ|L0GQ5_w?E8{G`|6Q;n~(ylGcoX0klS`eNv~ z_$k)0vPK@-+QGrpNsGD0k+6QVhZiBR%Cj$5wDcCU~30I*rDPNEdtp6_)@mC@PslAP}=Yg zCiYV)2+U0fYp!S9xd3&5n`10(Q6M*fVuYR2Fo)cDM_>RhmjI3fMUFd3*g>BPH#daS zr3h}66fpy{Db{)`Bx{RWEn#|=1F@cUrS=J95;Uy0Ug?4DaSdGj+)1rPVzHk*Y-M>p zkpYSBX&f&uKSxFxa1j{5h7a~dV&zCg#v_a-;EZ1hctJE1u(au3P;_D_$MTNY$FhW6 z@R(@jSsqrM%Mv2@fNGGHMtpI~*hw`8sX3@Ylae!9QI{_3|J68cAIuHc0 zfPi8NxdA9dd0PXZ+Y&r&8eAVJtRZX}Fs)^-`atj$qj&s~+5sLG2!!y8x>eIdz7_PK z5RsNU8bO;1heV+0+|ZwVbWkKTDI)?xt+t(}B0MGD@D9}DkRA|B%(I2bp#3Vyr1?xT zO>H`2=*YLbO2-N-q?Ri?h*#JU>}gVGNz)s}gNqifF=6;o!h*okwt^Y~T}eG71VuK; z$O>`CNZqNh8Ufk}5;%n0$0=ExqJYR!Nr>{1o>NC@soHH<>1#Y_Kg=bj)L|EhZ!s(5ojkvTTc7DHxNH$F31QFNK6F{UrxP zJ(ZD*;S9up$(5!p@VeI0e8h(6X#xCzT`0Nej)?mqn4DJ7Xtd4lNy&~~;afHv@TdBH zaE^?q%w$CPk8@2BjnlrO{VT+ClV2*wJ^|QPC$3{SxR%;3u(YY3cR(<;KzWs@#mC@@S;kNgFCaCI^W)nCWB#n_3Kb z1aW|?I!ghY+_L0|HN}_O>0GD4|7pe}E5 zqov2>MH_BO8sEuJ;$u?W=uZx`M`T6?mjU@J=lOq8VPhWF${z3k02NJ{*K#u*_Bge_ z30g>HEt~$e*Pz6S&0~Y4@B;b%c0}3pIrBWx@o7-^Jln}}`9*11qMSU)nmjJMwS$B} zMN%Biz5>i_Dte|Xa#%$yW=~FjCQo}ot6xQId)P|#3>HelXeeJFmw#nuu&d~KgR}=7 ztrMN?rbko!dmLCU;2mq%V!m4+g;;go!v=?7NBOp8cP4XmbVl_(qOr z0c3x5^Y5bmvVt5~`hQoE&7FpuGx%^@*mB{>0ns^<1Hoyqoimc<%f>V$FRS12NYS~V z+r^>Ad#l+Uj0EUfWJ#TlXISk4pec7tJL$Z6E4_f(ckqhVn(Sui(EBA?_TsTGCg9EY>y9knSlk6*+p3hIMS|G0c#Q z1)3`@B$DMzauQbK>kLLSHb4OGb%mVi-5(-s$l6+49ocf1Cbo9(lGc3_=y~VE=@Ivv zap04>T3CA4OQh`r!lS}(WNeMT01ER4IOAw@g}HT1bdk%*mgwQnox~0a_e5-Ryjz@2 zs$Qj!mo`jp$9HQ1-qwyO8xA`JgjgH0wynZ1q7g%06D);78RLG~K_3XlW`K&p9%(ZR+f!kOKAL@vS%vbsjq=%jJ+L{hoq_Y{?H z=y`(i*%nUJ21veA1;&ohi-@*lvviy#y{u-O=I$W^h+RE#Aq6iPlo@G<`YQ9u~` zXkJ!bau$}jo@lQwK1uzH$D`(~iqjFn!ZcAQB8us8$`b|Sh=81lm0Ct*9(0b_sdUOV z18XS|3JO(Ag#Hzh3Bng+J-!zy5v@fbf&pbsth2)8ZL*n-s)-802)(Is@Oh-`BhsIz zVv27O3a2wfQd(BhwVEowDIA8^gt#!1pGpRCXn7>HBKb%qBML3z$)n0{Jd5RLqmX%m@oXjPcqRLMF*JDhgeQzycS$RxklcH@3*D*`&0s{)<)1)E4 zg@dC0mlth!b?PDK8BBdP_;$LJnU;Ynl5( z*0K->3Pu*VQ43-z`B{g==z=HTYSLjr>421I|_KeP%+siIh-0i8(7@lxX#i8mRG5DV)q``5PlG=QU@A+*_n}+ zTU-b}#eCcPxAjB%d=bHkCv48yAn+C|59@sHmkZ+rNIZ9LmN)8NwUO3y4B0NXAM{-O z-ytc<_6g&A*xP=v>f{*4OTOdotRU(c*=+`S-sAaMEEal`@50vLGzNj;LN4p3%lJzA zEU=>`-OeQbRpbN!tMQ`&qLYk0zbmVaHS8@R=D04pLe{02^)f*es@S+nJcHd{6S2HF zTCHq!Q!ICDdD0T|*|2aAWeMIG!+W&5GRJTW*>4YwfQzM_1tO2CcFlCC3!jfJu9fuc zJ;#)+kTK?n7P+chdRqwM^+MVNtDIh>$pG5U?qI0^Si(#L{sJ=RKIP2B{ z1vVS(gz>+G1E3xFL6nW7$Z5Ms%_tfhKx8SJM`;R*Y+)fJg>gN}_(ND z#s+f;f4g`p#y_es96+am_*loqXMe)si~uW1)53p>S@ax!yVIq2{_TEKj_bWT54cJk zeizM}!qmeWQC1`1pXs%q>iC-9@FFgEi&WEnN1rQ+?*JbGQc*=_mh)j;a)u_(%bXDT z4HT1^Zr;jqp}n@CTeKU|u`flh(=IK0+4D<4_M5s{u80dV#d!--<`+9jq=zT@0DDb) zl@QZg6r1#j*)po%XWPr(h<1sJ3IxK^03 z@ueiN2@s1PjUqY#0YX%32bm~B4;NTf#NZ-m?s zA0aLn^eiW7xZ?OziXJqbh8?Ey28%Wyg)c!_M*t&)QIBVY9GgmqWBt4&=f}~~(Y^o% zf=ebK29o9nOs-rt;ZtP9LDJObN3yB%V!P5-gk^3dhBu2^zLAytcC!wE_YxJCPIH<< zryXHBN7A}ZU4SiWaIyga+WFT@oPgD;z{=3t?3bvH8rbEqj2;O1RM`0!R-I(F5z`I5 z!j*nxv)s6%y)v8>4z(Cq@xq`V)0p8HKNC^oufE=Xe>OfxBHWwyFk17txX!5gDbzm>< zAKfJRp1 zOtLy0ENuhZOQ0eauCGF@XDwv}kgGtO1M4t)MQfy|o z8UFxiM?O_{#&_J3s-t0W%})S>bWOgFsqZZu6waE3NH=k>Te{%wY4%8<<)Yn9@e} zwwRQJ0i>+2t)k5C&{7gVxcQ}qyLq zzFrg1W1Ap~NF5X~2DJO7T@4bga$D(|_(&Tzc`5K3B`jOILUSyu0}*&P!hwhmIZDx! z0SsXB5rieX0d^)hN&p)7Rt;se5)ejgN7BA@xMQUGj{GRT6I_2N;51AMTx(usCDqsl zz0)m>_~+S7Gu$nJ(tRo2nQgx-T*{!~TUl5lq8tqntugwfFy=>zGr)OobsrbPX(P(M z%W@0NctvDEp-qkMqTP;Bn%!+VA_1CSnZP!@uD#Tkke45Yj7CZGPL5tsIXv%+rn1?mZBL4v9 ztFDa|Amz>FU($^xXz_ZmZEadX*1@YQ4qQhC4cRYe1z(Tk)cYk|X`fTY>QP8GMW%#| zcLXn#LDl4pi$ChDBKnE#t9SaYsreB7k$i0*N^KqrpJ=6m$NHG++^pdGjihY<0Ch#l z5A0tSY8=t!Ei|l-$sH79V=N+DLF>cvWb+l<80|9 z{9lEABzA!4MevcK(g*IdSCGiVod~gxrHxx9RY!r^jKwAI;IQ7CO zU`Fi#E;!N*AS7q5uw8& z+6v8(f_P73<9t%INj8P>;&Uq1?hzuCA;htup2x-@@UE5$(PN19X#pfE{Ah?)P_)t_ zG#G6PXnTTTqf4ZCSOp$l5OwKOW%RIb?OPYL9zq0=HN%BB35=QZ%)XP&HB`;hOm46W z`egVa!-ZLs(?JLAQLYrm(;tM_M&Wj~Ju-7cYQG}~({+KQ&JZndMi+<~r^lV$p)hhB z5*Ti_s}?Xg1#~V8%aHT#t1~B(jVqZV0C20aqSpvC$5>|aawYCNQ|YDB$N?oAE)Tnn zCVC_wOKNI+DfDUjUN6`Q3gxiVcth#Yj=-$_HPM(yO6Nvbn-RUM4@55rsBnR_1W{5W zn?VynaGP5JpbK`U8$nE#v}~kvbD0}X!YLRZQk|?}7O)+EC)9I-vg=)5>;kfL%<0}u zrKj{#5QFi<$s{hf6~4);QfO0Bii@`qzByi(3>NU5YCBp8q}YIDI-VH;)D~)180KF{ z&&36&OB>bkI~?OnpO!PQpjgs1%o|NW?@mDBvE@)QS48SgLz(viO5=T$M&AlXO29#) zt+7RXg@wcFA~FCvS{x3gWN%Bif|g-AG-aAz4ERIG$`T4CI>6U(x^P_pBP4L5=!hbd z%?%uoq+b<%#4Up+SC6!9C6ScI$wfPxG;Ig8k7b$5lsap_DtwBU_>`85r%K01qv28Y z8Czu-$~`}{g-@R=wx8&<79UOFH#@7v-TPX5kMO>rX|aLqu%FY2gtO#K;rl>nTX~oXx@Ve;5ARQEt z6S9NppXEE*YHf;#8z==aNTHxfU3_GkP6pnWjC4hgP&R?agS3J|bFc*_H$(7)nJCA` zmRD#orNv~8qFHjsEUt~N1?>I8_)3=S>r3W63+f4*^*g1OQ|V77JRR!S)%_u(@4^#G zy$8?$DVx&cV)Edb^!uDNq*y~vkub1ktr{z>g~qpZi`qv43^s+1KH*#@p^Oq*?my;* z6G-7b8#}7eNJhoofYb1yCLjQR&0D|)c9Fu#e^ff8eJ|}1m&Zf2Ov#k~R#lghzr;C%3hcK20 zmb?CG=b!pYxTom-N+xWhxsC)MA#@903F%*7q56V6fY~D_w5Cf!lP|=3x}{&#^1DxH-n$%j`- z8Dc6liYKEm${f*YPNNVlC&68c+C`#d9{B!g2in1XGZoYs>CSzRl&7iguR69ja|r`$ zHu75X3iG)q#hnT0&vl{6u!h+^*BtOiDq#lkMFGVX#?Xo&JiIdF%s)u--ympS@D!g(f7R zmYLkrR)$O*j<3R6Nf)JkT@;+MZ~)1?Uu8wj4hb3^0mgr63fmSyRZF9zXwb@8O%Z zGg!voa9L8y>ImWlcMDk#rS%b%kk>3GUtE_e zLK@#uKdXd)-doLY3=XH{46gGzl7~-4qm>WSMvkw5I1unIQ(PNIvU}*-6 zDVSQP96RAM$iPgHt$>}E63KO_+IzIM`Hl^Y*RsU*x#`3tlrHO`ZKai+x8V%l`cRgiA?l(-1aoT{Z-4cBQ5)y zEmhWqBNIHiF4oc##gKPJZ(oHE{2?bkBtRMgE{u_jYo%eLp^)NR_XVk!0gfbw+O5yP z4QZgWY_}UUztgw`T_y6?-tx543$m(yjx7HG(II4Y_ShrkT)C7DqdtyHdrgaI@DyA$ zidt*!&{6CqFrdM!uk>B)E*lgP*%f$QF_&trkap-Hh8nW4NHlZoxkS5- z@QN6G4JmqNtp*c}ZH1ru?Zr9j8nd;E>ew!1T@%n}|2^r)cf5OFe96(|%4GN4uBd5wG!T+e~O6Q9rCY~c@>7XsQ^*`!3a@!Y5>GL*7n0x1Y9c^*& z+w)xi0A=&bQ0Kh;5Ya#Sm?4heE54f=h)RJzS1~|R04{=7P?>op0!H$PtX1P`6pZGm z5wvtuu@ct;xBmc1bwFYCWp?~pz_B@tJE3?jG>l)1(4>8AIG@^4U}^6QHT3scWU;n6 zoIAbB*1_6a+&62sX;)|rVS|l65$uWlP%12_|=4{{Sf|g(gZy z_VL12NjFKkUG{vWD&J`sK<9zzl6{sVJ-}o-mfxv@F^c*AdyncJzbl*MA?2MCZA?1C zRj%HVT67m6;Yr{qIBiG^;c>%zSi~9;K~ZXzdn{m}=qa>X2()G&2n?8J4n30tU$!sbp=GQ5xr};%OaJ!B`y(7led~@q%NY8#z1_hPrIb`Q* zQb}o9T#>QR37(LiO7fYQ+OC+BbW|;sNEDN7fP`h=Di6NxaD_#4)eV8%`zwOULjf8w zw3-Gp0ivFcN=U|z(^Q^s1;WND$fV0M54x+3{{Se#;aPHlqT(E{m0=D?xmmVKK}^Sy zUQ;en9GZCN2g)#D#loe+EQ2n@R72Jrj^feBq4{$&4NO*b~oK%E?tJlw6s-EnJ;a36;G2h%j}hLnHO#8QRaQ5r)aWE=o98G+f5(rl#?n)ow%V*A-Y0{ z_ehnqW*2=irOp>?UDKn`S?-Mm_UUY}Af}BfLMLqpiZk27WsvztiXCv7WJM1;JMxfHJ9a$xP66PkV3gsza#Rk+M_jsOLy z==~+q7LpAtmiiI0JwFBWI1_7S(fUYlX(MT_mD1ZATcx1Kh(;&?x@K_LPZALe0+f<( zOV4!#yNNC-!ihclDRR>Cn2s{wC}|MJ8ZXMh(P0$@QPqn>94?jI=e2~aXJ5?iaR8#; zA>r#A`hTy<(&ls_o}^DVUd!pYvbQG$Of(!UTKL~VjLnwEI9Tor>+qkkpb8aK7Kf;p zI!W|%vX44a00yR689HzZPlQo60&*ha{A`sD4Gp8yK>Kt($_to%`B=c+vb;r*6vRg8 zc;!e%&vDuT1kuVf8TJBKC^v55DU+1v62@t?ut4<*%z&|)Xx00w9H?|v(}$9h%MGHo z42+LZi=8abP>gR&UDXJuQpxoPea>9=hSG*;$8xdgeRDse$=!o9n$m5|J8QooBQD9L zBc%k|dW`!%hll-4osov+^!TNC{{S-{TYtvaC;p}9JXSsyG)LXUzx_16cVg6<e^T>X5fb3zK3X6+In5v2{_3OZFRZ$kTZBO&WuFn`rv9QGt%`SFu~-wzbEfxxp`aAeZXZUe)9d)zY?-~y;TB2t_Ntza z&dfG`6UBF`Z3D#hmz}AQ19i%aMSd5a>kAozkeX19mQ>xc5o2b80+roZRx$i9n&+SX zsnzbjk=NcCjP0+QR+DNs#$zgDy zfH4{wu}jQ!(SM^m(mFFnLp8hhTU1Za|~a`5DwkO5k`U=?pw`8hFTf*J*@%V1_wUXo*J zbImFo=!1KMH}bU^`7%uM2L)5*WA=-X{{Rc78x@#dqX}e%#Uo{2^^gn8M}NEEpVeOHaJ@bOv&{K>>63D*bi}_w)xnklS4|PTps6s)Q?Vyvk^Ws ztmv;{dO4dP zV*)E067kI6aF}F`_f!#t0ZzQqfRVo8LP^>ISW$kU@ECTmzfc`JYgwN-Anjwi+`Zkw zbMtZ}hf-qm9Ok^yRkJz7m2H^OWmibe>2c`k2sxHhSXic4&wU9 zYb5F7GwAn0=!DTNTi%({+Z#r>P4u3Y7{=RG(_jn~0_%=H6efu>F3>3ztn*2)Npwb^ z4WdtFoi4?VXKBX6WfXwGwi`vvVU0?<-uejBKe^NqabT^LlGlLJyFHzlU0b?P=GiCP?U`v+98uAX#1qyAP1BlrM;T|hB?|if|{3eg^Kd1g=bs0CP1d`4;`BpyPGRwgpx-H zF-)h`xqYoH3kprT)yLGCe!f8idsLvVPFJjSOn<5)Z3(B{6o2N_0!%J~R|hI>Zx*mg zH2Weq{S+EL5nA8wfr%2lHdhY|mVibYX?M8=uKvrt!E6{iU2Di}E6W)DAjt3T@TJ<> z4OJ+|C-`7;r4R02TSfFPG)-ip%F^NOpa7_&fVx2Cpuhq)4(?HrXmU6~Hsu_rHG-On z>RW!)3ESQkS3(sz^F892swBj7zUP}N>UOp}CxDQiUse<8U$S{-De}EI2aEDncc|nU z2@#LvtE{|dy~fI}%89VFxy!CFR%H5&eW&i(D!I{vbQOkto}|Q%JS5K7Wcn0U@uj4U z?O3=Wx;xq-MLew_pg5InEH(;GNOqNtg3OSDHk*o4m-LaNWgP{y*f|^Y6EXf$vA01r z650^$Wrfnp2$6iEI0_WAK@)p)myjnV6gWyqRGKiXfdR&fW#KO(n-m!;ZF&oj{{W@X zQh;PU)|8t2q<4+MqwpjjIgULCJhp+BUX5Bw+-n( z8rl|%>@0Bb4J&AIz-Zws)4OOZTWA7`^&)W`>{;;yypaUs-YG2ijixdak`R`XAx*7=|Maz5gnUEWGQfUSRl+0;ldRCJGWpf=wd|3gmx6(ItOMxX#Fn<7v~tA zR;{|TLwlA#sJrhX)T*4ZrD=N~RipcpadlvS%&-&Wy_^)d>e4aQb^NTkv)EiS>J92w z<#JA<(A(}`JcFz~!){Fupy!RbC3CYW!$+Icfdm3pdp3UivVks1Bb2k`v%Ri*ru~eC zWp8`9Lu?l60W=z1QqfIQXfs=4%U*@x5UQ`V4%CW zV-}qD$i)b2KoqCx{3ja{^O^ux!g?v`fV^7Jr7VCYB%gX%A)esSlbT-b1qZyigh*lT zHtrJp8_@`3B-uWfa^f7CM2qHbo#x{nxg^(G>vuZLjlMHV7UX_|m0eJaC z>RAoP#&e0SA_3cEtasJ@K=!&e9uRL9g%mwst6c7)sb}hhlSNnNz2(I1R!H|<61b|~ zlt{FfJe%1`%amPdGU8W(Q*_2iBb-UHhT^zfpyOdENVwTi1$3yDfOX4-d2s_02&~aV zVA?ZBfk*`tZ^D-RE;K1tmI=V7xB}|NICv@!3S~vyEQ|r?zR#%8QUB$mGxYY)MS%(El-lX zQI}$q%4Lwb$|Fmkg~GVlRv{>A4{;60-nMD<&ga$hJMq#ZW5W{>af$W4G z{nUe&G3OdXbGco6tqJ7=XjZ$0FmBS!ax)FA9T<&yaxyqxSYz~?W9%iosJ+H10XhoL zA|{Wx4XWIc>NJJsv{y@f3ELnQM1^L;=LMJPPINL+H?^q6cymJ70q95!`w zl>pOvDCw}~MmUsNt)yKGA+P`eSrSJk&?aDzY$r?wCS+_aJ)m01_1NSsb4AkIWaAP0 zcvTtLz0DxCS}z4?`F!myc@79GOV#>e?r~@~7JgWr8^~~_-xjF@0af^2rO-B~N`@DE zFTz$>zq+#R(AV}tk~{FT7_2dQUJTl4?W7~4w4gk}v1y3S6t8u1h$LG~I>#b+&`60m z+Ub~5+FhIT6PWV$P$&&%kr>M>AN7VpZnfSO*P9cm!gWAB>pX?M%=xJnz98!_iNl7`|j^6?kMH9ei0*yD8QK#D`HFt=q`q%hSD)J zYq?FRkKr*JZ4WKa=J|dXHHzar>=i)cXoemziBtx#U_lB>nuXTXM_a+)B9V*?d_-c|4h# z!&uW}mFh`jP6!*)eYD`LAV||O$K02Ue`NkJ32@pHD67IaZ3Wh%wYo%73>#8-vC8iV zGsqayRxpoJm_-x4)&r}w&m){|YI>ZeSCQdU&j)bfPm{ijLNNLG^?Fn}F&4P3*HFdL z(#&-TD3!}{GbmhXa5$;V(i~9yD43O%#l_;WjupbXyh_F*xJv0!K7G(JHaRgF309PN z%E3b|{3(A5Eh=MS0ghEJQBoq8iUuH{^P-6DAh}}$a)XrI6c55=D+J-n5k_(uui-F^ zh(%%;Q)yTOFg&rr!t}TQ07-Rk+Z>Ly^Tw3!4qLIMwv_Q+EX`^5?^&ih1aPXlmsXDz zrpQ?6ey1#lw7YbudZ$&BE&l+x(&>D%mV*l~s5;rnwz1k-tFmu)qD`HhF9Nx4-7(U_ zWpIVo?RK^0OeAZ_3!_V*72*&8MmYFIYqv-MZ-p>)uto!vy-$>}c7o@6#uzTX6bVCX zl>BXD5(8+>KPWBX5FJ_p7>tGG2;I6~SUP^J$aoDqRWeS_`UqKhjv>PIsy%y{SzJCP zbiBuL^|Z8j0n{$lA53dr(C;ey9l^d-+8D8Ki&Lu_5ZBkC=xSQo)j+!xy z$;2^kw3S*Oq#9#+S<-&uySVRAr0azjiwZ#h04vT;VHt%bBs86$gTU@H+FUskcMJuBYnSM~@meOWj@}gz2%A25#dYfME(p9h8C~`*3 z5UDgM18A`!rWl31C*qF$C14g+lnu~Kg>36%AIcz)NX(5DthCrJiuy~fkA4$kFN8q| zM-8HljTMZdnh`I0b;2ndLSr~^tQ*FaYjl1SP|4wSaoz~f(u0q(n@94zTT1>~C^}PP zZ`pG}d(;j80Q9z-2ABnKyx~Z2$PbimLfQ-!lpvPvHuhSGjbNd6#+Z&haE}}a-kln^?iu3r60Q%!u*1+&UOm_<9XYn&Gg{O{5?X`u$$)yYjYO5njY9C$h>D;nxYU zEk;pITf}WM{YD{2OCi-GqE^qTngwIJtH#QQB;eboV=NBhl{!phy5uQhR~s1RuUIN1 z?`y!IVr0=#CYQUE30TLk`z{l;8=$%h#t77S{3VAeh|n1fG$J(#j^8M)JRmVKzbl1w zT`y9`2J63sHzN_WH!KDL$WBI+a$y-8*-}vQx(Z+g2DnoWK(!{5 z6p3sitzuP7KDFK`sxKe~Yn7~wop z;^AFt#wI!8OmV`9s!|IWBY0D93JgSU{m>Z&XNq23U|v`!-!j~GZj~{~4WDISb$Ck{ z>I)^3#|M+O&kIT$E|=5;Kpo3ZiIUk_qqXvkY{?=Z60|)lt#-IusV#Avv*>Y6Mv8Hg z2YFe{UZ)i7A8@qOa=_rI<^;Pk06E$jnw4P91pUQz`Ec{<1%f#&;0>zGtcKaz23CAx z>ETl4^%$BD-DrCb2hGXk0;)2np+M~?lhi?RqO0c28*wY55t=>JvMHsY@~O>|Pr8;w z=0}o-9J?W67XpV;RRQe5F=_ZnAFj^S%7(+M&QTKo}hZ_2k7c@wpSj8>-}J=T-Vh-n#-fVvcs z(MG@kLy?~f!QcRukOEU+1RTq??$FU?F#2xQes)k}2(_?bUZa)R(l2Wp)^l{~DekYI zLEBjT+=DC5^rOjmQe;al=0}oBU$(p>9#mYp8Ym13Dq%#BIZ-Y^DUu~Xx{j9|^dkM0 zItXy$rV3rIxMg5^82rAE1y4ej`X>CtSUs_>v1S=^1NJz=X|bGha7gJ+=k~bcPuDURC~4Nkjtzu zU91v!y(tT&2BI-z6KL8qKh+2$H3l*W)A>eX6Go8*pfFHZNz#jK(&-BX;V5K#q7inu zM+*R5<7h4krw_^tf+$vn+$jmkhY#$6;c1YJ8tB*MamU$kIWUC4_fu<7!P+Zo$UtOB zFMbz+3POqjIs)iiUbLnNH%P452mqr;$Uq@$NbRCP!gTX7v1pP17F^l`GX=Zb< zv~<3YnT7~DS#GnB*vD&YnqCf>fy-cHs8U^$mKy~#)f(X;Dwv=!@!~ND|%;0%ZGdj6R_>jfi z-D4o#$bY(3-9}ekxS!=!83|Hrtfo+qOCQy|{{SnVvyUYz(u&@6ogfDeQo2J1g6DE{ z%+ONh3sJjWX!4GO4?l|XfbbIpYyOHMmQZZiDCq4C(D92rh@= zT}cfkWQ+tbMphVirPjk%ob4aN_h6x63;?)|Ma-n@SS!LDjO7dliRcvGDf^Hf+B|Zn z38V0~2TROiG_2y_DqL6at)P94r$8KHVTCOjw8IYhOaScVF&O+M*`&mN5dcyYRKn-` zA_L8Dy7QP6!jT#)1V(0GgoMo6wvZ%dXy397bXK{PP@Kfm#UQ&$M?HpnB>a`Bz{qH5 z1!OymO^#DBXBG{Wek)V#ON}=}F@O)bMcM2gLd8F-k>vjXbhN!y&blRZ{&^+%S+$#~ zuz5;5skiU~nd0IGR&q;`@sNavlDE1&ok z#(Os@(zxwczl2wy$fLXLe84V4d8IcUpuF=)FcEXPLN4cP&pe?LQnM3tlsVerI{8uw z7*Qs2j@^U+jL-E*FkomdhbWF0S`Y|ky2>+mT1&JPcC0W;_T@#xYu32putB(>NNpA6 zF865>SP6Mb$;x5rc@Y4+`9t|A&O;$#P7YL>R2P9*!vO6y1ciw7oZ0VDz(`5dku2NYYLa$|K&S|bvvt<)|Rea#^E~V;Q z@R_AOOP;^J)`_E28cCfi}bck+bu$SCg{OUM=# zf^o8=e(Qwoa`dpI!jSx^56YMe7@HBA5aS-`%^nd6A>kE>ZA4;LG7QnAW(^^*CK%uB zfXM4wrP!v?@T3|L9|3lFTysAt;lJ5=&9TA;!AG?L?fj=))CaXP6`?1JiV3d3l_jU|}s_Dn|W6=*Xcpea=q5LCv(QI0PaBTI+`@HC3Y zY!oWe?;Icz#<&os)1;$cMHjhX2QCC5J=)MwFQA6!B4f@pfah=7Nylg}cJ#0b6TVRx z@01HuVfI+ab|Oz@oiUL5O-?XXA$`ikG7y;~AD64#T>Xif^xT}0_2lAN&wu#A@-NHgI z3AlM`CLC$uH5p1rz)Ue<2kzCbgJ@0UDKmsXFyDnFDE|Oznc-ldQXk4na-Ng+l!ix? ztYjr8DR-mFmwE$&e+hXCG@K8@UQPD{!2xVZAas-EViu4!qm<$uQ;R(ox z_b5ArMF8=3fd2q=)43=P^Z8687LX8@kvBJltiDzN2cbC`{!m}aR%4#ZLzN#PO}r&x zCedQi8)##J_(f+a7{ME~R)q#|jLrfBB28_DAQzj$nEj;zh#&P_B}qldm>ZBDHjd;s ze&v8D@PP1|e{w{!mMZOuGn5f9zP4n41fRGvpkPV literal 0 HcmV?d00001 diff --git a/tests/FeatureTests/BaseRequestTestCase.php b/tests/FeatureTests/BaseRequestTestCase.php index a3e606cb..59ff6975 100644 --- a/tests/FeatureTests/BaseRequestTestCase.php +++ b/tests/FeatureTests/BaseRequestTestCase.php @@ -2,12 +2,16 @@ declare(strict_types=1); -namespace App\tests\FeatureTests; +namespace App\Tests\FeatureTests; use GuzzleHttp\Client; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; +/** + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + */ abstract class BaseRequestTestCase extends TestCase { /** @@ -127,6 +131,34 @@ public function runRequest(string $method, string $uri, ?string $token = null, ? ); } + /** + * @param resource $body + */ + public function runUploadRequest(string $method, string $uri, $body, ?string $token = null, ?array $headers = []): ResponseInterface + { + $client = new Client([ + 'base_uri' => $_ENV['API_DOMAIN'], + 'http_errors' => false, + ]); + + $options = [ + 'headers' => $headers, + 'body' => $body, + ]; + if (null !== $token) { + $options['headers']['Authorization'] = sprintf( + 'Bearer %s', + $token + ); + } + + return $client->request( + $method, + $uri, + $options + ); + } + public function getBody(ResponseInterface $response): array { return \Safe\json_decode((string) $response->getBody(), true); @@ -244,6 +276,21 @@ public function assertIsRelationResponse(ResponseInterface $response, string $ty $this->assertIsArray($body['data']); } + public function assertIsBinaryStreamResponse(ResponseInterface $response, string $expectedMimeType): void + { + $this->assertSame(200, $response->getStatusCode()); + + $contentTypeHeaders = $response->getHeader('content-type'); + if (1 !== count($contentTypeHeaders)) { + $this->fail(sprintf('Expected to find one content-type header in response, got %d.', count($contentTypeHeaders))); + } + $contentTypeHeader = $contentTypeHeaders[0]; + $responseMimeType = strtolower(explode(';', $contentTypeHeader)[0]); + + $this->assertSame(strtolower($expectedMimeType), $responseMimeType); + $this->assertCount(1, $response->getHeader('Content-Disposition')); + } + public function assertIsProblemResponse(ResponseInterface $response, int $status): void { $this->assertSame($status, $response->getStatusCode()); @@ -273,18 +320,20 @@ public function assertIsProblemResponse(ResponseInterface $response, int $status ); } - public function assertIsCreatedResponse(ResponseInterface $response): void + public function assertIsCreatedResponse(ResponseInterface $response, bool $requireLocation = true): void { $this->assertSame(201, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->assertIsString($response->getHeader('Location')[0]); + if ($requireLocation) { + $this->assertIsString($response->getHeader('Location')[0]); + } } - public function assertNoContentResponse(ResponseInterface $response): void + public function assertNoContentResponse(ResponseInterface $response, bool $hasHeader = false): void { $this->assertSame(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->assertFalse($response->hasHeader('Location')); + $this->assertSame($hasHeader, $response->hasHeader('Location')); } public function assertNotModifiedResponse(ResponseInterface $response): void @@ -355,4 +404,90 @@ public function getUuidFromLocation(ResponseInterface $response): string return array_reverse(explode('/', $location))[0]; } + + public function generateDeterministicFile(int $seed, int $targetSize, string $outputPath): void + { + $lineWidth = 120; + $chunkLines = 4096; + $groupSize = 32; + + $fh = \Safe\fopen($outputPath, 'wb'); + mt_srand($seed); + + $written = 0; + $state = (string) mt_rand(); // rolling state, re-seeded every $groupSize lines + + for ($counter = 0; $written < $targetSize; ++$counter) { + // Re-seed state from mt_rand every $groupSize lines + if (0 === $counter % $groupSize) { + $state = hash('xxh128', (string) mt_rand().$counter); + } + + // Roll state forward, build 4 × 32 = 128 hex chars, trim to 120 + $a = hash('xxh128', $state.$counter); + $b = hash('xxh128', $a.$counter); + $c = hash('xxh128', $b.$counter); + $d = hash('xxh128', $c.$counter); + $state = $d; // carry forward into next line / next group seed + + $line = substr($a.$b.$c.$d, 0, $lineWidth); + + // Buffer into chunks for efficient fwrite + $chunk ??= ''; + $chunk .= $line."\n"; + + if ($counter % $chunkLines === $chunkLines - 1 || $written + strlen($chunk) >= $targetSize) { + if ($written + strlen($chunk) > $targetSize) { + $chunk = substr($chunk, 0, $targetSize - $written); + } + $written += fwrite($fh, $chunk); + $chunk = ''; + } + } + + \Safe\fclose($fh); + } + + /** + * @return string[] + */ + public function splitFileToChunks(string $inputPath, int $chunkSize): array + { + if (!is_file($inputPath) || !is_readable($inputPath)) { + throw new InvalidArgumentException(sprintf('File not readable: %s', $inputPath)); + } + + $uid = bin2hex(random_bytes(8)); + $handle = fopen($inputPath, 'rb'); + $index = 0; + $paths = []; + + while (!feof($handle)) { + $chunk = fread($handle, $chunkSize); + if (false === $chunk || 0 === strlen($chunk)) { + break; + } + + $filename = sprintf('/tmp/upload-%s-%02d.part', $uid, $index); + file_put_contents($filename, $chunk); + $paths[] = $filename; + ++$index; + } + + fclose($handle); + + return $paths; + } + + /** + * @param string[] $paths + */ + public function cleanupChunks(array $paths): void + { + foreach ($paths as $path) { + if (is_file($path)) { + unlink($path); + } + } + } } diff --git a/tests/FeatureTests/Command/RevokeTokenGroupTest.php b/tests/FeatureTests/Command/RevokeTokenGroupTest.php index 27e72800..4adfbb68 100644 --- a/tests/FeatureTests/Command/RevokeTokenGroupTest.php +++ b/tests/FeatureTests/Command/RevokeTokenGroupTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Command; +namespace App\Tests\FeatureTests\Command; use App\Tests\FeatureTests\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/FeatureTests/Command/RevokeTokenIssuedAfterTest.php b/tests/FeatureTests/Command/RevokeTokenIssuedAfterTest.php index 1cb2f2fc..43e24ec5 100644 --- a/tests/FeatureTests/Command/RevokeTokenIssuedAfterTest.php +++ b/tests/FeatureTests/Command/RevokeTokenIssuedAfterTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Command; +namespace App\Tests\FeatureTests\Command; use App\Tests\FeatureTests\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/FeatureTests/Command/RevokeTokenIssuedBeforeTest.php b/tests/FeatureTests/Command/RevokeTokenIssuedBeforeTest.php index 9d116940..f302e766 100644 --- a/tests/FeatureTests/Command/RevokeTokenIssuedBeforeTest.php +++ b/tests/FeatureTests/Command/RevokeTokenIssuedBeforeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Command; +namespace App\Tests\FeatureTests\Command; use App\Tests\FeatureTests\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/FeatureTests/Command/RevokeTokenIssuedWithExpirationDateTest.php b/tests/FeatureTests/Command/RevokeTokenIssuedWithExpirationDateTest.php index 12d403d3..ab56c93a 100644 --- a/tests/FeatureTests/Command/RevokeTokenIssuedWithExpirationDateTest.php +++ b/tests/FeatureTests/Command/RevokeTokenIssuedWithExpirationDateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Command; +namespace App\Tests\FeatureTests\Command; use App\Tests\FeatureTests\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/FeatureTests/Command/RevokeTokenIssuedWithoutExpirationDateTest.php b/tests/FeatureTests/Command/RevokeTokenIssuedWithoutExpirationDateTest.php index ccfb711a..25d4c67f 100644 --- a/tests/FeatureTests/Command/RevokeTokenIssuedWithoutExpirationDateTest.php +++ b/tests/FeatureTests/Command/RevokeTokenIssuedWithoutExpirationDateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Command; +namespace App\Tests\FeatureTests\Command; use App\Tests\FeatureTests\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/FeatureTests/Command/RevokeTokenUserTest.php b/tests/FeatureTests/Command/RevokeTokenUserTest.php index d3e43310..36607161 100644 --- a/tests/FeatureTests/Command/RevokeTokenUserTest.php +++ b/tests/FeatureTests/Command/RevokeTokenUserTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Command; +namespace App\Tests\FeatureTests\Command; use App\Tests\FeatureTests\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/FeatureTests/Endpoint/Element/DeleteElementNodeTest.php b/tests/FeatureTests/Endpoint/Element/DeleteElementNodeTest.php index fadc6c36..8f066f31 100644 --- a/tests/FeatureTests/Endpoint/Element/DeleteElementNodeTest.php +++ b/tests/FeatureTests/Endpoint/Element/DeleteElementNodeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/DeleteElementRelationTest.php b/tests/FeatureTests/Endpoint/Element/DeleteElementRelationTest.php index db942567..ee1e49e4 100644 --- a/tests/FeatureTests/Endpoint/Element/DeleteElementRelationTest.php +++ b/tests/FeatureTests/Endpoint/Element/DeleteElementRelationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/GetChildrenTest.php b/tests/FeatureTests/Endpoint/Element/GetChildrenTest.php index f3f2433d..d93fda24 100644 --- a/tests/FeatureTests/Endpoint/Element/GetChildrenTest.php +++ b/tests/FeatureTests/Endpoint/Element/GetChildrenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/GetElementNodeTest.php b/tests/FeatureTests/Endpoint/Element/GetElementNodeTest.php index 0f81af3b..f60db127 100644 --- a/tests/FeatureTests/Endpoint/Element/GetElementNodeTest.php +++ b/tests/FeatureTests/Endpoint/Element/GetElementNodeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/GetElementRelationTest.php b/tests/FeatureTests/Endpoint/Element/GetElementRelationTest.php index c62633f4..f6006067 100644 --- a/tests/FeatureTests/Endpoint/Element/GetElementRelationTest.php +++ b/tests/FeatureTests/Endpoint/Element/GetElementRelationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/GetIndexTest.php b/tests/FeatureTests/Endpoint/Element/GetIndexTest.php index d7c08a6f..c99c4682 100644 --- a/tests/FeatureTests/Endpoint/Element/GetIndexTest.php +++ b/tests/FeatureTests/Endpoint/Element/GetIndexTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/GetParentsTest.php b/tests/FeatureTests/Endpoint/Element/GetParentsTest.php index 28be1969..e263c09f 100644 --- a/tests/FeatureTests/Endpoint/Element/GetParentsTest.php +++ b/tests/FeatureTests/Endpoint/Element/GetParentsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/GetRelatedTest.php b/tests/FeatureTests/Endpoint/Element/GetRelatedTest.php index 3f92b095..ce69093d 100644 --- a/tests/FeatureTests/Endpoint/Element/GetRelatedTest.php +++ b/tests/FeatureTests/Endpoint/Element/GetRelatedTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/PatchElementNodeTest.php b/tests/FeatureTests/Endpoint/Element/PatchElementNodeTest.php index d78bdc71..20c90b48 100644 --- a/tests/FeatureTests/Endpoint/Element/PatchElementNodeTest.php +++ b/tests/FeatureTests/Endpoint/Element/PatchElementNodeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/PatchElementRelationTest.php b/tests/FeatureTests/Endpoint/Element/PatchElementRelationTest.php index a3b353c3..0b4c2045 100644 --- a/tests/FeatureTests/Endpoint/Element/PatchElementRelationTest.php +++ b/tests/FeatureTests/Endpoint/Element/PatchElementRelationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/PostElementNodeTest.php b/tests/FeatureTests/Endpoint/Element/PostElementNodeTest.php index b59d74e3..4522c169 100644 --- a/tests/FeatureTests/Endpoint/Element/PostElementNodeTest.php +++ b/tests/FeatureTests/Endpoint/Element/PostElementNodeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/PostIndexNodeTest.php b/tests/FeatureTests/Endpoint/Element/PostIndexNodeTest.php index 5254eb7a..4888a26f 100644 --- a/tests/FeatureTests/Endpoint/Element/PostIndexNodeTest.php +++ b/tests/FeatureTests/Endpoint/Element/PostIndexNodeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/PostIndexRelationTest.php b/tests/FeatureTests/Endpoint/Element/PostIndexRelationTest.php index 24963fb4..ceabe8a6 100644 --- a/tests/FeatureTests/Endpoint/Element/PostIndexRelationTest.php +++ b/tests/FeatureTests/Endpoint/Element/PostIndexRelationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/PutElementNodeTest.php b/tests/FeatureTests/Endpoint/Element/PutElementNodeTest.php index 029dad95..ba9ec108 100644 --- a/tests/FeatureTests/Endpoint/Element/PutElementNodeTest.php +++ b/tests/FeatureTests/Endpoint/Element/PutElementNodeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Element/PutElementRelationTest.php b/tests/FeatureTests/Endpoint/Element/PutElementRelationTest.php index 0c961a10..8d771258 100644 --- a/tests/FeatureTests/Endpoint/Element/PutElementRelationTest.php +++ b/tests/FeatureTests/Endpoint/Element/PutElementRelationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Element; +namespace App\Tests\FeatureTests\Endpoint\Element; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Error/Get400BadContentTest.php b/tests/FeatureTests/Endpoint/Error/Get400BadContentTest.php index 6dee1b18..4d566530 100644 --- a/tests/FeatureTests/Endpoint/Error/Get400BadContentTest.php +++ b/tests/FeatureTests/Endpoint/Error/Get400BadContentTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Error; +namespace App\Tests\FeatureTests\Endpoint\Error; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Error/Get400ForbiddenPropertyTest.php b/tests/FeatureTests/Endpoint/Error/Get400ForbiddenPropertyTest.php index 5730f852..2a563e13 100644 --- a/tests/FeatureTests/Endpoint/Error/Get400ForbiddenPropertyTest.php +++ b/tests/FeatureTests/Endpoint/Error/Get400ForbiddenPropertyTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Error; +namespace App\Tests\FeatureTests\Endpoint\Error; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Error/Get400IncompleteMutualDependencyTest.php b/tests/FeatureTests/Endpoint/Error/Get400IncompleteMutualDependencyTest.php index c00b83c8..f4e9d940 100644 --- a/tests/FeatureTests/Endpoint/Error/Get400IncompleteMutualDependencyTest.php +++ b/tests/FeatureTests/Endpoint/Error/Get400IncompleteMutualDependencyTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Error; +namespace App\Tests\FeatureTests\Endpoint\Error; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Error/Get400MissingPropertyTest.php b/tests/FeatureTests/Endpoint/Error/Get400MissingPropertyTest.php index c370791b..9415356c 100644 --- a/tests/FeatureTests/Endpoint/Error/Get400MissingPropertyTest.php +++ b/tests/FeatureTests/Endpoint/Error/Get400MissingPropertyTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Error; +namespace App\Tests\FeatureTests\Endpoint\Error; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Error/Get400ReservedIdentifierTest.php b/tests/FeatureTests/Endpoint/Error/Get400ReservedIdentifierTest.php index 3772c2d7..dea5f181 100644 --- a/tests/FeatureTests/Endpoint/Error/Get400ReservedIdentifierTest.php +++ b/tests/FeatureTests/Endpoint/Error/Get400ReservedIdentifierTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Error; +namespace App\Tests\FeatureTests\Endpoint\Error; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Error/Get400ReservedTypeTest.php b/tests/FeatureTests/Endpoint/Error/Get400ReservedTypeTest.php new file mode 100644 index 00000000..f990f7fe --- /dev/null +++ b/tests/FeatureTests/Endpoint/Error/Get400ReservedTypeTest.php @@ -0,0 +1,16 @@ +runGetRequest('/error/400/reserved-type', null); + $this->assertIsTextResponse($response, 200); + } +} diff --git a/tests/FeatureTests/Endpoint/Error/Get401UnauthorizedTest.php b/tests/FeatureTests/Endpoint/Error/Get401UnauthorizedTest.php index 751d41d5..499b15bf 100644 --- a/tests/FeatureTests/Endpoint/Error/Get401UnauthorizedTest.php +++ b/tests/FeatureTests/Endpoint/Error/Get401UnauthorizedTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Error; +namespace App\Tests\FeatureTests\Endpoint\Error; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Error/Get403ForbiddenTest.php b/tests/FeatureTests/Endpoint/Error/Get403ForbiddenTest.php index 446c576b..6497a0a6 100644 --- a/tests/FeatureTests/Endpoint/Error/Get403ForbiddenTest.php +++ b/tests/FeatureTests/Endpoint/Error/Get403ForbiddenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Error; +namespace App\Tests\FeatureTests\Endpoint\Error; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Error/Get404NotFoundTest.php b/tests/FeatureTests/Endpoint/Error/Get404NotFoundTest.php index 25b78949..f6c4c3c9 100644 --- a/tests/FeatureTests/Endpoint/Error/Get404NotFoundTest.php +++ b/tests/FeatureTests/Endpoint/Error/Get404NotFoundTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Error; +namespace App\Tests\FeatureTests\Endpoint\Error; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/Error/Get501NotImplementedTest.php b/tests/FeatureTests/Endpoint/Error/Get501NotImplementedTest.php index 159e2673..e3dcc2db 100644 --- a/tests/FeatureTests/Endpoint/Error/Get501NotImplementedTest.php +++ b/tests/FeatureTests/Endpoint/Error/Get501NotImplementedTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Error; +namespace App\Tests\FeatureTests\Endpoint\Error; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/File/DeleteFileTest.php b/tests/FeatureTests/Endpoint/File/DeleteFileTest.php index 7ab3640f..86d7d0d1 100644 --- a/tests/FeatureTests/Endpoint/File/DeleteFileTest.php +++ b/tests/FeatureTests/Endpoint/File/DeleteFileTest.php @@ -2,17 +2,148 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\File; +namespace App\Tests\FeatureTests\Endpoint\File; use App\Tests\FeatureTests\BaseRequestTestCase; +use PHPUnit\Framework\Attributes\Group; +#[Group('test')] class DeleteFileTest extends BaseRequestTestCase { - private const string SOME_UUID = 'f686f998-cb92-4db2-89e5-d088e4ac34cc'; + private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; - public function testDeleteFileIsNotImplemented(): void + public function testDeleteFileFromNode(): void { - $response = $this->runDeleteRequest(sprintf('/%s/file', self::SOME_UUID), null); - $this->assertIsProblemResponse($response, 501); + // create new node for file upload + $node = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $location = $node->getHeader('Location')[0]; + + // upload file + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('%s/file', $location), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + + // verify file is accessible + $getFileResponse = $this->runGetRequest( + sprintf('%s/file', $location), + self::TOKEN + ); + $this->assertIsBinaryStreamResponse($getFileResponse, 'application/octet-stream'); + + // delete file + $deleteFileResponse = $this->runDeleteRequest( + sprintf('%s/file', $location), + self::TOKEN + ); + $this->assertIsDeletedResponse($deleteFileResponse); + + // verify file is no longer accessible + $getFileResponse = $this->runGetRequest( + sprintf('%s/file', $location), + self::TOKEN + ); + $this->assertIsProblemResponse($getFileResponse, 404); + } + + public function testDeleteFileFromRelation(): void + { + // create new relation for file upload + $startNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $startNodeId = substr($startNode->getHeader('Location')[0], 1); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $endNodeId = substr($endNode->getHeader('Location')[0], 1); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $location = $relation->getHeader('Location')[0]; + + // upload file + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'PUT', + sprintf('%s/file', $location), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + + // verify file is accessible + $getFileResponse = $this->runGetRequest( + sprintf('%s/file', $location), + self::TOKEN + ); + $this->assertIsBinaryStreamResponse($getFileResponse, 'application/octet-stream'); + + // verify relation has file-property + $getResponse = $this->runGetRequest( + sprintf('%s', $location), + self::TOKEN + ); + $data = json_decode((string) $getResponse->getBody(), true); + $this->assertSame( + [ + 'contentLength' => 63933, + 'extension' => 'bin', + 'mimeType' => 'image/jpeg', + ], + $data['file'] + ); + + // delete file + $deleteFileResponse = $this->runDeleteRequest( + sprintf('%s/file', $location), + self::TOKEN + ); + $this->assertIsDeletedResponse($deleteFileResponse); + + // verify file is no longer accessible + $getFileResponse = $this->runGetRequest( + sprintf('%s/file', $location), + self::TOKEN + ); + $this->assertIsProblemResponse($getFileResponse, 404); } } diff --git a/tests/FeatureTests/Endpoint/File/GetFileTest.php b/tests/FeatureTests/Endpoint/File/GetFileTest.php index 790c3ba1..53c2c5ef 100644 --- a/tests/FeatureTests/Endpoint/File/GetFileTest.php +++ b/tests/FeatureTests/Endpoint/File/GetFileTest.php @@ -2,17 +2,43 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\File; +namespace App\Tests\FeatureTests\Endpoint\File; use App\Tests\FeatureTests\BaseRequestTestCase; +use PHPUnit\Framework\Attributes\Group; +#[Group('test')] class GetFileTest extends BaseRequestTestCase { - private const string SOME_UUID = 'f686f998-cb92-4db2-89e5-d088e4ac34cc'; + private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; + private const string NODE_WITH_FILE_UUID = '0fdd52ba-55da-430c-b015-3277a231e895'; + private const string NODE_WITHOUT_FILE_UUID = 'b7121a49-8f17-4858-b988-4bd837a5f1fe'; - public function testGetFileIsNotImplemented(): void + public function testGetFileOnNodeWithFile(): void { - $response = $this->runGetRequest(sprintf('/%s/file', self::SOME_UUID), null); - $this->assertIsProblemResponse($response, 501); + $response = $this->runGetRequest(sprintf('/%s/file', self::NODE_WITH_FILE_UUID), self::TOKEN); + $this->assertIsBinaryStreamResponse($response, 'application/octet-stream'); + } + + public function testGetFileOnNodeWithoutFile(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', self::NODE_WITHOUT_FILE_UUID), self::TOKEN); + $this->assertIsProblemResponse($response, 404); + } + + /** + * @todo + */ + public function testGetFileOnRelationWithFile(): void + { + $this->markTestSkipped(); + } + + /** + * @todo + */ + public function testGetFileOnRelationWithoutFile(): void + { + $this->markTestSkipped(); } } diff --git a/tests/FeatureTests/Endpoint/File/PatchFileTest.php b/tests/FeatureTests/Endpoint/File/PatchFileTest.php deleted file mode 100644 index 970c48fe..00000000 --- a/tests/FeatureTests/Endpoint/File/PatchFileTest.php +++ /dev/null @@ -1,22 +0,0 @@ -runPatchRequest( - sprintf('/%s/file', self::SOME_UUID), - null, - [] - ); - $this->assertIsProblemResponse($response, 501); - } -} diff --git a/tests/FeatureTests/Endpoint/File/PostFileInSingleRequestTest.php b/tests/FeatureTests/Endpoint/File/PostFileInSingleRequestTest.php new file mode 100644 index 00000000..6cf51347 --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/PostFileInSingleRequestTest.php @@ -0,0 +1,190 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $location = $node->getHeader('Location')[0]; + + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + + $response = $this->runUploadRequest( + 'POST', + sprintf('%s/file', $location), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + } + + public function testPostFileToNodeWithExistingFileFails(): void + { + // create new node for file upload + $node = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $location = $node->getHeader('Location')[0]; + + // request 1 - ok + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('%s/file', $location), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + + // request 2 - fail + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('%s/file', $location), + $file, + self::TOKEN + ); + $this->assertIsProblemResponse($response, 409); + } + + public function testPostFileToRelation(): void + { + // create new relation for file upload + $startNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $startNodeId = substr($startNode->getHeader('Location')[0], 1); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $endNodeId = substr($endNode->getHeader('Location')[0], 1); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $relationId = substr($relation->getHeader('Location')[0], 1); + + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + } + + public function testPostFileToRelationWithExistingFileFails(): void + { + // create new relation for file upload + $startNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $startNodeId = substr($startNode->getHeader('Location')[0], 1); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $endNodeId = substr($endNode->getHeader('Location')[0], 1); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $relationId = substr($relation->getHeader('Location')[0], 1); + + // request 1 - ok + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + + // request 2 - fail + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN + ); + $this->assertIsProblemResponse($response, 409); + } +} diff --git a/tests/FeatureTests/Endpoint/File/PostFileTest.php b/tests/FeatureTests/Endpoint/File/PostFileTest.php deleted file mode 100644 index bc27cbfd..00000000 --- a/tests/FeatureTests/Endpoint/File/PostFileTest.php +++ /dev/null @@ -1,22 +0,0 @@ -runPostRequest( - sprintf('/%s/file', self::SOME_UUID), - null, - [] - ); - $this->assertIsProblemResponse($response, 501); - } -} diff --git a/tests/FeatureTests/Endpoint/File/PutFileInSingleRequestTest.php b/tests/FeatureTests/Endpoint/File/PutFileInSingleRequestTest.php new file mode 100644 index 00000000..5819ebae --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/PutFileInSingleRequestTest.php @@ -0,0 +1,190 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $location = $node->getHeader('Location')[0]; + + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + + $response = $this->runUploadRequest( + 'PUT', + sprintf('%s/file', $location), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + } + + public function testPutFileToNodeWithExistingFileWorks(): void + { + // create new node for file upload + $node = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $location = $node->getHeader('Location')[0]; + + // request 1 - ok + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'PUT', + sprintf('%s/file', $location), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + + // request 2 - ok + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'PUT', + sprintf('%s/file', $location), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + } + + public function testPutFileToRelation(): void + { + // create new relation for file upload + $startNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $startNodeId = substr($startNode->getHeader('Location')[0], 1); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $endNodeId = substr($endNode->getHeader('Location')[0], 1); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $relationId = substr($relation->getHeader('Location')[0], 1); + + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + } + + public function testPutFileToRelationWithExistingFileWorks(): void + { + // create new relation for file upload + $startNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $startNodeId = substr($startNode->getHeader('Location')[0], 1); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $endNodeId = substr($endNode->getHeader('Location')[0], 1); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $relationId = substr($relation->getHeader('Location')[0], 1); + + // request 1 - ok + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + + // request 2 - ok + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($response, false); + } +} diff --git a/tests/FeatureTests/Endpoint/File/PutFileTest.php b/tests/FeatureTests/Endpoint/File/PutFileTest.php deleted file mode 100644 index db96dab4..00000000 --- a/tests/FeatureTests/Endpoint/File/PutFileTest.php +++ /dev/null @@ -1,22 +0,0 @@ -runPutRequest( - sprintf('/%s/file', self::SOME_UUID), - null, - [] - ); - $this->assertIsProblemResponse($response, 501); - } -} diff --git a/tests/FeatureTests/Endpoint/Search/SearchTest.php b/tests/FeatureTests/Endpoint/Search/SearchTest.php index a4a02be2..4aae9bc7 100644 --- a/tests/FeatureTests/Endpoint/Search/SearchTest.php +++ b/tests/FeatureTests/Endpoint/Search/SearchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\Search; +namespace App\Tests\FeatureTests\Endpoint\Search; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/System/GetInstanceConfigurationTest.php b/tests/FeatureTests/Endpoint/System/GetInstanceConfigurationTest.php index ed722da4..a79a68b8 100644 --- a/tests/FeatureTests/Endpoint/System/GetInstanceConfigurationTest.php +++ b/tests/FeatureTests/Endpoint/System/GetInstanceConfigurationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\System; +namespace App\Tests\FeatureTests\Endpoint\System; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/System/GetWellKnownSecurityTxtTest.php b/tests/FeatureTests/Endpoint/System/GetWellKnownSecurityTxtTest.php index ac93f500..ebbfe07c 100644 --- a/tests/FeatureTests/Endpoint/System/GetWellKnownSecurityTxtTest.php +++ b/tests/FeatureTests/Endpoint/System/GetWellKnownSecurityTxtTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\System; +namespace App\Tests\FeatureTests\Endpoint\System; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/User/DeleteTokenTest.php b/tests/FeatureTests/Endpoint/User/DeleteTokenTest.php index 3724ee11..e8468f60 100644 --- a/tests/FeatureTests/Endpoint/User/DeleteTokenTest.php +++ b/tests/FeatureTests/Endpoint/User/DeleteTokenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\User; +namespace App\Tests\FeatureTests\Endpoint\User; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/User/GetMeTest.php b/tests/FeatureTests/Endpoint/User/GetMeTest.php index 9814e626..99aa84ef 100644 --- a/tests/FeatureTests/Endpoint/User/GetMeTest.php +++ b/tests/FeatureTests/Endpoint/User/GetMeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\User; +namespace App\Tests\FeatureTests\Endpoint\User; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/User/GetTokenTest.php b/tests/FeatureTests/Endpoint/User/GetTokenTest.php index aca0db68..978ab466 100644 --- a/tests/FeatureTests/Endpoint/User/GetTokenTest.php +++ b/tests/FeatureTests/Endpoint/User/GetTokenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\User; +namespace App\Tests\FeatureTests\Endpoint\User; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/User/PostChangePasswordOldTest.php b/tests/FeatureTests/Endpoint/User/PostChangePasswordOldTest.php index 06f58bb7..0adf13d6 100644 --- a/tests/FeatureTests/Endpoint/User/PostChangePasswordOldTest.php +++ b/tests/FeatureTests/Endpoint/User/PostChangePasswordOldTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\User; +namespace App\Tests\FeatureTests\Endpoint\User; use App\Tests\FeatureTests\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/FeatureTests/Endpoint/User/PostChangePasswordTest.php b/tests/FeatureTests/Endpoint/User/PostChangePasswordTest.php index 75b5f15d..c22c86c6 100644 --- a/tests/FeatureTests/Endpoint/User/PostChangePasswordTest.php +++ b/tests/FeatureTests/Endpoint/User/PostChangePasswordTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\User; +namespace App\Tests\FeatureTests\Endpoint\User; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/User/PostRegisterOldTest.php b/tests/FeatureTests/Endpoint/User/PostRegisterOldTest.php index b7717044..af1d74aa 100644 --- a/tests/FeatureTests/Endpoint/User/PostRegisterOldTest.php +++ b/tests/FeatureTests/Endpoint/User/PostRegisterOldTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\User; +namespace App\Tests\FeatureTests\Endpoint\User; use App\Tests\FeatureTests\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/FeatureTests/Endpoint/User/PostRegisterTest.php b/tests/FeatureTests/Endpoint/User/PostRegisterTest.php index a487ae2f..a0ce58f1 100644 --- a/tests/FeatureTests/Endpoint/User/PostRegisterTest.php +++ b/tests/FeatureTests/Endpoint/User/PostRegisterTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\User; +namespace App\Tests\FeatureTests\Endpoint\User; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/User/PostTokenOldTest.php b/tests/FeatureTests/Endpoint/User/PostTokenOldTest.php index 1e95fdc2..0b28dc7a 100644 --- a/tests/FeatureTests/Endpoint/User/PostTokenOldTest.php +++ b/tests/FeatureTests/Endpoint/User/PostTokenOldTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\User; +namespace App\Tests\FeatureTests\Endpoint\User; use App\Tests\FeatureTests\BaseRequestTestCase; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/FeatureTests/Endpoint/User/PostTokenTest.php b/tests/FeatureTests/Endpoint/User/PostTokenTest.php index 55889ff9..841356a8 100644 --- a/tests/FeatureTests/Endpoint/User/PostTokenTest.php +++ b/tests/FeatureTests/Endpoint/User/PostTokenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\User; +namespace App\Tests\FeatureTests\Endpoint\User; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/WebDAV/CopyElementTest.php b/tests/FeatureTests/Endpoint/WebDAV/CopyElementTest.php index 0f79aeaf..76a2c6f6 100644 --- a/tests/FeatureTests/Endpoint/WebDAV/CopyElementTest.php +++ b/tests/FeatureTests/Endpoint/WebDAV/CopyElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\WebDAV; +namespace App\Tests\FeatureTests\Endpoint\WebDAV; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/WebDAV/LockElementTest.php b/tests/FeatureTests/Endpoint/WebDAV/LockElementTest.php index 1369c4a3..226652dc 100644 --- a/tests/FeatureTests/Endpoint/WebDAV/LockElementTest.php +++ b/tests/FeatureTests/Endpoint/WebDAV/LockElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\WebDAV; +namespace App\Tests\FeatureTests\Endpoint\WebDAV; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/WebDAV/MkcolElementTest.php b/tests/FeatureTests/Endpoint/WebDAV/MkcolElementTest.php index 7ec9d4df..21a891a6 100644 --- a/tests/FeatureTests/Endpoint/WebDAV/MkcolElementTest.php +++ b/tests/FeatureTests/Endpoint/WebDAV/MkcolElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\WebDAV; +namespace App\Tests\FeatureTests\Endpoint\WebDAV; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/WebDAV/MoveElementTest.php b/tests/FeatureTests/Endpoint/WebDAV/MoveElementTest.php index 8912ecd1..74236375 100644 --- a/tests/FeatureTests/Endpoint/WebDAV/MoveElementTest.php +++ b/tests/FeatureTests/Endpoint/WebDAV/MoveElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\WebDAV; +namespace App\Tests\FeatureTests\Endpoint\WebDAV; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/WebDAV/PropfindElementTest.php b/tests/FeatureTests/Endpoint/WebDAV/PropfindElementTest.php index fab63681..9928cd62 100644 --- a/tests/FeatureTests/Endpoint/WebDAV/PropfindElementTest.php +++ b/tests/FeatureTests/Endpoint/WebDAV/PropfindElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\WebDAV; +namespace App\Tests\FeatureTests\Endpoint\WebDAV; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/WebDAV/ProppatchElementTest.php b/tests/FeatureTests/Endpoint/WebDAV/ProppatchElementTest.php index 5ee68175..33640f49 100644 --- a/tests/FeatureTests/Endpoint/WebDAV/ProppatchElementTest.php +++ b/tests/FeatureTests/Endpoint/WebDAV/ProppatchElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\WebDAV; +namespace App\Tests\FeatureTests\Endpoint\WebDAV; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Endpoint/WebDAV/UnlockElementTest.php b/tests/FeatureTests/Endpoint/WebDAV/UnlockElementTest.php index cd422075..49ac1af2 100644 --- a/tests/FeatureTests/Endpoint/WebDAV/UnlockElementTest.php +++ b/tests/FeatureTests/Endpoint/WebDAV/UnlockElementTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Endpoint\WebDAV; +namespace App\Tests\FeatureTests\Endpoint\WebDAV; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Edgecases/NoRouteTest.php b/tests/FeatureTests/General/Edgecases/NoRouteTest.php index 9a2839b0..36b955eb 100644 --- a/tests/FeatureTests/General/Edgecases/NoRouteTest.php +++ b/tests/FeatureTests/General/Edgecases/NoRouteTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Edgecases; +namespace App\Tests\FeatureTests\General\Edgecases; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/CreateNodeEtagTest.php b/tests/FeatureTests/General/Etag/CreateNodeEtagTest.php index 94e076a3..e4e6c704 100644 --- a/tests/FeatureTests/General/Etag/CreateNodeEtagTest.php +++ b/tests/FeatureTests/General/Etag/CreateNodeEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/CreateNormalRelationEtagTest.php b/tests/FeatureTests/General/Etag/CreateNormalRelationEtagTest.php index 1be4ca4b..5fce29a2 100644 --- a/tests/FeatureTests/General/Etag/CreateNormalRelationEtagTest.php +++ b/tests/FeatureTests/General/Etag/CreateNormalRelationEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/CreateOwnsRelationEtagTest.php b/tests/FeatureTests/General/Etag/CreateOwnsRelationEtagTest.php index 5738f33b..01a8f57d 100644 --- a/tests/FeatureTests/General/Etag/CreateOwnsRelationEtagTest.php +++ b/tests/FeatureTests/General/Etag/CreateOwnsRelationEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/DeleteNodeEtagTest.php b/tests/FeatureTests/General/Etag/DeleteNodeEtagTest.php index dec3270a..2dd576c6 100644 --- a/tests/FeatureTests/General/Etag/DeleteNodeEtagTest.php +++ b/tests/FeatureTests/General/Etag/DeleteNodeEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/DeleteNormalRelationEtagTest.php b/tests/FeatureTests/General/Etag/DeleteNormalRelationEtagTest.php index a902e40d..aebee678 100644 --- a/tests/FeatureTests/General/Etag/DeleteNormalRelationEtagTest.php +++ b/tests/FeatureTests/General/Etag/DeleteNormalRelationEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/DeleteOwnsRelationEtagTest.php b/tests/FeatureTests/General/Etag/DeleteOwnsRelationEtagTest.php index 2480f43c..af4f264a 100644 --- a/tests/FeatureTests/General/Etag/DeleteOwnsRelationEtagTest.php +++ b/tests/FeatureTests/General/Etag/DeleteOwnsRelationEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/MaximumEtagTest.php b/tests/FeatureTests/General/Etag/MaximumEtagTest.php index d9c1bcef..c0d3c4cd 100644 --- a/tests/FeatureTests/General/Etag/MaximumEtagTest.php +++ b/tests/FeatureTests/General/Etag/MaximumEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/UpdateNodeEtagTest.php b/tests/FeatureTests/General/Etag/UpdateNodeEtagTest.php index 00b9807f..e7ea74d6 100644 --- a/tests/FeatureTests/General/Etag/UpdateNodeEtagTest.php +++ b/tests/FeatureTests/General/Etag/UpdateNodeEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/UpdateNormalRelationEtagTest.php b/tests/FeatureTests/General/Etag/UpdateNormalRelationEtagTest.php index 30296648..ee1ab9cb 100644 --- a/tests/FeatureTests/General/Etag/UpdateNormalRelationEtagTest.php +++ b/tests/FeatureTests/General/Etag/UpdateNormalRelationEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Etag/UpdateOwnsRelationEtagTest.php b/tests/FeatureTests/General/Etag/UpdateOwnsRelationEtagTest.php index c8f1893a..6ff2b916 100644 --- a/tests/FeatureTests/General/Etag/UpdateOwnsRelationEtagTest.php +++ b/tests/FeatureTests/General/Etag/UpdateOwnsRelationEtagTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Etag; +namespace App\Tests\FeatureTests\General\Etag; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Exception/MalformedJsonRequestTest.php b/tests/FeatureTests/General/Exception/MalformedJsonRequestTest.php index 5742817b..82a72f2a 100644 --- a/tests/FeatureTests/General/Exception/MalformedJsonRequestTest.php +++ b/tests/FeatureTests/General/Exception/MalformedJsonRequestTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Exception; +namespace App\Tests\FeatureTests\General\Exception; use App\Tests\FeatureTests\BaseRequestTestCase; use GuzzleHttp\Client; diff --git a/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php b/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php new file mode 100644 index 00000000..497d404a --- /dev/null +++ b/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php @@ -0,0 +1,128 @@ +generateDeterministicFile(self::FILE_SEED, self::FILE_SIZE, self::FILE_PATH); + // split big file into chunks + $chunks = $this->splitFileToChunks(self::FILE_PATH, self::CHUNK_SIZE); + + // create new node for file upload + $postNodeResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $elementId = substr($postNodeResponse->getHeader('Location')[0], 1); + + // verify that node does not have file property + $getNodeResponse1 = $this->runGetRequest( + sprintf('/%s', $elementId), + self::TOKEN + ); + $getNodeResponseData1 = json_decode((string) $getNodeResponse1->getBody(), true); + $this->assertArrayNotHasKey('file', $getNodeResponseData1); + + // verify that accessing file results in 404 error + $getFileResponse1 = $this->runGetRequest( + sprintf('/%s/file', $elementId), + self::TOKEN + ); + $this->assertIsProblemResponse($getFileResponse1, 404); + + // create new resumable upload --------------------------------------------------------------------------------- + $chunk1 = \Safe\fopen($chunks[0], 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $chunk1, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Disposition' => 'inline; filename=file.txt', + 'Content-Type' => 'text/plain', + ] + ); + $this->assertNoContentResponse($postFileResponse, true); + $uploadId = substr($postFileResponse->getHeader('Location')[0], 8); + + // upload chunk 2 ---------------------------------------------------------------------------------------------- + $chunk2 = \Safe\fopen($chunks[1], 'r'); + $patchChunkResponse2 = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $chunk2, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Upload-Offset' => 10 * 1024 * 1024, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($patchChunkResponse2); + $this->assertSame('?0', $patchChunkResponse2->getHeader('Upload-Complete')[0]); + + // upload chunk 3 ---------------------------------------------------------------------------------------------- + $chunk3 = \Safe\fopen($chunks[2], 'r'); + $patchChunkResponse3 = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $chunk3, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 20 * 1024 * 1024, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($patchChunkResponse3); + $this->assertSame('?1', $patchChunkResponse3->getHeader('Upload-Complete')[0]); + + // verify that node now does have file property, and etag is different + $getNodeResponse2 = $this->runGetRequest( + sprintf('/%s', $elementId), + self::TOKEN + ); + $getNodeResponseData2 = json_decode((string) $getNodeResponse2->getBody(), true); + $this->assertNotSame($getNodeResponse1->getHeader('ETag'), $getNodeResponse2->getHeader('ETag')); + $this->assertArrayHasKey('file', $getNodeResponseData2); + $this->assertSame( + [ + 'contentLength' => 24117248, + 'extension' => 'txt', + 'mimeType' => 'text/plain', + ], + $getNodeResponseData2['file'] + ); + + // verify that file can be downloaded + $getFileResponse2 = $this->runGetRequest( + sprintf('/%s/file', $elementId), + self::TOKEN + ); + $this->assertIsBinaryStreamResponse($getFileResponse2, 'application/octet-stream'); + + $this->cleanupChunks($chunks); + } +} diff --git a/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php b/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php new file mode 100644 index 00000000..5a3dbb24 --- /dev/null +++ b/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php @@ -0,0 +1,107 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-test', + ], + ] + ); + $elementId = substr($postNodeResponse->getHeader('Location')[0], 1); + + // verify that node does not have file property + $getNodeResponse1 = $this->runGetRequest( + sprintf('/%s', $elementId), + self::TOKEN + ); + $getNodeResponseData1 = json_decode((string) $getNodeResponse1->getBody(), true); + $this->assertArrayNotHasKey('file', $getNodeResponseData1); + + // verify that accessing file results in 404 error + $getFileResponse1 = $this->runGetRequest( + sprintf('/%s/file', $elementId), + self::TOKEN + ); + $this->assertIsProblemResponse($getFileResponse1, 404); + + // upload file ------------------------------------------------------------------------------------------------- + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Disposition' => 'inline; filename=cherry-blossoms.jpg', + 'Content-Type' => 'image/jpg', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + + // verify that node now does have file property, and etag is different + $getNodeResponse2 = $this->runGetRequest( + sprintf('/%s', $elementId), + self::TOKEN + ); + $getNodeResponseData2 = json_decode((string) $getNodeResponse2->getBody(), true); + $this->assertNotSame($getNodeResponse1->getHeader('ETag'), $getNodeResponse2->getHeader('ETag')); + $this->assertArrayHasKey('file', $getNodeResponseData2); + $this->assertSame( + [ + 'contentLength' => 63933, + 'extension' => 'jpg', + 'mimeType' => 'image/jpeg', + ], + $getNodeResponseData2['file'] + ); + + // verify that file can be downloaded + $getFileResponse2 = $this->runGetRequest( + sprintf('/%s/file', $elementId), + self::TOKEN + ); + $this->assertIsBinaryStreamResponse($getFileResponse2, 'application/octet-stream'); + + // delete file ------------------------------------------------------------------------------------------------- + $deleteFileResponse = $this->runDeleteRequest( + sprintf('/%s/file', $elementId), + self::TOKEN + ); + $this->assertIsDeletedResponse($deleteFileResponse); + + // verify that accessing file results in 404 error + $getFileResponse3 = $this->runGetRequest( + sprintf('/%s/file', $elementId), + self::TOKEN + ); + $this->assertIsProblemResponse($getFileResponse3, 404); + + // verify that node does not have file property + $getNodeResponse3 = $this->runGetRequest( + sprintf('/%s', $elementId), + self::TOKEN + ); + $getNodeResponseData3 = json_decode((string) $getNodeResponse3->getBody(), true); + $this->assertArrayNotHasKey('file', $getNodeResponseData3); + $this->assertNotSame($getNodeResponse1->getHeader('ETag'), $getNodeResponse3->getHeader('ETag')); + $this->assertNotSame($getNodeResponse2->getHeader('ETag'), $getNodeResponse3->getHeader('ETag')); + } +} diff --git a/tests/FeatureTests/General/IfMatchTest.php b/tests/FeatureTests/General/IfMatchTest.php index 1251ee8d..e5870d89 100644 --- a/tests/FeatureTests/General/IfMatchTest.php +++ b/tests/FeatureTests/General/IfMatchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General; +namespace App\Tests\FeatureTests\General; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/IfNoneMatchTest.php b/tests/FeatureTests/General/IfNoneMatchTest.php index be5833d7..5dcfb720 100644 --- a/tests/FeatureTests/General/IfNoneMatchTest.php +++ b/tests/FeatureTests/General/IfNoneMatchTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General; +namespace App\Tests\FeatureTests\General; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/OwnershipModel/OwnershipModelTest.php b/tests/FeatureTests/General/OwnershipModel/OwnershipModelTest.php index 9038e994..603c9ba9 100644 --- a/tests/FeatureTests/General/OwnershipModel/OwnershipModelTest.php +++ b/tests/FeatureTests/General/OwnershipModel/OwnershipModelTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\OwnershipModel; +namespace App\Tests\FeatureTests\General\OwnershipModel; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Pagination/ChildrenPaginationTest.php b/tests/FeatureTests/General/Pagination/ChildrenPaginationTest.php index a05ebb2e..0c4d6c03 100644 --- a/tests/FeatureTests/General/Pagination/ChildrenPaginationTest.php +++ b/tests/FeatureTests/General/Pagination/ChildrenPaginationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Pagination; +namespace App\Tests\FeatureTests\General\Pagination; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Pagination/IndexPaginationTest.php b/tests/FeatureTests/General/Pagination/IndexPaginationTest.php index e25f9ea6..25469417 100644 --- a/tests/FeatureTests/General/Pagination/IndexPaginationTest.php +++ b/tests/FeatureTests/General/Pagination/IndexPaginationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Pagination; +namespace App\Tests\FeatureTests\General\Pagination; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Pagination/ParentsPaginationTest.php b/tests/FeatureTests/General/Pagination/ParentsPaginationTest.php index 3f531ecb..3aad5ad9 100644 --- a/tests/FeatureTests/General/Pagination/ParentsPaginationTest.php +++ b/tests/FeatureTests/General/Pagination/ParentsPaginationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Pagination; +namespace App\Tests\FeatureTests\General\Pagination; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/General/Pagination/RelatedPaginationTest.php b/tests/FeatureTests/General/Pagination/RelatedPaginationTest.php index feb82308..b89d29ca 100644 --- a/tests/FeatureTests/General/Pagination/RelatedPaginationTest.php +++ b/tests/FeatureTests/General/Pagination/RelatedPaginationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\General\Pagination; +namespace App\Tests\FeatureTests\General\Pagination; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/DeleteTokenTest.php b/tests/FeatureTests/Security/DeleteTokenTest.php index 39e19b71..d914552b 100644 --- a/tests/FeatureTests/Security/DeleteTokenTest.php +++ b/tests/FeatureTests/Security/DeleteTokenTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_01_NoConnectionTest.php b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_01_NoConnectionTest.php index 8ee78ba5..9f536274 100644 --- a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_01_NoConnectionTest.php +++ b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_01_NoConnectionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security\Scenario01BasicNegativeTests; +namespace App\Tests\FeatureTests\Security\Scenario01BasicNegativeTests; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php index 9db2f883..c32d1338 100644 --- a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php +++ b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security\Scenario01BasicNegativeTests; +namespace App\Tests\FeatureTests\Security\Scenario01BasicNegativeTests; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php index 554a808b..736570e2 100644 --- a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php +++ b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security\Scenario01BasicNegativeTests; +namespace App\Tests\FeatureTests\Security\Scenario01BasicNegativeTests; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_04_OwnsRelationshipsAreDirectionalTest.php b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_04_OwnsRelationshipsAreDirectionalTest.php index eee7db3a..9a98635d 100644 --- a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_04_OwnsRelationshipsAreDirectionalTest.php +++ b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_04_OwnsRelationshipsAreDirectionalTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security\Scenario01BasicNegativeTests; +namespace App\Tests\FeatureTests\Security\Scenario01BasicNegativeTests; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php b/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php index 1f934572..9ff40d39 100644 --- a/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security\Scenario02BasicPositiveTests; +namespace App\Tests\FeatureTests\Security\Scenario02BasicPositiveTests; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/Scenario07SearchAccessTests/_07_01_SearchAccessTest.php b/tests/FeatureTests/Security/Scenario07SearchAccessTests/_07_01_SearchAccessTest.php index d4b93edc..04fbb86f 100644 --- a/tests/FeatureTests/Security/Scenario07SearchAccessTests/_07_01_SearchAccessTest.php +++ b/tests/FeatureTests/Security/Scenario07SearchAccessTests/_07_01_SearchAccessTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security\Scenario07SearchAccessTests; +namespace App\Tests\FeatureTests\Security\Scenario07SearchAccessTests; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/TokenDoesNotReturnInternalFieldsTest.php b/tests/FeatureTests/Security/TokenDoesNotReturnInternalFieldsTest.php index 876b2ef1..99d3a058 100644 --- a/tests/FeatureTests/Security/TokenDoesNotReturnInternalFieldsTest.php +++ b/tests/FeatureTests/Security/TokenDoesNotReturnInternalFieldsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/TokenStateTest.php b/tests/FeatureTests/Security/TokenStateTest.php index f91dc570..d55f43f6 100644 --- a/tests/FeatureTests/Security/TokenStateTest.php +++ b/tests/FeatureTests/Security/TokenStateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_02_02_ImmediateRelationOwnershipTest.php b/tests/FeatureTests/Security/_02_02_ImmediateRelationOwnershipTest.php index 5df9dae9..66d55597 100644 --- a/tests/FeatureTests/Security/_02_02_ImmediateRelationOwnershipTest.php +++ b/tests/FeatureTests/Security/_02_02_ImmediateRelationOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_02_03_PathNodeOwnershipTest.php b/tests/FeatureTests/Security/_02_03_PathNodeOwnershipTest.php index 84489955..1eaeec3d 100644 --- a/tests/FeatureTests/Security/_02_03_PathNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/_02_03_PathNodeOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_02_04_PathRelationOwnershipTest.php b/tests/FeatureTests/Security/_02_04_PathRelationOwnershipTest.php index 1289d267..56196bc2 100644 --- a/tests/FeatureTests/Security/_02_04_PathRelationOwnershipTest.php +++ b/tests/FeatureTests/Security/_02_04_PathRelationOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_03_01_GroupImmediateNodeOwnershipTest.php b/tests/FeatureTests/Security/_03_01_GroupImmediateNodeOwnershipTest.php index e04c01f6..c6d8318a 100644 --- a/tests/FeatureTests/Security/_03_01_GroupImmediateNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/_03_01_GroupImmediateNodeOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_03_02_GroupImmediateRelationOwnershipTest.php b/tests/FeatureTests/Security/_03_02_GroupImmediateRelationOwnershipTest.php index e6e97805..d8603dc6 100644 --- a/tests/FeatureTests/Security/_03_02_GroupImmediateRelationOwnershipTest.php +++ b/tests/FeatureTests/Security/_03_02_GroupImmediateRelationOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_03_03_GroupPathNodeOwnershipTest.php b/tests/FeatureTests/Security/_03_03_GroupPathNodeOwnershipTest.php index a734b5c6..3f1d4065 100644 --- a/tests/FeatureTests/Security/_03_03_GroupPathNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/_03_03_GroupPathNodeOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_03_04_GroupPathRelationOwnershipTest.php b/tests/FeatureTests/Security/_03_04_GroupPathRelationOwnershipTest.php index 3a757c30..04294049 100644 --- a/tests/FeatureTests/Security/_03_04_GroupPathRelationOwnershipTest.php +++ b/tests/FeatureTests/Security/_03_04_GroupPathRelationOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_04_01_GroupsImmediateNodeOwnershipTest.php b/tests/FeatureTests/Security/_04_01_GroupsImmediateNodeOwnershipTest.php index c07008a9..81901a7b 100644 --- a/tests/FeatureTests/Security/_04_01_GroupsImmediateNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/_04_01_GroupsImmediateNodeOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_04_02_GroupsImmediateRelationOwnershipTest.php b/tests/FeatureTests/Security/_04_02_GroupsImmediateRelationOwnershipTest.php index a9f715a7..6a58bab6 100644 --- a/tests/FeatureTests/Security/_04_02_GroupsImmediateRelationOwnershipTest.php +++ b/tests/FeatureTests/Security/_04_02_GroupsImmediateRelationOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_04_03_GroupsPathNodeOwnershipTest.php b/tests/FeatureTests/Security/_04_03_GroupsPathNodeOwnershipTest.php index 8242efc4..3facf6b2 100644 --- a/tests/FeatureTests/Security/_04_03_GroupsPathNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/_04_03_GroupsPathNodeOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_04_04_GroupsPathNodeOwnershipTest.php b/tests/FeatureTests/Security/_04_04_GroupsPathNodeOwnershipTest.php index ecced2ad..d15e7014 100644 --- a/tests/FeatureTests/Security/_04_04_GroupsPathNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/_04_04_GroupsPathNodeOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_05_01_MixedNodeOwnershipTest.php b/tests/FeatureTests/Security/_05_01_MixedNodeOwnershipTest.php index ca4f7a1a..d481c38c 100644 --- a/tests/FeatureTests/Security/_05_01_MixedNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/_05_01_MixedNodeOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_05_02_MixedRelationOwnershipTest.php b/tests/FeatureTests/Security/_05_02_MixedRelationOwnershipTest.php index a679eeb6..1260b265 100644 --- a/tests/FeatureTests/Security/_05_02_MixedRelationOwnershipTest.php +++ b/tests/FeatureTests/Security/_05_02_MixedRelationOwnershipTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_06_01_LimitedReadAccessTest.php b/tests/FeatureTests/Security/_06_01_LimitedReadAccessTest.php index 2ee704cc..4caddb6e 100644 --- a/tests/FeatureTests/Security/_06_01_LimitedReadAccessTest.php +++ b/tests/FeatureTests/Security/_06_01_LimitedReadAccessTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_99_01_IsInGroupAfterOwnsHaveNoEffectTest.php b/tests/FeatureTests/Security/_99_01_IsInGroupAfterOwnsHaveNoEffectTest.php index 99abe5a7..c9b68c0c 100644 --- a/tests/FeatureTests/Security/_99_01_IsInGroupAfterOwnsHaveNoEffectTest.php +++ b/tests/FeatureTests/Security/_99_01_IsInGroupAfterOwnsHaveNoEffectTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_99_02_OwningGroupsGiveDirectAccessButNotToRelatedGroupsTest.php b/tests/FeatureTests/Security/_99_02_OwningGroupsGiveDirectAccessButNotToRelatedGroupsTest.php index 81f4397e..38c58223 100644 --- a/tests/FeatureTests/Security/_99_02_OwningGroupsGiveDirectAccessButNotToRelatedGroupsTest.php +++ b/tests/FeatureTests/Security/_99_02_OwningGroupsGiveDirectAccessButNotToRelatedGroupsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_99_05_TokenHasNoOwnerTest.php b/tests/FeatureTests/Security/_99_05_TokenHasNoOwnerTest.php index 552bf5ee..e5012a6f 100644 --- a/tests/FeatureTests/Security/_99_05_TokenHasNoOwnerTest.php +++ b/tests/FeatureTests/Security/_99_05_TokenHasNoOwnerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/Security/_99_06_TokenHasTwoOwnersTest.php b/tests/FeatureTests/Security/_99_06_TokenHasTwoOwnersTest.php index 05ecfea2..b8552fe1 100644 --- a/tests/FeatureTests/Security/_99_06_TokenHasTwoOwnersTest.php +++ b/tests/FeatureTests/Security/_99_06_TokenHasTwoOwnersTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\FeatureTests\Security; +namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; diff --git a/tests/FeatureTests/docker-compose-neo4j-5.20.yml b/tests/FeatureTests/docker-compose-neo4j-5.20.yml index e9c503c5..2481b4e6 100644 --- a/tests/FeatureTests/docker-compose-neo4j-5.20.yml +++ b/tests/FeatureTests/docker-compose-neo4j-5.20.yml @@ -62,3 +62,37 @@ services: environment: - RABBITMQ_DEFAULT_USER=user - RABBITMQ_DEFAULT_PASS=password + + minio: + container_name: minio + image: quay.io/minio/minio:latest + expose: + - "9000" + - "9001" + environment: + - MINIO_ROOT_USER=ROOTNAME + - MINIO_ROOT_PASSWORD=CHANGEME123 + command: server /data --console-address ":9001" + + minio-init: + image: quay.io/minio/mc:latest + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set minio http://minio:9000 ROOTNAME CHANGEME123; do + echo 'Waiting for MinIO...'; + sleep 1; + done; + + mc mb --ignore-existing minio/api-storage; + mc mb --ignore-existing minio/api-upload; + + mc anonymous set none minio/api-storage; + mc anonymous set none minio/api-upload; + + mc admin user add minio accessKey secretKey || true; + mc admin policy attach minio readwrite --user accessKey; + + echo 'MinIO buckets and user configured'; + " diff --git a/tests/FeatureTests/docker-compose-neo4j-5.7.yml b/tests/FeatureTests/docker-compose-neo4j-5.7.yml index c8d66ba6..0e8f669e 100644 --- a/tests/FeatureTests/docker-compose-neo4j-5.7.yml +++ b/tests/FeatureTests/docker-compose-neo4j-5.7.yml @@ -62,3 +62,37 @@ services: environment: - RABBITMQ_DEFAULT_USER=user - RABBITMQ_DEFAULT_PASS=password + + minio: + container_name: minio + image: quay.io/minio/minio:latest + expose: + - "9000" + - "9001" + environment: + - MINIO_ROOT_USER=ROOTNAME + - MINIO_ROOT_PASSWORD=CHANGEME123 + command: server /data --console-address ":9001" + + minio-init: + image: quay.io/minio/mc:latest + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set minio http://minio:9000 ROOTNAME CHANGEME123; do + echo 'Waiting for MinIO...'; + sleep 1; + done; + + mc mb --ignore-existing minio/api-storage; + mc mb --ignore-existing minio/api-upload; + + mc anonymous set none minio/api-storage; + mc anonymous set none minio/api-upload; + + mc admin user add minio accessKey secretKey || true; + mc admin policy attach minio readwrite --user accessKey; + + echo 'MinIO buckets and user configured'; + " diff --git a/tests/FeatureTests/docker-compose-neo4j-latest.yml b/tests/FeatureTests/docker-compose-neo4j-latest.yml index 2403c300..f0a60502 100644 --- a/tests/FeatureTests/docker-compose-neo4j-latest.yml +++ b/tests/FeatureTests/docker-compose-neo4j-latest.yml @@ -62,3 +62,37 @@ services: environment: - RABBITMQ_DEFAULT_USER=user - RABBITMQ_DEFAULT_PASS=password + + minio: + container_name: minio + image: quay.io/minio/minio:latest + expose: + - "9000" + - "9001" + environment: + - MINIO_ROOT_USER=ROOTNAME + - MINIO_ROOT_PASSWORD=CHANGEME123 + command: server /data --console-address ":9001" + + minio-init: + image: quay.io/minio/mc:latest + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set minio http://minio:9000 ROOTNAME CHANGEME123; do + echo 'Waiting for MinIO...'; + sleep 1; + done; + + mc mb --ignore-existing minio/api-storage; + mc mb --ignore-existing minio/api-upload; + + mc anonymous set none minio/api-storage; + mc anonymous set none minio/api-upload; + + mc admin user add minio accessKey secretKey || true; + mc admin policy attach minio readwrite --user accessKey; + + echo 'MinIO buckets and user configured'; + " diff --git a/tests/UnitTests/Console/EmberNexusOutputWrapperTest.php b/tests/UnitTests/Console/EmberNexusOutputWrapperTest.php index cf5bbdff..bdb0abb0 100644 --- a/tests/UnitTests/Console/EmberNexusOutputWrapperTest.php +++ b/tests/UnitTests/Console/EmberNexusOutputWrapperTest.php @@ -121,6 +121,30 @@ public function testIsQuietFalse(): void $this->assertSame(false, $emberNexusOutputWrapper->isQuiet()); } + public function testIsSilentTrue(): void + { + $output = $this->prophesize(OutputInterface::class); + $output + ->isSilent() + ->shouldBeCalledOnce() + ->willReturn(true); + + $emberNexusOutputWrapper = new EmberNexusOutputWrapper($output->reveal()); + $this->assertSame(true, $emberNexusOutputWrapper->isSilent()); + } + + public function testIsSilentFalse(): void + { + $output = $this->prophesize(OutputInterface::class); + $output + ->isSilent() + ->shouldBeCalledOnce() + ->willReturn(false); + + $emberNexusOutputWrapper = new EmberNexusOutputWrapper($output->reveal()); + $this->assertSame(false, $emberNexusOutputWrapper->isSilent()); + } + public function testIsVerboseTrue(): void { $output = $this->prophesize(OutputInterface::class); diff --git a/tests/UnitTests/Controller/User/PostChangePasswordControllerTest.php b/tests/UnitTests/Controller/User/PostChangePasswordControllerTest.php index 13233452..5895ecc1 100644 --- a/tests/UnitTests/Controller/User/PostChangePasswordControllerTest.php +++ b/tests/UnitTests/Controller/User/PostChangePasswordControllerTest.php @@ -9,10 +9,10 @@ use App\Exception\Client401UnauthorizedException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; -use App\Response\NoContentResponse; use App\Service\RequestUtilService; use App\Service\SecurityUtilService; use App\Type\NodeElement; +use App\Type\Response\NoContentResponse; use Exception; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/UnitTests/Controller/User/PostRegisterControllerTest.php b/tests/UnitTests/Controller/User/PostRegisterControllerTest.php index d20c2e54..99d8bf55 100644 --- a/tests/UnitTests/Controller/User/PostRegisterControllerTest.php +++ b/tests/UnitTests/Controller/User/PostRegisterControllerTest.php @@ -11,13 +11,13 @@ use App\Factory\Exception\Client400MissingPropertyExceptionFactory; use App\Factory\Exception\Client400ReservedIdentifierExceptionFactory; use App\Factory\Exception\Client403ForbiddenExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; -use App\Response\CreatedResponse; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\UserPasswordHasher; use App\Service\CreateElementFromRawDataService; use App\Service\ElementManager; use App\Service\RequestUtilService; use App\Type\NodeElement; +use App\Type\Response\CreatedResponse; use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; use Laudis\Neo4j\Contracts\ClientInterface; @@ -56,7 +56,7 @@ private function getPostRegisterController( ?CreateElementFromRawDataService $createElementFromRawDataService = null, ?Client400ReservedIdentifierExceptionFactory $client400ReservedIdentifierExceptionFactory = null, ?Client403ForbiddenExceptionFactory $client403ForbiddenExceptionFactory = null, - ?Server500LogicExceptionFactory $server500LogicExceptionFactory = null, + ?Server500LogicErrorExceptionFactory $server500LogicExceptionFactory = null, ): PostRegisterController { return new PostRegisterController( $elementManager ?? $this->createMock(ElementManager::class), @@ -68,7 +68,7 @@ private function getPostRegisterController( $createElementFromRawDataService ?? $this->createMock(CreateElementFromRawDataService::class), $client400ReservedIdentifierExceptionFactory ?? $this->createMock(Client400ReservedIdentifierExceptionFactory::class), $client403ForbiddenExceptionFactory ?? $this->createMock(Client403ForbiddenExceptionFactory::class), - $server500LogicExceptionFactory ?? $this->createMock(Server500LogicExceptionFactory::class) + $server500LogicExceptionFactory ?? $this->createMock(Server500LogicErrorExceptionFactory::class) ); } diff --git a/tests/UnitTests/Controller/User/PostTokenControllerTest.php b/tests/UnitTests/Controller/User/PostTokenControllerTest.php index f2e58bdd..a5a04e5d 100644 --- a/tests/UnitTests/Controller/User/PostTokenControllerTest.php +++ b/tests/UnitTests/Controller/User/PostTokenControllerTest.php @@ -9,11 +9,11 @@ use App\Exception\Client401UnauthorizedException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; -use App\Response\JsonResponse; use App\Security\TokenGenerator; use App\Service\RequestUtilService; use App\Service\SecurityUtilService; use App\Type\NodeElement; +use App\Type\Response\JsonResponse; use Exception; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/UnitTests/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListenerTest.php b/tests/UnitTests/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListenerTest.php index b62eb57d..914e6dda 100644 --- a/tests/UnitTests/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListenerTest.php +++ b/tests/UnitTests/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListenerTest.php @@ -8,10 +8,10 @@ use App\EventSystem\Controller\EventListener\IfNoneMatchControllerEventListener; use App\Exception\Client412PreconditionFailedException; use App\Factory\Exception\Client412PreconditionFailedExceptionFactory; -use App\Response\NotModifiedResponse; use App\Service\EtagService; use App\Type\Etag; use App\Type\EtagType; +use App\Type\Response\NotModifiedResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; diff --git a/tests/UnitTests/EventSystem/ElementFileDelete/Event/ElementFileDeleteEventTest.php b/tests/UnitTests/EventSystem/ElementFileDelete/Event/ElementFileDeleteEventTest.php new file mode 100644 index 00000000..1d1b17df --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementFileDelete/Event/ElementFileDeleteEventTest.php @@ -0,0 +1,24 @@ +assertSame($id, $event->getElementId()); + } +} diff --git a/tests/UnitTests/EventSystem/ElementFileReplace/Event/ElementFileReplaceEventTest.php b/tests/UnitTests/EventSystem/ElementFileReplace/Event/ElementFileReplaceEventTest.php new file mode 100644 index 00000000..f214940c --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementFileReplace/Event/ElementFileReplaceEventTest.php @@ -0,0 +1,24 @@ +assertSame($id, $event->getElementId()); + } +} diff --git a/tests/UnitTests/EventSystem/Etag/Event/FileEtagEventTest.php b/tests/UnitTests/EventSystem/Etag/Event/FileEtagEventTest.php new file mode 100644 index 00000000..6e62aa95 --- /dev/null +++ b/tests/UnitTests/EventSystem/Etag/Event/FileEtagEventTest.php @@ -0,0 +1,38 @@ +setEtag($etag); + + $this->assertSame($etag, $event->getEtag()); + $this->assertSame($id, $event->getElementId()); + } + + public function testEventWithNull(): void + { + $id = Uuid::fromString('87877759-2856-4b4a-80f6-df53296d12a4'); + $event = new FileEtagEvent($id); + $event->setEtag(null); + + $this->assertNull($event->getEtag()); + $this->assertSame($id, $event->getElementId()); + } +} diff --git a/tests/UnitTests/EventSystem/Etag/EventListener/LiveFileEtagEventListenerTest.php b/tests/UnitTests/EventSystem/Etag/EventListener/LiveFileEtagEventListenerTest.php new file mode 100644 index 00000000..fd51e298 --- /dev/null +++ b/tests/UnitTests/EventSystem/Etag/EventListener/LiveFileEtagEventListenerTest.php @@ -0,0 +1,80 @@ +toString()); + $fileEtagEvent = new FileEtagEvent($id); + $etag = new Etag('someEtag'); + + // setup event listener dependencies + $redisClient = $this->prophesize(RedisClient::class); + $redisClient->set( + Argument::is((string) $redisKey), + Argument::is($etag), + Argument::is('EX'), + Argument::is(3600) + )->shouldBeCalledOnce()->willReturn(null); + + $redisKeyFactory = $this->prophesize(RedisKeyFactory::class); + $redisKeyFactory->getEtagFileRedisKey(Argument::is($id))->shouldBeCalledOnce()->willReturn( + $redisKey + ); + + $etagCalculatorService = $this->prophesize(EtagCalculatorService::class); + $etagCalculatorService->calculateFileEtag(Argument::is($id))->shouldBeCalledOnce()->willReturn($etag); + + $logger = TestLogger::create(); + + // setup event listener + $redisFileEtagEventListener = new LiveFileEtagEventListener( + $redisClient->reveal(), + $redisKeyFactory->reveal(), + $etagCalculatorService->reveal(), + $logger + ); + + // run event listener + $redisFileEtagEventListener->onFileEtagEvent($fileEtagEvent); + + // assert event + $this->assertTrue($fileEtagEvent->isPropagationStopped()); + $this->assertSame('someEtag', (string) $fileEtagEvent->getEtag()); + + // assert logs + $this->assertLogHappened($logger, 'debug', 'Trying to persist Etag for file in Redis.', [ + 'elementId' => '5fe57bf8-799d-4a19-9d0e-8566035521e1', + 'redisKey' => 'etag:file:5fe57bf8-799d-4a19-9d0e-8566035521e1', + 'etag' => $etag, + ]); + } +} diff --git a/tests/UnitTests/EventSystem/Etag/EventListener/RedisFileEtagEventListenerTest.php b/tests/UnitTests/EventSystem/Etag/EventListener/RedisFileEtagEventListenerTest.php new file mode 100644 index 00000000..54c8b0e4 --- /dev/null +++ b/tests/UnitTests/EventSystem/Etag/EventListener/RedisFileEtagEventListenerTest.php @@ -0,0 +1,161 @@ +toString()); + $fileEtagEvent = new FileEtagEvent($id); + + // setup event listener dependencies + $redisClient = $this->prophesize(RedisClient::class); + $redisClient->get(Argument::is((string) $redisKey))->shouldBeCalledOnce()->willReturn(null); + + $redisKeyFactory = $this->prophesize(RedisKeyFactory::class); + $redisKeyFactory->getEtagFileRedisKey(Argument::is($id))->shouldBeCalledOnce()->willReturn( + $redisKey + ); + + $logger = TestLogger::create(); + + // setup event listener + $redisFileEtagEventListener = new RedisFileEtagEventListener( + $redisClient->reveal(), + $redisKeyFactory->reveal(), + $logger + ); + + // run event listener + $redisFileEtagEventListener->onFileEtagEvent($fileEtagEvent); + + // assert event + $this->assertFalse($fileEtagEvent->isPropagationStopped()); + $this->assertNull($fileEtagEvent->getEtag()); + + // assert logs + $this->assertLogHappened($logger, 'debug', 'Trying to find Etag for file in Redis.', [ + 'elementId' => '3079fc16-9bd0-4b06-8cdf-b0b6cc16c307', + 'redisKey' => 'etag:file:3079fc16-9bd0-4b06-8cdf-b0b6cc16c307', + ]); + $this->assertLogHappened($logger, 'debug', 'Unable to find Etag for file in Redis.', [ + 'elementId' => '3079fc16-9bd0-4b06-8cdf-b0b6cc16c307', + 'redisKey' => 'etag:file:3079fc16-9bd0-4b06-8cdf-b0b6cc16c307', + ]); + } + + public function testRedisFileEtagEventListenerWithFileInRedisWithNullValue(): void + { + // setup variables + $id = Uuid::fromString('0eaa9823-3131-448d-b8cd-122b5eada7a5'); + $redisKey = new RedisKey(RedisPrefixType::ETAG_FILE, $id->toString()); + $fileEtagEvent = new FileEtagEvent($id); + + // setup event listener dependencies + $redisClient = $this->prophesize(RedisClient::class); + $redisClient->get(Argument::is((string) $redisKey))->shouldBeCalledOnce()->willReturn(RedisValueType::NULL->value); + + $redisKeyFactory = $this->prophesize(RedisKeyFactory::class); + $redisKeyFactory->getEtagFileRedisKey(Argument::is($id))->shouldBeCalledOnce()->willReturn( + $redisKey + ); + + $logger = TestLogger::create(); + + // setup event listener + $redisFileEtagEventListener = new RedisFileEtagEventListener( + $redisClient->reveal(), + $redisKeyFactory->reveal(), + $logger + ); + + // run event listener + $redisFileEtagEventListener->onFileEtagEvent($fileEtagEvent); + + // assert event + $this->assertTrue($fileEtagEvent->isPropagationStopped()); + $this->assertNull($fileEtagEvent->getEtag()); + + // assert logs + $this->assertLogHappened($logger, 'debug', 'Trying to find Etag for file in Redis.', [ + 'elementId' => '0eaa9823-3131-448d-b8cd-122b5eada7a5', + 'redisKey' => 'etag:file:0eaa9823-3131-448d-b8cd-122b5eada7a5', + ]); + $this->assertLogHappened($logger, 'debug', 'Found Etag for file in Redis.', [ + 'elementId' => '0eaa9823-3131-448d-b8cd-122b5eada7a5', + 'redisKey' => 'etag:file:0eaa9823-3131-448d-b8cd-122b5eada7a5', + 'etag' => null, + ]); + } + + public function testRedisFileEtagEventListenerWithFileInRedisWithValue(): void + { + // setup variables + $id = Uuid::fromString('e0350f14-2aa0-4878-83a8-188dae3c3d93'); + $redisKey = new RedisKey(RedisPrefixType::ETAG_FILE, $id->toString()); + $fileEtagEvent = new FileEtagEvent($id); + $etag = new Etag('someEtag'); + + // setup event listener dependencies + $redisClient = $this->prophesize(RedisClient::class); + $redisClient->get(Argument::is((string) $redisKey))->shouldBeCalledOnce()->willReturn((string) $etag); + + $redisKeyFactory = $this->prophesize(RedisKeyFactory::class); + $redisKeyFactory->getEtagFileRedisKey(Argument::is($id))->shouldBeCalledOnce()->willReturn( + $redisKey + ); + + $logger = TestLogger::create(); + + // setup event listener + $redisFileEtagEventListener = new RedisFileEtagEventListener( + $redisClient->reveal(), + $redisKeyFactory->reveal(), + $logger + ); + + // run event listener + $redisFileEtagEventListener->onFileEtagEvent($fileEtagEvent); + + // assert event + $this->assertTrue($fileEtagEvent->isPropagationStopped()); + $this->assertSame('someEtag', (string) $fileEtagEvent->getEtag()); + + // assert logs + $this->assertLogHappened($logger, 'debug', 'Trying to find Etag for file in Redis.', [ + 'elementId' => 'e0350f14-2aa0-4878-83a8-188dae3c3d93', + 'redisKey' => 'etag:file:e0350f14-2aa0-4878-83a8-188dae3c3d93', + ]); + $data = $this->assertLogHappened($logger, 'debug', 'Found Etag for file in Redis.', false); + $this->assertSame('e0350f14-2aa0-4878-83a8-188dae3c3d93', $data['elementId']); + $this->assertSame('etag:file:e0350f14-2aa0-4878-83a8-188dae3c3d93', $data['redisKey']); + $this->assertSame('someEtag', (string) $data['etag']); + } +} diff --git a/tests/UnitTests/EventSystem/Exception/EventListener/ExceptionEventListenerTest.php b/tests/UnitTests/EventSystem/Exception/EventListener/ExceptionEventListenerTest.php index ea70953d..9b85dc7b 100644 --- a/tests/UnitTests/EventSystem/Exception/EventListener/ExceptionEventListenerTest.php +++ b/tests/UnitTests/EventSystem/Exception/EventListener/ExceptionEventListenerTest.php @@ -8,8 +8,8 @@ use App\Exception\Client403ForbiddenException; use App\Exception\Server500InternalServerErrorException; use App\Factory\Exception\Server500InternalServerErrorExceptionFactory; -use App\Response\ProblemJsonResponse; use App\Tests\UnitTests\AssertLoggerTrait; +use App\Type\Response\ProblemJsonResponse; use Beste\Psr\Log\TestLogger; use Exception; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/UnitTests/EventSystem/Response/EventListener/EtagResponseEventListenerTest.php b/tests/UnitTests/EventSystem/Response/EventListener/EtagResponseEventListenerTest.php index 0b9069c1..a3b8e021 100644 --- a/tests/UnitTests/EventSystem/Response/EventListener/EtagResponseEventListenerTest.php +++ b/tests/UnitTests/EventSystem/Response/EventListener/EtagResponseEventListenerTest.php @@ -5,10 +5,10 @@ namespace App\Tests\UnitTests\EventSystem\Response\EventListener; use App\EventSystem\Response\EventListener\EtagResponseEventListener; -use App\Response\CollectionResponse; -use App\Response\ElementResponse; use App\Service\EtagService; use App\Type\Etag; +use App\Type\Response\CollectionResponse; +use App\Type\Response\ElementResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; diff --git a/tests/UnitTests/EventSystem/SearchStep/Event/SearchStepEventTest.php b/tests/UnitTests/EventSystem/SearchStep/Event/SearchStepEventTest.php index 2f233646..cb8399dc 100644 --- a/tests/UnitTests/EventSystem/SearchStep/Event/SearchStepEventTest.php +++ b/tests/UnitTests/EventSystem/SearchStep/Event/SearchStepEventTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\UnitTests\EventSystem\SearchStep\Event; +namespace App\Tests\UnitTests\EventSystem\SearchStep\Event; use App\EventSystem\SearchStep\Event\SearchStepEvent; use App\Type\SearchStepType; diff --git a/tests/UnitTests/EventSystem/SearchStep/EventListener/CypherPathSearchStepEventListenerTest.php b/tests/UnitTests/EventSystem/SearchStep/EventListener/CypherPathSearchStepEventListenerTest.php index 5d4728c9..147b96af 100644 --- a/tests/UnitTests/EventSystem/SearchStep/EventListener/CypherPathSearchStepEventListenerTest.php +++ b/tests/UnitTests/EventSystem/SearchStep/EventListener/CypherPathSearchStepEventListenerTest.php @@ -9,7 +9,7 @@ use App\EventSystem\SearchStep\EventListener\CypherPathSearchStepEventListener; use App\Exception\Client400BadContentException; use App\Factory\Exception\Client400BadContentExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Type\SearchStepType; @@ -48,7 +48,7 @@ public function buildCypherPathSearchStepEventListener( ?AuthProvider $authProvider = null, ?Stopwatch $stopwatch = null, ?Client400BadContentExceptionFactory $client400BadContentExceptionFactory = null, - ?Server500LogicExceptionFactory $server500LogicExceptionFactory = null, + ?Server500LogicErrorExceptionFactory $server500LogicExceptionFactory = null, ): CypherPathSearchStepEventListener { $cypherPathSubsetGrammar = $cypherPathSubsetGrammar ?? self::prophesize(CypherPathSubsetGrammar::class)->reveal(); $cypherEntityManager = $cypherEntityManager ?? self::prophesize(CypherEntityManager::class)->reveal(); @@ -56,7 +56,7 @@ public function buildCypherPathSearchStepEventListener( $authProvider = $authProvider ?? self::prophesize(AuthProvider::class)->reveal(); $stopwatch = $stopwatch ?? self::prophesize(Stopwatch::class)->reveal(); $client400BadContentExceptionFactory = $client400BadContentExceptionFactory ?? self::prophesize(Client400BadContentExceptionFactory::class)->reveal(); - $server500LogicExceptionFactory = $server500LogicExceptionFactory ?? self::prophesize(Server500LogicExceptionFactory::class)->reveal(); + $server500LogicExceptionFactory = $server500LogicExceptionFactory ?? self::prophesize(Server500LogicErrorExceptionFactory::class)->reveal(); return new CypherPathSearchStepEventListener( $cypherPathSubsetGrammar, diff --git a/tests/UnitTests/Exception/Client400BadGrammarExceptionTest.php b/tests/UnitTests/Exception/Client400BadGrammarExceptionTest.php index 95c035af..e5972b4c 100644 --- a/tests/UnitTests/Exception/Client400BadGrammarExceptionTest.php +++ b/tests/UnitTests/Exception/Client400BadGrammarExceptionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\UnitTests\Exception; +namespace App\Tests\UnitTests\Exception; use App\Exception\Client400BadGrammarException; use Exception; diff --git a/tests/UnitTests/Exception/Client400ReservedTypeExceptionTest.php b/tests/UnitTests/Exception/Client400ReservedTypeExceptionTest.php new file mode 100644 index 00000000..69619914 --- /dev/null +++ b/tests/UnitTests/Exception/Client400ReservedTypeExceptionTest.php @@ -0,0 +1,73 @@ +assertSame('type', $client400ReservedTypeException->getType()); + $this->assertSame('Reserved type', $client400ReservedTypeException->getTitle()); + $this->assertSame(400, $client400ReservedTypeException->getStatus()); + $this->assertSame('', $client400ReservedTypeException->getDetail()); + $this->assertNull($client400ReservedTypeException->getInstance()); + $this->assertSame('', $client400ReservedTypeException->getMessage()); + $this->assertNull($client400ReservedTypeException->getPrevious()); + $this->assertSame(0, $client400ReservedTypeException->getCode()); + } + + public function testGetter(): void + { + $client400ReservedTypeException = new Client400ReservedTypeException( + 'type', + 'title', + 123, + 'detail', + 'instance', + null + ); + + $this->assertSame('type', $client400ReservedTypeException->getType()); + $this->assertSame('title', $client400ReservedTypeException->getTitle()); + $this->assertSame(123, $client400ReservedTypeException->getStatus()); + $this->assertSame('detail', $client400ReservedTypeException->getDetail()); + $this->assertSame('instance', $client400ReservedTypeException->getInstance()); + $this->assertSame('', $client400ReservedTypeException->getMessage()); + $this->assertNull($client400ReservedTypeException->getPrevious()); + $this->assertSame(0, $client400ReservedTypeException->getCode()); + } + + public function testWithPrevious(): void + { + try { + throw new Exception('Test'); + } catch (Exception $exception) { + $client400ReservedTypeException = new Client400ReservedTypeException( + 'type', + 'title', + 123, + 'detail', + 'instance', + $exception + ); + + $this->assertSame('', $client400ReservedTypeException->getMessage()); + $this->assertSame($exception, $client400ReservedTypeException->getPrevious()); + $this->assertSame(0, $client400ReservedTypeException->getCode()); + } + } +} diff --git a/tests/UnitTests/Exception/Client409ConflictExceptionTest.php b/tests/UnitTests/Exception/Client409ConflictExceptionTest.php new file mode 100644 index 00000000..4d7b91ad --- /dev/null +++ b/tests/UnitTests/Exception/Client409ConflictExceptionTest.php @@ -0,0 +1,73 @@ +assertSame('type', $client409ConflictException->getType()); + $this->assertSame('Conflict', $client409ConflictException->getTitle()); + $this->assertSame(409, $client409ConflictException->getStatus()); + $this->assertSame('Operation can not be performed due to some sort of conflict.', $client409ConflictException->getDetail()); + $this->assertNull($client409ConflictException->getInstance()); + $this->assertSame('', $client409ConflictException->getMessage()); + $this->assertNull($client409ConflictException->getPrevious()); + $this->assertSame(0, $client409ConflictException->getCode()); + } + + public function testGetter(): void + { + $client409ConflictException = new Client409ConflictException( + 'type', + 'title', + 123, + 'detail', + 'instance', + null + ); + + $this->assertSame('type', $client409ConflictException->getType()); + $this->assertSame('title', $client409ConflictException->getTitle()); + $this->assertSame(123, $client409ConflictException->getStatus()); + $this->assertSame('detail', $client409ConflictException->getDetail()); + $this->assertSame('instance', $client409ConflictException->getInstance()); + $this->assertSame('', $client409ConflictException->getMessage()); + $this->assertNull($client409ConflictException->getPrevious()); + $this->assertSame(0, $client409ConflictException->getCode()); + } + + public function testWithPrevious(): void + { + try { + throw new Exception('Test'); + } catch (Exception $exception) { + $client409ConflictException = new Client409ConflictException( + 'type', + 'title', + 123, + 'detail', + 'instance', + $exception + ); + + $this->assertSame('', $client409ConflictException->getMessage()); + $this->assertSame($exception, $client409ConflictException->getPrevious()); + $this->assertSame(0, $client409ConflictException->getCode()); + } + } +} diff --git a/tests/UnitTests/Exception/Client410GoneExceptionTest.php b/tests/UnitTests/Exception/Client410GoneExceptionTest.php new file mode 100644 index 00000000..ec20d3ba --- /dev/null +++ b/tests/UnitTests/Exception/Client410GoneExceptionTest.php @@ -0,0 +1,73 @@ +assertSame('type', $client410GoneException->getType()); + $this->assertSame('Gone', $client410GoneException->getTitle()); + $this->assertSame(410, $client410GoneException->getStatus()); + $this->assertSame('Requested resource is no longer available and is expected to soon be permanently deleted.', $client410GoneException->getDetail()); + $this->assertNull($client410GoneException->getInstance()); + $this->assertSame('', $client410GoneException->getMessage()); + $this->assertNull($client410GoneException->getPrevious()); + $this->assertSame(0, $client410GoneException->getCode()); + } + + public function testGetter(): void + { + $client410GoneException = new Client410GoneException( + 'type', + 'title', + 123, + 'detail', + 'instance', + null + ); + + $this->assertSame('type', $client410GoneException->getType()); + $this->assertSame('title', $client410GoneException->getTitle()); + $this->assertSame(123, $client410GoneException->getStatus()); + $this->assertSame('detail', $client410GoneException->getDetail()); + $this->assertSame('instance', $client410GoneException->getInstance()); + $this->assertSame('', $client410GoneException->getMessage()); + $this->assertNull($client410GoneException->getPrevious()); + $this->assertSame(0, $client410GoneException->getCode()); + } + + public function testWithPrevious(): void + { + try { + throw new Exception('Test'); + } catch (Exception $exception) { + $client410GoneException = new Client410GoneException( + 'type', + 'title', + 123, + 'detail', + 'instance', + $exception + ); + + $this->assertSame('', $client410GoneException->getMessage()); + $this->assertSame($exception, $client410GoneException->getPrevious()); + $this->assertSame(0, $client410GoneException->getCode()); + } + } +} diff --git a/tests/UnitTests/Exception/ProblemJsonExceptionTest.php b/tests/UnitTests/Exception/ProblemJsonExceptionTest.php index a8375a5e..26dc775a 100644 --- a/tests/UnitTests/Exception/ProblemJsonExceptionTest.php +++ b/tests/UnitTests/Exception/ProblemJsonExceptionTest.php @@ -31,6 +31,7 @@ public function testDefault(): void $this->assertSame('', $problemJsonException->getMessage()); $this->assertNull($problemJsonException->getPrevious()); $this->assertSame(0, $problemJsonException->getCode()); + $this->assertSame([], $problemJsonException->getAdditionalProperties()); } public function testGetter(): void @@ -41,7 +42,10 @@ public function testGetter(): void 123, 'detail', 'instance', - null + null, + [ + 'a' => 'b', + ] ); $this->assertSame('type', $problemJsonException->getType()); @@ -52,6 +56,7 @@ public function testGetter(): void $this->assertSame('', $problemJsonException->getMessage()); $this->assertNull($problemJsonException->getPrevious()); $this->assertSame(0, $problemJsonException->getCode()); + $this->assertSame(['a' => 'b'], $problemJsonException->getAdditionalProperties()); } public function testWithPrevious(): void diff --git a/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactoryTest.php b/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactoryTest.php index 3d202645..2d91af2f 100644 --- a/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactoryTest.php +++ b/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactoryTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\UnitTests\Factory\EventSystem\ElementFragmentize\Event; +namespace App\Tests\UnitTests\Factory\EventSystem\ElementFragmentize\Event; use App\Factory\EventSystem\ElementFragmentize\Event\RelationElementFragmentizeEventFactory; use App\Type\RelationElement; diff --git a/tests/UnitTests/Factory/Exception/Client400BadGrammarExceptionFactoryTest.php b/tests/UnitTests/Factory/Exception/Client400BadGrammarExceptionFactoryTest.php index 2a758987..4bd69d7f 100644 --- a/tests/UnitTests/Factory/Exception/Client400BadGrammarExceptionFactoryTest.php +++ b/tests/UnitTests/Factory/Exception/Client400BadGrammarExceptionFactoryTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\UnitTests\Factory\Exception; +namespace App\Tests\UnitTests\Factory\Exception; use App\Factory\Exception\Client400BadGrammarExceptionFactory; use Exception; diff --git a/tests/UnitTests/Factory/Exception/Client400IncompleteMutualDependencyExceptionFactoryTest.php b/tests/UnitTests/Factory/Exception/Client400IncompleteMutualDependencyExceptionFactoryTest.php index c23a2a03..9b44c6b3 100644 --- a/tests/UnitTests/Factory/Exception/Client400IncompleteMutualDependencyExceptionFactoryTest.php +++ b/tests/UnitTests/Factory/Exception/Client400IncompleteMutualDependencyExceptionFactoryTest.php @@ -6,7 +6,7 @@ use App\Exception\Server500LogicErrorException; use App\Factory\Exception\Client400IncompleteMutualDependencyExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -23,7 +23,7 @@ class Client400IncompleteMutualDependencyExceptionFactoryTest extends TestCase public function testFailWithLessThanTwoProperties(): void { $expectedException = new Server500LogicErrorException(''); - $serverExceptionFactory = $this->prophesize(Server500LogicExceptionFactory::class); + $serverExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); $serverExceptionFactory ->createFromTemplate(Argument::is('Mutual dependency requires at least two properties.')) ->shouldBeCalledOnce() @@ -39,7 +39,7 @@ public function testFailWithLessThanTwoProperties(): void public function testFailWithNoMissingProperty(): void { $expectedException = new Server500LogicErrorException(''); - $serverExceptionFactory = $this->prophesize(Server500LogicExceptionFactory::class); + $serverExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); $serverExceptionFactory ->createFromTemplate(Argument::is('At least one missing property is required.')) ->shouldBeCalledOnce() @@ -65,7 +65,7 @@ public function testSuccessWithNoSetProperty(): void )->shouldBeCalledOnce()->willReturn('https://mock.dev/123'); $factory = new Client400IncompleteMutualDependencyExceptionFactory( $urlGenerator->reveal(), - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $exception = $factory->createFromTemplate(['a', 'b'], [], ['a']); @@ -91,7 +91,7 @@ public function testSuccessWithOneSetProperty(): void )->shouldBeCalledOnce()->willReturn('https://mock.dev/123'); $factory = new Client400IncompleteMutualDependencyExceptionFactory( $urlGenerator->reveal(), - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $exception = $factory->createFromTemplate(['a', 'b'], ['b'], ['a']); @@ -117,7 +117,7 @@ public function testSuccessWithMultipleSetProperties(): void )->shouldBeCalledOnce()->willReturn('https://mock.dev/123'); $factory = new Client400IncompleteMutualDependencyExceptionFactory( $urlGenerator->reveal(), - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $exception = $factory->createFromTemplate(['a', 'b', 'c'], ['b', 'c'], ['a']); @@ -143,7 +143,7 @@ public function testSuccessWithSingleMissingProperty(): void )->shouldBeCalledOnce()->willReturn('https://mock.dev/123'); $factory = new Client400IncompleteMutualDependencyExceptionFactory( $urlGenerator->reveal(), - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $exception = $factory->createFromTemplate(['a', 'b', 'c'], [], ['a']); @@ -169,7 +169,7 @@ public function testSuccessWithMultipleMissingProperties(): void )->shouldBeCalledOnce()->willReturn('https://mock.dev/123'); $factory = new Client400IncompleteMutualDependencyExceptionFactory( $urlGenerator->reveal(), - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $exception = $factory->createFromTemplate(['a', 'b', 'c'], [], ['a', 'b']); diff --git a/tests/UnitTests/Factory/Exception/Client400ReservedTypeExceptionFactoryTest.php b/tests/UnitTests/Factory/Exception/Client400ReservedTypeExceptionFactoryTest.php new file mode 100644 index 00000000..da8a3e8a --- /dev/null +++ b/tests/UnitTests/Factory/Exception/Client400ReservedTypeExceptionFactoryTest.php @@ -0,0 +1,43 @@ +prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate( + Argument::is('exception-detail'), + Argument::is([ + 'code' => '400', + 'name' => 'reserved-type', + ]), + Argument::is(UrlGeneratorInterface::ABSOLUTE_URL) + )->shouldBeCalledOnce()->willReturn('https://mock.dev/123'); + $factory = new Client400ReservedTypeExceptionFactory($urlGenerator->reveal()); + + $exception = $factory->createFromTemplate('a'); + + $this->assertSame(400, $exception->getStatus()); + $this->assertSame('Reserved type', $exception->getTitle()); + $this->assertSame('https://mock.dev/123', $exception->getType()); + $this->assertSame("The requested type 'a' is reserved and can not be used.", $exception->getDetail()); + $this->assertSame(null, $exception->getInstance()); + $this->assertSame('', $exception->getMessage()); + } +} diff --git a/tests/UnitTests/Factory/Exception/Client409ConflictExceptionFactoryTest.php b/tests/UnitTests/Factory/Exception/Client409ConflictExceptionFactoryTest.php new file mode 100644 index 00000000..29807994 --- /dev/null +++ b/tests/UnitTests/Factory/Exception/Client409ConflictExceptionFactoryTest.php @@ -0,0 +1,47 @@ +prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate( + Argument::is('exception-detail'), + Argument::is([ + 'code' => '409', + 'name' => 'conflict', + ]), + Argument::is(UrlGeneratorInterface::ABSOLUTE_URL) + )->shouldBeCalledOnce()->willReturn('https://mock.dev/123'); + $factory = new Client409ConflictExceptionFactory($urlGenerator->reveal()); + + $additionalDetails = [ + 'a' => 'b', + ]; + $exception = $factory->createFromDetail('some detail', $additionalDetails); + + $this->assertSame(409, $exception->getStatus()); + $this->assertSame('Conflict', $exception->getTitle()); + $this->assertSame('https://mock.dev/123', $exception->getType()); + $this->assertSame('some detail', $exception->getDetail()); + $this->assertSame(null, $exception->getInstance()); + $this->assertSame('', $exception->getMessage()); + $this->assertSame($additionalDetails, $exception->getAdditionalProperties()); + } +} diff --git a/tests/UnitTests/Factory/Exception/Client410GoneExceptionFactoryTest.php b/tests/UnitTests/Factory/Exception/Client410GoneExceptionFactoryTest.php new file mode 100644 index 00000000..56ac4f10 --- /dev/null +++ b/tests/UnitTests/Factory/Exception/Client410GoneExceptionFactoryTest.php @@ -0,0 +1,44 @@ +prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate( + Argument::is('exception-detail'), + Argument::is([ + 'code' => '410', + 'name' => 'gone', + ]), + Argument::is(UrlGeneratorInterface::ABSOLUTE_URL) + )->shouldBeCalledOnce()->willReturn('https://mock.dev/123'); + $factory = new Client410GoneExceptionFactory($urlGenerator->reveal()); + + $exception = $factory->createFromTemplate(); + + $this->assertSame(410, $exception->getStatus()); + $this->assertSame('Gone', $exception->getTitle()); + $this->assertSame('https://mock.dev/123', $exception->getType()); + $this->assertSame('Requested resource is no longer available and is expected to soon be permanently deleted.', $exception->getDetail()); + $this->assertSame(null, $exception->getInstance()); + $this->assertSame('', $exception->getMessage()); + $this->assertSame([], $exception->getAdditionalProperties()); + } +} diff --git a/tests/UnitTests/Factory/Exception/Server500LogicExceptionFactoryTest.php b/tests/UnitTests/Factory/Exception/Server500LogicExceptionFactoryTest.php index f70228b0..df0daffd 100644 --- a/tests/UnitTests/Factory/Exception/Server500LogicExceptionFactoryTest.php +++ b/tests/UnitTests/Factory/Exception/Server500LogicExceptionFactoryTest.php @@ -4,7 +4,7 @@ namespace App\Tests\UnitTests\Factory\Exception; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -15,7 +15,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; #[Small] -#[CoversClass(Server500LogicExceptionFactory::class)] +#[CoversClass(Server500LogicErrorExceptionFactory::class)] class Server500LogicExceptionFactoryTest extends TestCase { use ProphecyTrait; @@ -35,7 +35,7 @@ public function testCreateFromTemplateInProductionEnvironment(): void $bag->get(Argument::is('kernel.environment'))->shouldBeCalledOnce()->willReturn('prod'); $logger = $this->prophesize(LoggerInterface::class); $logger->error(Argument::is('a'), Argument::is([]))->shouldBeCalledOnce(); - $factory = new Server500LogicExceptionFactory($urlGenerator->reveal(), $bag->reveal(), $logger->reveal()); + $factory = new Server500LogicErrorExceptionFactory($urlGenerator->reveal(), $bag->reveal(), $logger->reveal()); $exception = $factory->createFromTemplate('a'); @@ -62,7 +62,7 @@ public function testCreateFromTemplateInDevelopmentEnvironment(): void $bag->get(Argument::is('kernel.environment'))->shouldBeCalledOnce()->willReturn('dev'); $logger = $this->prophesize(LoggerInterface::class); $logger->error(Argument::is('a'), Argument::is([]))->shouldBeCalledOnce(); - $factory = new Server500LogicExceptionFactory($urlGenerator->reveal(), $bag->reveal(), $logger->reveal()); + $factory = new Server500LogicErrorExceptionFactory($urlGenerator->reveal(), $bag->reveal(), $logger->reveal()); $exception = $factory->createFromTemplate('a'); diff --git a/tests/UnitTests/Factory/Type/RedisKeyFactoryTest.php b/tests/UnitTests/Factory/Type/RedisKeyFactoryTest.php index caced8d4..95cf294d 100644 --- a/tests/UnitTests/Factory/Type/RedisKeyFactoryTest.php +++ b/tests/UnitTests/Factory/Type/RedisKeyFactoryTest.php @@ -71,4 +71,12 @@ public function testGetEtagIndexCollectionRedisKey(): void $etagIndexCollectionRedisKey = $redisKeyTypeFactory->getEtagIndexCollectionRedisKey(Uuid::fromString('644ddc69-de1e-4636-b3b6-06c8b001fbc1')); $this->assertSame('etag:index:644ddc69-de1e-4636-b3b6-06c8b001fbc1', (string) $etagIndexCollectionRedisKey); } + + public function testGetEtagFileRedisKey(): void + { + $redisKeyTypeFactory = new RedisKeyFactory(); + + $etagFileRedisKey = $redisKeyTypeFactory->getEtagFileRedisKey(Uuid::fromString('fde09c2c-932c-4d61-bbfd-52096d5205c3')); + $this->assertSame('etag:file:fde09c2c-932c-4d61-bbfd-52096d5205c3', (string) $etagFileRedisKey); + } } diff --git a/tests/UnitTests/Factory/Type/Request/PartialUploadRequestFactoryTest.php b/tests/UnitTests/Factory/Type/Request/PartialUploadRequestFactoryTest.php new file mode 100644 index 00000000..d9fedbf8 --- /dev/null +++ b/tests/UnitTests/Factory/Type/Request/PartialUploadRequestFactoryTest.php @@ -0,0 +1,49 @@ +prophesize(HeaderBag::class)->reveal(); + + $request = $this->prophesize(Request::class); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request = $request->reveal(); + $request->headers = $headers; + + $headerParseService = $this->prophesize(HeaderParseService::class); + + $headerParseService->getContentTypeFromHeaders(Argument::is($headers), Argument::is('application/partial-upload'))->shouldBeCalledOnce()->willReturn('application/partial-upload'); + $headerParseService->getUploadOffsetFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(1234); + $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(false); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(4321); + + $partialUploadRequestFactory = new PartialUploadRequestFactory($headerParseService->reveal()); + + $partialUploadRequest = $partialUploadRequestFactory->createPartialUploadRequestFromRequest($request); + + $this->assertSame('some content', $partialUploadRequest->getContent()); + $this->assertSame('application/partial-upload', $partialUploadRequest->getContentType()); + $this->assertSame(1234, $partialUploadRequest->getUploadOffset()); + $this->assertFalse($partialUploadRequest->isUploadComplete()); + $this->assertSame(4321, $partialUploadRequest->getContentLength()); + } +} diff --git a/tests/UnitTests/Factory/Type/Request/ResumableUploadRequestFactoryTest.php b/tests/UnitTests/Factory/Type/Request/ResumableUploadRequestFactoryTest.php new file mode 100644 index 00000000..0eb83dc5 --- /dev/null +++ b/tests/UnitTests/Factory/Type/Request/ResumableUploadRequestFactoryTest.php @@ -0,0 +1,150 @@ +prophesize(HeaderParseService::class)->reveal(), + $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), + ); + } + + public function testCreateResumableUploadRequestFromRequest(): void + { + $elementId = Uuid::fromString('c01ab4f8-ab3c-4372-9aca-e919f501ad5c'); + + $headers = $this->prophesize(HeaderBag::class)->reveal(); + + $request = $this->prophesize(Request::class); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request = $request->reveal(); + $request->headers = $headers; + + $headerParseService = $this->prophesize(HeaderParseService::class); + + $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(false); + $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(654321); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(4321); + $headerParseService->getExtensionFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn('png'); + + $resumableUploadRequestFactory = $this->buildResumableUploadRequestFactory( + headerParseService: $headerParseService->reveal() + ); + + $resumableUploadRequest = $resumableUploadRequestFactory->createResumableUploadRequestFromRequest($request, $elementId); + + $this->assertSame($elementId, $resumableUploadRequest->getElementId()); + $this->assertSame('some content', $resumableUploadRequest->getContent()); + $this->assertFalse($resumableUploadRequest->isUploadComplete()); + $this->assertSame(654321, $resumableUploadRequest->getUploadLength()); + $this->assertSame(4321, $resumableUploadRequest->getContentLength()); + $this->assertSame('png', $resumableUploadRequest->getExtension()); + } + + public function testCreateResumableUploadRequestFromRequestThrowsOnLengthMismatchForCompleteUploads(): void + { + $elementId = Uuid::fromString('c01ab4f8-ab3c-4372-9aca-e919f501ad5c'); + + $headers = $this->prophesize(HeaderBag::class)->reveal(); + + $request = $this->prophesize(Request::class); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request = $request->reveal(); + $request->headers = $headers; + + $headerParseService = $this->prophesize(HeaderParseService::class); + + $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(true); + $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(654321); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(4321); + $headerParseService->getExtensionFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn('png'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Inconsistent length values provided in headers 'Content-Length' and 'Upload-Length'."))->shouldBeCalledOnce()->willReturn($exception); + + $resumableUploadRequestFactory = $this->buildResumableUploadRequestFactory( + headerParseService: $headerParseService->reveal(), + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $resumableUploadRequestFactory->createResumableUploadRequestFromRequest($request, $elementId); + } + + public function testCreateResumableUploadRequestFromRequestDoesNotThrowWhenUploadLengthIsNull(): void + { + $elementId = Uuid::fromString('c01ab4f8-ab3c-4372-9aca-e919f501ad5c'); + + $headers = $this->prophesize(HeaderBag::class)->reveal(); + + $request = $this->prophesize(Request::class); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request = $request->reveal(); + $request->headers = $headers; + + $headerParseService = $this->prophesize(HeaderParseService::class); + + $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(true); + $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(null); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(4321); + $headerParseService->getExtensionFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn('png'); + + $resumableUploadRequestFactory = $this->buildResumableUploadRequestFactory( + headerParseService: $headerParseService->reveal(), + ); + + $resumableUploadRequestFactory->createResumableUploadRequestFromRequest($request, $elementId); + } + + public function testCreateResumableUploadRequestFromRequestDoesNotThrowWhenContentLengthIsNull(): void + { + $elementId = Uuid::fromString('c01ab4f8-ab3c-4372-9aca-e919f501ad5c'); + + $headers = $this->prophesize(HeaderBag::class)->reveal(); + + $request = $this->prophesize(Request::class); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request = $request->reveal(); + $request->headers = $headers; + + $headerParseService = $this->prophesize(HeaderParseService::class); + + $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(true); + $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(654321); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(null); + $headerParseService->getExtensionFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn('png'); + + $resumableUploadRequestFactory = $this->buildResumableUploadRequestFactory( + headerParseService: $headerParseService->reveal(), + ); + + $resumableUploadRequestFactory->createResumableUploadRequestFromRequest($request, $elementId); + } +} diff --git a/tests/UnitTests/Factory/Type/Response/NoContentResponseFactoryTest.php b/tests/UnitTests/Factory/Type/Response/NoContentResponseFactoryTest.php new file mode 100644 index 00000000..4014ec66 --- /dev/null +++ b/tests/UnitTests/Factory/Type/Response/NoContentResponseFactoryTest.php @@ -0,0 +1,87 @@ +prophesize(UploadInterface::class); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); + $upload->getUploadOffset()->shouldBeCalledOnce()->willReturn(1234); + $upload->getUploadLength()->shouldBeCalledOnce()->willReturn(null); + $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest()->shouldBeCalledOnce()->willReturn(666666); + $emberNexusConfiguration->getFileMaxFileSizeInBytes()->shouldBeCalledOnce()->willReturn(555555); + $emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()->shouldBeCalledOnce()->willReturn(444444); + $emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()->shouldBeCalledOnce()->willReturn(333333); + + $noContentResponseFactory = new NoContentResponseFactory( + $emberNexusConfiguration->reveal() + ); + + $response = $noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload->reveal()); + + $headers = $response->headers; + + $this->assertSame('?0', $headers->get('Upload-Complete')); + $this->assertSame('1234', $headers->get('Upload-Offset')); + $this->assertNull($headers->get('Location')); + $this->assertNull($headers->get('Upload-Length')); + $this->assertSame('max-age=666666, max-size=555555, min-append-size=444444, max-append-size=333333', $headers->get('Upload-Limit')); + $this->assertSame('Sun, 19 Apr 2026 13:34:00 GMT', $headers->get('Expires')); + $this->assertSame('no-store, private', $headers->get('Cache-Control')); + } + + public function testCreateNoContentResponseWithResumableUploadHeadersFromUploadWorksWithOptionalValuesAsWell(): void + { + $expires = DateTime::createFromFormat('Y-m-d H:i:s', '2026-04-20 00:00:00'); + + $upload = $this->prophesize(UploadInterface::class); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $upload->getUploadOffset()->shouldBeCalledOnce()->willReturn(1234); + $upload->getUploadLength()->shouldBeCalledOnce()->willReturn(4567); + $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest()->shouldBeCalledOnce()->willReturn(666666); + $emberNexusConfiguration->getFileMaxFileSizeInBytes()->shouldBeCalledOnce()->willReturn(555555); + $emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()->shouldBeCalledOnce()->willReturn(444444); + $emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()->shouldBeCalledOnce()->willReturn(333333); + + $noContentResponseFactory = new NoContentResponseFactory( + $emberNexusConfiguration->reveal() + ); + + $response = $noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload->reveal(), 'new-location'); + + $headers = $response->headers; + + $this->assertSame('?1', $headers->get('Upload-Complete')); + $this->assertSame('1234', $headers->get('Upload-Offset')); + $this->assertSame('new-location', $headers->get('Location')); + $this->assertSame('4567', $headers->get('Upload-Length')); + $this->assertSame('max-age=666666, max-size=555555, min-append-size=444444, max-append-size=333333', $headers->get('Upload-Limit')); + $this->assertSame('Mon, 20 Apr 2026 00:00:00 GMT', $headers->get('Expires')); + $this->assertSame('no-store, private', $headers->get('Cache-Control')); + } +} diff --git a/tests/UnitTests/Factory/Type/S3/FileOperationFactoryTest.php b/tests/UnitTests/Factory/Type/S3/FileOperationFactoryTest.php new file mode 100644 index 00000000..bb45432b --- /dev/null +++ b/tests/UnitTests/Factory/Type/S3/FileOperationFactoryTest.php @@ -0,0 +1,115 @@ +prophesize(EmberNexusConfiguration::class)->reveal(), + $fileService ?? $this->prophesize(FileService::class)->reveal(), + $elementService ?? $this->prophesize(ElementService::class)->reveal(), + $server500LogicExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + } + + public function testCreateFileOperationFromUpload(): void + { + $id = Uuid::fromString('2e92baf4-850e-4a98-ab03-a5b2f717808f'); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($id), Argument::is(3))->shouldBeCalledOnce()->willReturn('upload-key'); + + $factory = $this->buildFileOperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + fileService: $fileService->reveal() + ); + + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->shouldBeCalledOnce()->willReturn($id); + + $operation = $factory->createFileOperationFromUpload($upload->reveal(), 3); + + $this->assertInstanceOf(FileOperation::class, $operation); + $this->assertSame('upload-bucket', $operation->getBucket()); + $this->assertSame('upload-key', $operation->getKey()); + } + + public function testCreateFileOperationFromElement(): void + { + $id = Uuid::fromString('2e92baf4-850e-4a98-ab03-a5b2f717808f'); + + $element = $this->prophesize(NodeElement::class); + $element->getId()->shouldBeCalledOnce()->willReturn($id); + $element = $element->reveal(); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('test'); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getStorageBucketKey(Argument::is($id), Argument::is('test'))->shouldBeCalledOnce()->willReturn('storage-key.test'); + + $factory = $this->buildFileOperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + fileService: $fileService->reveal(), + elementService: $elementService->reveal() + ); + + $operation = $factory->createFileOperationFromElement($element); + + $this->assertInstanceOf(FileOperation::class, $operation); + $this->assertSame('storage-bucket', $operation->getBucket()); + $this->assertSame('storage-key.test', $operation->getKey()); + } + + public function testCreateFileOperationFromElementFailsOnMissingElementId(): void + { + $element = $this->prophesize(NodeElementInterface::class)->reveal(); + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicExceptionFactory->createFromTemplate(Argument::is('Expected elementId to be not null.'))->shouldBeCalledOnce()->willReturn($exception); + + $factory = $this->buildFileOperationFactory( + server500LogicExceptionFactory: $server500LogicExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $factory->createFileOperationFromElement($element); + } +} diff --git a/tests/UnitTests/Factory/Type/S3/MergeFileChunksOperationFactoryTest.php b/tests/UnitTests/Factory/Type/S3/MergeFileChunksOperationFactoryTest.php new file mode 100644 index 00000000..c32ecc45 --- /dev/null +++ b/tests/UnitTests/Factory/Type/S3/MergeFileChunksOperationFactoryTest.php @@ -0,0 +1,158 @@ +prophesize(EmberNexusConfiguration::class)->reveal(), + $elementManager ?? $this->prophesize(ElementManager::class)->reveal(), + $elementService ?? $this->prophesize(ElementService::class)->reveal(), + $fileService ?? $this->prophesize(FileService::class)->reveal(), + $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), + ); + } + + public function testCreateMergeFileOperationFromUpload(): void + { + $uploadId = Uuid::fromString('8fb0017a-c2af-4140-96fd-c8e7bbe03f5e'); + $uploadTarget = Uuid::fromString('25825901-d004-4c13-a9d9-a0f813de49b4'); + + $upload = $this->prophesize(UploadInterface::class); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); + $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); + $upload->getUploadTarget()->shouldBeCalledTimes(2)->willReturn($uploadTarget); + $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); + + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty(Argument::is('file'))->shouldBeCalledOnce()->willReturn(false); + $element = $element->reveal(); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($uploadTarget))->shouldBeCalledOnce()->willReturn($element); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(1))->shouldBeCalledOnce()->willReturn('upload-key-0001'); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(2))->shouldBeCalledOnce()->willReturn('upload-key-0002'); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(3))->shouldBeCalledOnce()->willReturn('upload-key-0003'); + $fileService->getStorageBucketKey(Argument::is($uploadTarget), Argument::is('ext'))->shouldBeCalledOnce()->willReturn('target-key.ext'); + + $mergeFileChunksOperationFactory = $this->buildMergeFileChunksOperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementManager: $elementManager->reveal(), + fileService: $fileService->reveal() + ); + + $mergeFileChunksOperation = $mergeFileChunksOperationFactory->createMergeFileOperationFromUpload($upload->reveal()); + + $this->assertInstanceOf(MergeFileChunksOperation::class, $mergeFileChunksOperation); + $this->assertSame('upload-bucket', $mergeFileChunksOperation->getUploadBucket()); + $this->assertSame(['upload-key-0001', 'upload-key-0002', 'upload-key-0003'], $mergeFileChunksOperation->getUploadKeys()); + $this->assertSame('storage-bucket', $mergeFileChunksOperation->getStorageBucket()); + $this->assertNull($mergeFileChunksOperation->getPreviousStorageKey()); + $this->assertSame('target-key.ext', $mergeFileChunksOperation->getStorageKey()); + } + + public function testCreateMergeFileOperationFromUploadSetsPreviousStorageKeyWhenPossible(): void + { + $uploadId = Uuid::fromString('8fb0017a-c2af-4140-96fd-c8e7bbe03f5e'); + $uploadTarget = Uuid::fromString('25825901-d004-4c13-a9d9-a0f813de49b4'); + + $upload = $this->prophesize(UploadInterface::class); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(2); + $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); + $upload->getUploadTarget()->shouldBeCalledTimes(3)->willReturn($uploadTarget); + $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); + + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty(Argument::is('file'))->shouldBeCalledOnce()->willReturn(true); + $element = $element->reveal(); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($uploadTarget))->shouldBeCalledOnce()->willReturn($element); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(1))->shouldBeCalledOnce()->willReturn('upload-key-0001'); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(2))->shouldBeCalledOnce()->willReturn('upload-key-0002'); + $fileService->getStorageBucketKey(Argument::is($uploadTarget), Argument::is('ext'))->shouldBeCalledOnce()->willReturn('target-key.ext'); + $fileService->getStorageBucketKey(Argument::is($uploadTarget), Argument::is('prev'))->shouldBeCalledOnce()->willReturn('target-key.prev'); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('prev'); + + $mergeFileChunksOperationFactory = $this->buildMergeFileChunksOperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementManager: $elementManager->reveal(), + elementService: $elementService->reveal(), + fileService: $fileService->reveal() + ); + + $mergeFileChunksOperation = $mergeFileChunksOperationFactory->createMergeFileOperationFromUpload($upload->reveal()); + + $this->assertInstanceOf(MergeFileChunksOperation::class, $mergeFileChunksOperation); + $this->assertSame('upload-bucket', $mergeFileChunksOperation->getUploadBucket()); + $this->assertSame(['upload-key-0001', 'upload-key-0002'], $mergeFileChunksOperation->getUploadKeys()); + $this->assertSame('storage-bucket', $mergeFileChunksOperation->getStorageBucket()); + $this->assertSame('target-key.prev', $mergeFileChunksOperation->getPreviousStorageKey()); + $this->assertSame('target-key.ext', $mergeFileChunksOperation->getStorageKey()); + } + + public function testCreateMergeFileOperationFromUploadThrowsWhenUploadIsIncomplete(): void + { + $upload = $this->prophesize(UploadInterface::class); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("'MergeFileChunksOperation' requires 'Upload' to be complete."))->shouldBeCalledOnce()->willReturn($exception); + + $mergeFileChunksOperationFactory = $this->buildMergeFileChunksOperationFactory( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $mergeFileChunksOperationFactory->createMergeFileOperationFromUpload($upload->reveal()); + } +} diff --git a/tests/UnitTests/Factory/Type/S3/UploadFileChunkOperationFactoryTest.php b/tests/UnitTests/Factory/Type/S3/UploadFileChunkOperationFactoryTest.php new file mode 100644 index 00000000..bbeceb80 --- /dev/null +++ b/tests/UnitTests/Factory/Type/S3/UploadFileChunkOperationFactoryTest.php @@ -0,0 +1,149 @@ +prophesize(EmberNexusConfiguration::class)->reveal(), + $fileService ?? $this->prophesize(FileService::class)->reveal(), + $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal() + ); + } + + public function testCreateUploadFileChunkOperationFromResumableUploadRequest(): void + { + $uploadId = Uuid::fromString('8bcc36eb-0a16-4a2f-b5f9-a892a81c56c5'); + + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); + $resumableUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $resumableUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); + $resumableUploadRequest = $resumableUploadRequest->reveal(); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(1))->shouldBeCalledOnce()->willReturn('upload-key'); + + $factory = $this->buildUploadFileChunkOperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + fileService: $fileService->reveal() + ); + + $operation = $factory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId); + + $this->assertInstanceOf(UploadFileChunkOperation::class, $operation); + $this->assertSame('upload-bucket', $operation->getUploadBucket()); + $this->assertSame('upload-key', $operation->getUploadKey()); + $this->assertSame('some content', $operation->getContent()); + $this->assertSame(12, $operation->getContentLength()); + $this->assertSame('application/octet-stream', $operation->getMimeType()); + } + + public function testCreateUploadFileChunkOperationFromResumableUploadRequestFailsOnCompleteUpload(): void + { + $uploadId = Uuid::fromString('8bcc36eb-0a16-4a2f-b5f9-a892a81c56c5'); + + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $resumableUploadRequest = $resumableUploadRequest->reveal(); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("'UploadFileChunkOperation' requires 'ResumableUploadRequest' to contain partial content, i.e. be a chunked upload request."))->shouldBeCalledOnce()->willReturn($exception); + + $factory = $this->buildUploadFileChunkOperationFactory( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $factory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId); + } + + public function testCreateUploadFileChunkOperationFromPartialUploadRequest(): void + { + $uploadId = Uuid::fromString('8bcc36eb-0a16-4a2f-b5f9-a892a81c56c5'); + + $partialUploadRequest = $this->prophesize(PartialUploadRequestInterface::class); + $partialUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $partialUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); + $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(4))->shouldBeCalledOnce()->willReturn('upload-key'); + + $factory = $this->buildUploadFileChunkOperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + fileService: $fileService->reveal() + ); + + $operation = $factory->createUploadFileChunkOperationFromPartialUploadRequest($partialUploadRequest->reveal(), $upload->reveal()); + + $this->assertInstanceOf(UploadFileChunkOperation::class, $operation); + $this->assertSame('upload-bucket', $operation->getUploadBucket()); + $this->assertSame('upload-key', $operation->getUploadKey()); + $this->assertSame('some content', $operation->getContent()); + $this->assertSame(12, $operation->getContentLength()); + $this->assertSame('application/octet-stream', $operation->getMimeType()); + } + + public function testCreateUploadFileChunkOperationFromUploadFileOperation(): void + { + $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $uploadFileOperation->getUploadKey()->shouldBeCalledOnce()->willReturn('upload-key'); + $uploadFileOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $uploadFileOperation->getContentLength()->shouldBeCalledOnce()->willReturn(12); + $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + + $factory = $this->buildUploadFileChunkOperationFactory(); + + $operation = $factory->createUploadFileChunkOperationFromUploadFileOperation($uploadFileOperation->reveal()); + + $this->assertInstanceOf(UploadFileChunkOperationInterface::class, $operation); + $this->assertSame('upload-bucket', $operation->getUploadBucket()); + $this->assertSame('upload-key', $operation->getUploadKey()); + $this->assertSame('some content', $operation->getContent()); + $this->assertSame(12, $operation->getContentLength()); + $this->assertSame('text/plain', $operation->getMimeType()); + } +} diff --git a/tests/UnitTests/Factory/Type/S3/UploadFileOperationFactoryTest.php b/tests/UnitTests/Factory/Type/S3/UploadFileOperationFactoryTest.php new file mode 100644 index 00000000..81d824f4 --- /dev/null +++ b/tests/UnitTests/Factory/Type/S3/UploadFileOperationFactoryTest.php @@ -0,0 +1,229 @@ +prophesize(EmberNexusConfiguration::class)->reveal(), + $elementManager ?? $this->prophesize(ElementManager::class)->reveal(), + $elementService ?? $this->prophesize(ElementService::class)->reveal(), + $fileService ?? $this->prophesize(FileService::class)->reveal(), + $mimeTypeService ?? $this->prophesize(MimeTypeService::class)->reveal(), + $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), + $server500LogicExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + } + + public function testCreateUploadFileOperationFromResumableUploadRequest(): void + { + $elementId = Uuid::fromString('9c981309-8269-4469-a595-282eb62ec04f'); + + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $resumableUploadRequest->getElementId()->shouldBeCalledOnce()->willReturn($elementId); + $resumableUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $resumableUploadRequest->getExtension()->shouldBeCalledOnce()->willReturn('txt'); + $resumableUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty(Argument::is('file'))->shouldBeCalledOnce()->willReturn(false); + $element = $element->reveal(); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($elementId))->shouldBeCalledOnce()->willReturn($element); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($elementId), Argument::is(0))->shouldBeCalledOnce()->willReturn('upload-key'); + $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('txt'))->shouldBeCalledOnce()->willReturn('storage-key.txt'); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementManager: $elementManager->reveal(), + fileService: $fileService->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $uploadFileOperation = $uploadFileOperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest->reveal()); + + $this->assertInstanceOf(UploadFileOperation::class, $uploadFileOperation); + $this->assertSame('upload-bucket', $uploadFileOperation->getUploadBucket()); + $this->assertSame('upload-key', $uploadFileOperation->getUploadKey()); + $this->assertSame('storage-bucket', $uploadFileOperation->getStorageBucket()); + $this->assertNull($uploadFileOperation->getPreviousStorageKey()); + $this->assertSame('storage-key.txt', $uploadFileOperation->getStorageKey()); + $this->assertSame('some content', $uploadFileOperation->getContent()); + $this->assertSame(12, $uploadFileOperation->getContentLength()); + $this->assertSame('text/plain', $uploadFileOperation->getMimeType()); + } + + public function testCreateUploadFileOperationFromResumableUploadRequestSetsPreviousStorageKeyWhenPossible(): void + { + $elementId = Uuid::fromString('9c981309-8269-4469-a595-282eb62ec04f'); + + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $resumableUploadRequest->getElementId()->shouldBeCalledOnce()->willReturn($elementId); + $resumableUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $resumableUploadRequest->getExtension()->shouldBeCalledOnce()->willReturn('txt'); + $resumableUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty(Argument::is('file'))->shouldBeCalledOnce()->willReturn(true); + $element = $element->reveal(); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($elementId))->shouldBeCalledOnce()->willReturn($element); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('prev'))->shouldBeCalledOnce()->willReturn('storage-key.prev'); + $fileService->getUploadBucketKey(Argument::is($elementId), Argument::is(0))->shouldBeCalledOnce()->willReturn('upload-key'); + $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('txt'))->shouldBeCalledOnce()->willReturn('storage-key.txt'); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('prev'); + + $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementManager: $elementManager->reveal(), + elementService: $elementService->reveal(), + fileService: $fileService->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $uploadFileOperation = $uploadFileOperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest->reveal()); + + $this->assertInstanceOf(UploadFileOperation::class, $uploadFileOperation); + $this->assertSame('storage-key.prev', $uploadFileOperation->getPreviousStorageKey()); + } + + public function testCreateUploadFileOperationFromResumableUploadRequestFailsWhenUploadIsIncomplete(): void + { + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("'UploadFileOperation' requires 'ResumableUploadRequest' to contain the whole content, i.e. be a non-chunked upload request."))->shouldBeCalledOnce()->willReturn($exception); + + $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $uploadFileOperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest->reveal()); + } + + public function testCreateUploadFileOperationFromElementAndResource(): void + { + $elementId = Uuid::fromString('9c981309-8269-4469-a595-282eb62ec04f'); + + $element = $this->prophesize(NodeElementInterface::class); + $element->getId()->shouldBeCalledOnce()->willReturn($elementId); + $element = $element->reveal(); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($elementId), Argument::is(0))->shouldBeCalledOnce()->willReturn('upload-key'); + $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('ext'))->shouldBeCalledOnce()->willReturn('storage-key.ext'); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('ext'); + + $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementService: $elementService->reveal(), + fileService: $fileService->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $uploadFileOperation = $uploadFileOperationFactory->createUploadFileOperationFromElementAndResource($element, 'some content'); + + $this->assertInstanceOf(UploadFileOperation::class, $uploadFileOperation); + $this->assertSame('upload-bucket', $uploadFileOperation->getUploadBucket()); + $this->assertSame('upload-key', $uploadFileOperation->getUploadKey()); + $this->assertSame('storage-bucket', $uploadFileOperation->getStorageBucket()); + $this->assertNull($uploadFileOperation->getPreviousStorageKey()); + $this->assertSame('storage-key.ext', $uploadFileOperation->getStorageKey()); + $this->assertSame('some content', $uploadFileOperation->getContent()); + $this->assertNull($uploadFileOperation->getContentLength()); + $this->assertSame('text/plain', $uploadFileOperation->getMimeType()); + } + + public function testCreateUploadFileOperationFromElementAndResourceFailsWhenElementIdIsMissing(): void + { + $element = $this->prophesize(NodeElementInterface::class); + $element->getId()->shouldBeCalledOnce()->willReturn(null); + $element = $element->reveal(); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicExceptionFactory->createFromTemplate(Argument::is('Expected element.id to not be null.'))->shouldBeCalledOnce()->willReturn($exception); + + $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( + server500LogicExceptionFactory: $server500LogicExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $uploadFileOperationFactory->createUploadFileOperationFromElementAndResource($element, 'some content'); + } +} diff --git a/tests/UnitTests/Factory/Type/UploadFactoryTest.php b/tests/UnitTests/Factory/Type/UploadFactoryTest.php new file mode 100644 index 00000000..179fd80a --- /dev/null +++ b/tests/UnitTests/Factory/Type/UploadFactoryTest.php @@ -0,0 +1,201 @@ +prophesize(PropertyParseService::class)->reveal(), + $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), + ); + } + + public function testCreateUploadFromElement(): void + { + $elementId = Uuid::fromString('95ddb14a-bf07-4bab-958a-64ec5240800b'); + $uploadTarget = Uuid::fromString('5c9d0f01-251e-47a0-983c-b39201b07a32'); + $uploadOwner = Uuid::fromString('5bdafd97-fe7c-4e68-b094-63da5c992a69'); + $expires = new DateTime(); + + $properties = []; + + $element = $this->prophesize(NodeElementInterface::class); + $element->getLabel()->shouldBeCalledOnce()->willReturn('Upload'); + $element->getId()->shouldBeCalledOnce()->willReturn($elementId); + $element->getProperties()->shouldBeCalledOnce()->willReturn($properties); + + $propertyParseService = $this->prophesize(PropertyParseService::class); + $propertyParseService->getUploadLengthFromProperties(Argument::is($properties))->willReturn(1111); + $propertyParseService->getUploadOffsetFromProperties(Argument::is($properties))->willReturn(2222); + $propertyParseService->getIsUploadCompleteFromProperties(Argument::is($properties))->willReturn(false); + $propertyParseService->getUploadTargetFromProperties(Argument::is($properties))->willReturn($uploadTarget); + $propertyParseService->getAlreadyUploadedChunksFromProperties(Argument::is($properties))->willReturn(5); + $propertyParseService->getUploadOwnerFromProperties(Argument::is($properties))->willReturn($uploadOwner); + $propertyParseService->getExtensionFromProperties(Argument::is($properties))->willReturn('some-ext'); + $propertyParseService->getExpiresFromProperties(Argument::is($properties))->willReturn($expires); + + $uploadFactory = $this->buildUploadFactory( + propertyParseService: $propertyParseService->reveal() + ); + + $upload = $uploadFactory->createUploadFromElement($element->reveal()); + + $this->assertSame($elementId, $upload->getId()); + $this->assertSame(1111, $upload->getUploadLength()); + $this->assertSame(2222, $upload->getUploadOffset()); + $this->assertSame(false, $upload->isUploadComplete()); + $this->assertSame($uploadTarget, $upload->getUploadTarget()); + $this->assertSame(5, $upload->getAlreadyUploadedChunks()); + $this->assertSame($uploadOwner, $upload->getUploadOwner()); + $this->assertSame('some-ext', $upload->getExtension()); + $this->assertSame($expires, $upload->getExpires()); + } + + public function testCreateUploadFromElementThrowsWhenElementIsNotANode(): void + { + $element = $this->prophesize(RelationElementInterface::class); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is('Upload element must be a node, not a relation.'))->shouldBeCalledOnce()->willReturn($exception); + + $uploadFactory = $this->buildUploadFactory( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $uploadFactory->createUploadFromElement($element->reveal()); + } + + public function testCreateUploadFromElementThrowsWhenNodeIsNotOfTypeUpload(): void + { + $element = $this->prophesize(NodeElementInterface::class); + $element->getLabel()->shouldBeCalledOnce()->willReturn('NotAnUpload'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is('Can not cast element of type NotAnUpload to upload.'))->shouldBeCalledOnce()->willReturn($exception); + + $uploadFactory = $this->buildUploadFactory( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $uploadFactory->createUploadFromElement($element->reveal()); + } + + public function testCreateUploadFromElementThrowsWhenNodeHasMissingId(): void + { + $element = $this->prophesize(NodeElementInterface::class); + $element->getLabel()->shouldBeCalledOnce()->willReturn('Upload'); + $element->getId()->shouldBeCalledOnce()->willReturn(null); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is('Upload expects element id to not be null.'))->shouldBeCalledOnce()->willReturn($exception); + + $uploadFactory = $this->buildUploadFactory( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $uploadFactory->createUploadFromElement($element->reveal()); + } + + public function testMarkUploadAsComplete(): void + { + $uploadId = Uuid::fromString('c4c01f55-3b29-48e9-aa4a-814ea1b7f3c8'); + $uploadTarget = Uuid::fromString('9c6dd5e1-b58c-4b04-be08-c81cd0eda6e4'); + $uploadOwner = Uuid::fromString('db37197b-415d-4659-825e-f5d374b4d2a7'); + $expires = new DateTime(); + + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); + $upload->getUploadLength()->shouldBeCalledOnce()->willReturn(1111); + $upload->getUploadOffset()->shouldBeCalledOnce()->willReturn(2222); + $upload->isUploadComplete()->shouldNotBeCalled(); + $upload->getUploadTarget()->shouldBeCalledOnce()->willReturn($uploadTarget); + $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); + $upload->getUploadOwner()->shouldBeCalledOnce()->willReturn($uploadOwner); + $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); + $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); + + $uploadFactory = $this->buildUploadFactory(); + + $result = $uploadFactory->markUploadAsComplete($upload->reveal()); + + $this->assertSame($uploadId, $result->getId()); + $this->assertSame(1111, $result->getUploadLength()); + $this->assertSame(2222, $result->getUploadOffset()); + $this->assertSame(true, $result->isUploadComplete()); + $this->assertSame($uploadTarget, $result->getUploadTarget()); + $this->assertSame(3, $result->getAlreadyUploadedChunks()); + $this->assertSame($uploadOwner, $result->getUploadOwner()); + $this->assertSame('ext', $result->getExtension()); + $this->assertSame($expires, $result->getExpires()); + } + + public function testAddNewChunkToUpload(): void + { + $uploadId = Uuid::fromString('7f27bcda-3d08-4790-b6f0-a5727fbc2bde'); + $uploadTarget = Uuid::fromString('f253379f-3533-4719-8337-871df7d336f6'); + $uploadOwner = Uuid::fromString('2be74c20-89d7-4f61-9c5b-c47a7ae11b5e'); + $expires = new DateTime(); + + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); + $upload->getUploadLength()->shouldBeCalledOnce()->willReturn(20000); + $upload->getUploadOffset()->shouldBeCalledOnce()->willReturn(10000); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); + $upload->getUploadTarget()->shouldBeCalledOnce()->willReturn($uploadTarget); + $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); + $upload->getUploadOwner()->shouldBeCalledOnce()->willReturn($uploadOwner); + $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); + $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); + + $uploadFactory = $this->buildUploadFactory(); + + $result = $uploadFactory->addNewChunkToUpload($upload->reveal(), 9999); + + $this->assertSame($uploadId, $result->getId()); + $this->assertSame(20000, $result->getUploadLength()); + $this->assertSame(19999, $result->getUploadOffset()); + $this->assertSame(false, $result->isUploadComplete()); + $this->assertSame($uploadTarget, $result->getUploadTarget()); + $this->assertSame(4, $result->getAlreadyUploadedChunks()); + $this->assertSame($uploadOwner, $result->getUploadOwner()); + $this->assertSame('ext', $result->getExtension()); + $this->assertSame($expires, $result->getExpires()); + } +} diff --git a/tests/UnitTests/Response/CollectionResponseTest.php b/tests/UnitTests/Response/CollectionResponseTest.php index 8607a973..6291ded9 100644 --- a/tests/UnitTests/Response/CollectionResponseTest.php +++ b/tests/UnitTests/Response/CollectionResponseTest.php @@ -4,7 +4,8 @@ namespace App\Tests\UnitTests\Response; -use App\Response\CollectionResponse; +use App\Type\Etag; +use App\Type\Response\CollectionResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -30,4 +31,13 @@ public function testBasicCollectionResponse(): void $this->assertSame('application/json; charset=utf-8', $response->headers->get('Content-Type')); $this->assertSame('UTF-8', $response->getCharset()); } + + public function testSetEtagFromEtagInstance(): void + { + $response = new CollectionResponse(); + $this->assertNull($response->getEtag()); + $etag = new Etag('some etag'); + $response->setEtagFromEtagInstance($etag); + $this->assertSame('"some etag"', $response->getEtag()); + } } diff --git a/tests/UnitTests/Response/CreatedResponseTest.php b/tests/UnitTests/Response/CreatedResponseTest.php index 6d0d0853..564fc115 100644 --- a/tests/UnitTests/Response/CreatedResponseTest.php +++ b/tests/UnitTests/Response/CreatedResponseTest.php @@ -4,7 +4,7 @@ namespace App\Tests\UnitTests\Response; -use App\Response\CreatedResponse; +use App\Type\Response\CreatedResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -18,7 +18,7 @@ public function testCreatedResponseWithNoLocation(): void $response = new CreatedResponse(); $this->assertSame(201, $response->getStatusCode()); $this->assertSame('', $response->getContent()); - $this->assertSame('', $response->headers->get('Location')); + $this->assertNull($response->headers->get('Location')); $this->assertFalse($response->headers->has('Content-Type')); } diff --git a/tests/UnitTests/Response/ElementResponseTest.php b/tests/UnitTests/Response/ElementResponseTest.php index c5fdc370..875f51ed 100644 --- a/tests/UnitTests/Response/ElementResponseTest.php +++ b/tests/UnitTests/Response/ElementResponseTest.php @@ -4,7 +4,8 @@ namespace App\Tests\UnitTests\Response; -use App\Response\ElementResponse; +use App\Type\Etag; +use App\Type\Response\ElementResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -30,4 +31,13 @@ public function testBasicElementResponse(): void $this->assertSame('application/json; charset=utf-8', $response->headers->get('Content-Type')); $this->assertSame('UTF-8', $response->getCharset()); } + + public function testSetEtagFromEtagInstance(): void + { + $response = new ElementResponse(); + $this->assertNull($response->getEtag()); + $etag = new Etag('some etag'); + $response->setEtagFromEtagInstance($etag); + $this->assertSame('"some etag"', $response->getEtag()); + } } diff --git a/tests/UnitTests/Response/JsonResponseTest.php b/tests/UnitTests/Response/JsonResponseTest.php index c836dc42..27196d68 100644 --- a/tests/UnitTests/Response/JsonResponseTest.php +++ b/tests/UnitTests/Response/JsonResponseTest.php @@ -4,8 +4,7 @@ namespace App\Tests\UnitTests\Response; -use App\Response\JsonResponse; -use App\Type\Etag; +use App\Type\Response\JsonResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -31,13 +30,4 @@ public function testBasicJsonResponse(): void $this->assertSame('application/json; charset=utf-8', $response->headers->get('Content-Type')); $this->assertSame('UTF-8', $response->getCharset()); } - - public function testSetEtagFromEtagInstance(): void - { - $response = new JsonResponse(); - $this->assertNull($response->getEtag()); - $etag = new Etag('some etag'); - $response->setEtagFromEtagInstance($etag); - $this->assertSame('"some etag"', $response->getEtag()); - } } diff --git a/tests/UnitTests/Response/NoContentResponseTest.php b/tests/UnitTests/Response/NoContentResponseTest.php index 0dd70b9a..d1b11884 100644 --- a/tests/UnitTests/Response/NoContentResponseTest.php +++ b/tests/UnitTests/Response/NoContentResponseTest.php @@ -4,7 +4,7 @@ namespace App\Tests\UnitTests\Response; -use App\Response\NoContentResponse; +use App\Type\Response\NoContentResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; diff --git a/tests/UnitTests/Response/NotModifiedResponseTest.php b/tests/UnitTests/Response/NotModifiedResponseTest.php index 13b5afa0..6c65cf06 100644 --- a/tests/UnitTests/Response/NotModifiedResponseTest.php +++ b/tests/UnitTests/Response/NotModifiedResponseTest.php @@ -4,8 +4,8 @@ namespace App\Tests\UnitTests\Response; -use App\Response\NotModifiedResponse; use App\Type\Etag; +use App\Type\Response\NotModifiedResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -20,9 +20,14 @@ public function testNotModifiedResponse(): void $this->assertSame(304, $response->getStatusCode()); $this->assertSame('', $response->getContent()); $this->assertFalse($response->headers->has('Content-Type')); + } + + public function testSetEtagFromEtagInstance(): void + { + $response = new NotModifiedResponse(); $this->assertNull($response->getEtag()); - $etag = new Etag('someEtag'); + $etag = new Etag('some etag'); $response->setEtagFromEtagInstance($etag); - $this->assertSame('"someEtag"', $response->getEtag()); + $this->assertSame('"some etag"', $response->getEtag()); } } diff --git a/tests/UnitTests/Response/ProblemJsonResponseTest.php b/tests/UnitTests/Response/ProblemJsonResponseTest.php index 55a70f84..deae7014 100644 --- a/tests/UnitTests/Response/ProblemJsonResponseTest.php +++ b/tests/UnitTests/Response/ProblemJsonResponseTest.php @@ -4,7 +4,7 @@ namespace App\Tests\UnitTests\Response; -use App\Response\ProblemJsonResponse; +use App\Type\Response\ProblemJsonResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; diff --git a/tests/UnitTests/Response/TextResponseTest.php b/tests/UnitTests/Response/TextResponseTest.php index c8bd88db..afcf7615 100644 --- a/tests/UnitTests/Response/TextResponseTest.php +++ b/tests/UnitTests/Response/TextResponseTest.php @@ -4,7 +4,7 @@ namespace App\Tests\UnitTests\Response; -use App\Response\TextResponse; +use App\Type\Response\TextResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; diff --git a/tests/UnitTests/Security/AuthProviderTest.php b/tests/UnitTests/Security/AuthProviderTest.php index 58c2c968..506bdfc6 100644 --- a/tests/UnitTests/Security/AuthProviderTest.php +++ b/tests/UnitTests/Security/AuthProviderTest.php @@ -5,7 +5,7 @@ namespace App\Tests\UnitTests\Security; use App\Exception\Server500LogicErrorException; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\AuthProvider; use App\Security\TokenGenerator; use PHPUnit\Framework\Attributes\CoversClass; @@ -30,7 +30,7 @@ public function testFailWhenAnonymousUserIsNotConfigured(): void $serverException = new Server500LogicErrorException(''); - $serverExceptionFactory = $this->prophesize(Server500LogicExceptionFactory::class); + $serverExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); $serverExceptionFactory ->createFromTemplate(Argument::is('anonymousUserUUID must be set to a valid UUID')) ->shouldBeCalledOnce() @@ -52,7 +52,7 @@ public function testInitialState(): void $authProvider = new AuthProvider( $bag->reveal(), $this->prophesize(TokenGenerator::class)->reveal(), - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->assertTrue($authProvider->isAnonymous()); @@ -68,7 +68,7 @@ public function testSetUserAndToken(): void $authProvider = new AuthProvider( $bag->reveal(), $this->prophesize(TokenGenerator::class)->reveal(), - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $authProvider->setUserAndToken(Uuid::fromString('d3e0ce1e-cdf1-4c80-beae-266008d5e520')); @@ -109,7 +109,7 @@ public function testRedisToken(): void $authProvider = new AuthProvider( $bag->reveal(), $tokenGenerator->reveal(), - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $redisTokenKeyFromHashedToken = $authProvider->getRedisTokenKeyFromHashedToken('test'); diff --git a/tests/UnitTests/Service/AppStateServiceTest.php b/tests/UnitTests/Service/AppStateServiceTest.php index 00b4ac19..edbd30e8 100644 --- a/tests/UnitTests/Service/AppStateServiceTest.php +++ b/tests/UnitTests/Service/AppStateServiceTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\UnitTests\Service; +namespace App\Tests\UnitTests\Service; use App\Service\AppStateService; use App\Type\AppStateType; diff --git a/tests/UnitTests/Service/ElementDefragmentizeServiceTest.php b/tests/UnitTests/Service/ElementDefragmentizeServiceTest.php index 4c049167..e00bf9a6 100644 --- a/tests/UnitTests/Service/ElementDefragmentizeServiceTest.php +++ b/tests/UnitTests/Service/ElementDefragmentizeServiceTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\UnitTests\Service; +namespace App\Tests\UnitTests\Service; use App\EventSystem\ElementDefragmentize\Event\NodeElementDefragmentizeEvent; use App\EventSystem\ElementDefragmentize\Event\RelationElementDefragmentizeEvent; diff --git a/tests/UnitTests/Service/ElementFragmentizeServiceTest.php b/tests/UnitTests/Service/ElementFragmentizeServiceTest.php index 93008ef4..61d79272 100644 --- a/tests/UnitTests/Service/ElementFragmentizeServiceTest.php +++ b/tests/UnitTests/Service/ElementFragmentizeServiceTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\UnitTests\Service; +namespace App\Tests\UnitTests\Service; use App\EventSystem\ElementFragmentize\Event\NodeElementFragmentizeEvent; use App\EventSystem\ElementFragmentize\Event\RelationElementFragmentizeEvent; diff --git a/tests/UnitTests/Service/ElementResponseServiceTest.php b/tests/UnitTests/Service/ElementResponseServiceTest.php index d87e24fc..664efeab 100644 --- a/tests/UnitTests/Service/ElementResponseServiceTest.php +++ b/tests/UnitTests/Service/ElementResponseServiceTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace App\tests\UnitTests\Service; +namespace App\Tests\UnitTests\Service; use App\Exception\Server500LogicErrorException; -use App\Factory\Exception\Server500LogicExceptionFactory; -use App\Response\ElementResponse; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Service\ElementManager; use App\Service\ElementResponseService; use App\Service\ElementToRawService; use App\Type\NodeElement; +use App\Type\Response\ElementResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -42,7 +42,7 @@ public function testBuildElementResponseFromIdWithElement(): void ->shouldBeCalledOnce() ->willReturn($data); - $server500LogicExceptionFactory = $this->prophesize(Server500LogicExceptionFactory::class); + $server500LogicExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); $elementResponseService = new ElementResponseService( $elementManager->reveal(), @@ -68,7 +68,7 @@ public function testBuildElementResponseFromIdWithoutElement(): void $elementToRawService = $this->prophesize(ElementToRawService::class); - $server500LogicExceptionFactory = $this->prophesize(Server500LogicExceptionFactory::class); + $server500LogicExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); $server500LogicExceptionFactory ->createFromTemplate(Argument::is("Unable to find element with the id '336eff5e-ff4e-463f-abed-168c06af5fae'.")) ->shouldBeCalledOnce() diff --git a/tests/UnitTests/Service/EtagCalculatorServiceTest.php b/tests/UnitTests/Service/EtagCalculatorServiceTest.php index d0751dd1..73d72ad0 100644 --- a/tests/UnitTests/Service/EtagCalculatorServiceTest.php +++ b/tests/UnitTests/Service/EtagCalculatorServiceTest.php @@ -4,8 +4,14 @@ namespace App\Tests\UnitTests\Service; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; +use App\Factory\Type\S3\FileOperationFactory; +use App\Service\ElementManager; use App\Service\EtagCalculatorService; +use App\Service\S3Service; +use App\Type\NodeElement; +use App\Type\S3\FileOperation; +use AsyncAws\Core\Exception\Http\ClientException; use Beste\Psr\Log\TestLogger; use EmberNexusBundle\Service\EmberNexusConfiguration; use Laudis\Neo4j\Contracts\ClientInterface; @@ -73,8 +79,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -137,8 +146,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -190,8 +202,11 @@ public function testCalculateElementEtagForElementWhichDoesNotExist(): void $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -233,8 +248,11 @@ public function testCalculateElementEtagWithEdgecaseWhereDifferentObjectIsReturn $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->expectExceptionMessage('Unable to get DateTime from stdClass.'); @@ -297,8 +315,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -374,8 +395,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -447,8 +471,11 @@ public function testCalculateChildrenCollectionEtagWithTooManyElements(): void $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -499,8 +526,11 @@ public function testCalculateChildrenCollectionEtagDifferentObjectIsReturned(): $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->expectExceptionMessage('Unable to get DateTime from stdClass.'); @@ -538,8 +568,11 @@ public function testCalculateChildrenCollectionEtagWhereNoDataIsReturned(): void $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->expectExceptionMessage('Unexpected result.'); @@ -602,8 +635,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -679,8 +715,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -752,8 +791,11 @@ public function testCalculateParentsCollectionEtagWithTooManyElements(): void $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -804,8 +846,11 @@ public function testCalculateParentsCollectionEtagDifferentObjectIsReturned(): v $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->expectExceptionMessage('Unable to get DateTime from stdClass.'); @@ -843,8 +888,11 @@ public function testCalculateParentsCollectionEtagWhereNoDataIsReturned(): void $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->expectExceptionMessage('Unexpected result.'); @@ -907,8 +955,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -983,8 +1034,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -1055,8 +1109,11 @@ public function testCalculateRelatedCollectionEtagWithTooManyElements(): void $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -1107,8 +1164,11 @@ public function testCalculateRelatedCollectionEtagDifferentObjectIsReturned(): v $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->expectExceptionMessage('Unable to get DateTime from stdClass.'); @@ -1146,8 +1206,11 @@ public function testCalculateRelatedCollectionEtagWhereNoDataIsReturned(): void $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->expectExceptionMessage('Unexpected result.'); @@ -1210,8 +1273,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -1286,8 +1352,11 @@ function ($internalStatement) use (&$statement) { $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -1358,8 +1427,11 @@ public function testCalculateIndexCollectionEtagWithTooManyElements(): void $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); // run service method @@ -1410,8 +1482,11 @@ public function testCalculateIndexCollectionEtagDifferentObjectIsReturned(): voi $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->expectExceptionMessage('Unable to get DateTime from stdClass.'); @@ -1449,8 +1524,11 @@ public function testCalculateIndexCollectionEtagWhereNoDataIsReturned(): void $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(S3Service::class)->reveal(), + $this->prophesize(FileOperationFactory::class)->reveal(), $logger, - $this->prophesize(Server500LogicExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); $this->expectExceptionMessage('Unexpected result.'); @@ -1461,4 +1539,97 @@ public function testCalculateIndexCollectionEtagWhereNoDataIsReturned(): void // assert logs $this->assertTrue($logger->records->includeMessagesContaining('Calculating Etag for index collection.')); } + + public function testCalculateFileEtag(): void + { + // setup variables + $id = Uuid::fromString('544e0cf6-d351-435c-828f-7a0762240ce6'); + + $element = new NodeElement(); + $element + ->addProperty('file', ['size' => 1024, 'mimetype' => 'image/png']) + ->addProperty('name', 'some name'); + + $fileOperation = new FileOperation('someBucket', 'someKey'); + + // setup service dependencies + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getCacheEtagSeed()->shouldBeCalledOnce()->willReturn('seed'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); + + $fileOperationFactory = $this->prophesize(FileOperationFactory::class); + $fileOperationFactory->createFileOperationFromElement(Argument::is($element))->shouldBeCalledOnce()->willReturn($fileOperation); + + $s3Service = $this->prophesize(S3Service::class); + $s3Service->getEtag(Argument::is($fileOperation))->shouldBeCalledOnce()->willReturn('some s3 etag'); + + $logger = TestLogger::create(); + + // setup service + $etagCalculatorService = new EtagCalculatorService( + $emberNexusConfiguration->reveal(), + $this->prophesize(CypherEntityManager::class)->reveal(), + $elementManager->reveal(), + $s3Service->reveal(), + $fileOperationFactory->reveal(), + $logger, + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + + // run service method + $etag = $etagCalculatorService->calculateFileEtag($id); + $this->assertSame('dYDnWUfIMVL', (string) $etag); + + // assert logs + $this->assertTrue($logger->records->includeMessagesContaining('Calculated Etag for file.')); + } + + public function testCalculateFileEtagCapturesExceptionIfS3FileIsMissing(): void + { + // setup variables + $id = Uuid::fromString('662a045f-7d90-4fa2-85f8-9f972f2bdbd3'); + + $element = new NodeElement(); + $element + ->addProperty('file', ['size' => 1024, 'mimetype' => 'image/png']) + ->addProperty('name', 'some name'); + + $fileOperation = new FileOperation('someBucket', 'someKey'); + + // setup service dependencies + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); + + $fileOperationFactory = $this->prophesize(FileOperationFactory::class); + $fileOperationFactory->createFileOperationFromElement(Argument::is($element))->shouldBeCalledOnce()->willReturn($fileOperation); + + $exception = $this->prophesize(ClientException::class)->reveal(); + + $s3Service = $this->prophesize(S3Service::class); + $s3Service->getEtag(Argument::is($fileOperation))->shouldBeCalledOnce()->willThrow($exception); + + $logger = TestLogger::create(); + + // setup service + $etagCalculatorService = new EtagCalculatorService( + $emberNexusConfiguration->reveal(), + $this->prophesize(CypherEntityManager::class)->reveal(), + $elementManager->reveal(), + $s3Service->reveal(), + $fileOperationFactory->reveal(), + $logger, + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + + // run service method + $etag = $etagCalculatorService->calculateFileEtag($id); + $this->assertNull($etag); + + // assert logs + $this->assertTrue($logger->records->includeMessagesContaining('Unable to calculate Etag for file of element 662a045f-7d90-4fa2-85f8-9f972f2bdbd3.')); + } } diff --git a/tests/UnitTests/Service/EtagServiceTest.php b/tests/UnitTests/Service/EtagServiceTest.php index 354a95aa..ee5fa1e3 100644 --- a/tests/UnitTests/Service/EtagServiceTest.php +++ b/tests/UnitTests/Service/EtagServiceTest.php @@ -6,6 +6,7 @@ use App\EventSystem\Etag\Event\ChildrenCollectionEtagEvent; use App\EventSystem\Etag\Event\ElementEtagEvent; +use App\EventSystem\Etag\Event\FileEtagEvent; use App\EventSystem\Etag\Event\IndexCollectionEtagEvent; use App\EventSystem\Etag\Event\ParentsCollectionEtagEvent; use App\EventSystem\Etag\Event\RelatedCollectionEtagEvent; @@ -38,6 +39,7 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeEleme * @var ElementEtagEvent $event */ $self->assertSame('224b322a-c2a1-4971-8b05-28af080d67f1', $event->getElementId()->toString()); + $self->assertInstanceOf(ElementEtagEvent::class, $event); $event->setEtag(new Etag('someEtag')); })->shouldBeCalledTimes(1); $authProvider = $this->prophesize(AuthProvider::class); @@ -63,6 +65,7 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeChild * @var ChildrenCollectionEtagEvent $event */ $self->assertSame('47d86985-68e0-4747-8921-33f3a9090549', $event->getParentId()->toString()); + $self->assertInstanceOf(ChildrenCollectionEtagEvent::class, $event); $event->setEtag(new Etag('someEtag')); })->shouldBeCalledTimes(1); $authProvider = $this->prophesize(AuthProvider::class); @@ -88,6 +91,7 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeParen * @var ParentsCollectionEtagEvent $event */ $self->assertSame('685b5a01-f2d2-4764-9c69-3fd87c45d5b0', $event->getChildId()->toString()); + $self->assertInstanceOf(ParentsCollectionEtagEvent::class, $event); $event->setEtag(new Etag('someEtag')); })->shouldBeCalledTimes(1); $authProvider = $this->prophesize(AuthProvider::class); @@ -113,6 +117,7 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeRelat * @var RelatedCollectionEtagEvent $event */ $self->assertSame('adfd47a3-7d25-4bdb-b546-f5744382d488', $event->getCenterId()->toString()); + $self->assertInstanceOf(RelatedCollectionEtagEvent::class, $event); $event->setEtag(new Etag('someEtag')); })->shouldBeCalledTimes(1); $authProvider = $this->prophesize(AuthProvider::class); @@ -128,6 +133,31 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeRelat $this->assertSame('someEtag', $returnedEtag->getCurrentRequestEtag()->getEtag()); } + public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeFile(): void + { + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $self = $this; + $eventDispatcher->dispatch(Argument::type(FileEtagEvent::class))->will(function ($args) use ($self) { + $event = $args[0]; + /** + * @var FileEtagEvent $event + */ + $self->assertSame('e70224a0-9dd6-4e57-a1c1-e90985834620', $event->getElementId()->toString()); + $self->assertInstanceOf(FileEtagEvent::class, $event); + $event->setEtag(new Etag('someEtag')); + })->shouldBeCalledTimes(1); + + $request = new Request(attributes: ['id' => 'e70224a0-9dd6-4e57-a1c1-e90985834620']); + + $etagService = new EtagService( + $eventDispatcher->reveal(), + $this->prophesize(AuthProvider::class)->reveal() + ); + + $returnedEtag = $etagService->setCurrentRequestEtagFromRequestAndEtagType($request, EtagType::FILE); + $this->assertSame('someEtag', $returnedEtag->getCurrentRequestEtag()->getEtag()); + } + public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeIndexCollection(): void { $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); @@ -138,6 +168,7 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeIndex * @var IndexCollectionEtagEvent $event */ $self->assertSame('405599eb-f72b-4505-9ad6-fabe458e9607', $event->getUserId()->toString()); + $self->assertInstanceOf(IndexCollectionEtagEvent::class, $event); $event->setEtag(new Etag('someEtag')); })->shouldBeCalledTimes(1); $authProvider = $this->prophesize(AuthProvider::class); diff --git a/tests/UnitTests/Service/ExpressionServiceTest.php b/tests/UnitTests/Service/ExpressionServiceTest.php index 6a1c0170..f589a0a9 100644 --- a/tests/UnitTests/Service/ExpressionServiceTest.php +++ b/tests/UnitTests/Service/ExpressionServiceTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\UnitTests\Service; +namespace App\Tests\UnitTests\Service; use App\Exception\Client400BadContentException; use App\Exception\Client403ForbiddenException; diff --git a/tests/UnitTests/Service/FileServiceTest.php b/tests/UnitTests/Service/FileServiceTest.php new file mode 100644 index 00000000..914bd428 --- /dev/null +++ b/tests/UnitTests/Service/FileServiceTest.php @@ -0,0 +1,272 @@ +createMock(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('url'); + $server500Bag = $this->createMock(ParameterBagInterface::class); + $server500Bag->method('get')->willReturn('dev'); + $server500LogicExceptionFactory = new Server500LogicErrorExceptionFactory( + $urlGenerator, + $server500Bag, + $this->createMock(LoggerInterface::class) + ); + $stringService = new StringService($server500LogicExceptionFactory); + + return new FileService( + $emberNexusConfiguration ?? $this->createMock(EmberNexusConfiguration::class), + $stringService, + $server500LogicExceptionFactory, + ); + } + + public function testGetUploadBucketKeyThrowsOnNegativeIndex(): void + { + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileUploadChunkDigitsLength()->shouldBeCalledOnce()->willReturn(4); + + $storageService = $this->buildFileService( + emberNexusConfiguration: $emberNexusConfiguration->reveal() + ); + + try { + $storageService->getUploadBucketKey(Uuid::fromString('8ba117cf-8983-4f13-be93-415e175cb64d'), -1); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var Server500LogicErrorException $e + */ + $this->assertSame('Chunk index can not be less than 0.', $e->getDetail()); + } + } + + public function testGetUploadBucketKeyThrowsWhenIndexExceedsMaxDigitsValue(): void + { + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileUploadChunkDigitsLength()->shouldBeCalledOnce()->willReturn(4); + + $storageService = $this->buildFileService( + emberNexusConfiguration: $emberNexusConfiguration->reveal() + ); + + try { + $storageService->getUploadBucketKey(Uuid::fromString('8ba117cf-8983-4f13-be93-415e175cb64d'), 10000); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var Server500LogicErrorException $e + */ + $this->assertSame('Chunk index can not be longer than 4 digits, i.e. bigger than 9999.', $e->getDetail()); + } + } + + public function testGetUploadBucketKey(): void + { + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileUploadChunkDigitsLength()->shouldBeCalledOnce()->willReturn(4); + $emberNexusConfiguration->getFileS3UploadBucketLevels()->shouldBeCalledOnce()->willReturn(3); + $emberNexusConfiguration->getFileS3UploadBucketLevelLength()->shouldBeCalledOnce()->willReturn(2); + + $storageService = $this->buildFileService( + emberNexusConfiguration: $emberNexusConfiguration->reveal() + ); + + $key = $storageService->getUploadBucketKey(Uuid::fromString('8ba117cf-8983-4f13-be93-415e175cb64d'), 1234); + $this->assertSame('8b/a1/17/8ba117cf-8983-4f13-be93-415e175cb64d-1234.wip', $key); + } + + public function testGetStorageBucketKeySupportsOtherExtension(): void + { + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3StorageBucketLevels()->shouldBeCalledOnce()->willReturn(3); + $emberNexusConfiguration->getFileS3StorageBucketLevelLength()->shouldBeCalledOnce()->willReturn(2); + + $storageService = $this->buildFileService( + emberNexusConfiguration: $emberNexusConfiguration->reveal() + ); + + $key = $storageService->getStorageBucketKey(Uuid::fromString('5dbd948b-d9dc-4b6c-af1c-c12dc8120755'), 'png'); + $this->assertSame('5d/bd/94/5dbd948b-d9dc-4b6c-af1c-c12dc8120755.png', $key); + } + + public function testUuidToNestedFolderStructure(): void + { + $storageService = $this->buildFileService(); + + $uuid = Uuid::fromString('3207d629-7199-4c2d-9b2a-b0fba10fe309'); + + try { + $storageService->uuidToNestedFolderStructure($uuid, -1); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var Server500LogicErrorException $e + */ + $this->assertSame('Unable to generate nested folder structure from uuid with negative level argument.', $e->getDetail()); + } + + try { + $storageService->uuidToNestedFolderStructure($uuid, 0, -1); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var Server500LogicErrorException $e + */ + $this->assertSame('Unable to generate nested folder structure from uuid with level length less than 1.', $e->getDetail()); + } + + try { + $storageService->uuidToNestedFolderStructure($uuid, 0, 0); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var Server500LogicErrorException $e + */ + $this->assertSame('Unable to generate nested folder structure from uuid with level length less than 1.', $e->getDetail()); + } + + try { + $storageService->uuidToNestedFolderStructure($uuid, 8, 9); + } catch (Exception $e) { + $this->assertInstanceOf(Server500LogicErrorException::class, $e); + /** + * @var Server500LogicErrorException $e + */ + $this->assertSame('Unable to generate nested folder structure as long as product of levels and level length exceeds length of uuid without dashes.', $e->getDetail()); + } + + $generatedStructure = $storageService->uuidToNestedFolderStructure($uuid); + $this->assertSame('3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageService->uuidToNestedFolderStructure($uuid, 1, 1); + $this->assertSame('3/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageService->uuidToNestedFolderStructure($uuid, 1, 3); + $this->assertSame('320/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageService->uuidToNestedFolderStructure($uuid, 3, 1); + $this->assertSame('3/2/0/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageService->uuidToNestedFolderStructure($uuid, 2, 2); + $this->assertSame('32/07/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageService->uuidToNestedFolderStructure($uuid, 6, 5); + $this->assertSame('3207d/62971/994c2/d9b2a/b0fba/10fe3/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + + $generatedStructure = $storageService->uuidToNestedFolderStructure($uuid, 15, 2); + $this->assertSame('32/07/d6/29/71/99/4c/2d/9b/2a/b0/fb/a1/0f/e3/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); + } + + public static function getAsciiSafeFileNameProvider(): array + { + return [ + ['', ''], + ['abc', 'abc'], + [str_repeat('ooooooooo-', 30), 'ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooooooo-ooooo'], + ['abc.txt', 'abc.txt'], + ['AbC.txt', 'AbC.txt'], + ['aä-oö-uü.txt', 'aa-oo-uu.txt'], + ['0123.txt', '0123.txt'], + ['hello world.txt', 'hello world.txt'], + ['fichier été résumé.txt', 'fichier ete resume.txt'], + ['garçon.txt', 'garcon.txt'], + ['mañana.txt', 'manana.txt'], + ['📄 emoji.txt', 'page facing up emoji.txt'], + ['😃 emoji.txt', 'grinning face with big eyes emoji.txt'], + ['✅ emoji.txt', 'check mark button emoji.txt'], + ['😃😃😃😃😃😃😃😃😃😃😃😃😃😃😃😃😃😃😃.txt', 'grinning face with big eyesgrinning face with big eyesgrinning face with big eyesgrinning face with big eyesgrinning face with big eyesgrinning face with big eyesgrinning face with big eyesgrinning face with big eyesgrinning face with big eyesgrinning.txt'], + ['서울.txt', 'seoul.txt'], + ['한국어.txt', 'hangug-eo.txt'], + ['हिन्दी.txt', 'hindi.txt'], + ['தமிழ்.txt', 'tamil.txt'], + ['বাংলা.txt', 'banla.txt'], + ['日本語.txt', 'ri ben yu.txt'], + ]; + } + + #[DataProvider('getAsciiSafeFileNameProvider')] + public function testGetAsciiSafeFileName(string $input, string $output): void + { + $fileUtilService = $this->buildFileService(); + $this->assertSame($output, $fileUtilService->getAsciiSafeFileName($input)); + } + + public static function removeReservedCharactersFromFileNameProvider(): array + { + return [ + ['', ''], + [' ', ''], + ['hello', 'hello'], + ['Hello', 'Hello'], + ['HelLo', 'HelLo'], + [' prefix trim', 'prefix trim'], + ['suffix trim ', 'suffix trim'], + ['multi word name', 'multi word name'], + ['"quoted string"', 'quoted string'], + ['wild card *', 'wild card'], + [' * prefix sanitized trim', 'prefix sanitized trim'], + ['suffix sanitized trim * ', 'suffix sanitized trim'], + ['slash /', 'slash'], + ['colon :', 'colon'], + ['less <', 'less'], + ['greater >', 'greater'], + ['question ?', 'question'], + ['reverse slash \\', 'reverse slash'], + ['pipe |', 'pipe'], + ]; + } + + #[DataProvider('removeReservedCharactersFromFileNameProvider')] + public function testRemoveReservedCharactersFromFileName(string $input, string $output): void + { + $fileUtilService = $this->buildFileService(); + $this->assertSame($output, $fileUtilService->removeReservedCharactersFromFileName($input)); + } + + public static function fileNameFromPartsProvider(): array + { + return [ + ['name', 'ext', 'name.ext'], + ['name', 'extensionWhichIsWayLongerThanExpected', 'name.extensionWhichIs'], + ['name', 'ext with long extension and whitespace ', 'name.extwithlongexten'], + ['name-----1---------2---------3---------4---------5---------6---------7---------8---------9---------a---------b---------c---------d---------e---------f---------g---------h---------i---------j---------k---------l---------m---------n---------o---------p---------q---------r---------s---------t---------u---------v', 'ext', 'name-----1---------2---------3---------4---------5---------6---------7---------8---------9---------a---------b---------c---------d---------e---------f---------g---------h---------i---------j---------k---------l---------m---------n---------o---------p-.ext'], + ['', 'env', '.env'], + ]; + } + + #[DataProvider('fileNameFromPartsProvider')] + public function testBuildFileNameFromParts(string $name, string $extension, string $result): void + { + $fileUtilService = $this->buildFileService(); + $this->assertSame($result, $fileUtilService->buildFileNameFromParts($name, $extension)); + } +} diff --git a/tests/UnitTests/Service/HeaderParseServiceTest.php b/tests/UnitTests/Service/HeaderParseServiceTest.php new file mode 100644 index 00000000..f6f68567 --- /dev/null +++ b/tests/UnitTests/Service/HeaderParseServiceTest.php @@ -0,0 +1,500 @@ +prophesize(FileService::class)->reveal(), + $contentDispositionWrapper ?? $this->prophesize(ContentDispositionWrapper::class)->reveal(), + $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), + ); + } + + public static function contentTypeProvider(): array + { + return [ + ['text/html', 'text/html'], + ['Text/HTML', 'text/html'], + ['TEXT/HTML', 'text/html'], + ['Text/HTML;Charset="utf-8"', 'text/html'], + ['text/html ; charset="utf-8"', 'text/html'], + ['application/partial-upload', 'application/partial-upload'], + ]; + } + + #[DataProvider('contentTypeProvider')] + public function testGetContentTypeFromHeaders(string $headerValue, string $contentType): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Type'))->shouldBeCalledOnce()->willReturn($headerValue); + + $headerParseService = $this->buildHeaderParseService(); + + $result = $headerParseService->getContentTypeFromHeaders($headerBag->reveal()); + $this->assertSame($contentType, $result); + } + + public static function contentTypeWithExpectedContentTypeProvider(): array + { + return [ + ['text/html', 'text/html', 'text/html'], + ['Text/Html', 'text/HTML', 'text/html'], + ['TEXT/HTML', 'TEXT/HTML', 'text/html'], + ]; + } + + #[DataProvider('contentTypeWithExpectedContentTypeProvider')] + public function testGetContentTypeFromHeadersWorksWhenContentTypeMatchesExpectedContentType(string $headerValue, string $expectedContentType, string $contentType): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Type'))->shouldBeCalledOnce()->willReturn($headerValue); + + $headerParseService = $this->buildHeaderParseService(); + + $result = $headerParseService->getContentTypeFromHeaders($headerBag->reveal(), $expectedContentType); + $this->assertSame($contentType, $result); + } + + public function testGetContentTypeFromHeadersThrowsWhenContentTypeDoesNotMatchExpectedContentType(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Type'))->shouldBeCalledOnce()->willReturn('text/html'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Expected content type 'text/plain', got 'text/html'."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getContentTypeFromHeaders($headerBag->reveal(), 'text/plain'); + } + + public function testGetContentTypeFromHeadersThrowsWhenHeaderIsMissing(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Type'))->shouldBeCalledOnce()->willReturn(null); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Endpoint requires the header 'content-type' to be present."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getContentTypeFromHeaders($headerBag->reveal()); + } + + public function testGetContentTypeFromHeadersThrowsWhenContentTypeIsEmptyString(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Type'))->shouldBeCalledOnce()->willReturn(''); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Header 'Content-Type' must contain a valid MIME type, got an empty value."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getContentTypeFromHeaders($headerBag->reveal()); + } + + public function testGetExtensionFromHeaders(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Disposition'))->shouldBeCalledOnce()->willReturn('inline; filename=Test.jpg'); + + $contentDispositionWrapper = $this->prophesize(ContentDispositionWrapper::class); + $contentDispositionWrapper->parseContentDisposition(Argument::is('inline; filename=Test.jpg'))->shouldBeCalledOnce()->willReturn('Test.jpg'); + + $fileService = $this->prophesize(FileService::class); + $fileService->removeReservedCharactersFromFileName(Argument::is('Test.jpg'))->shouldBeCalledOnce()->willReturn('Test.jpg'); + + $headerParseService = $this->buildHeaderParseService( + fileService: $fileService->reveal(), + contentDispositionWrapper: $contentDispositionWrapper->reveal() + ); + + $result = $headerParseService->getExtensionFromHeaders($headerBag->reveal()); + $this->assertSame('jpg', $result); + } + + public function testGetExtensionFromHeadersReturnsDefaultExtensionWhenHeaderIsMissing(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Disposition'))->shouldBeCalledOnce()->willReturn(null); + + $headerParseService = $this->buildHeaderParseService(); + + $result = $headerParseService->getExtensionFromHeaders($headerBag->reveal()); + $this->assertSame('bin', $result); + } + + public function testGetExtensionFromHeadersRethrowsExceptionDuringContentDispositionParsing(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Disposition'))->shouldBeCalledOnce()->willReturn('inline; filename=Test.jpg'); + + $originalException = new Exception('some message'); + + $finalException = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Could not parse 'Content-Disposition' header: some message"))->shouldBeCalledOnce()->willReturn($finalException); + + $contentDispositionWrapper = $this->prophesize(ContentDispositionWrapper::class); + $contentDispositionWrapper->parseContentDisposition(Argument::is('inline; filename=Test.jpg'))->shouldBeCalledOnce()->willThrow($originalException); + + $headerParseService = $this->buildHeaderParseService( + contentDispositionWrapper: $contentDispositionWrapper->reveal(), + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getExtensionFromHeaders($headerBag->reveal()); + } + + public function testGetExtensionFromHeadersReturnsDefaultExtensionWhenHeaderIsParsedAsNull(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Disposition'))->shouldBeCalledOnce()->willReturn('inline; filename=Test.jpg'); + + $contentDispositionWrapper = $this->prophesize(ContentDispositionWrapper::class); + $contentDispositionWrapper->parseContentDisposition(Argument::is('inline; filename=Test.jpg'))->shouldBeCalledOnce()->willReturn(null); + + $headerParseService = $this->buildHeaderParseService( + contentDispositionWrapper: $contentDispositionWrapper->reveal() + ); + + $result = $headerParseService->getExtensionFromHeaders($headerBag->reveal()); + $this->assertSame('bin', $result); + } + + public function testGetExtensionFromHeadersThrowsWhenFileNameDoesNotContainExtension(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Disposition'))->shouldBeCalledOnce()->willReturn('inline; filename=Test'); + + $contentDispositionWrapper = $this->prophesize(ContentDispositionWrapper::class); + $contentDispositionWrapper->parseContentDisposition(Argument::is('inline; filename=Test'))->shouldBeCalledOnce()->willReturn('Test'); + + $fileService = $this->prophesize(FileService::class); + $fileService->removeReservedCharactersFromFileName(Argument::is('Test'))->shouldBeCalledOnce()->willReturn('Test'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Could not parse a file extension from the filename in 'Content-Disposition': 'Test'."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + fileService: $fileService->reveal(), + contentDispositionWrapper: $contentDispositionWrapper->reveal(), + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getExtensionFromHeaders($headerBag->reveal()); + } + + public static function uploadOffset(): array + { + return [ + ['0', 0], + ['1', 1], + ['1234', 1234], + ['6543', 6543], + ]; + } + + #[DataProvider('uploadOffset')] + public function testGetUploadOffsetFromHeaders(string $headerValue, int $uploadOffset): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Offset'))->shouldBeCalledOnce()->willReturn($headerValue); + + $headerParseService = $this->buildHeaderParseService(); + + $result = $headerParseService->getUploadOffsetFromHeaders($headerBag->reveal()); + $this->assertSame($uploadOffset, $result); + } + + public function testGetUploadOffsetFromHeadersThrowsWhenOffsetIsMissing(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Offset'))->shouldBeCalledOnce()->willReturn(null); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Header 'Upload-Offset' is required."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getUploadOffsetFromHeaders($headerBag->reveal()); + } + + public function testGetUploadOffsetFromHeadersThrowsWhenOffsetIsNotNumeric(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Offset'))->shouldBeCalledOnce()->willReturn('123abc'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Header 'Upload-Offset' requires a non-negative integer as its value, got '123abc'."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getUploadOffsetFromHeaders($headerBag->reveal()); + } + + public function testGetUploadOffsetFromHeadersThrowsWhenOffsetIsNegative(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Offset'))->shouldBeCalledOnce()->willReturn('-4321'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Header 'Upload-Offset' requires a non-negative integer as its value, got '-4321'."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getUploadOffsetFromHeaders($headerBag->reveal()); + } + + public static function isUploadCompleteProvider(): array + { + return [ + ['?0', false], + ['?1', true], + [null, null], + ]; + } + + #[DataProvider('isUploadCompleteProvider')] + public function testIsUploadCompleteFromHeaders(?string $headerValue, ?bool $contentType): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Complete'))->shouldBeCalledOnce()->willReturn($headerValue); + + $headerParseService = $this->buildHeaderParseService(); + + $result = $headerParseService->isUploadCompleteFromHeaders($headerBag->reveal()); + $this->assertSame($contentType, $result); + } + + public function testIsUploadCompleteFromHeadersThrowsWhenValueIsInvalid(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Complete'))->shouldBeCalledOnce()->willReturn('iDoNotExist'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Header 'Upload-Complete' must contain a boolean value, either '?0' or '?1', got 'iDoNotExist'."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->isUploadCompleteFromHeaders($headerBag->reveal()); + } + + public static function contentLength(): array + { + return [ + ['0', 0], + ['1', 1], + ['1234', 1234], + ['6543', 6543], + ]; + } + + #[DataProvider('contentLength')] + public function testGetContentLengthFromHeaders(string $headerValue, int $contentLength): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Length'))->shouldBeCalledOnce()->willReturn($headerValue); + + $headerParseService = $this->buildHeaderParseService(); + + $result = $headerParseService->getContentLengthFromHeaders($headerBag->reveal()); + $this->assertSame($contentLength, $result); + } + + public function testGetContentLengthFromHeadersReturnsNullWhenOffsetIsMissing(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Length'))->shouldBeCalledOnce()->willReturn(null); + + $headerParseService = $this->buildHeaderParseService(); + + $result = $headerParseService->getContentLengthFromHeaders($headerBag->reveal()); + $this->assertNull($result); + } + + public function testGetContentLengthFromHeadersThrowsWhenOffsetIsNotNumeric(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Length'))->shouldBeCalledOnce()->willReturn('123abc'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Header 'Content-Length' requires a non-negative integer as its value, got '123abc'."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getContentLengthFromHeaders($headerBag->reveal()); + } + + public function testGetContentLengthFromHeadersThrowsWhenOffsetIsNegative(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Length'))->shouldBeCalledOnce()->willReturn('-4321'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Header 'Content-Length' requires a non-negative integer as its value, got '-4321'."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getContentLengthFromHeaders($headerBag->reveal()); + } + + public static function uploadLength(): array + { + return [ + ['0', 0], + ['1', 1], + ['1234', 1234], + ['6543', 6543], + ]; + } + + #[DataProvider('uploadLength')] + public function testGetUploadLengthFromHeaders(string $headerValue, int $uploadLength): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Length'))->shouldBeCalledOnce()->willReturn($headerValue); + + $headerParseService = $this->buildHeaderParseService(); + + $result = $headerParseService->getUploadLengthFromHeaders($headerBag->reveal()); + $this->assertSame($uploadLength, $result); + } + + public function testGetUploadLengthFromHeadersReturnsNullWhenOffsetIsMissing(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Length'))->shouldBeCalledOnce()->willReturn(null); + + $headerParseService = $this->buildHeaderParseService(); + + $result = $headerParseService->getUploadLengthFromHeaders($headerBag->reveal()); + $this->assertNull($result); + } + + public function testGetUploadLengthFromHeadersThrowsWhenOffsetIsNotNumeric(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Length'))->shouldBeCalledOnce()->willReturn('123abc'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Header 'Upload-Length' requires a non-negative integer as its value, got '123abc'."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getUploadLengthFromHeaders($headerBag->reveal()); + } + + public function testGetUploadLengthFromHeadersThrowsWhenOffsetIsNegative(): void + { + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Upload-Length'))->shouldBeCalledOnce()->willReturn('-4321'); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("Header 'Upload-Length' requires a non-negative integer as its value, got '-4321'."))->shouldBeCalledOnce()->willReturn($exception); + + $headerParseService = $this->buildHeaderParseService( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $headerParseService->getUploadLengthFromHeaders($headerBag->reveal()); + } +} diff --git a/tests/UnitTests/Service/MimeTypeServiceTest.php b/tests/UnitTests/Service/MimeTypeServiceTest.php new file mode 100644 index 00000000..a73f05f7 --- /dev/null +++ b/tests/UnitTests/Service/MimeTypeServiceTest.php @@ -0,0 +1,51 @@ +buildMimeTypeService(); + $mimeType = $fileService->getMimeTypeFromResource($resource); + + self::assertSame('text/plain', $mimeType); + + fclose($resource); + } + + public function testGetMimeTypeFromResourceReturnsFallbackForUnknownContent(): void + { + $resource = fopen('php://memory', 'r+'); + fwrite($resource, str_repeat("\x00", 16)); // null bytes → undetectable type + rewind($resource); + + $fileService = $this->buildMimeTypeService(); + $mimeType = $fileService->getMimeTypeFromResource($resource); + + self::assertSame('application/octet-stream', $mimeType); + + fclose($resource); + } +} diff --git a/tests/UnitTests/Service/ResetElementPropertiesServiceTest.php b/tests/UnitTests/Service/ResetElementPropertiesServiceTest.php index b5766072..6b6e3e55 100644 --- a/tests/UnitTests/Service/ResetElementPropertiesServiceTest.php +++ b/tests/UnitTests/Service/ResetElementPropertiesServiceTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\tests\UnitTests\Service; +namespace App\Tests\UnitTests\Service; use App\EventSystem\ElementPropertyReset\Event\ElementPropertyResetEvent; use App\Service\ResetElementPropertiesService; diff --git a/tests/UnitTests/Service/S3ServiceTest.php b/tests/UnitTests/Service/S3ServiceTest.php new file mode 100644 index 00000000..3ecb1ebc --- /dev/null +++ b/tests/UnitTests/Service/S3ServiceTest.php @@ -0,0 +1,1491 @@ +prophesize(S3Client::class)->reveal(), + $fileChunkOperationFactory ?? $this->prophesize(UploadFileChunkOperationFactory::class)->reveal(), + $s3ClientWrapper ?? $this->prophesize(S3ClientWrapper::class)->reveal(), + $mimeTypeService ?? $this->prophesize(MimeTypeService::class)->reveal(), + $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), + $server500LogicErrorExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + ); + } + + public function testCreateMultipartUploadFromMergeFileChunksOperation(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn(['upload-key-0001']); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); + $createMultipartUploadOutput->getUploadId()->shouldBeCalledOnce()->willReturn('some id'); + $createMultipartUploadOutput = $createMultipartUploadOutput->reveal(); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class); + $getObjectOutput->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce()->willReturn($createMultipartUploadOutput); + $s3Client->getObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + 'Range' => 'bytes=0-12', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $output = $s3Service->createMultipartUploadFromMergeFileChunksOperation($mergeFileChunksOperation); + $this->assertSame('some id', $output); + } + + public function testCreateMultipartUploadFromMergeFileChunksOperationThrowsWhenUploadCouldNotBeCreated(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn(['upload-key-0001']); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); + $createMultipartUploadOutput->getUploadId()->shouldBeCalledOnce()->willReturn(null); + $createMultipartUploadOutput = $createMultipartUploadOutput->reveal(); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class); + $getObjectOutput->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce()->willReturn($createMultipartUploadOutput); + $s3Client->getObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + 'Range' => 'bytes=0-12', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate('Unable to create multipart upload.')->shouldBeCalledOnce()->willReturn($exception); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + mimeTypeService: $mimeTypeService->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $s3Service->createMultipartUploadFromMergeFileChunksOperation($mergeFileChunksOperation); + } + + public function testCreateUploadPartsFromMergeFileChunksOperation(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledTimes(3)->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(3)->willReturn('storage-key'); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(3)->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn(['upload-key-0001', 'upload-key-0002', 'upload-key-0003']); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $copyPartResult1 = new CopyPartResult(['ETag' => 'etag-1']); + $uploadPartCopyOutput1 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput1->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult1); + + $copyPartResult2 = new CopyPartResult(['ETag' => 'etag-2']); + $uploadPartCopyOutput2 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput2->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult2); + + $copyPartResult3 = new CopyPartResult(['ETag' => 'etag-3']); + $uploadPartCopyOutput3 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput3->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult3); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 1, + 'CopySource' => 'upload-bucket/upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput1->reveal()); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 2, + 'CopySource' => 'upload-bucket/upload-key-0002', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput2->reveal()); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 3, + 'CopySource' => 'upload-bucket/upload-key-0003', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput3->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + ); + + $parts = $s3Service->createUploadPartsFromMergeFileChunksOperation($mergeFileChunksOperation, 'upload-id'); + $this->assertSame([ + [ + 'PartNumber' => 1, + 'ETag' => 'etag-1', + ], + [ + 'PartNumber' => 2, + 'ETag' => 'etag-2', + ], + [ + 'PartNumber' => 3, + 'ETag' => 'etag-3', + ], + ], $parts); + } + + public function testCreateUploadPartsFromMergeFileChunksOperationThrowsWhenCopyPartResultIsNull(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn(['upload-key-0001']); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $uploadPartCopyOutput1 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput1->getCopyPartResult()->shouldBeCalledOnce()->willReturn(null); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 1, + 'CopySource' => 'upload-bucket/upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput1->reveal()); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate('Unable to read copy part result.')->shouldBeCalledOnce()->willReturn($exception); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $s3Service->createUploadPartsFromMergeFileChunksOperation($mergeFileChunksOperation, 'upload-id'); + } + + public function testCreateUploadPartsFromMergeFileChunksOperationThrowsWhenEtagIsNull(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn(['upload-key-0001']); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $copyPartResult1 = new CopyPartResult([]); + $uploadPartCopyOutput1 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput1->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult1); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 1, + 'CopySource' => 'upload-bucket/upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput1->reveal()); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate('Unable to read etag of copy part result.')->shouldBeCalledOnce()->willReturn($exception); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $s3Service->createUploadPartsFromMergeFileChunksOperation($mergeFileChunksOperation, 'upload-id'); + } + + public function testMergeFileChunks(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledTimes(6)->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(6)->willReturn('storage-key'); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(4)->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledTimes(2)->willReturn(['upload-key-0001', 'upload-key-0002', 'upload-key-0003']); + $mergeFileChunksOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn(null); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); + $createMultipartUploadOutput->getUploadId()->shouldBeCalledOnce()->willReturn('upload-id'); + $createMultipartUploadOutput = $createMultipartUploadOutput->reveal(); + + $copyPartResult1 = new CopyPartResult(['ETag' => 'etag-1']); + $uploadPartCopyOutput1 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput1->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult1); + + $copyPartResult2 = new CopyPartResult(['ETag' => 'etag-2']); + $uploadPartCopyOutput2 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput2->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult2); + + $copyPartResult3 = new CopyPartResult(['ETag' => 'etag-3']); + $uploadPartCopyOutput3 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput3->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult3); + + $headObjectOutput1 = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput1->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput1 = $this->prophesize(GetObjectOutput::class); + $getObjectOutput1->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $headObjectOutput2 = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput2->getContentLength()->shouldBeCalledOnce()->willReturn(12345678); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce()->willReturn($createMultipartUploadOutput); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 1, + 'CopySource' => 'upload-bucket/upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput1->reveal()); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 2, + 'CopySource' => 'upload-bucket/upload-key-0002', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput2->reveal()); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 3, + 'CopySource' => 'upload-bucket/upload-key-0003', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput3->reveal()); + $s3Client->completeMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'MultipartUpload' => [ + 'Parts' => [ + [ + 'PartNumber' => 1, + 'ETag' => 'etag-1', + ], + [ + 'PartNumber' => 2, + 'ETag' => 'etag-2', + ], + [ + 'PartNumber' => 3, + 'ETag' => 'etag-3', + ], + ], + ], + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput1->reveal()); + $s3Client->getObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + 'Range' => 'bytes=0-12', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput1->reveal()); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput2->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $contentLength = $s3Service->mergeFileChunks($mergeFileChunksOperation); + $this->assertSame(12345678, $contentLength); + } + + public function testMergeFileChunksAbortsUploadWhenExceptionIsThrownDuringUpload(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledTimes(6)->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(6)->willReturn('storage-key'); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(4)->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledTimes(2)->willReturn(['upload-key-0001', 'upload-key-0002', 'upload-key-0003']); + $mergeFileChunksOperation->getPreviousStorageKey()->shouldNotBeCalled(); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); + $createMultipartUploadOutput->getUploadId()->shouldBeCalledOnce()->willReturn('upload-id'); + $createMultipartUploadOutput = $createMultipartUploadOutput->reveal(); + + $copyPartResult1 = new CopyPartResult(['ETag' => 'etag-1']); + $uploadPartCopyOutput1 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput1->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult1); + + $copyPartResult2 = new CopyPartResult(['ETag' => 'etag-2']); + $uploadPartCopyOutput2 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput2->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult2); + + $copyPartResult3 = new CopyPartResult(['ETag' => 'etag-3']); + $uploadPartCopyOutput3 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput3->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult3); + + $headObjectOutput1 = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput1->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput1 = $this->prophesize(GetObjectOutput::class); + $getObjectOutput1->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $originalException = new Exception('some message'); + + $finalException = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate(Argument::is("Caught exception 'some message' during multipart upload."), Argument::is([]), Argument::is($originalException))->shouldBeCalledOnce()->willReturn($finalException); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce()->willReturn($createMultipartUploadOutput); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 1, + 'CopySource' => 'upload-bucket/upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput1->reveal()); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 2, + 'CopySource' => 'upload-bucket/upload-key-0002', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput2->reveal()); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 3, + 'CopySource' => 'upload-bucket/upload-key-0003', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput3->reveal()); + $s3Client->completeMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'MultipartUpload' => [ + 'Parts' => [ + [ + 'PartNumber' => 1, + 'ETag' => 'etag-1', + ], + [ + 'PartNumber' => 2, + 'ETag' => 'etag-2', + ], + [ + 'PartNumber' => 3, + 'ETag' => 'etag-3', + ], + ], + ], + ]))->shouldBeCalledOnce()->willThrow($originalException); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput1->reveal()); + $s3Client->getObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + 'Range' => 'bytes=0-12', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput1->reveal()); + $s3Client->abortMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + ]))->shouldBeCalledOnce(); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + mimeTypeService: $mimeTypeService->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $s3Service->mergeFileChunks($mergeFileChunksOperation); + } + + public function testMergeFileChunksDeletesPreviousFileIfAvailable(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledTimes(5)->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(5)->willReturn('storage-key'); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledTimes(2)->willReturn(['upload-key-0001']); + $mergeFileChunksOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn('storage-key.prev'); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); + $createMultipartUploadOutput->getUploadId()->shouldBeCalledOnce()->willReturn('upload-id'); + $createMultipartUploadOutput = $createMultipartUploadOutput->reveal(); + + $copyPartResult1 = new CopyPartResult(['ETag' => 'etag-1']); + $uploadPartCopyOutput1 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput1->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult1); + + $headObjectOutput1 = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput1->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput1 = $this->prophesize(GetObjectOutput::class); + $getObjectOutput1->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $headObjectOutput2 = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput2->getContentLength()->shouldBeCalledOnce()->willReturn(12345678); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $objectExistsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce()->willReturn($createMultipartUploadOutput); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 1, + 'CopySource' => 'upload-bucket/upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput1->reveal()); + $s3Client->completeMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'MultipartUpload' => [ + 'Parts' => [ + [ + 'PartNumber' => 1, + 'ETag' => 'etag-1', + ], + ], + ], + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput1->reveal()); + $s3Client->getObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + 'Range' => 'bytes=0-12', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput1->reveal()); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput2->reveal()); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.prev', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.prev', + ]))->shouldBeCalledOnce(); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledTimes(2)->willReturn(true, false); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + mimeTypeService: $mimeTypeService->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $contentLength = $s3Service->mergeFileChunks($mergeFileChunksOperation); + $this->assertSame(12345678, $contentLength); + } + + public function testDeleteFileChunks(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn(['upload-key-0001', 'upload-key-0002', 'upload-key-0003']); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(3)->willReturn('upload-bucket'); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $objectExistsWaiter1 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + $objectExistsWaiter2 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + $objectExistsWaiter3 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter1); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + ]))->shouldBeCalledOnce(); + + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0002', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter2); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0002', + ]))->shouldBeCalledOnce(); + + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0003', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter3); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0003', + ]))->shouldBeCalledOnce(); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter1))->shouldBeCalledTimes(2)->willReturn(true, false); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter2))->shouldBeCalledTimes(2)->willReturn(true, false); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter3))->shouldBeCalledTimes(2)->willReturn(true, false); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $s3Service->deleteFileChunks($mergeFileChunksOperation); + } + + public function testDeleteFileSkipsMissingFile(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $objectExistsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($objectExistsWaiter); + $s3Client->deleteObject(Argument::any())->shouldNotBeCalled(); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledOnce()->willReturn(false); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $s3Service->deleteFile($fileOperation); + } + + public function testDeleteFileDeletesExistingFile(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $objectExistsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce(); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledTimes(2)->willReturn(true, false); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $s3Service->deleteFile($fileOperation); + } + + public function testDeleteFileThrowsWhenDeletedFileWasNotDeleted(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $objectExistsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce(); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledTimes(2)->willReturn(true, true); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate('Unable to delete file.')->shouldBeCalledOnce()->willReturn($exception); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $s3Service->deleteFile($fileOperation); + } + + public function testUploadFileChunkWithNoProvidedContentLengthWorks(): void + { + $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); + $uploadFileChunkOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileChunkOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileChunkOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $uploadFileChunkOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileChunkOperation->getContentLength()->shouldBeCalledOnce()->willReturn(null); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1234); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->putObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + 'Body' => 'some content', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $contentLength = $s3Service->uploadFileChunk($uploadFileChunkOperation->reveal()); + $this->assertSame(1234, $contentLength); + } + + public function testUploadFileChunkWithProvidedCorrectContentLengthWorks(): void + { + $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); + $uploadFileChunkOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileChunkOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileChunkOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $uploadFileChunkOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileChunkOperation->getContentLength()->shouldBeCalledOnce()->willReturn(1234); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1234); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->putObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + 'Body' => 'some content', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $contentLength = $s3Service->uploadFileChunk($uploadFileChunkOperation->reveal()); + $this->assertSame(1234, $contentLength); + } + + public function testUploadFileChunkWithProvidedIncorrectContentLengthThrowsException(): void + { + $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); + $uploadFileChunkOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileChunkOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileChunkOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $uploadFileChunkOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileChunkOperation->getContentLength()->shouldBeCalledOnce()->willReturn(4321); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1234); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->putObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + 'Body' => 'some content', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is('Inconsistent length values between provided content-length (4321) and actual content length (1234) detected.'))->shouldBeCalledOnce()->willReturn($exception); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $s3Service->uploadFileChunk($uploadFileChunkOperation->reveal()); + } + + public function testUploadFile(): void + { + $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $uploadFileOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $uploadFileOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn(null); + $uploadFileOperation = $uploadFileOperation->reveal(); + + $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); + $uploadFileChunkOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileChunkOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileChunkOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $uploadFileChunkOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileChunkOperation->getContentLength()->shouldBeCalledOnce()->willReturn(null); + + $copyObjectOutput = $this->prophesize(CopyObjectOutput::class)->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1234); + + $objectExistsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->putObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + 'Body' => 'some content', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + $s3Client->copyObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'CopySource' => 'upload-bucket/upload-key', + 'ContentType' => 'text/plain', + 'MetadataDirective' => 'REPLACE', + ]))->shouldBeCalledOnce()->willReturn($copyObjectOutput); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce(); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledTimes(2)->willReturn(true, false); + $s3ClientWrapper->resolveCopyObjectOutput(Argument::is($copyObjectOutput))->shouldBeCalledOnce(); + + $uploadFileChunkOperationFactory = $this->prophesize(UploadFileChunkOperationFactory::class); + $uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) + ->shouldBeCalledOnce()->willReturn($uploadFileChunkOperation); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + fileChunkOperationFactory: $uploadFileChunkOperationFactory->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $contentLength = $s3Service->uploadFile($uploadFileOperation); + $this->assertSame(1234, $contentLength); + } + + public function testUploadFileDeletesPreviousFileIfItExists(): void + { + $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getStorageBucket()->shouldBeCalledTimes(2)->willReturn('storage-bucket'); + $uploadFileOperation->getStorageKey()->shouldBeCalledTimes(2)->willReturn('storage-key'); + $uploadFileOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn('storage-key.prev'); + $uploadFileOperation = $uploadFileOperation->reveal(); + + $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); + $uploadFileChunkOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileChunkOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileChunkOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $uploadFileChunkOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileChunkOperation->getContentLength()->shouldBeCalledOnce()->willReturn(null); + + $copyObjectOutput = $this->prophesize(CopyObjectOutput::class)->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1234); + + $objectExistsWaiter1 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + $objectExistsWaiter2 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->putObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + 'Body' => 'some content', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + $s3Client->copyObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'CopySource' => 'upload-bucket/upload-key', + 'ContentType' => 'text/plain', + 'MetadataDirective' => 'REPLACE', + ]))->shouldBeCalledOnce()->willReturn($copyObjectOutput); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.prev', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter1); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.prev', + ]))->shouldBeCalledOnce(); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter2); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce(); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter1))->shouldBeCalledTimes(2)->willReturn(true, false); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter2))->shouldBeCalledTimes(2)->willReturn(true, false); + $s3ClientWrapper->resolveCopyObjectOutput(Argument::is($copyObjectOutput))->shouldBeCalledOnce(); + + $uploadFileChunkOperationFactory = $this->prophesize(UploadFileChunkOperationFactory::class); + $uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) + ->shouldBeCalledOnce()->willReturn($uploadFileChunkOperation); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + fileChunkOperationFactory: $uploadFileChunkOperationFactory->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $contentLength = $s3Service->uploadFile($uploadFileOperation); + $this->assertSame(1234, $contentLength); + } + + public function testUploadFileRethrowsExceptionDuringUpload(): void + { + $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $uploadFileOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $uploadFileOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $uploadFileOperation->getUploadKey()->shouldBeCalledOnce()->willReturn('upload-key'); + $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileOperation->getPreviousStorageKey()->shouldNotBeCalled(); + $uploadFileOperation = $uploadFileOperation->reveal(); + + $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); + $uploadFileChunkOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileChunkOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileChunkOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $uploadFileChunkOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileChunkOperation->getContentLength()->shouldBeCalledOnce()->willReturn(null); + + $copyObjectOutput = $this->prophesize(CopyObjectOutput::class)->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1234); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->putObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + 'Body' => 'some content', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + $s3Client->copyObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'CopySource' => 'upload-bucket/upload-key', + 'ContentType' => 'text/plain', + 'MetadataDirective' => 'REPLACE', + ]))->shouldBeCalledOnce()->willReturn($copyObjectOutput); + + $originalException = new Exception('some message'); + + $finalException = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate(Argument::is('Upload failed: some message'), Argument::is([]), Argument::is($originalException))->shouldBeCalledOnce()->willReturn($finalException); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->resolveCopyObjectOutput(Argument::is($copyObjectOutput))->shouldBeCalledOnce()->willThrow($originalException); + + $uploadFileChunkOperationFactory = $this->prophesize(UploadFileChunkOperationFactory::class); + $uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) + ->shouldBeCalledOnce()->willReturn($uploadFileChunkOperation); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + fileChunkOperationFactory: $uploadFileChunkOperationFactory->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $s3Service->uploadFile($uploadFileOperation); + } + + public function testExistsFile(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $objectExistsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($objectExistsWaiter); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledOnce()->willReturn(true); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $output = $s3Service->existsFile($fileOperation); + $this->assertSame(true, $output); + } + + public function testGetFile(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->getObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $output = $s3Service->getFile($fileOperation); + $this->assertSame($getObjectOutput, $output); + } + + public function testGetFileAsResource(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class); + $getObjectOutput->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->getObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $resource = $s3Service->getFileAsResource($fileOperation); + $this->assertSame('some content', $resource); + } + + public function testGetFileRangeAsResourceClipsResourceLengthToMaxContentLength(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledTimes(2)->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledTimes(2)->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(12345678); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class); + $getObjectOutput->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $s3Client->getObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + 'Range' => 'bytes=0-5000000', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $resource = $s3Service->getFileRangeAsResource($fileOperation, 5000000); + $this->assertSame('some content', $resource); + } + + public function testGetFileRangeAsResourceClipsResourceLengthToActualContentLengthWhenTooShort(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledTimes(2)->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledTimes(2)->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(4321); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class); + $getObjectOutput->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $s3Client->getObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + 'Range' => 'bytes=0-4321', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $resource = $s3Service->getFileRangeAsResource($fileOperation, 5000000); + $this->assertSame('some content', $resource); + } + + public function testGetFileRangeAsResourceThrowsWhenContentLengthIsNotDeterminable(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(null); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate('Unable to read content length of file.')->shouldBeCalledOnce()->willReturn($exception); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $s3Service->getFileRangeAsResource($fileOperation, 5000000); + } + + public function testGetContentLength(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1234); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $contentLength = $s3Service->getContentLength($fileOperation); + $this->assertSame(1234, $contentLength); + } + + public function testGetContentLengthThrowsWhenContentLengthIsNull(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(null); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate('Unable to read content length of file.')->shouldBeCalledOnce()->willReturn($exception); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $s3Service->getContentLength($fileOperation); + } + + public function testGetEtag(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getETag()->shouldBeCalledOnce()->willReturn('some etag'); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $etag = $s3Service->getEtag($fileOperation); + $this->assertSame('some etag', $etag); + } + + public function testGetEtagThrowsWhenEtagIsNull(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getETag()->shouldBeCalledOnce()->willReturn(null); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate('Unable to retrieve file.')->shouldBeCalledOnce()->willReturn($exception); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $s3Service->getEtag($fileOperation); + } + + public function testGetMimeTypeFromFile(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledTimes(2)->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledTimes(2)->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(12345678); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class); + $getObjectOutput->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $s3Client->getObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + 'Range' => 'bytes=0-5242880', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $mimeType = $s3Service->getMimeTypeFromFile($fileOperation); + $this->assertSame('text/plain', $mimeType); + } + + public function testGetMimeTypeFromMergeFileChunksOperation(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn(['upload-key-0001.bin', 'upload-key-0002.bin']); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(12345678); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001.bin', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class); + $getObjectOutput->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $s3Client->getObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001.bin', + 'Range' => 'bytes=0-5242880', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $mimeType = $s3Service->getMimeTypeFromMergeFileChunksOperation($mergeFileChunksOperation); + $this->assertSame('text/plain', $mimeType); + } + + public function testGetMimeTypeFromMergeFileChunksOperationThrowsWhenMergeContainsNoUploadChunk(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getUploadBucket()->shouldNotBeCalled(); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn([]); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is('Creating a single file from multiple uploaded chunks requires at least one chunk to be present, got none.'))->shouldBeCalledOnce()->willReturn($exception); + + $s3Service = $this->buildS3Service( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + $s3Service->getMimeTypeFromMergeFileChunksOperation($mergeFileChunksOperation); + } +} diff --git a/tests/UnitTests/Service/SecurityUtilServiceTest.php b/tests/UnitTests/Service/SecurityUtilServiceTest.php index 1740d3e9..1422e9f8 100644 --- a/tests/UnitTests/Service/SecurityUtilServiceTest.php +++ b/tests/UnitTests/Service/SecurityUtilServiceTest.php @@ -12,7 +12,7 @@ use App\Factory\Exception\Client400MissingPropertyExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; use App\Factory\Exception\Client403ForbiddenExceptionFactory; -use App\Factory\Exception\Server500LogicExceptionFactory; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\UserPasswordHasher; use App\Service\ElementManager; use App\Service\SecurityUtilService; @@ -28,6 +28,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\LoggerInterface; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -40,6 +42,8 @@ #[AllowMockObjectsWithoutExpectations] class SecurityUtilServiceTest extends TestCase { + use ProphecyTrait; + private function getSecurityUtilService( ?EmberNexusConfiguration $emberNexusConfiguration = null, ?CypherEntityManager $cypherEntityManager = null, @@ -51,7 +55,7 @@ private function getSecurityUtilService( $urlGenerator->method('generate')->willReturn('url'); $server500Bag = $this->createMock(ParameterBagInterface::class); $server500Bag->method('get')->willReturn('dev'); - $server500LogicExceptionFactory = new Server500LogicExceptionFactory( + $server500LogicExceptionFactory = new Server500LogicErrorExceptionFactory( $urlGenerator, $server500Bag, $this->createMock(LoggerInterface::class) @@ -200,26 +204,23 @@ public function testValidateUserIsNotAnonymousUserWithNormalUser(): void public function testChangeUserPassword(): void { - $userPasswordHasher = $this->createMock(UserPasswordHasher::class); - $userPasswordHasher->method('hashPassword')->willReturn('someHash'); + $userNode = $this->prophesize(NodeElementInterface::class); + $userNode->addProperty(Argument::is('_passwordHash'), Argument::is('newPasswordHash'))->shouldBeCalledOnce()->willReturn($this->prophesize(NodeElementInterface::class)->reveal()); + $userNode = $userNode->reveal(); - $elementManager = $this->createMock(ElementManager::class); - $elementManager->method('merge') - ->with( - $this->callback(function (NodeElementInterface $userNode) { - return $userNode->hasProperty('_passwordHash') - && 'someHash' === $userNode->getProperty('_passwordHash'); - }) - ); + $userPasswordHasher = $this->prophesize(UserPasswordHasher::class); + $userPasswordHasher->hashPassword(Argument::is('newPassword'))->willReturn('newPasswordHash'); - $userNode = new NodeElement(); + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->merge(Argument::is($userNode))->shouldBeCalledOnce()->willReturn($this->prophesize(ElementManager::class)->reveal()); + $elementManager->flush()->shouldBeCalledOnce()->willReturn($this->prophesize(ElementManager::class)->reveal()); $securityUtilService = $this->getSecurityUtilService( - elementManager: $elementManager, - userPasswordHasher: $userPasswordHasher + elementManager: $elementManager->reveal(), + userPasswordHasher: $userPasswordHasher->reveal(), ); - $securityUtilService->changeUserPassword($userNode, '1234'); + $securityUtilService->changeUserPassword($userNode, 'newPassword'); } public function testFindUserByUniqueUserIdentifierWithNoResults(): void @@ -309,20 +310,19 @@ public function testFindUserByUniqueUserIdentifierWithTooManyResults(): void public function testFindUserByUniqueUserIdentifierWithMissingNode(): void { - $emberNexusConfiguration = $this->createMock(EmberNexusConfiguration::class); - $emberNexusConfiguration->method('getRegisterUniqueIdentifier')->willReturn('email'); + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getRegisterUniqueIdentifier()->willReturn('email'); $expectedCypherQuery = 'MATCH (u:User {email: $uniqueUserIdentifier}) RETURN u.id AS id'; $null = null; - $cypherClient = $this->createMock(ClientInterface::class); - $cypherClient->expects($this->once()) - ->method('runStatement') - ->with( - $this->callback(function (Statement $statement) use ($expectedCypherQuery) { - return $statement->getText() === $expectedCypherQuery; - }) - ) + $cypherClient = $this->prophesize(ClientInterface::class); + $cypherClient->runStatement( + Argument::that(function (Statement $statement) use ($expectedCypherQuery) { + return $statement->getText() === $expectedCypherQuery; + }) + ) + ->shouldBeCalledOnce() ->willReturn(new SummarizedResult( $null, [ @@ -331,32 +331,37 @@ public function testFindUserByUniqueUserIdentifierWithMissingNode(): void ]), ] )); - $cypherEntityManager = $this->createMock(CypherEntityManager::class); - $cypherEntityManager->method('getClient')->willReturn($cypherClient); - $elementManager = $this->createMock(ElementManager::class); - $elementManager->method('getElement') - ->with( - $this->callback(function (UuidInterface $elementId) { - return 'd8defdd8-8e79-40af-84dd-169587bf2bcc' === $elementId->toString(); - }) - ) + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($cypherClient->reveal()); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement( + Argument::that(function (UuidInterface $elementId) { + return 'd8defdd8-8e79-40af-84dd-169587bf2bcc' === $elementId->toString(); + }) + ) + ->shouldBeCalledOnce() ->willReturn(null); $securityUtilService = $this->getSecurityUtilService( - emberNexusConfiguration: $emberNexusConfiguration, - cypherEntityManager: $cypherEntityManager, - elementManager: $elementManager + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + cypherEntityManager: $cypherEntityManager->reveal(), + elementManager: $elementManager->reveal(), ); try { $securityUtilService->findUserByUniqueUserIdentifier('test@localhost.dev'); + $this->fail('Expected Client401UnauthorizedException to be thrown.'); } catch (Exception $e) { $this->assertInstanceOf(Client401UnauthorizedException::class, $e); /** * @var Client401UnauthorizedException $e */ - $this->assertSame("Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted).", $e->getDetail()); + $this->assertSame( + "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted).", + $e->getDetail() + ); } } diff --git a/tests/UnitTests/Service/StorageUtilServiceTest.php b/tests/UnitTests/Service/StorageUtilServiceTest.php deleted file mode 100644 index 8a55f829..00000000 --- a/tests/UnitTests/Service/StorageUtilServiceTest.php +++ /dev/null @@ -1,106 +0,0 @@ -createMock(UrlGeneratorInterface::class); - $urlGenerator->method('generate')->willReturn('url'); - $server500Bag = $this->createMock(ParameterBagInterface::class); - $server500Bag->method('get')->willReturn('dev'); - $server500LogicExceptionFactory = new Server500LogicExceptionFactory( - $urlGenerator, - $server500Bag, - $this->createMock(LoggerInterface::class) - ); - - return new StorageUtilService( - $emberNexusConfiguration ?? $this->createMock(EmberNexusConfiguration::class), - $server500LogicExceptionFactory, - ); - } - - public function testUuidToNestedFolderStructure(): void - { - $storageUtilService = $this->getStorageUtilService(); - - $uuid = Uuid::fromString('3207d629-7199-4c2d-9b2a-b0fba10fe309'); - - try { - $storageUtilService->uuidToNestedFolderStructure($uuid, -1); - } catch (Exception $e) { - $this->assertInstanceOf(Server500LogicErrorException::class, $e); - /** - * @var $e Server500LogicErrorException - */ - $this->assertSame('Unable to generate nested folder structure from uuid with negative level argument.', $e->getDetail()); - } - - try { - $storageUtilService->uuidToNestedFolderStructure($uuid, 0, -1); - } catch (Exception $e) { - $this->assertInstanceOf(Server500LogicErrorException::class, $e); - /** - * @var $e Server500LogicErrorException - */ - $this->assertSame('Unable to generate nested folder structure from uuid with level length less than 1.', $e->getDetail()); - } - - try { - $storageUtilService->uuidToNestedFolderStructure($uuid, 0, 0); - } catch (Exception $e) { - $this->assertInstanceOf(Server500LogicErrorException::class, $e); - /** - * @var $e Server500LogicErrorException - */ - $this->assertSame('Unable to generate nested folder structure from uuid with level length less than 1.', $e->getDetail()); - } - - try { - $storageUtilService->uuidToNestedFolderStructure($uuid, 8, 9); - } catch (Exception $e) { - $this->assertInstanceOf(Server500LogicErrorException::class, $e); - /** - * @var $e Server500LogicErrorException - */ - $this->assertSame('Unable to generate nested folder structure as long as product of levels and level length exceeds length of uuid without dashes.', $e->getDetail()); - } - - $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid); - $this->assertSame('3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); - - $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 1, 1); - $this->assertSame('3/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); - - $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 1, 3); - $this->assertSame('320/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); - - $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 3, 1); - $this->assertSame('3/2/0/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); - - $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 2, 2); - $this->assertSame('32/07/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); - - $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 6, 5); - $this->assertSame('3207d/62971/994c2/d9b2a/b0fba/10fe3/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); - - $generatedStructure = $storageUtilService->uuidToNestedFolderStructure($uuid, 15, 2); - $this->assertSame('32/07/d6/29/71/99/4c/2d/9b/2a/b0/fb/a1/0f/e3/3207d629-7199-4c2d-9b2a-b0fba10fe309', $generatedStructure); - } -} diff --git a/tests/UnitTests/Service/StringServiceTest.php b/tests/UnitTests/Service/StringServiceTest.php new file mode 100644 index 00000000..642f40a1 --- /dev/null +++ b/tests/UnitTests/Service/StringServiceTest.php @@ -0,0 +1,73 @@ +prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicExceptionFactory = $server500LogicExceptionFactory->reveal(); + } + + return new StringService( + $server500LogicExceptionFactory + ); + } + + public static function getAsciiSafeStringProvider(): array + { + return [ + ['', ''], + [' ', ''], + ['hello', 'hello'], + ['Hello', 'Hello'], + ['HelLo', 'HelLo'], + [' prefix trim', 'prefix trim'], + ['suffix trim ', 'suffix trim'], + ['multi word name', 'multi word name'], + ['', ''], + ['abc', 'abc'], + ['abc.txt', 'abc.txt'], + ['AbC.txt', 'AbC.txt'], + ['aä-oö-uü.txt', 'aa-oo-uu.txt'], + ['0123.txt', '0123.txt'], + ['hello world.txt', 'hello world.txt'], + ['fichier été résumé.txt', 'fichier ete resume.txt'], + ['garçon.txt', 'garcon.txt'], + ['mañana.txt', 'manana.txt'], + ['📄 emoji.txt', 'page facing up emoji.txt'], + ['😃 emoji.txt', 'grinning face with big eyes emoji.txt'], + ['✅ emoji.txt', 'check mark button emoji.txt'], + ['서울.txt', 'seoul.txt'], + ['한국어.txt', 'hangug-eo.txt'], + ['हिन्दी.txt', 'hindi.txt'], + ['தமிழ்.txt', 'tamil.txt'], + ['বাংলা.txt', 'banla.txt'], + ['日本語.txt', 'ri ben yu.txt'], + ]; + } + + #[DataProvider('getAsciiSafeStringProvider')] + public function testGetAsciiSafeString(string $input, string $output): void + { + $fileService = $this->buildStringService(); + $this->assertSame($output, $fileService->getAsciiSafeString($input)); + } +} diff --git a/tests/UnitTests/Type/EtagCalculatorTest.php b/tests/UnitTests/Type/EtagCalculatorTest.php index c8a6f7a5..e9c24c1a 100644 --- a/tests/UnitTests/Type/EtagCalculatorTest.php +++ b/tests/UnitTests/Type/EtagCalculatorTest.php @@ -62,6 +62,19 @@ public function testAddUuidChangesEtag(): void $this->assertSame('D7h1i75muKb', (string) $etag); } + public function testAddStringChangesEtag(): void + { + $etagCalculator = new EtagCalculator('seed'); + $etagCalculator->addString('some string'); + $etag = $etagCalculator->getEtag(); + + $emptyEtagCalculator = new EtagCalculator('seed'); + $emptyEtag = $emptyEtagCalculator->getEtag(); + + $this->assertNotSame((string) $emptyEtag, (string) $etag); + $this->assertSame('WjVTubjtlPd', (string) $etag); + } + public function testCallingGetEtagMultipleTimesReturnsTheSameEtag(): void { $etagCalculator = new EtagCalculator('seed'); @@ -87,4 +100,13 @@ public function testAddUuidAfterGettingEtagResultsInException(): void $this->expectExceptionMessage('Etag is already finalized, no new data can be added.'); $etagCalculator->addUuid(Uuid::fromString('67b1689e-e6c7-4463-a48a-74236fd5f08a')); } + + public function testAddStringAfterGettingEtagResultsInException(): void + { + $etagCalculator = new EtagCalculator('seed'); + $etagCalculator->getEtag(); + + $this->expectExceptionMessage('Etag is already finalized, no new data can be added.'); + $etagCalculator->addString('some string'); + } } diff --git a/tests/UnitTests/Type/Request/PartialUploadRequestTest.php b/tests/UnitTests/Type/Request/PartialUploadRequestTest.php new file mode 100644 index 00000000..635f5fe2 --- /dev/null +++ b/tests/UnitTests/Type/Request/PartialUploadRequestTest.php @@ -0,0 +1,43 @@ +assertSame('some content', $partialUploadRequest->getContent()); + $this->assertSame('application/partial-upload', $partialUploadRequest->getContentType()); + $this->assertSame(321, $partialUploadRequest->getUploadOffset()); + $this->assertTrue($partialUploadRequest->isUploadComplete()); + $this->assertNull($partialUploadRequest->getContentLength()); + } + + public function testPartialUploadRequestDefaults(): void + { + $partialUploadRequest = new PartialUploadRequest( + 'some content', + 'application/partial-upload', + 321, + ); + + $this->assertFalse($partialUploadRequest->isUploadComplete()); + $this->assertNull($partialUploadRequest->getContentLength()); + } +} diff --git a/tests/UnitTests/Type/Request/ResumableUploadRequestTest.php b/tests/UnitTests/Type/Request/ResumableUploadRequestTest.php new file mode 100644 index 00000000..74a1031c --- /dev/null +++ b/tests/UnitTests/Type/Request/ResumableUploadRequestTest.php @@ -0,0 +1,47 @@ +assertSame('d62f9b55-5577-4e2d-8884-d678d60fa7ba', (string) $resumableUploadRequest->getElementId()); + $this->assertSame('some content', $resumableUploadRequest->getContent()); + $this->assertNull($resumableUploadRequest->isUploadComplete()); + $this->assertNull($resumableUploadRequest->getUploadLength()); + $this->assertSame(12, $resumableUploadRequest->getContentLength()); + $this->assertSame('bin', $resumableUploadRequest->getExtension()); + } + + public function testResumableUploadRequestDefaults(): void + { + $resumableUploadRequest = new ResumableUploadRequest( + Uuid::fromString('d62f9b55-5577-4e2d-8884-d678d60fa7ba'), + 'some content', + ); + + $this->assertFalse($resumableUploadRequest->isUploadComplete()); + $this->assertNull($resumableUploadRequest->getUploadLength()); + $this->assertNull($resumableUploadRequest->getContentLength()); + $this->assertSame('bin', $resumableUploadRequest->getExtension()); + } +} diff --git a/tests/UnitTests/Type/S3/FileOperationTest.php b/tests/UnitTests/Type/S3/FileOperationTest.php new file mode 100644 index 00000000..6b3fafea --- /dev/null +++ b/tests/UnitTests/Type/S3/FileOperationTest.php @@ -0,0 +1,23 @@ +assertSame('bucket', $fileOperation->getBucket()); + $this->assertSame('key', $fileOperation->getKey()); + } +} diff --git a/tests/UnitTests/Type/S3/MergeFileChunksOperationTest.php b/tests/UnitTests/Type/S3/MergeFileChunksOperationTest.php new file mode 100644 index 00000000..305be0b8 --- /dev/null +++ b/tests/UnitTests/Type/S3/MergeFileChunksOperationTest.php @@ -0,0 +1,32 @@ +assertSame('uploadBucket', $mergeFileChunksOperation->getUploadBucket()); + $this->assertSame(['uploadKey1', 'uploadKey2', 'uploadKey3'], $mergeFileChunksOperation->getUploadKeys()); + $this->assertSame('storageBucket', $mergeFileChunksOperation->getStorageBucket()); + $this->assertSame('previousStorageKey', $mergeFileChunksOperation->getPreviousStorageKey()); + $this->assertSame('storageKey', $mergeFileChunksOperation->getStorageKey()); + } +} diff --git a/tests/UnitTests/Type/S3/UploadFileChunkOperationTest.php b/tests/UnitTests/Type/S3/UploadFileChunkOperationTest.php new file mode 100644 index 00000000..fab5138c --- /dev/null +++ b/tests/UnitTests/Type/S3/UploadFileChunkOperationTest.php @@ -0,0 +1,32 @@ +assertSame('uploadBucket', $uploadFileChunkOperation->getUploadBucket()); + $this->assertSame('uploadKey', $uploadFileChunkOperation->getUploadKey()); + $this->assertSame('some content', $uploadFileChunkOperation->getContent()); + $this->assertSame(12, $uploadFileChunkOperation->getContentLength()); + $this->assertSame('some mimetype', $uploadFileChunkOperation->getMimeType()); + } +} diff --git a/tests/UnitTests/Type/S3/UploadFileOperationTest.php b/tests/UnitTests/Type/S3/UploadFileOperationTest.php new file mode 100644 index 00000000..14939df7 --- /dev/null +++ b/tests/UnitTests/Type/S3/UploadFileOperationTest.php @@ -0,0 +1,38 @@ +assertSame('uploadBucket', $uploadFileOperation->getUploadBucket()); + $this->assertSame('uploadKey', $uploadFileOperation->getUploadKey()); + $this->assertSame('storageBucket', $uploadFileOperation->getStorageBucket()); + $this->assertSame('previousStorageKey', $uploadFileOperation->getPreviousStorageKey()); + $this->assertSame('storageKey', $uploadFileOperation->getStorageKey()); + $this->assertSame('some content', $uploadFileOperation->getContent()); + $this->assertSame(12, $uploadFileOperation->getContentLength()); + $this->assertSame('some mimetype', $uploadFileOperation->getMimeType()); + } +} diff --git a/tests/UnitTests/Type/UploadTest.php b/tests/UnitTests/Type/UploadTest.php new file mode 100644 index 00000000..c353fa7b --- /dev/null +++ b/tests/UnitTests/Type/UploadTest.php @@ -0,0 +1,43 @@ +assertSame('93257437-6f77-4b2d-87a4-3eea785d13ae', (string) $upload->getId()); + $this->assertSame(1234, $upload->getUploadLength()); + $this->assertSame(321, $upload->getUploadOffset()); + $this->assertSame(false, $upload->isUploadComplete()); + $this->assertSame('81fbe257-08e0-465b-aa32-8b002a7517c5', (string) $upload->getUploadTarget()); + $this->assertSame(1, $upload->getAlreadyUploadedChunks()); + $this->assertSame('beb42bb3-3b3d-40d2-895b-e54ffb7816dd', (string) $upload->getUploadOwner()); + $this->assertSame('bin', $upload->getExtension()); + $this->assertSame($expires, $upload->getExpires()); + } +} diff --git a/tests/phpunit.xml.dist b/tests/phpunit.xml.dist index 5c3f4587..97c3f8e7 100644 --- a/tests/phpunit.xml.dist +++ b/tests/phpunit.xml.dist @@ -1,30 +1,48 @@ - - - - - - - - - - - UnitTests - - - - - ../src - - + + + + + + + + + + + UnitTests + + + + + ../src + + + + + + + diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml index 8ad4120e..67d127c6 100644 --- a/tools/docker-compose.yml +++ b/tools/docker-compose.yml @@ -98,3 +98,34 @@ services: - MINIO_ROOT_USER=ROOTNAME - MINIO_ROOT_PASSWORD=CHANGEME123 command: server /data --console-address ":9001" + + minio-init: + image: quay.io/minio/mc:latest + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set minio http://minio:9000 ROOTNAME CHANGEME123; do + echo 'Waiting for MinIO...'; + sleep 1; + done; + + mc mb --ignore-existing minio/api-storage; + mc mb --ignore-existing minio/api-upload; + + mc anonymous set none minio/api-storage; + mc anonymous set none minio/api-upload; + + mc admin user add minio accessKey secretKey || true; + mc admin policy attach minio readwrite --user accessKey; + + echo 'MinIO buckets and user configured'; + " + + dozzle: + container_name: dozzle + image: amir20/dozzle:latest + ports: + - 8080:8080 + volumes: + - /var/run/docker.sock:/var/run/docker.sock diff --git a/tools/generate-swagger.sh b/tools/generate-swagger.sh new file mode 100755 index 00000000..41de189d --- /dev/null +++ b/tools/generate-swagger.sh @@ -0,0 +1,52 @@ +#!/bin/sh +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +TEMPLATE="../docs/open-api/swagger_template.json" +OUTPUT="../docs/open-api/swagger.json" + +SCHEMAS=$( + for f in $(ls ../docs/open-api/schemas/*.json | sort); do + key=$(basename "$f" .json) + jq -n --arg key "$key" --slurpfile val "$f" '{ ($key): $val[0] }' + done | jq -s 'add' +) + +EXAMPLES=$( + for f in $(ls ../docs/open-api/examples/*.json | sort); do + key=$(basename "$f" .json) + jq -n --arg key "$key" --slurpfile val "$f" '{ ($key): $val[0] }' + done | jq -s 'add' +) + +PARAMETERS=$( + for f in $(ls ../docs/open-api/parameters/*.json | sort); do + key=$(basename "$f" .json) + jq -n --arg key "$key" --slurpfile val "$f" '{ ($key): $val[0] }' + done | jq -s 'add' +) + +PATHS=$( + for f in $(find ../docs/open-api/paths -mindepth 2 -maxdepth 2 -name "*.json" | sort); do + tag=$(basename "$(dirname "$f")") + jq --arg tag "$tag" '{(.path): {(.method): (.content + {tags: [$tag]})}}' "$f" + done | jq -s ' + reduce .[] as $item ( + {}; + . * $item + ) + ' +) + +jq \ + --argjson schemas "$SCHEMAS" \ + --argjson examples "$EXAMPLES" \ + --argjson parameters "$PARAMETERS" \ + --argjson paths "$PATHS" \ + ' + .components.schemas = $schemas | + .components.examples = $examples | + .components.parameters = $parameters | + .paths = $paths + ' \ + "$TEMPLATE" > "$OUTPUT" From 6352442bc5051a01acc9f8aec6f529d05cecb08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Klein?= Date: Mon, 24 Aug 2026 18:10:01 +0200 Subject: [PATCH 3/7] manual 2026 work II --- .env | 2 +- TODO-BACKUP-LARGE-FILES.md | 42 +++ TODO-NEXT.md | 107 ++++++ TODO.md | 101 +++++ config/reference.php | 1 + config/services.yaml | 6 + docker/supercronic | 2 +- .../204-response-header.txt | 8 + ...ponse-body.json => 401-response-body.json} | 0 ...nse-header.txt => 401-response-header.txt} | 2 +- .../404-response-body.json | 6 + .../404-response-header.txt} | 2 +- .../get-element-file/200-response-header.txt | 9 +- .../get-element-file/206-response-header.txt | 14 + .../get-element-file/400-response-body.json | 6 + .../400-response-header.txt} | 2 +- ...ponse-body.json => 401-response-body.json} | 0 .../get-element-file/401-response-header.txt} | 2 +- .../get-element-file/404-response-body.json | 6 + .../get-element-file/404-response-header.txt | 10 + .../get-element-file/416-response-body.json | 8 + .../get-element-file/416-response-header.txt | 10 + .../post-element-file/201-response-header.txt | 10 + .../204-with-initial-data-response-header.txt | 13 + ...4-without-initial-data-response-header.txt | 13 + .../post-element-file/400-response-body.json | 6 + .../post-element-file/400-response-header.txt | 10 + ...ponse-body.json => 401-response-body.json} | 0 .../post-element-file/401-response-header.txt | 10 + .../post-element-file/404-response-body.json | 6 + .../post-element-file/404-response-header.txt | 10 + .../post-element-file/409-response-body.json | 6 + .../post-element-file/409-response-header.txt | 10 + .../put-element-file/201-response-header.txt | 10 + .../204-with-initial-data-response-header.txt | 13 + ...4-without-initial-data-response-header.txt | 13 + .../put-element-file/400-response-body.json | 6 + .../put-element-file/400-response-header.txt | 10 + ...ponse-body.json => 401-response-body.json} | 0 .../put-element-file/401-response-header.txt | 10 + .../put-element-file/404-response-body.json | 6 + .../put-element-file/404-response-header.txt | 10 + .../delete-upload/204-response-header.txt | 8 + ...ponse-body.json => 401-response-body.json} | 0 .../delete-upload/401-response-header.txt | 10 + .../delete-upload/404-response-body.json | 6 + .../delete-upload/404-response-header.txt | 10 + .../head-upload/200-response-header.txt | 10 - .../head-upload/204-response-header.txt | 12 + .../head-upload/401-response-header.txt | 9 + .../head-upload/404-response-header.txt | 9 + .../patch-upload/200-response-body.json | 6 - .../patch-upload/200-response-header.txt | 10 - .../204-final-chunk-response-header.txt | 12 + ...204-intermediate-chunk-response-header.txt | 12 + .../patch-upload/400-response-body.json | 6 + .../patch-upload/400-response-header.txt | 10 + .../401-response-body.json} | 0 .../patch-upload/401-response-header.txt | 11 + .../patch-upload/404-response-body.json | 6 + .../patch-upload/404-response-header.txt | 10 + .../patch-upload/409-response-body.json | 8 + .../patch-upload/409-response-header.txt | 10 + .../cron-delete-expired-uploads-help.html | 60 +++ .../assets/cron-delete-expired-uploads.html | 54 +++ .../assets/cron-reindex-files-help.html | 60 +++ docs/commands/assets/cron-reindex-files.html | 54 +++ docs/commands/assets/database-drop-help.html | 23 +- docs/example/default-parameters.yaml | 3 + .../src/DependencyInjection/Configuration.php | 6 + .../src/Service/EmberNexusConfiguration.php | 23 ++ src/Command/BackupFetchCommand.php | 42 ++- .../Cron/DeleteExpiredUploadsCommand.php | 92 ++++- src/Command/Cron/ReindexFilesCommand.php | 48 ++- src/Command/CronCommand.php | 14 +- src/Command/DatabaseDropCommand.php | 27 +- .../S3/S3TechnicalLimitsInterface.php | 28 ++ src/Contract/UploadInterface.php | 7 + .../Element/DeleteElementController.php | 8 + .../error-416-range-not-satisfiable.txt | 9 + .../File/DeleteElementFileController.php | 1 + .../File/GetElementFileController.php | 64 +++- .../Upload/DeleteUploadController.php | 13 +- .../Upload/HeadUploadController.php | 11 +- .../Upload/PatchUploadController.php | 87 ++++- .../Event/NodeElementDefragmentizeEvent.php | 6 - .../RelationElementDefragmentizeEvent.php | 6 - .../Event/NodeElementFragmentizeEvent.php | 6 - .../Event/RelationElementFragmentizeEvent.php | 6 - ...ropertyElementFragmentizeEventListener.php | 51 +++ ...FileElementPropertyChangeEventListener.php | 31 ++ ...FileElementPropertyChangeEventListener.php | 29 ++ .../FileElementPropertyResetEventListener.php | 21 ++ ...sFileElementPropertyResetEventListener.php | 17 + ...lementHydrationSearchStepEventListener.php | 4 +- .../Client416RangeNotSatisfiableException.php | 25 ++ .../NodeElementFragmentizeEventFactory.php | 3 +- ...RelationElementFragmentizeEventFactory.php | 3 +- ...416RangeNotSatisfiableExceptionFactory.php | 35 ++ src/Factory/Type/UploadFactory.php | 11 +- src/Helper/FragmentHelper.php | 3 +- src/Service/CollectionService.php | 12 +- src/Service/DigestService.php | 51 +++ src/Service/ElementDefragmentizeService.php | 7 +- src/Service/ElementManager.php | 6 +- src/Service/EtagCalculatorService.php | 65 +++- src/Service/FileHashService.php | 39 ++ src/Service/FileRangeService.php | 81 ++++ src/Service/IncrementalHashService.php | 91 +++++ src/Service/PropertyParseService.php | 16 + src/Service/QueueService.php | 24 ++ src/Service/S3Service.php | 30 +- src/Service/S3TechnicalLimitsValidator.php | 63 ++++ src/Service/UploadCreationService.php | 58 ++- src/Service/UploadService.php | 83 +++++ src/Type/ByteRange.php | 40 ++ src/Type/FragmentGroup.php | 13 - src/Type/Response/BinaryStreamResponse.php | 27 +- src/Type/S3/S3TechnicalLimits.php | 34 ++ src/Type/Upload.php | 6 + .../Cron/DeleteExpiredUploadsTest.php | 34 ++ .../Cron/ReindexFilesTest.php | 34 ++ .../Asset/.gitkeep | 0 .../BaseRequestTestCase.php | 2 +- .../File/DeleteElementFileTest.php | 105 ++++++ .../File/GetElementFileTest.php | 128 +++++++ .../File/PostElementFileTest.php | 266 ++++++++++++++ .../File/PutElementFileTest.php | 235 ++++++++++++ .../Upload/DeleteUploadTest.php | 118 ++++++ .../Upload/HeadUploadTest.php | 114 ++++++ .../Upload/PatchUploadTest.php | 280 ++++++++++++++ .../BackupFetchFolderStructureTest.php | 91 +++++ .../Endpoint/File/DeleteFileTest.php | 7 +- .../Endpoint/File/GetFileRangeTest.php | 149 ++++++++ .../Endpoint/File/GetFileTest.php | 2 +- .../ReplaceFileWithDifferentExtensionTest.php | 74 ++++ .../Endpoint/File/ZeroByteFileUploadTest.php | 66 ++++ .../Endpoint/Upload/DeleteUploadTest.php | 85 +++++ ...ingElementCleansUpInProgressUploadTest.php | 108 ++++++ .../Upload/PatchUploadIncorrectOffsetTest.php | 80 ++++ .../Upload/PatchUploadOversizedChunkTest.php | 97 +++++ ...hUploadUndersizedIntermediateChunkTest.php | 148 ++++++++ .../Upload/ResumableUploadCreationTest.php | 124 +++++++ .../Upload/ResumableUploadLifecycleTest.php | 93 +++++ .../Upload/ZeroByteResumableUploadTest.php | 81 ++++ .../General/File/AnonymousFileAccessTest.php | 28 ++ .../General/File/BotanicalFileTest.php | 165 +++++++++ .../General/File/FileDigestTest.php | 346 ++++++++++++++++++ .../General/File/FileNameFallbackTest.php | 103 ++++++ .../FileRemovedWhenElementRemovedTest.php | 54 +++ .../General/File/FileTopLevelPropertyTest.php | 229 ++++++++++++ .../File/FileUploadUpdatesElementTest.php | 181 +++++++++ .../File/MultiPartUploadLifecycleTest.php | 5 +- .../File/SimpleFileInteractivityTest.php | 85 +++++ .../File/SinglePartUploadLifecycleTest.php | 5 +- .../Security/FilePermissionTest.php | 38 ++ ..._03_01_GroupImmediateNodeOwnershipTest.php | 6 + ...eDirectAccessButNotToRelatedGroupsTest.php | 12 + .../Cron/DeleteExpiredUploadsCommandTest.php | 269 ++++++++++++++ .../Command/Cron/ReindexFilesCommandTest.php | 148 ++++++++ tests/UnitTests/Command/CronCommandTest.php | 130 +++++++ .../NodeElementDefragmentizeEventTest.php | 4 +- .../RelationElementDefragmentizeEventTest.php | 4 +- .../Event/NodeElementFragmentizeEventTest.php | 5 +- .../RelationElementFragmentizeEventTest.php | 5 +- ...ElementPropertyChangeEventListenerTest.php | 48 +++ ...ElementPropertyChangeEventListenerTest.php | 48 +++ ...NodeElementFragmentizeEventFactoryTest.php | 1 - ...tionElementFragmentizeEventFactoryTest.php | 1 - .../Factory/Type/UploadFactoryTest.php | 32 ++ tests/UnitTests/Service/DigestServiceTest.php | 86 +++++ .../ElementDefragmentizeServiceTest.php | 12 +- .../Service/EtagCalculatorServiceTest.php | 262 +++++++++---- .../UnitTests/Service/FileHashServiceTest.php | 39 ++ .../Service/FileRangeServiceTest.php | 144 ++++++++ .../Service/IncrementalHashServiceTest.php | 127 +++++++ tests/UnitTests/Service/QueueServiceTest.php | 93 +++++ tests/UnitTests/Service/S3ServiceTest.php | 214 +++++++++++ .../S3TechnicalLimitsValidatorTest.php | 130 +++++++ tests/UnitTests/Type/FragmentGroupTest.php | 8 +- .../Type/S3/S3TechnicalLimitsTest.php | 24 ++ tools/docker-compose.yml | 11 + 182 files changed, 7731 insertions(+), 322 deletions(-) create mode 100644 TODO-BACKUP-LARGE-FILES.md create mode 100644 TODO-NEXT.md create mode 100644 TODO.md create mode 100644 docs/api-endpoints/file/delete-element-file/204-response-header.txt rename docs/api-endpoints/file/delete-element-file/{200-response-body.json => 401-response-body.json} (100%) rename docs/api-endpoints/file/delete-element-file/{200-response-header.txt => 401-response-header.txt} (93%) create mode 100644 docs/api-endpoints/file/delete-element-file/404-response-body.json rename docs/api-endpoints/file/{post-element-file/200-response-header.txt => delete-element-file/404-response-header.txt} (93%) create mode 100644 docs/api-endpoints/file/get-element-file/206-response-header.txt create mode 100644 docs/api-endpoints/file/get-element-file/400-response-body.json rename docs/api-endpoints/file/{put-element-file/200-response-header.txt => get-element-file/400-response-header.txt} (93%) rename docs/api-endpoints/file/get-element-file/{200-response-body.json => 401-response-body.json} (100%) rename docs/api-endpoints/{upload/delete-upload/200-response-header.txt => file/get-element-file/401-response-header.txt} (93%) create mode 100644 docs/api-endpoints/file/get-element-file/404-response-body.json create mode 100644 docs/api-endpoints/file/get-element-file/404-response-header.txt create mode 100644 docs/api-endpoints/file/get-element-file/416-response-body.json create mode 100644 docs/api-endpoints/file/get-element-file/416-response-header.txt create mode 100644 docs/api-endpoints/file/post-element-file/201-response-header.txt create mode 100644 docs/api-endpoints/file/post-element-file/204-with-initial-data-response-header.txt create mode 100644 docs/api-endpoints/file/post-element-file/204-without-initial-data-response-header.txt create mode 100644 docs/api-endpoints/file/post-element-file/400-response-body.json create mode 100644 docs/api-endpoints/file/post-element-file/400-response-header.txt rename docs/api-endpoints/file/post-element-file/{200-response-body.json => 401-response-body.json} (100%) create mode 100644 docs/api-endpoints/file/post-element-file/401-response-header.txt create mode 100644 docs/api-endpoints/file/post-element-file/404-response-body.json create mode 100644 docs/api-endpoints/file/post-element-file/404-response-header.txt create mode 100644 docs/api-endpoints/file/post-element-file/409-response-body.json create mode 100644 docs/api-endpoints/file/post-element-file/409-response-header.txt create mode 100644 docs/api-endpoints/file/put-element-file/201-response-header.txt create mode 100644 docs/api-endpoints/file/put-element-file/204-with-initial-data-response-header.txt create mode 100644 docs/api-endpoints/file/put-element-file/204-without-initial-data-response-header.txt create mode 100644 docs/api-endpoints/file/put-element-file/400-response-body.json create mode 100644 docs/api-endpoints/file/put-element-file/400-response-header.txt rename docs/api-endpoints/file/put-element-file/{200-response-body.json => 401-response-body.json} (100%) create mode 100644 docs/api-endpoints/file/put-element-file/401-response-header.txt create mode 100644 docs/api-endpoints/file/put-element-file/404-response-body.json create mode 100644 docs/api-endpoints/file/put-element-file/404-response-header.txt create mode 100644 docs/api-endpoints/upload/delete-upload/204-response-header.txt rename docs/api-endpoints/upload/delete-upload/{200-response-body.json => 401-response-body.json} (100%) create mode 100644 docs/api-endpoints/upload/delete-upload/401-response-header.txt create mode 100644 docs/api-endpoints/upload/delete-upload/404-response-body.json create mode 100644 docs/api-endpoints/upload/delete-upload/404-response-header.txt delete mode 100644 docs/api-endpoints/upload/head-upload/200-response-header.txt create mode 100644 docs/api-endpoints/upload/head-upload/204-response-header.txt create mode 100644 docs/api-endpoints/upload/head-upload/401-response-header.txt create mode 100644 docs/api-endpoints/upload/head-upload/404-response-header.txt delete mode 100644 docs/api-endpoints/upload/patch-upload/200-response-body.json delete mode 100644 docs/api-endpoints/upload/patch-upload/200-response-header.txt create mode 100644 docs/api-endpoints/upload/patch-upload/204-final-chunk-response-header.txt create mode 100644 docs/api-endpoints/upload/patch-upload/204-intermediate-chunk-response-header.txt create mode 100644 docs/api-endpoints/upload/patch-upload/400-response-body.json create mode 100644 docs/api-endpoints/upload/patch-upload/400-response-header.txt rename docs/api-endpoints/upload/{head-upload/200-response-body.json => patch-upload/401-response-body.json} (100%) create mode 100644 docs/api-endpoints/upload/patch-upload/401-response-header.txt create mode 100644 docs/api-endpoints/upload/patch-upload/404-response-body.json create mode 100644 docs/api-endpoints/upload/patch-upload/404-response-header.txt create mode 100644 docs/api-endpoints/upload/patch-upload/409-response-body.json create mode 100644 docs/api-endpoints/upload/patch-upload/409-response-header.txt create mode 100644 docs/commands/assets/cron-delete-expired-uploads-help.html create mode 100644 docs/commands/assets/cron-delete-expired-uploads.html create mode 100644 docs/commands/assets/cron-reindex-files-help.html create mode 100644 docs/commands/assets/cron-reindex-files.html create mode 100644 src/Contract/S3/S3TechnicalLimitsInterface.php create mode 100644 src/Controller/Error/assets/error-416-range-not-satisfiable.txt create mode 100644 src/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListener.php create mode 100644 src/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListener.php create mode 100644 src/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListener.php create mode 100644 src/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListener.php create mode 100644 src/EventSystem/ElementPropertyReset/EventListener/HasFileElementPropertyResetEventListener.php create mode 100644 src/Exception/Client416RangeNotSatisfiableException.php create mode 100644 src/Factory/Exception/Client416RangeNotSatisfiableExceptionFactory.php create mode 100644 src/Service/DigestService.php create mode 100644 src/Service/FileHashService.php create mode 100644 src/Service/FileRangeService.php create mode 100644 src/Service/IncrementalHashService.php create mode 100644 src/Service/S3TechnicalLimitsValidator.php create mode 100644 src/Type/ByteRange.php create mode 100644 src/Type/S3/S3TechnicalLimits.php create mode 100644 tests/ExampleGenerationCommand/Cron/DeleteExpiredUploadsTest.php create mode 100644 tests/ExampleGenerationCommand/Cron/ReindexFilesTest.php create mode 100644 tests/ExampleGenerationController/Asset/.gitkeep create mode 100644 tests/ExampleGenerationController/File/DeleteElementFileTest.php create mode 100644 tests/ExampleGenerationController/File/GetElementFileTest.php create mode 100644 tests/ExampleGenerationController/File/PostElementFileTest.php create mode 100644 tests/ExampleGenerationController/File/PutElementFileTest.php create mode 100644 tests/ExampleGenerationController/Upload/DeleteUploadTest.php create mode 100644 tests/ExampleGenerationController/Upload/HeadUploadTest.php create mode 100644 tests/ExampleGenerationController/Upload/PatchUploadTest.php create mode 100644 tests/FeatureTests/Command/BackupFetchFolderStructureTest.php create mode 100644 tests/FeatureTests/Endpoint/File/GetFileRangeTest.php create mode 100644 tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionTest.php create mode 100644 tests/FeatureTests/Endpoint/File/ZeroByteFileUploadTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/DeleteUploadTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/DeletingElementCleansUpInProgressUploadTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/PatchUploadIncorrectOffsetTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/PatchUploadOversizedChunkTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/PatchUploadUndersizedIntermediateChunkTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/ResumableUploadCreationTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadTest.php create mode 100644 tests/FeatureTests/General/File/AnonymousFileAccessTest.php create mode 100644 tests/FeatureTests/General/File/BotanicalFileTest.php create mode 100644 tests/FeatureTests/General/File/FileDigestTest.php create mode 100644 tests/FeatureTests/General/File/FileNameFallbackTest.php create mode 100644 tests/FeatureTests/General/File/FileRemovedWhenElementRemovedTest.php create mode 100644 tests/FeatureTests/General/File/FileTopLevelPropertyTest.php create mode 100644 tests/FeatureTests/General/File/FileUploadUpdatesElementTest.php create mode 100644 tests/FeatureTests/General/File/SimpleFileInteractivityTest.php create mode 100644 tests/FeatureTests/Security/FilePermissionTest.php create mode 100644 tests/UnitTests/Command/Cron/DeleteExpiredUploadsCommandTest.php create mode 100644 tests/UnitTests/Command/Cron/ReindexFilesCommandTest.php create mode 100644 tests/UnitTests/Command/CronCommandTest.php create mode 100644 tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListenerTest.php create mode 100644 tests/UnitTests/Service/DigestServiceTest.php create mode 100644 tests/UnitTests/Service/FileHashServiceTest.php create mode 100644 tests/UnitTests/Service/FileRangeServiceTest.php create mode 100644 tests/UnitTests/Service/IncrementalHashServiceTest.php create mode 100644 tests/UnitTests/Service/QueueServiceTest.php create mode 100644 tests/UnitTests/Service/S3TechnicalLimitsValidatorTest.php create mode 100644 tests/UnitTests/Type/S3/S3TechnicalLimitsTest.php diff --git a/.env b/.env index c532eb0e..dd6b2623 100755 --- a/.env +++ b/.env @@ -32,4 +32,4 @@ S3_ENDPOINT=http://minio:9000 S3_ACCESS_KEY_ID=accessKey S3_SECRET_ACCESS_KEY=secretKey -REFERENCE_DATASET_VERSION=0.0.29 +REFERENCE_DATASET_VERSION=0.0.30 diff --git a/TODO-BACKUP-LARGE-FILES.md b/TODO-BACKUP-LARGE-FILES.md new file mode 100644 index 00000000..2cdec184 --- /dev/null +++ b/TODO-BACKUP-LARGE-FILES.md @@ -0,0 +1,42 @@ +# TODO: `BackupLoadCommand` cannot restore files larger than 5 GiB + +Development note, not permanent project documentation. Fold into `docs/` or into +https://github.com/ember-nexus/api/issues/452 once actioned. + +## Problem + +`S3Service::uploadFile()` writes the whole file with a single `putObject`, which caps it at S3's **5 GiB +single-object-upload limit**. It has two callers, and they are not equally bounded: + +| Caller | Bound on input size | +| --- | --- | +| `UploadCreationService::setOrReplaceElementFileDirectly()` (`src/Service/UploadCreationService.php:89`) | `post_max_size` / `upload_max_filesize` — ~101 MB (`docker/php-embed/php.dev.ini:6-7`, `php.prod.ini:6-7`) | +| `BackupLoadCommand::loadFiles()` (`src/Command/BackupLoadCommand.php:216`) | **none** | + +`BackupLoadCommand` streams files straight out of the backup archive (`$this->backupStorage->readStream($path)`) +into `uploadFile()`, with no PHP request limits involved. A backup containing a file larger than 5 GiB therefore +fails to restore. + +The sharp edge: the default `file.maxFileSizeInBytes` is **10 GiB** (`docs/example/default-parameters.yaml:83`), +and files that large are accepted fine through the resumable upload path — which builds a real multipart upload in +`S3Service::mergeFileChunks()`. So the API will happily accept a file it cannot later restore from its own backup. +The failure only surfaces at restore time, which is the worst possible moment to discover it. + +## Fix + +Give `uploadFile()` a multipart path for large resources, rather than leaving multipart exclusive to +`mergeFileChunks()`. The machinery already exists in `S3Service` +(`createMultipartUploadFromMergeFileChunksOperation()`, `createUploadPartsFromMergeFileChunksOperation()`, +`completeMultipartUpload`, with `abortMultipartUpload` on failure) and needs generalising away from +`MergeFileChunksOperationInterface` so a plain resource can use it too. + +Threshold: switch to multipart above some part-size boundary (S3's minimum part size is 5 MB, its single-PUT +maximum is 5 GiB), so ordinary API uploads keep taking the cheap single-PUT path. + +Tracked in https://github.com/ember-nexus/api/issues/452. + +## Test gap + +No test covers a backup round trip with a file over the single-PUT limit — understandably, since a >5 GiB fixture +is not something to put in CI. Worth at least a unit-level test that the multipart branch is selected above the +threshold, rather than a real large-file integration test. diff --git a/TODO-NEXT.md b/TODO-NEXT.md new file mode 100644 index 00000000..c826b83b --- /dev/null +++ b/TODO-NEXT.md @@ -0,0 +1,107 @@ +# TODO (next) + +Follow-up work surfaced while reviewing the file/upload branch, intentionally not addressed there. Same status as +`TODO.md`: development notes, not permanent project documentation. Fold into `docs/` or into issues once actioned. + +## Carried over from `TODO.md` (still open) + +### Enforce `file.maxFileSizeInBytes` at upload completion + +`EmberNexusConfiguration::getFileMaxFileSizeInBytes()` is advertised to clients — via the `max-size` field of the +`Upload-Limit` response header (`NoContentResponseFactory.php`) and via `/instance-configuration` +(`GetInstanceConfigurationController.php`) — and is validated at startup against the storage backend's technical +maximum object size (`S3TechnicalLimitsValidator.php`). But nothing enforces it at request time: neither a direct +upload (`POST`/`PUT //file`) nor a completing resumable upload chunk (`PatchUploadController.php`) rejects a +file whose size exceeds `maxFileSizeInBytes`. The public API docs (`/reference/upload`) already describe a +completing upload that exceeds `max-size` as failing with `400 Bad Request`, so this needs a real check — most +naturally in `UploadCreationService::setOrReplaceElementFileDirectly()` for direct uploads and in +`PatchUploadController::createFile()` for resumable ones, before the file/merge is written. + +### High-performance BLAKE3 for file hashing + +`file.hash` (`src/Service/FileHashService.php`) uses SHA-256. BLAKE3 is preferred long-term, but the only real +pure-PHP implementation (`tourze/blake3-php`) benchmarks at roughly 0.04 MB/s — about 3000x too slow for a 1 Gb/s +uplink. Real BLAKE3 performance requires either: + +- a Docker image change adding a build stage that compiles the official BLAKE3 C reference implementation into a + shared library, enabling the `ffi` PHP extension, and binding to it via FFI, or +- a compiled native PHP extension (e.g. `cypherbits/php-blake3`) added to the image build — smaller effort, but + currently a low-adoption, single-maintainer project, a real supply-chain trust tradeoff for a crypto primitive + that would need vetting first. + +`file.hash` is stored as `{: }` (not a flat pair), so adding or switching to `file.hash.blake3` +later needs no data migration — existing `file.hash.sha256` values stay valid alongside it. + +Note: SHA-256 is *not* to be consolidated with the `sha3-256` used in `TokenGenerator.php`. Those are unrelated use +cases with opposite constraints, and RFC 9530's digest registry (which `DigestService` implements) has no entry for +SHA-3 — switching file hashing to SHA-3 would break `Repr-Digest`/`Content-Digest` interoperability. + +### S3 upload bucket lifecycle policy + +`cron:delete-expired-uploads` (and its `file.expiredUploadCanBeDeletedAfterExpirationInSeconds` grace period) is an +application-level cleanup of expired uploads and their S3 chunks. As defense in depth, the S3/MinIO **upload** +bucket should also carry an object lifecycle policy expiring objects after a short time (e.g. 24-48h), in case the +cron job is disabled, not running, or falling behind. Infrastructure/ops concern — bucket provisioning lives +outside this repository. + +## New + +### `BackupLoadCommand` cannot restore files larger than 5 GiB + +Moved to its own note: see `TODO-BACKUP-LARGE-FILES.md`. + +### `file` properties in collection and search responses — DONE + +`file` is now returned everywhere an element is serialised. `ElementToRawService::elementToRaw()` lost its +`$includeFile` parameter, and the four call sites that passed `false` (`CollectionService` ×3, +`ElementHydrationSearchStepEventListener`) now get the same shape as the single-element endpoint. This also +resolves file properties in search results, with no new search step and no new query parameters. + +Rationale: `file` is an ordinary element property in the MongoDB document fragment, which +`ElementManager::getNode()`/`getRelation()` fetch unconditionally — so `includeFile: false` never saved a database +round trip, only ~160 bytes of payload per file-bearing element. The property is exactly four keys, written in only +two places (`UploadCreationService.php:96`, `PatchUploadController.php:179`). + +Accepted constraint: `file` is now part of the collection contract, so it must stay a small, fixed summary. +Thumbnails are expected to work like file downloads do today (implicit, link-free), further hash algorithms are +cheap to add, and storage keys / version history are explicitly out of scope. Reduced-payload responses can be +added later if the community asks. + +Caching needed no change: both file-setting paths and the delete path call `merge()` + `flush()`, which bumps +`updated` via `UpdatedElementPreWriteEventListener` (collection etags hash `children.updated`, +`EtagCalculatorService.php:91`) and fires `ElementPostMergeEvent`, expiring the element plus all related collection +etag keys in `ExpireEtagOnChangeEventListener`. + +### Null properties are removed from Neo4j but linger in MongoDB and Elasticsearch + +Found while checking whether `DeleteElementFileController.php:63` (`addProperty('file', null)`) removes the +property. Verdict per store: + +| Store | Write | Null removed? | +| --- | --- | --- | +| Neo4j | `SET node += $properties` (`NodeMergeToStatementEventListener.php:48`) | **Yes** — Cypher's `+=` deletes keys set to null | +| MongoDB | `$set` with the whole property array (`mongo-entity-manager/src/Type/EntityManager.php:110-119`) | **No** — stores an explicit `null`; would need `$unset` | +| Elasticsearch | `update` with `doc` (`elastic-entity-manager/src/Type/EntityManager.php:113-117`) | **No** — `null` stays in `_source`, though it is not indexed | + +The API is nonetheless correct today: `GenericPropertyElementDefragmentizeEventListener` ends by stripping every +null property from the element after defragmentizing, so a stored `file: null` never reaches `elementToRaw()` and +`hasProperty('file')` is false. That is why the always-include-`file` change above needed no null guard. + +What remains is storage cruft, not an API bug: deleted properties linger as explicit nulls in the Mongo document +and the ES `_source`. Fixing it properly means teaching `syndesi/mongo-entity-manager` to split null-valued +properties into an `$unset` clause (and the ES equivalent), which is an upstream change in those libraries rather +than something to work around here. + +### Dead `$fileFragment` plumbing — DONE + +Removed. `$fileFragment` was threaded through `FragmentGroup`, `FragmentHelper`, `ElementDefragmentizeService` and +all four fragmentize/defragmentize events, and was never read or written: the event factories hardcoded `null`, no +listener set it, `FragmentGroup::getFileFragment()` had no callers, `ElementManager::getNode()/getRelation()` +hardcoded `$fileFragment = null`, and none of the three defragmentize listeners read it. It predated this branch +(already present at the merge base). + +It looks like a placeholder for treating S3 as a fourth backing store alongside Cypher/Mongo/Elastic. The +implementation went a different way: file metadata rides along as an ordinary `file` property in the Mongo +document, and S3 I/O happens explicitly in `S3Service`/`UploadCreationService` *outside* merge/flush — deliberately +so, per the reentrancy note in `UploadService::deleteUploadsTargeting()`. If S3 writes should ever join the flush +batch, reintroduce the seam deliberately and with a real type rather than `mixed`. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..e4e13a99 --- /dev/null +++ b/TODO.md @@ -0,0 +1,101 @@ +# TODO + +Notes for follow-up work which is intentionally not addressed yet in this session. Not meant as permanent +project documentation; fold into `docs/` (or delete) once actioned. + +## Enforce `file.maxFileSizeInBytes` at upload completion + +`EmberNexusConfiguration::getFileMaxFileSizeInBytes()` is advertised to clients — via the `max-size` field of the +`Upload-Limit` response header (`NoContentResponseFactory.php`) and via `/instance-configuration` +(`GetInstanceConfigurationController.php`) — and is validated at startup against the storage backend's technical +maximum object size (`S3TechnicalLimitsValidator.php`). But nothing actually enforces it at request time: neither +a direct upload ([`POST`](src/Service/UploadCreationService.php)/`PUT //file`) nor a completing resumable +upload chunk (`PatchUploadController.php`) rejects a file whose size exceeds `maxFileSizeInBytes`. The public API +docs (`/reference/upload` on the docs site) now describe a completing upload that exceeds `max-size` as failing +with `400 Bad Request` — since that's the intended behavior, this needs an actual check (most naturally in +`UploadCreationService::setOrReplaceElementFileDirectly()` for direct uploads, and in +`PatchUploadController::createFile()` for resumable ones, before the file/merge is written) so the documented +behavior becomes true rather than aspirational. + +## High-performance BLAKE3 for file hashing + +`file.hash` (`src/Service/FileHashService.php`) uses SHA-256 for now. BLAKE3 is the preferred long-term +algorithm, but the only real pure-PHP implementation (`tourze/blake3-php`) benchmarks at roughly 0.04 MB/s — +about 3000x too slow for a 1 Gb/s uplink — so it was not used. Getting real BLAKE3 performance requires either: + +- A Docker image change adding a build stage that compiles the official BLAKE3 C reference implementation into + a shared library, enabling the `ffi` PHP extension, and binding to it via FFI, or +- A compiled native PHP extension (e.g. `cypherbits/php-blake3`) added to the image build — smaller effort, but + currently a low-adoption, single-maintainer project, a real supply-chain trust tradeoff for a crypto + primitive that would need vetting first. + +The `file.hash` property is already stored as `{: }` (not a flat pair) so adding/switching to +`file.hash.blake3` later needs no data migration — existing `file.hash.sha256` values simply stay valid +alongside it. + +## S3 upload bucket lifecycle policy + +`cron:delete-expired-uploads` (and its `file.expiredUploadCanBeDeletedAfterExpirationInSeconds` grace period) +is an application-level cleanup of expired uploads and their S3 chunks. As defense in depth, the S3/MinIO +**upload** bucket itself should also be configured with an object lifecycle policy that expires objects after a +reasonably short time (e.g. 24-48h), in case the cron job is disabled, not running, or falls behind. This is an +infrastructure/ops concern (bucket provisioning lives outside this repository) and is not implemented here. + + + +--- + +GetElementFileController: + + // older records (predating the 'mimeType' rename) use 'mimetype' instead + $mimeType = $fileProperty['mimeType'] ?? $fileProperty['mimetype'] ?? null; + if (!is_string($mimeType) || '' === $mimeType) { + return null; + } + +pls remove the "old" property. the file feature as a whole unit is solely built in this branch -> no "old" logic exists in teh wild. + +furthermore pls look through commits on this branch since august and check whether similar "old" fallback logic exists somewhere else. + +--- + +CollectionService currently ignores file related special properties from response collections. +I think there are pros and cons for enabling and keeping them disabled. what you think? what would be better? +note: search endpoints can already be used to filter only elements with files. +does the search endpoint allow returning file properties directly, i.e. element collection with file? their element hydration search step is a bit special / behaves differently iirc. + +--- + +iirc some code in this codebase uses sha3, while new file related stuff uses sha 256. +should I consolidate towards a single hash algorithm? if so, sha3? + +--- + +pls remove all mentions of todo.md from the codebase. the todo.md file is solely used for development and temporary notes inside this branch; it should not be referenced by code later merged towards main. + +--- + +s3service: + + /** + * todo: optimize upload for larger files using multipart-upload?, handled by https://github.com/ember-nexus/api/issues/452. + */ + public function uploadFile(UploadFileOperationInterface $uploadFileOperation): int + { + +should this be implemented now? + +--- + +class BinaryStreamResponse extends StreamedResponse implements EtagCapableResponseInterface +{ +public const int STREAM_CHUNK_SIZE = 8192; + +is the stream chunk size of 8k ok? like is it performant? should it be increased? does it fit into common mtus etc.? + +--- + +reduce code comments made in this branch since august to a minimum; remove them completely if they are just paraphrasing existing logic. + +--- + diff --git a/config/reference.php b/config/reference.php index d7c5e20f..acef2f43 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1075,6 +1075,7 @@ * uploadChunkDigitsLength?: int|Param, // Number of digits for the upload chunk counter. Number of max chunks can be limited by upstream services, e.g. AWS can only support up to 10.000 chunks, i.e. a chunk digits length of 4. // Default: 4 * uploadMinChunkSizeInBytes?: int|Param, // Minimum size in bytes of uploaded chunks. Limited by the S3 provider, e.g. AWS requires that chunks are at least 5 MiB big. // Default: 5242880 * uploadMaxChunkSizeInBytes?: int|Param, // Maximum size in bytes of uploaded chunks. Limited by the S3 provider, Caddy and the PHP configuration. // Default: 105906176 + * expiredUploadCanBeDeletedAfterExpirationInSeconds?: int|Param, // Grace period after an upload expires during which the cron:delete-expired-uploads command will not yet delete it. Gives clients a bit of leeway past the expiration date before their partial upload and its S3 chunks are removed. // Default: 3600 * S3StorageBucket?: scalar|Param|null, // Name of the S3 bucket used for storage of files. // Default: "api-storage" * S3UploadBucket?: scalar|Param|null, // Name of the S3 bucket used for temporary storage of uploads. // Default: "api-upload" * S3StorageBucketLevels?: int|Param, // Number of levels (folders) used within the S3 storage bucket. // Default: 3 diff --git a/config/services.yaml b/config/services.yaml index fa7dec79..d784d199 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -18,6 +18,12 @@ services: - '../src/DependencyInjection/' - '../src/Kernel.php' + # Only one storage backend/connection is supported today, so this is a plain alias to its one implementation. + # Once multiple connections with differing technical limits are supported, this alias is the extension point: + # replace it with a factory/registry service (still implementing S3TechnicalLimitsInterface) resolving the + # right implementation per connection, without any consumer of the interface needing to change. + App\Contract\S3\S3TechnicalLimitsInterface: '@App\Type\S3\S3TechnicalLimits' + # library services monolog_json_formatter: diff --git a/docker/supercronic b/docker/supercronic index 6188298c..72f58946 100644 --- a/docker/supercronic +++ b/docker/supercronic @@ -7,4 +7,4 @@ # | | | | | | + year # | | | | | | | + command # | | | | | | | | - 0 */2 * * * * * php bin/console cron + 0 */5 * * * * * php bin/console cron diff --git a/docs/api-endpoints/file/delete-element-file/204-response-header.txt b/docs/api-endpoints/file/delete-element-file/204-response-header.txt new file mode 100644 index 00000000..f36281ab --- /dev/null +++ b/docs/api-endpoints/file/delete-element-file/204-response-header.txt @@ -0,0 +1,8 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Date: Mon, 24 Aug 2026 21:47:09 GMT +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/delete-element-file/200-response-body.json b/docs/api-endpoints/file/delete-element-file/401-response-body.json similarity index 100% rename from docs/api-endpoints/file/delete-element-file/200-response-body.json rename to docs/api-endpoints/file/delete-element-file/401-response-body.json diff --git a/docs/api-endpoints/file/delete-element-file/200-response-header.txt b/docs/api-endpoints/file/delete-element-file/401-response-header.txt similarity index 93% rename from docs/api-endpoints/file/delete-element-file/200-response-header.txt rename to docs/api-endpoints/file/delete-element-file/401-response-header.txt index baead971..d570f880 100644 --- a/docs/api-endpoints/file/delete-element-file/200-response-header.txt +++ b/docs/api-endpoints/file/delete-element-file/401-response-header.txt @@ -5,6 +5,6 @@ Access-Control-Expose-Headers: ETag, Location Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:22:32 GMT +Date: Mon, 24 Aug 2026 21:47:09 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/delete-element-file/404-response-body.json b/docs/api-endpoints/file/delete-element-file/404-response-body.json new file mode 100644 index 00000000..d2abaadb --- /dev/null +++ b/docs/api-endpoints/file/delete-element-file/404-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/404/not-found", + "title": "Not found", + "status": 404, + "detail": "Requested element was not found." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/200-response-header.txt b/docs/api-endpoints/file/delete-element-file/404-response-header.txt similarity index 93% rename from docs/api-endpoints/file/post-element-file/200-response-header.txt rename to docs/api-endpoints/file/delete-element-file/404-response-header.txt index baead971..d570f880 100644 --- a/docs/api-endpoints/file/post-element-file/200-response-header.txt +++ b/docs/api-endpoints/file/delete-element-file/404-response-header.txt @@ -5,6 +5,6 @@ Access-Control-Expose-Headers: ETag, Location Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:22:32 GMT +Date: Mon, 24 Aug 2026 21:47:09 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/200-response-header.txt b/docs/api-endpoints/file/get-element-file/200-response-header.txt index baead971..63b573f0 100644 --- a/docs/api-endpoints/file/get-element-file/200-response-header.txt +++ b/docs/api-endpoints/file/get-element-file/200-response-header.txt @@ -1,10 +1,13 @@ +Accept-Ranges: bytes Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private -Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:22:32 GMT -Transfer-Encoding: chunked +Content-Disposition: attachment; filename=Rose.jpg +Content-Length: 138937 +Content-Type: image/jpeg +Date: Mon, 24 Aug 2026 21:39:11 GMT +Etag: "XHImZOcVmUc" X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/206-response-header.txt b/docs/api-endpoints/file/get-element-file/206-response-header.txt new file mode 100644 index 00000000..3eb27d6b --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/206-response-header.txt @@ -0,0 +1,14 @@ +Accept-Ranges: bytes +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Disposition: attachment; filename=Rose.jpg +Content-Length: 100 +Content-Range: bytes 0-99/138937 +Content-Type: image/jpeg +Date: Mon, 24 Aug 2026 21:39:11 GMT +Etag: "XHImZOcVmUc" +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/400-response-body.json b/docs/api-endpoints/file/get-element-file/400-response-body.json new file mode 100644 index 00000000..f6b50e78 --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/400-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/400/bad-content", + "title": "Bad content", + "status": 400, + "detail": "Could not parse 'Range' header: 'bytes=not-a-valid-range'. Only a single range of the form 'bytes=-', 'bytes=-' or 'bytes=-' is supported." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/200-response-header.txt b/docs/api-endpoints/file/get-element-file/400-response-header.txt similarity index 93% rename from docs/api-endpoints/file/put-element-file/200-response-header.txt rename to docs/api-endpoints/file/get-element-file/400-response-header.txt index baead971..288d4c95 100644 --- a/docs/api-endpoints/file/put-element-file/200-response-header.txt +++ b/docs/api-endpoints/file/get-element-file/400-response-header.txt @@ -5,6 +5,6 @@ Access-Control-Expose-Headers: ETag, Location Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:22:32 GMT +Date: Mon, 24 Aug 2026 21:39:11 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/200-response-body.json b/docs/api-endpoints/file/get-element-file/401-response-body.json similarity index 100% rename from docs/api-endpoints/file/get-element-file/200-response-body.json rename to docs/api-endpoints/file/get-element-file/401-response-body.json diff --git a/docs/api-endpoints/upload/delete-upload/200-response-header.txt b/docs/api-endpoints/file/get-element-file/401-response-header.txt similarity index 93% rename from docs/api-endpoints/upload/delete-upload/200-response-header.txt rename to docs/api-endpoints/file/get-element-file/401-response-header.txt index baead971..288d4c95 100644 --- a/docs/api-endpoints/upload/delete-upload/200-response-header.txt +++ b/docs/api-endpoints/file/get-element-file/401-response-header.txt @@ -5,6 +5,6 @@ Access-Control-Expose-Headers: ETag, Location Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:22:32 GMT +Date: Mon, 24 Aug 2026 21:39:11 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/404-response-body.json b/docs/api-endpoints/file/get-element-file/404-response-body.json new file mode 100644 index 00000000..d2abaadb --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/404-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/404/not-found", + "title": "Not found", + "status": 404, + "detail": "Requested element was not found." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/404-response-header.txt b/docs/api-endpoints/file/get-element-file/404-response-header.txt new file mode 100644 index 00000000..288d4c95 --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/404-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:39:11 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/416-response-body.json b/docs/api-endpoints/file/get-element-file/416-response-body.json new file mode 100644 index 00000000..d985ac39 --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/416-response-body.json @@ -0,0 +1,8 @@ +{ + "type": "http://api/error/416/range-not-satisfiable", + "title": "Range Not Satisfiable", + "status": 416, + "detail": "Requested range start (999999999) is beyond the end of the resource (138937 bytes long).", + "requested-start": 999999999, + "total-length": 138937 +} \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/416-response-header.txt b/docs/api-endpoints/file/get-element-file/416-response-header.txt new file mode 100644 index 00000000..288d4c95 --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/416-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:39:11 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/201-response-header.txt b/docs/api-endpoints/file/post-element-file/201-response-header.txt new file mode 100644 index 00000000..31dea429 --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/201-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: text/html; charset=UTF-8 +Date: Mon, 24 Aug 2026 21:41:44 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/204-with-initial-data-response-header.txt b/docs/api-endpoints/file/post-element-file/204-with-initial-data-response-header.txt new file mode 100644 index 00000000..ce4b8319 --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/204-with-initial-data-response-header.txt @@ -0,0 +1,13 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-store, private +Date: Mon, 24 Aug 2026 21:43:17 GMT +Expires: Tue, 25 Aug 2026 00:43:17 GMT +Location: /upload/31395bc1-b202-47fb-aebc-0f8ddfcc3d46 +Upload-Complete: ?0 +Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 +Upload-Offset: 5242880 +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/204-without-initial-data-response-header.txt b/docs/api-endpoints/file/post-element-file/204-without-initial-data-response-header.txt new file mode 100644 index 00000000..8b45df0d --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/204-without-initial-data-response-header.txt @@ -0,0 +1,13 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-store, private +Date: Mon, 24 Aug 2026 21:43:17 GMT +Expires: Tue, 25 Aug 2026 00:43:17 GMT +Location: /upload/a0505189-d7a8-49ac-8589-6f14c140acbe +Upload-Complete: ?0 +Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 +Upload-Offset: 0 +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/400-response-body.json b/docs/api-endpoints/file/post-element-file/400-response-body.json new file mode 100644 index 00000000..f891ae85 --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/400-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/400/bad-content", + "title": "Bad content", + "status": 400, + "detail": "Could not verify upload: the declared digest does not match the uploaded file's actual content." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/400-response-header.txt b/docs/api-endpoints/file/post-element-file/400-response-header.txt new file mode 100644 index 00000000..bb6ef52b --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/400-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:41:45 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/200-response-body.json b/docs/api-endpoints/file/post-element-file/401-response-body.json similarity index 100% rename from docs/api-endpoints/file/post-element-file/200-response-body.json rename to docs/api-endpoints/file/post-element-file/401-response-body.json diff --git a/docs/api-endpoints/file/post-element-file/401-response-header.txt b/docs/api-endpoints/file/post-element-file/401-response-header.txt new file mode 100644 index 00000000..bb6ef52b --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/401-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:41:45 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/404-response-body.json b/docs/api-endpoints/file/post-element-file/404-response-body.json new file mode 100644 index 00000000..d2abaadb --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/404-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/404/not-found", + "title": "Not found", + "status": 404, + "detail": "Requested element was not found." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/404-response-header.txt b/docs/api-endpoints/file/post-element-file/404-response-header.txt new file mode 100644 index 00000000..bb6ef52b --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/404-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:41:45 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/409-response-body.json b/docs/api-endpoints/file/post-element-file/409-response-body.json new file mode 100644 index 00000000..44f71494 --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/409-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/409/conflict", + "title": "Conflict", + "status": 409, + "detail": "Element with id '6b1c2d3e-4f5a-4b6c-8d9e-0f1a2b3c4d5e' already has an associated file; can not create new file. Delete existing file first or replace it with PUT." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/409-response-header.txt b/docs/api-endpoints/file/post-element-file/409-response-header.txt new file mode 100644 index 00000000..bb6ef52b --- /dev/null +++ b/docs/api-endpoints/file/post-element-file/409-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:41:45 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/201-response-header.txt b/docs/api-endpoints/file/put-element-file/201-response-header.txt new file mode 100644 index 00000000..3319b340 --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/201-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: text/html; charset=UTF-8 +Date: Mon, 24 Aug 2026 21:47:12 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/204-with-initial-data-response-header.txt b/docs/api-endpoints/file/put-element-file/204-with-initial-data-response-header.txt new file mode 100644 index 00000000..c5e5d4fc --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/204-with-initial-data-response-header.txt @@ -0,0 +1,13 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-store, private +Date: Mon, 24 Aug 2026 21:47:12 GMT +Expires: Tue, 25 Aug 2026 00:47:12 GMT +Location: /upload/17cf07da-f3a2-4b39-b29d-b6f63e0ba7f5 +Upload-Complete: ?0 +Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 +Upload-Offset: 5242880 +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/204-without-initial-data-response-header.txt b/docs/api-endpoints/file/put-element-file/204-without-initial-data-response-header.txt new file mode 100644 index 00000000..2239cad2 --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/204-without-initial-data-response-header.txt @@ -0,0 +1,13 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-store, private +Date: Mon, 24 Aug 2026 21:47:12 GMT +Expires: Tue, 25 Aug 2026 00:47:12 GMT +Location: /upload/ca02c95e-e93b-4759-96a1-f5e703ea802f +Upload-Complete: ?0 +Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 +Upload-Offset: 0 +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/400-response-body.json b/docs/api-endpoints/file/put-element-file/400-response-body.json new file mode 100644 index 00000000..f891ae85 --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/400-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/400/bad-content", + "title": "Bad content", + "status": 400, + "detail": "Could not verify upload: the declared digest does not match the uploaded file's actual content." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/400-response-header.txt b/docs/api-endpoints/file/put-element-file/400-response-header.txt new file mode 100644 index 00000000..bec870ac --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/400-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:47:13 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/200-response-body.json b/docs/api-endpoints/file/put-element-file/401-response-body.json similarity index 100% rename from docs/api-endpoints/file/put-element-file/200-response-body.json rename to docs/api-endpoints/file/put-element-file/401-response-body.json diff --git a/docs/api-endpoints/file/put-element-file/401-response-header.txt b/docs/api-endpoints/file/put-element-file/401-response-header.txt new file mode 100644 index 00000000..bec870ac --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/401-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:47:13 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/404-response-body.json b/docs/api-endpoints/file/put-element-file/404-response-body.json new file mode 100644 index 00000000..d2abaadb --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/404-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/404/not-found", + "title": "Not found", + "status": 404, + "detail": "Requested element was not found." +} \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/404-response-header.txt b/docs/api-endpoints/file/put-element-file/404-response-header.txt new file mode 100644 index 00000000..bec870ac --- /dev/null +++ b/docs/api-endpoints/file/put-element-file/404-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:47:13 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload/204-response-header.txt b/docs/api-endpoints/upload/delete-upload/204-response-header.txt new file mode 100644 index 00000000..6aa079b4 --- /dev/null +++ b/docs/api-endpoints/upload/delete-upload/204-response-header.txt @@ -0,0 +1,8 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Date: Mon, 24 Aug 2026 21:50:10 GMT +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload/200-response-body.json b/docs/api-endpoints/upload/delete-upload/401-response-body.json similarity index 100% rename from docs/api-endpoints/upload/delete-upload/200-response-body.json rename to docs/api-endpoints/upload/delete-upload/401-response-body.json diff --git a/docs/api-endpoints/upload/delete-upload/401-response-header.txt b/docs/api-endpoints/upload/delete-upload/401-response-header.txt new file mode 100644 index 00000000..f8ea676c --- /dev/null +++ b/docs/api-endpoints/upload/delete-upload/401-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:50:11 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload/404-response-body.json b/docs/api-endpoints/upload/delete-upload/404-response-body.json new file mode 100644 index 00000000..d2abaadb --- /dev/null +++ b/docs/api-endpoints/upload/delete-upload/404-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/404/not-found", + "title": "Not found", + "status": 404, + "detail": "Requested element was not found." +} \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload/404-response-header.txt b/docs/api-endpoints/upload/delete-upload/404-response-header.txt new file mode 100644 index 00000000..f8ea676c --- /dev/null +++ b/docs/api-endpoints/upload/delete-upload/404-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:50:11 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/head-upload/200-response-header.txt b/docs/api-endpoints/upload/head-upload/200-response-header.txt deleted file mode 100644 index baead971..00000000 --- a/docs/api-endpoints/upload/head-upload/200-response-header.txt +++ /dev/null @@ -1,10 +0,0 @@ -Access-Control-Allow-Headers: * -Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Access-Control-Allow-Origin: * -Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Cache-Control: no-cache, private -Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:22:32 GMT -Transfer-Encoding: chunked -X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/head-upload/204-response-header.txt b/docs/api-endpoints/upload/head-upload/204-response-header.txt new file mode 100644 index 00000000..ba939a79 --- /dev/null +++ b/docs/api-endpoints/upload/head-upload/204-response-header.txt @@ -0,0 +1,12 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-store, private +Date: Mon, 24 Aug 2026 21:50:11 GMT +Expires: Tue, 25 Aug 2026 00:50:11 GMT +Upload-Complete: ?0 +Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 +Upload-Offset: 5242880 +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/head-upload/401-response-header.txt b/docs/api-endpoints/upload/head-upload/401-response-header.txt new file mode 100644 index 00000000..ec1433d1 --- /dev/null +++ b/docs/api-endpoints/upload/head-upload/401-response-header.txt @@ -0,0 +1,9 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:52:20 GMT +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/head-upload/404-response-header.txt b/docs/api-endpoints/upload/head-upload/404-response-header.txt new file mode 100644 index 00000000..ec1433d1 --- /dev/null +++ b/docs/api-endpoints/upload/head-upload/404-response-header.txt @@ -0,0 +1,9 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:52:20 GMT +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/200-response-body.json b/docs/api-endpoints/upload/patch-upload/200-response-body.json deleted file mode 100644 index f91c5190..00000000 --- a/docs/api-endpoints/upload/patch-upload/200-response-body.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "http://api/error/401/unauthorized", - "title": "Unauthorized", - "status": 401, - "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." -} \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/200-response-header.txt b/docs/api-endpoints/upload/patch-upload/200-response-header.txt deleted file mode 100644 index baead971..00000000 --- a/docs/api-endpoints/upload/patch-upload/200-response-header.txt +++ /dev/null @@ -1,10 +0,0 @@ -Access-Control-Allow-Headers: * -Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Access-Control-Allow-Origin: * -Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Cache-Control: no-cache, private -Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:22:32 GMT -Transfer-Encoding: chunked -X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/204-final-chunk-response-header.txt b/docs/api-endpoints/upload/patch-upload/204-final-chunk-response-header.txt new file mode 100644 index 00000000..d15a91df --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/204-final-chunk-response-header.txt @@ -0,0 +1,12 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-store, private +Date: Mon, 24 Aug 2026 21:52:21 GMT +Expires: Tue, 25 Aug 2026 00:52:21 GMT +Upload-Complete: ?1 +Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 +Upload-Offset: 10485760 +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/204-intermediate-chunk-response-header.txt b/docs/api-endpoints/upload/patch-upload/204-intermediate-chunk-response-header.txt new file mode 100644 index 00000000..f4eb9461 --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/204-intermediate-chunk-response-header.txt @@ -0,0 +1,12 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-store, private +Date: Mon, 24 Aug 2026 21:52:21 GMT +Expires: Tue, 25 Aug 2026 00:52:20 GMT +Upload-Complete: ?0 +Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 +Upload-Offset: 10485760 +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/400-response-body.json b/docs/api-endpoints/upload/patch-upload/400-response-body.json new file mode 100644 index 00000000..f891ae85 --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/400-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/400/bad-content", + "title": "Bad content", + "status": 400, + "detail": "Could not verify upload: the declared digest does not match the uploaded file's actual content." +} \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/400-response-header.txt b/docs/api-endpoints/upload/patch-upload/400-response-header.txt new file mode 100644 index 00000000..543cac58 --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/400-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:50:13 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/head-upload/200-response-body.json b/docs/api-endpoints/upload/patch-upload/401-response-body.json similarity index 100% rename from docs/api-endpoints/upload/head-upload/200-response-body.json rename to docs/api-endpoints/upload/patch-upload/401-response-body.json diff --git a/docs/api-endpoints/upload/patch-upload/401-response-header.txt b/docs/api-endpoints/upload/patch-upload/401-response-header.txt new file mode 100644 index 00000000..484dcc39 --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/401-response-header.txt @@ -0,0 +1,11 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Connection: close +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:50:15 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/404-response-body.json b/docs/api-endpoints/upload/patch-upload/404-response-body.json new file mode 100644 index 00000000..d2abaadb --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/404-response-body.json @@ -0,0 +1,6 @@ +{ + "type": "http://api/error/404/not-found", + "title": "Not found", + "status": 404, + "detail": "Requested element was not found." +} \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/404-response-header.txt b/docs/api-endpoints/upload/patch-upload/404-response-header.txt new file mode 100644 index 00000000..d2eac4b4 --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/404-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:50:16 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/409-response-body.json b/docs/api-endpoints/upload/patch-upload/409-response-body.json new file mode 100644 index 00000000..0aa02e64 --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/409-response-body.json @@ -0,0 +1,8 @@ +{ + "type": "http://api/error/409/conflict", + "title": "Conflict", + "status": 409, + "detail": "Offset from request does not match offset of resource.", + "expected-offset": 5242880, + "provided-offset": 5242881 +} \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/409-response-header.txt b/docs/api-endpoints/upload/patch-upload/409-response-header.txt new file mode 100644 index 00000000..44b31c9e --- /dev/null +++ b/docs/api-endpoints/upload/patch-upload/409-response-header.txt @@ -0,0 +1,10 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Cache-Control: no-cache, private +Content-Type: application/problem+json; charset=utf-8 +Date: Mon, 24 Aug 2026 21:50:14 GMT +Transfer-Encoding: chunked +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/commands/assets/cron-delete-expired-uploads-help.html b/docs/commands/assets/cron-delete-expired-uploads-help.html new file mode 100644 index 00000000..7a55877f --- /dev/null +++ b/docs/commands/assets/cron-delete-expired-uploads-help.html @@ -0,0 +1,60 @@ + + + + + + +stdin + + + + +
+Description:
+  Deletes uploads which were not completed during their lifetime.
+
+Usage:
+  cron:delete-expired-uploads
+
+Options:
+  -h, --help            Display help for the given command. When no command is given display help for the list command
+      --silent          Do not output any message
+  -q, --quiet           Only errors are displayed. All other output is suppressed
+  -V, --version         Display this application version
+      --ansi|--no-ansi  Force (or disable --no-ansi) ANSI output
+  -n, --no-interaction  Do not ask any interactive question
+  -e, --env=ENV         The Environment name. [default: "prod"]
+      --no-debug        Switch off debug mode.
+      --profile         Enables profiling (requires debug).
+  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
+
+ + \ No newline at end of file diff --git a/docs/commands/assets/cron-delete-expired-uploads.html b/docs/commands/assets/cron-delete-expired-uploads.html new file mode 100644 index 00000000..63992b25 --- /dev/null +++ b/docs/commands/assets/cron-delete-expired-uploads.html @@ -0,0 +1,54 @@ + + + + + + +stdin + + + + +
+
+    ▄
+  ▄▀ ▀▄  Ember Nexus API
+   ▀█▀   v0.1.31, production mode
+
+  Cron
+
+  No expired uploads found.
+
+▶ Finished.
+
+
+ + \ No newline at end of file diff --git a/docs/commands/assets/cron-reindex-files-help.html b/docs/commands/assets/cron-reindex-files-help.html new file mode 100644 index 00000000..4975b244 --- /dev/null +++ b/docs/commands/assets/cron-reindex-files-help.html @@ -0,0 +1,60 @@ + + + + + + +stdin + + + + +
+Description:
+  Updates element files within Elasticsearch. Restricted to files marked as updated.
+
+Usage:
+  cron:reindex-files
+
+Options:
+  -h, --help            Display help for the given command. When no command is given display help for the list command
+      --silent          Do not output any message
+  -q, --quiet           Only errors are displayed. All other output is suppressed
+  -V, --version         Display this application version
+      --ansi|--no-ansi  Force (or disable --no-ansi) ANSI output
+  -n, --no-interaction  Do not ask any interactive question
+  -e, --env=ENV         The Environment name. [default: "prod"]
+      --no-debug        Switch off debug mode.
+      --profile         Enables profiling (requires debug).
+  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
+
+ + \ No newline at end of file diff --git a/docs/commands/assets/cron-reindex-files.html b/docs/commands/assets/cron-reindex-files.html new file mode 100644 index 00000000..f6583fcf --- /dev/null +++ b/docs/commands/assets/cron-reindex-files.html @@ -0,0 +1,54 @@ + + + + + + +stdin + + + + +
+
+    ▄
+  ▄▀ ▀▄  Ember Nexus API
+   ▀█▀   v0.1.31, production mode
+
+  Cron
+
+  Reindexing files marked as updated...
+
+▶ Reindexed 0 element file(s).
+
+
+ + \ No newline at end of file diff --git a/docs/commands/assets/database-drop-help.html b/docs/commands/assets/database-drop-help.html index 56f7c38c..a305f9cd 100644 --- a/docs/commands/assets/database-drop-help.html +++ b/docs/commands/assets/database-drop-help.html @@ -45,17 +45,18 @@ database:drop [options] Options: - -f, --force|--no-force If enabled, command will not ask for manual confirmation. - -h, --help Display help for the given command. When no command is given display help for the list command - --silent Do not output any message - -q, --quiet Only errors are displayed. All other output is suppressed - -V, --version Display this application version - --ansi|--no-ansi Force (or disable --no-ansi) ANSI output - -n, --no-interaction Do not ask any interactive question - -e, --env=ENV The Environment name. [default: "prod"] - --no-debug Switch off debug mode. - --profile Enables profiling (requires debug). - -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + -f, --force|--no-force If enabled, command will not ask for manual confirmation. + --no-files|--no-no-files Disable deletion of object storage (S3) data, i.e. keep the storage and upload buckets untouched. + -h, --help Display help for the given command. When no command is given display help for the list command + --silent Do not output any message + -q, --quiet Only errors are displayed. All other output is suppressed + -V, --version Display this application version + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The Environment name. [default: "prod"] + --no-debug Switch off debug mode. + --profile Enables profiling (requires debug). + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug \ No newline at end of file diff --git a/docs/example/default-parameters.yaml b/docs/example/default-parameters.yaml index a1aae4ef..07ee11a3 100644 --- a/docs/example/default-parameters.yaml +++ b/docs/example/default-parameters.yaml @@ -94,6 +94,9 @@ ember_nexus: # Maximum size in bytes of uploaded chunks. Limited by the S3 provider, Caddy and the PHP configuration. uploadMaxChunkSizeInBytes: 105906176 + # Grace period after an upload expires during which the cron:delete-expired-uploads command will not yet delete it. Gives clients a bit of leeway past the expiration date before their partial upload and its S3 chunks are removed. + expiredUploadCanBeDeletedAfterExpirationInSeconds: 3600 + # Name of the S3 bucket used for storage of files. S3StorageBucket: api-storage diff --git a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php index 32e2cb1a..3389208c 100644 --- a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php +++ b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php @@ -20,6 +20,7 @@ class Configuration implements ConfigurationInterface public const int SIZE_OF_10GIB_IN_BYTES = 10 * 1024 * 1024 * 1024; public const int SIZE_OF_101MIB_IN_BYTES = 101 * 1024 * 1024; public const int SIZE_OF_5MIB_IN_BYTES = 5 * 1024 * 1024; + public const int ONE_HOUR_IN_SECONDS = 3600; public function getConfigTreeBuilder(): TreeBuilder { @@ -183,6 +184,11 @@ public function getConfigTreeBuilder(): TreeBuilder ->min(1) ->defaultValue(self::SIZE_OF_101MIB_IN_BYTES) ->end() + ->integerNode(EmberNexusConfiguration::FILE_EXPIRED_UPLOAD_CAN_BE_DELETED_AFTER_EXPIRATION_IN_SECONDS) + ->info('Grace period after an upload expires during which the cron:delete-expired-uploads command will not yet delete it. Gives clients a bit of leeway past the expiration date before their partial upload and its S3 chunks are removed.') + ->min(0) + ->defaultValue(self::ONE_HOUR_IN_SECONDS) + ->end() ->scalarNode(EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET) ->info('Name of the S3 bucket used for storage of files.') ->defaultValue('api-storage') diff --git a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php index b48f0cb7..a5a22edf 100644 --- a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php +++ b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php @@ -51,6 +51,7 @@ class EmberNexusConfiguration public const string FILE_UPLOAD_CHUNK_DIGITS_LENGTH = 'uploadChunkDigitsLength'; public const string FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES = 'uploadMinChunkSizeInBytes'; public const string FILE_UPLOAD_MAX_CHUNK_SIZE_IN_BYTES = 'uploadMaxChunkSizeInBytes'; + public const string FILE_EXPIRED_UPLOAD_CAN_BE_DELETED_AFTER_EXPIRATION_IN_SECONDS = 'expiredUploadCanBeDeletedAfterExpirationInSeconds'; public const string FILE_S3_STORAGE_BUCKET = 'S3StorageBucket'; public const string FILE_S3_UPLOAD_BUCKET = 'S3UploadBucket'; public const string FILE_S3_STORAGE_BUCKET_LEVELS = 'S3StorageBucketLevels'; @@ -82,6 +83,7 @@ class EmberNexusConfiguration private int $fileUploadChunkDigitsLength; private int $fileUploadMinChunkSizeInBytes; private int $fileUploadMaxChunkSizeInBytes; + private int $fileExpiredUploadCanBeDeletedAfterExpirationInSeconds; private string $fileS3StorageBucket; private string $fileS3UploadBucket; private int $fileS3StorageBucketLevels; @@ -324,6 +326,15 @@ public static function createFromConfiguration(array $configuration): static throw new Exception(sprintf('%s.%s can not be smaller than %s.%s.', self::FILE, self::FILE_MAX_FILE_SIZE_IN_BYTES, self::FILE, self::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES)); } + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_EXPIRED_UPLOAD_CAN_BE_DELETED_AFTER_EXPIRATION_IN_SECONDS, + ] + ); + $emberNexusConfiguration->setFileExpiredUploadCanBeDeletedAfterExpirationInSeconds($value); + $value = (string) self::getValueFromConfig( $configuration, [ @@ -666,6 +677,18 @@ public function setFileUploadMaxChunkSizeInBytes(int $fileUploadMaxChunkSizeInBy return $this; } + public function getFileExpiredUploadCanBeDeletedAfterExpirationInSeconds(): int + { + return $this->fileExpiredUploadCanBeDeletedAfterExpirationInSeconds; + } + + public function setFileExpiredUploadCanBeDeletedAfterExpirationInSeconds(int $fileExpiredUploadCanBeDeletedAfterExpirationInSeconds): static + { + $this->fileExpiredUploadCanBeDeletedAfterExpirationInSeconds = $fileExpiredUploadCanBeDeletedAfterExpirationInSeconds; + + return $this; + } + public function getFileS3StorageBucket(): string { return $this->fileS3StorageBucket; diff --git a/src/Command/BackupFetchCommand.php b/src/Command/BackupFetchCommand.php index 5c9f3e66..619ea27b 100644 --- a/src/Command/BackupFetchCommand.php +++ b/src/Command/BackupFetchCommand.php @@ -81,7 +81,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (null === $backupLocation) { throw new Exception('Unable to find the file summary.json in backup archive.'); } - $this->io->writeln(sprintf('Found backup inside ZIP in folder %s.', $backupLocation)); + $this->io->writeln(sprintf('Found backup inside ZIP in folder %s.', '' === $backupLocation ? '/' : $backupLocation)); if (!$filesystem->directoryExists(sprintf('%s/node', $backupLocation))) { throw new Exception('ZIP archive does not contain required node folder.'); } @@ -175,14 +175,18 @@ public function copyFolder(FilesystemOperator $source, string $sourcePath, Files 'dest' => $destination, ]); $manager->createDirectory(sprintf('dest://%s', $destinationPath)); - $listing = $manager->listContents('source://'.$sourcePath, true); + // Flysystem always normalizes stored paths to be free of leading slashes, regardless of whether the + // requested path had one; $normalizedSourcePath must match that normalized form to be strippable below. + $normalizedSourcePath = trim($sourcePath, '/'); + $listing = $manager->listContents(sprintf('source://%s', $normalizedSourcePath), true); $progressBar = $this->io->createProgressBarInInteractiveTerminal(); $progressBar?->start(); /** @var \League\Flysystem\StorageAttributes $item */ foreach ($listing as $item) { $itemPath = $item->path(); $itemName = basename($itemPath); - $itemDir = str_replace(sprintf('source://%s', $sourcePath), '', dirname($itemPath)); + $normalizedItemDir = ltrim(str_replace('source://', '', dirname($itemPath)), '/'); + $itemDir = substr($normalizedItemDir, strlen($normalizedSourcePath)); if ($item->isFile()) { $manager->copy( @@ -222,23 +226,31 @@ public function copyFile(FilesystemOperator $source, string $sourcePath, Filesys /** * @SuppressWarnings("PHPMD.CountInLoopExpression") */ + /** + * A backup archive either contains the `node`/`relation` folders directly at its root (e.g. an archive built + * by `git archive` without a `--prefix`), or wrapped inside a single top level folder (e.g. GitHub's release + * archives, which always wrap their content in a `-/` folder). No deeper nesting is supported. + * + * The returned path never has a trailing slash; the root itself is represented as an empty string, so that + * appending `/node` etc. to it never produces a doubled leading slash. + */ private function findBackupRootFolder(Filesystem $filesystem): ?string { - $stack = ['/']; - while (count($stack) > 0) { - $currentPath = array_shift($stack); - $listing = $filesystem->listContents($currentPath, false); - /** @var \League\Flysystem\StorageAttributes $item */ - foreach ($listing as $item) { - if ($item->isFile() && str_ends_with($item->path(), 'summary.json')) { - return $currentPath; - } - if ($item->isDir()) { - array_push($stack, $item->path()); - } + if ($filesystem->fileExists('summary.json')) { + return ''; + } + + $topLevelDirectories = []; + foreach ($filesystem->listContents('', false) as $item) { + if ($item->isDir()) { + $topLevelDirectories[] = $item->path(); } } + if (1 === count($topLevelDirectories) && $filesystem->fileExists(sprintf('%s/summary.json', $topLevelDirectories[0]))) { + return $topLevelDirectories[0]; + } + return null; } diff --git a/src/Command/Cron/DeleteExpiredUploadsCommand.php b/src/Command/Cron/DeleteExpiredUploadsCommand.php index 44c8c66f..cff63b48 100755 --- a/src/Command/Cron/DeleteExpiredUploadsCommand.php +++ b/src/Command/Cron/DeleteExpiredUploadsCommand.php @@ -4,14 +4,23 @@ namespace App\Command\Cron; +use App\Factory\Exception\Server500LogicErrorExceptionFactory; +use App\Factory\Type\UploadFactory; +use App\Service\ElementManager; +use App\Service\UploadService; use App\Style\EmberNexusStyle; +use DateInterval; +use EmberNexusBundle\Service\EmberNexusConfiguration; +use Laudis\Neo4j\Databags\Statement; use LogicException; +use Ramsey\Uuid\Uuid; +use Safe\DateTime; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\OutputStyle; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; /** * @psalm-suppress PropertyNotSetInConstructor $io @@ -19,10 +28,16 @@ #[AsCommand(name: 'cron:delete-expired-uploads', description: 'Deletes uploads which were not completed during their lifetime.')] class DeleteExpiredUploadsCommand extends Command { - private OutputStyle $io; + private EmberNexusStyle $io; public function __construct( private ParameterBagInterface $bag, + private EmberNexusConfiguration $emberNexusConfiguration, + private CypherEntityManager $cypherEntityManager, + private ElementManager $elementManager, + private UploadFactory $uploadFactory, + private UploadService $uploadService, + private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { parent::__construct(); } @@ -33,11 +48,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->title('Cron'); - $this->io->writeln('This command is currently a placeholder.'); - $this->io->newLine(); - - // todo: implement command, see https://github.com/ember-nexus/api/issues/441 - $isCronDisabled = $this->bag->get('isCronDisabled'); if (!is_bool($isCronDisabled)) { throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); @@ -48,8 +58,74 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - $this->io->finalMessage('Finished.'); + $expiredUploadIds = $this->getExpiredUploadIds(); + + if (0 === count($expiredUploadIds)) { + $this->io->writeln(' No expired uploads found.'); + $this->io->newLine(); + $this->io->finalMessage('Finished.'); + + return Command::SUCCESS; + } + + $this->io->writeln(sprintf( + ' Found %d expired upload(s), deleting them...', + count($expiredUploadIds) + )); + + foreach ($expiredUploadIds as $expiredUploadId) { + $this->deleteExpiredUpload($expiredUploadId); + } + + $this->io->newLine(); + $this->io->finalMessage(sprintf('Deleted %d expired upload(s).', count($expiredUploadIds))); return Command::SUCCESS; } + + /** + * @return string[] + */ + private function getExpiredUploadIds(): array + { + $gracePeriodInSeconds = $this->emberNexusConfiguration->getFileExpiredUploadCanBeDeletedAfterExpirationInSeconds(); + $deletionThreshold = (new DateTime())->sub(new DateInterval(sprintf('PT%sS', $gracePeriodInSeconds))); + + $queryResult = $this->cypherEntityManager->getClient()->runStatement(new Statement( + 'MATCH (u:Upload) WHERE u.expires < $deletionThreshold RETURN u.id', + [ + 'deletionThreshold' => $deletionThreshold, + ] + )); + + $expiredUploadIds = []; + foreach ($queryResult as $queryResultLine) { + $expiredUploadId = $queryResultLine['u.id']; + if (!is_string($expiredUploadId)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property u.id as string, not %s.', get_debug_type($expiredUploadId))); // @codeCoverageIgnore + } + $expiredUploadIds[] = $expiredUploadId; + } + + return $expiredUploadIds; + } + + private function deleteExpiredUpload(string $uploadId): void + { + $uploadElement = $this->elementManager->getElement(Uuid::fromString($uploadId)); + if (null === $uploadElement) { + // upload was already deleted in the meantime, nothing left to do + return; + } + + $upload = $this->uploadFactory->createUploadFromElement($uploadElement); + + $this->uploadService->deleteUploadAndChunks($upload); + $this->elementManager->flush(); + + $this->io->writeln(sprintf( + ' Deleted expired upload %s.', + $upload->getId()->toString() + )); + } } diff --git a/src/Command/Cron/ReindexFilesCommand.php b/src/Command/Cron/ReindexFilesCommand.php index 9289f2f5..701e57ef 100755 --- a/src/Command/Cron/ReindexFilesCommand.php +++ b/src/Command/Cron/ReindexFilesCommand.php @@ -4,13 +4,16 @@ namespace App\Command\Cron; +use App\Service\ElementManager; +use App\Service\QueueService; use App\Style\EmberNexusStyle; +use App\Type\RabbitMQQueueType; use LogicException; +use Ramsey\Uuid\Uuid; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\OutputStyle; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; /** @@ -19,10 +22,12 @@ #[AsCommand(name: 'cron:reindex-files', description: 'Updates element files within Elasticsearch. Restricted to files marked as updated.')] class ReindexFilesCommand extends Command { - private OutputStyle $io; + private EmberNexusStyle $io; public function __construct( private ParameterBagInterface $bag, + private QueueService $queueService, + private ElementManager $elementManager, ) { parent::__construct(); } @@ -33,11 +38,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->title('Cron'); - $this->io->writeln('This command is currently a placeholder.'); - $this->io->newLine(); - - // todo: implement command - $isCronDisabled = $this->bag->get('isCronDisabled'); if (!is_bool($isCronDisabled)) { throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); @@ -48,8 +48,40 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - $this->io->finalMessage('Finished.'); + $this->io->writeln(' Reindexing files marked as updated...'); + + $reindexedElementCount = $this->queueService->consumeQueue( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + function (array $eventData): void { + $this->reindexElementFile($eventData); + } + ); + + $this->io->newLine(); + $this->io->finalMessage(sprintf('Reindexed %d element file(s).', $reindexedElementCount)); return Command::SUCCESS; } + + /** + * @param array $eventData + */ + private function reindexElementFile(array $eventData): void + { + $elementId = Uuid::fromString($eventData['elementId']); + $element = $this->elementManager->getElement($elementId); + if (null === $element) { + // element was already deleted in the meantime, nothing left to reindex + return; + } + + // re-merging the element resyncs all of its properties, including its `file` metadata, into Elasticsearch + $this->elementManager->merge($element); + $this->elementManager->flush(); + + $this->io->writeln(sprintf( + ' Reindexed file of element %s.', + $elementId->toString() + )); + } } diff --git a/src/Command/CronCommand.php b/src/Command/CronCommand.php index aa821341..a0eeb9aa 100755 --- a/src/Command/CronCommand.php +++ b/src/Command/CronCommand.php @@ -4,10 +4,13 @@ namespace App\Command; +use App\Command\Cron\DeleteExpiredUploadsCommand; +use App\Command\Cron\ReindexFilesCommand; use App\Style\EmberNexusStyle; use LogicException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\OutputStyle; @@ -23,6 +26,8 @@ class CronCommand extends Command public function __construct( private ParameterBagInterface $bag, + private DeleteExpiredUploadsCommand $deleteExpiredUploadsCommand, + private ReindexFilesCommand $reindexFilesCommand, ) { parent::__construct(); } @@ -33,9 +38,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->title('Cron'); - $this->io->writeln('This command is currently a placeholder.'); - $this->io->newLine(); - $isCronDisabled = $this->bag->get('isCronDisabled'); if (!is_bool($isCronDisabled)) { throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); @@ -46,6 +48,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + // Note: cron:update-ownership is intentionally not dispatched here yet, see + // https://github.com/ember-nexus/api/issues/438. + + $this->deleteExpiredUploadsCommand->run(new ArrayInput([]), $output); + $this->reindexFilesCommand->run(new ArrayInput([]), $output); + $this->io->finalMessage('Finished.'); return Command::SUCCESS; diff --git a/src/Command/DatabaseDropCommand.php b/src/Command/DatabaseDropCommand.php index a63f3ab9..ac6f90de 100755 --- a/src/Command/DatabaseDropCommand.php +++ b/src/Command/DatabaseDropCommand.php @@ -52,6 +52,13 @@ public function configure(): void 'If enabled, command will not ask for manual confirmation.', false ); + $this->addOption( + 'no-files', + null, + InputOption::VALUE_NEGATABLE, + 'Disable deletion of object storage (S3) data, i.e. keep the storage and upload buckets untouched.', + false + ); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -78,7 +85,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->deleteMongo(); - $this->deleteObjectStorage(); + if ($input->getOption('no-files')) { + $this->io->startSection('Task 3 of 6: Object Storage'); + $this->io->writeln('Skipping object storage deletion (--no-files).'); + $this->io->stopSection('Skipped object storage deletion.'); + } else { + $this->deleteObjectStorage(); + } $this->deleteElastic(); @@ -116,9 +129,16 @@ private function deleteObjectStorage(): void { $this->io->startSection('Task 3 of 6: Object Storage'); $this->io->writeln('Deleting Object data...'); + $this->deleteAllObjectsFromBucket($this->emberNexusConfiguration->getFileS3StorageBucket()); + $this->deleteAllObjectsFromBucket($this->emberNexusConfiguration->getFileS3UploadBucket()); + $this->io->stopSection('Successfully deleted object storage.'); + } + + private function deleteAllObjectsFromBucket(string $bucket): void + { do { $objects = $this->s3Client->listObjectsV2([ - 'Bucket' => $this->emberNexusConfiguration->getFileS3StorageBucket(), + 'Bucket' => $bucket, ]); $keyCount = $objects->getKeyCount(); if ($keyCount > 0) { @@ -129,14 +149,13 @@ private function deleteObjectStorage(): void ]; } $this->s3Client->deleteObjects([ - 'Bucket' => $this->emberNexusConfiguration->getFileS3StorageBucket(), + 'Bucket' => $bucket, 'Delete' => [ 'Objects' => $objectsToBeDeleted, ], ]); } } while ($keyCount > 0); - $this->io->stopSection('Successfully deleted object storage.'); } private function deleteElastic(): void diff --git a/src/Contract/S3/S3TechnicalLimitsInterface.php b/src/Contract/S3/S3TechnicalLimitsInterface.php new file mode 100644 index 00000000..52eedd10 --- /dev/null +++ b/src/Contract/S3/S3TechnicalLimitsInterface.php @@ -0,0 +1,28 @@ +elementManager->getElementOrFail($elementId); + + // deleted, and flushed, as its own separate step before the element itself - see + // UploadService::deleteUploadsTargeting() for why this can not be done via an event listener instead + $this->uploadService->deleteUploadsTargeting($elementId); + $this->elementManager->flush(); + $this->elementManager->delete($element); $this->elementManager->flush(); diff --git a/src/Controller/Error/assets/error-416-range-not-satisfiable.txt b/src/Controller/Error/assets/error-416-range-not-satisfiable.txt new file mode 100644 index 00000000..104c1a3c --- /dev/null +++ b/src/Controller/Error/assets/error-416-range-not-satisfiable.txt @@ -0,0 +1,9 @@ + Error 416 - Range Not Satisfiable + + The HTTP 416 Range Not Satisfiable client error response status code indicates that a server can not serve the + requested ranges. The most likely reason is that the document does not contain such ranges, or that the range + header is invalid. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 416 error code: https://http.dev/416 diff --git a/src/Controller/File/DeleteElementFileController.php b/src/Controller/File/DeleteElementFileController.php index e12fd80d..97195518 100644 --- a/src/Controller/File/DeleteElementFileController.php +++ b/src/Controller/File/DeleteElementFileController.php @@ -61,6 +61,7 @@ public function deleteElementFile(string $id): Response $this->s3Service->deleteFile($deleteFileOperation); $element->addProperty('file', null); + $element->addProperty('hasFile', false); $this->elementManager->merge($element); $this->elementManager->flush(); diff --git a/src/Controller/File/GetElementFileController.php b/src/Controller/File/GetElementFileController.php index ff6f9ec8..124c465f 100644 --- a/src/Controller/File/GetElementFileController.php +++ b/src/Controller/File/GetElementFileController.php @@ -5,20 +5,26 @@ namespace App\Controller\File; use App\Attribute\EndpointSupportsEtag; +use App\Contract\NodeElementInterface; +use App\Contract\RelationElementInterface; use App\Factory\Exception\Client404NotFoundExceptionFactory; use App\Factory\Type\S3\FileOperationFactory; use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; +use App\Service\DigestService; use App\Service\ElementManager; use App\Service\ElementService; +use App\Service\FileRangeService; use App\Service\FileService; use App\Service\S3Service; use App\Type\AccessType; use App\Type\EtagType; use App\Type\Response\BinaryStreamResponse; +use ArrayAccess; use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; class GetElementFileController extends AbstractController @@ -31,6 +37,8 @@ public function __construct( private FileService $fileService, private FileOperationFactory $fileOperationFactory, private S3Service $s3Service, + private FileRangeService $fileRangeService, + private DigestService $digestService, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -44,7 +52,7 @@ public function __construct( methods: ['GET'] )] #[EndpointSupportsEtag(EtagType::FILE)] - public function getElementFile(string $id): BinaryStreamResponse + public function getElementFile(string $id, Request $request): BinaryStreamResponse { $elementId = UuidV4::fromString($id); $userId = $this->authProvider->getUserId(); @@ -65,8 +73,60 @@ public function getElementFile(string $id): BinaryStreamResponse throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } + $reprDigestHeaderValue = $this->getReprDigestHeaderValue($element); + $contentType = $this->getStoredContentType($element); + + $rangeHeader = $request->headers->get('Range'); + if (null !== $rangeHeader) { + $totalContentLength = $this->s3Service->getContentLength($fileOperation); + $range = $this->fileRangeService->parseRangeHeader($rangeHeader, $totalContentLength); + $object = $this->s3Service->getFileByteRange($fileOperation, $range->getStart(), $range->getEnd()); + + return new BinaryStreamResponse($object, $fileName, $fileNameFallback, $range, $reprDigestHeaderValue, $contentType); + } + $object = $this->s3Service->getFile($fileOperation); - return new BinaryStreamResponse($object, $fileName, $fileNameFallback); + return new BinaryStreamResponse($object, $fileName, $fileNameFallback, reprDigestHeaderValue: $reprDigestHeaderValue, contentType: $contentType); + } + + private function getStoredContentType(NodeElementInterface|RelationElementInterface $element): ?string + { + if (!$element->hasProperty('file')) { + return null; + } + $fileProperty = $element->getProperty('file'); + if (!is_array($fileProperty) && !($fileProperty instanceof ArrayAccess)) { + return null; + } + $mimeType = $fileProperty['mimeType'] ?? null; + if (!is_string($mimeType) || '' === $mimeType) { + return null; + } + + return $mimeType; + } + + private function getReprDigestHeaderValue(NodeElementInterface|RelationElementInterface $element): ?string + { + if (!$element->hasProperty('file')) { + return null; + } + $fileProperty = $element->getProperty('file'); + // 'file' is read back from MongoDB; nested values (like 'hash') may still be BSONDocument/ArrayAccess + // instances rather than plain arrays at this point, so array-offset access is used instead of is_array(). + if (!is_array($fileProperty) && !($fileProperty instanceof ArrayAccess)) { + return null; + } + $hash = $fileProperty['hash'] ?? null; + if (!is_array($hash) && !($hash instanceof ArrayAccess)) { + return null; + } + $sha256 = $hash['sha256'] ?? null; + if (!is_string($sha256) || 64 !== strlen($sha256)) { + return null; + } + + return $this->digestService->formatDigestHeaderValue($sha256); } } diff --git a/src/Controller/Upload/DeleteUploadController.php b/src/Controller/Upload/DeleteUploadController.php index 4860ea16..177cb454 100644 --- a/src/Controller/Upload/DeleteUploadController.php +++ b/src/Controller/Upload/DeleteUploadController.php @@ -5,12 +5,11 @@ namespace App\Controller\Upload; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Factory\Type\S3\FileOperationFactory; use App\Factory\Type\UploadFactory; use App\Helper\Regex; use App\Security\AuthProvider; use App\Service\ElementManager; -use App\Service\S3Service; +use App\Service\UploadService; use App\Type\Response\NoContentResponse; use Exception; use Psr\Log\LoggerInterface; @@ -24,9 +23,8 @@ public function __construct( private AuthProvider $authProvider, private ElementManager $elementManager, private LoggerInterface $logger, - private S3Service $s3Service, - private FileOperationFactory $fileOperationFactory, private UploadFactory $uploadFactory, + private UploadService $uploadService, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -60,12 +58,7 @@ public function deleteUpload(string $id): NoContentResponse ] ); - for ($i = 0; $i <= $upload->getAlreadyUploadedChunks(); ++$i) { - $deleteChunkOperation = $this->fileOperationFactory->createFileOperationFromUpload($upload, $i); - $this->s3Service->deleteFile($deleteChunkOperation); - } - - $this->elementManager->delete($uploadElement); + $this->uploadService->deleteUploadAndChunks($upload); $this->elementManager->flush(); return new NoContentResponse(); diff --git a/src/Controller/Upload/HeadUploadController.php b/src/Controller/Upload/HeadUploadController.php index 4e9f887e..120fba8c 100644 --- a/src/Controller/Upload/HeadUploadController.php +++ b/src/Controller/Upload/HeadUploadController.php @@ -8,8 +8,10 @@ use App\Factory\Type\Response\NoContentResponseFactory; use App\Factory\Type\UploadFactory; use App\Helper\Regex; +use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\ElementManager; +use App\Type\AccessType; use Exception; use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -20,6 +22,7 @@ class HeadUploadController extends AbstractController { public function __construct( private AuthProvider $authProvider, + private AccessChecker $accessChecker, private ElementManager $elementManager, private NoContentResponseFactory $noContentResponseFactory, private UploadFactory $uploadFactory, @@ -44,7 +47,13 @@ public function headUpload(string $id): Response throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } - if ($upload->getUploadOwner()->toString() !== $this->authProvider->getUserId()->toString()) { + $userId = $this->authProvider->getUserId(); + if ($upload->getUploadOwner()->toString() !== $userId->toString()) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + // an upload whose target element is gone (e.g. deleted mid-upload) or no longer accessible can never be + // completed - report it the same way PATCH already would, instead of an upload that looks healthy + if (!$this->accessChecker->hasAccessToElement($userId, $upload->getUploadTarget(), AccessType::UPDATE)) { throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } diff --git a/src/Controller/Upload/PatchUploadController.php b/src/Controller/Upload/PatchUploadController.php index dc604204..47efdc1c 100644 --- a/src/Controller/Upload/PatchUploadController.php +++ b/src/Controller/Upload/PatchUploadController.php @@ -6,6 +6,8 @@ use App\Contract\UploadInterface; use App\EventSystem\ElementFileReplace\Event\ElementFileReplaceEvent; +use App\Exception\Client400BadContentException; +use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client404NotFoundExceptionFactory; use App\Factory\Exception\Client409ConflictExceptionFactory; use App\Factory\Exception\Client410GoneExceptionFactory; @@ -17,11 +19,15 @@ use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; +use App\Service\DigestService; use App\Service\ElementManager; +use App\Service\FileHashService; +use App\Service\IncrementalHashService; use App\Service\S3Service; use App\Service\UploadService; use App\Type\AccessType; use App\Type\Response\JsonResponse; +use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; use Ramsey\Uuid\Rfc4122\UuidV4; use Safe\DateTime; @@ -42,6 +48,7 @@ public function __construct( private AuthProvider $authProvider, private AccessChecker $accessChecker, private ElementManager $elementManager, + private EmberNexusConfiguration $emberNexusConfiguration, private EventDispatcherInterface $eventDispatcher, private PartialUploadRequestFactory $partialUploadRequestFactory, private NoContentResponseFactory $noContentResponseFactory, @@ -50,6 +57,9 @@ public function __construct( private UploadFileChunkOperationFactory $uploadFileChunkOperationFactory, private MergeFileChunksOperationFactory $mergeFileChunksOperationFactory, private S3Service $s3Service, + private IncrementalHashService $incrementalHashService, + private DigestService $digestService, + private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, private Client409ConflictExceptionFactory $client409ConflictExceptionFactory, private Client410GoneExceptionFactory $client410GoneExceptionFactory, @@ -77,7 +87,6 @@ public function patchUpload(string $id, Request $request): Response if ($upload->getUploadOwner()->toString() !== $userId->toString()) { throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } - // verify that user has still update access to upload target if (!$this->accessChecker->hasAccessToElement($userId, $upload->getUploadTarget(), AccessType::UPDATE)) { throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } @@ -92,8 +101,31 @@ public function patchUpload(string $id, Request $request): Response throw $this->client409ConflictExceptionFactory->createFromDetail('Offset from request does not match offset of resource.', additionalDetails: ['expected-offset' => $upload->getUploadOffset(), 'provided-offset' => $partialUploadRequest->getUploadOffset()]); } + // the chunk's resource is hashed once, locally, then rewound, before S3Service ever sees it - see + // IncrementalHashService for why this is not done via a persistently-attached stream filter instead. + $resource = $partialUploadRequest->getContent(); + $hashContext = null !== $upload->getHashState() + ? $this->incrementalHashService->unserializeContextFromStorage($upload->getHashState()) + : $this->incrementalHashService->createContext(FileHashService::ALGORITHM); + $this->incrementalHashService->updateFromResource($hashContext, $resource); + $uploadFileChunkOperation = $this->uploadFileChunkOperationFactory->createUploadFileChunkOperationFromPartialUploadRequest($partialUploadRequest, $upload); $chunkLength = $this->s3Service->uploadFileChunk($uploadFileChunkOperation); + if (is_resource($resource)) { + \Safe\fclose($resource); + } + + /** + * mirrors the same check for an upload's very first chunk (see UploadCreationService): a chunk has to be + * at least bytes long, unless it is the chunk which completes the upload, which may be of any + * length - including zero, e.g. to just close out an upload that already has all its data. + */ + if (false === $partialUploadRequest->isUploadComplete() && $chunkLength < $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at least %d bytes long, got %d.', $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes(), $chunkLength)); + } + if ($chunkLength > $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at most %d bytes long, got %d.', $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(), $chunkLength)); + } if (null !== $upload->getUploadLength()) { if ($upload->getUploadLength() < $upload->getUploadOffset() + $chunkLength) { @@ -101,14 +133,19 @@ public function patchUpload(string $id, Request $request): Response } } - $upload = $this->uploadFactory->addNewChunkToUpload($upload, $chunkLength); - $this->uploadService->mergeUploadElement($upload); - if ($partialUploadRequest->isUploadComplete()) { - // the final chunk was successfully uploaded -> we can create the file + // the final chunk was successfully uploaded -> we can create the file. The hash is already complete + // at this point - no need to store its (now finalized, no longer resumable) state on the upload. + $finalHash = $this->incrementalHashService->finalize($hashContext); + $upload = $this->uploadFactory->addNewChunkToUpload($upload, $chunkLength); $upload = $this->uploadFactory->markUploadAsComplete($upload); $this->uploadService->mergeUploadElement($upload); - $this->createFile($upload); + $requestDigestHeaderValue = $request->headers->get('Repr-Digest') ?? $request->headers->get('Content-Digest'); + $this->createFile($upload, $finalHash, $requestDigestHeaderValue); + } else { + $hashState = $this->incrementalHashService->serializeContextForStorage($hashContext); + $upload = $this->uploadFactory->addNewChunkToUpload($upload, $chunkLength, $hashState); + $this->uploadService->mergeUploadElement($upload); } $this->elementManager->flush(); @@ -116,17 +153,38 @@ public function patchUpload(string $id, Request $request): Response return $this->noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload); } - public function createFile(UploadInterface $upload): Response + public function createFile(UploadInterface $upload, string $hash, ?string $requestDigestHeaderValue = null): Response { + $element = $this->elementManager->getElementOrFail($upload->getUploadTarget()); + + // building the operation only resolves storage keys, it does not touch S3 - so the hash, already known + // from the incremental chunk hashing above, can be verified before the chunks are merged into the + // storage bucket. On a mismatch, this means an existing file at this element is never + // replaced/overwritten in the first place; only the now-unneeded uploaded chunks need cleaning up. $mergeFileChunksOperation = $this->mergeFileChunksOperationFactory->createMergeFileOperationFromUpload($upload); - $mergedContentLength = $this->s3Service->mergeFileChunks($mergeFileChunksOperation); - $element = $this->elementManager->getElementOrFail($upload->getUploadTarget()); + if (null !== $requestDigestHeaderValue) { + try { + $this->verifyRequestDigest($requestDigestHeaderValue, $hash); + } catch (Client400BadContentException $exception) { + $this->s3Service->deleteFileChunks($mergeFileChunksOperation); + + throw $exception; + } + } + + $mergedContentLength = $this->s3Service->mergeFileChunks($mergeFileChunksOperation); + $mergedMimeType = $this->s3Service->getMimeTypeFromMergeFileChunksOperation($mergeFileChunksOperation); $element->addProperty('file', [ 'contentLength' => $mergedContentLength, 'extension' => $upload->getExtension(), + 'mimeType' => $mergedMimeType, + 'hash' => [ + FileHashService::ALGORITHM => $hash, + ], ]); + $element->addProperty('hasFile', true); $this->elementManager->merge($element); $this->elementManager->flush(); @@ -140,4 +198,15 @@ public function createFile(UploadInterface $upload): Response 'upload' => 'complete', ]); } + + private function verifyRequestDigest(string $requestDigestHeaderValue, string $actualHash): void + { + $expectedHash = $this->digestService->parseSha256HexFromHeaderValue($requestDigestHeaderValue); + if (null === $expectedHash) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not verify upload: 'Repr-Digest'/'Content-Digest' header '%s' does not declare a supported digest algorithm; only 'sha-256' is supported.", $requestDigestHeaderValue)); + } + if ($expectedHash !== $actualHash) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Could not verify upload: the declared digest does not match the uploaded file\'s actual content.'); + } + } } diff --git a/src/EventSystem/ElementDefragmentize/Event/NodeElementDefragmentizeEvent.php b/src/EventSystem/ElementDefragmentize/Event/NodeElementDefragmentizeEvent.php index 12151332..62f52806 100644 --- a/src/EventSystem/ElementDefragmentize/Event/NodeElementDefragmentizeEvent.php +++ b/src/EventSystem/ElementDefragmentize/Event/NodeElementDefragmentizeEvent.php @@ -18,7 +18,6 @@ public function __construct( private NodeElementInterface $nodeElement, private NodeInterface $cypherFragment, private ?DocumentInterface $documentFragment, - private mixed $fileFragment = null, ) { } @@ -36,9 +35,4 @@ public function getDocumentFragment(): ?DocumentInterface { return $this->documentFragment; } - - public function getFileFragment(): mixed - { - return $this->fileFragment; - } } diff --git a/src/EventSystem/ElementDefragmentize/Event/RelationElementDefragmentizeEvent.php b/src/EventSystem/ElementDefragmentize/Event/RelationElementDefragmentizeEvent.php index 7b5a5862..c3704b87 100644 --- a/src/EventSystem/ElementDefragmentize/Event/RelationElementDefragmentizeEvent.php +++ b/src/EventSystem/ElementDefragmentize/Event/RelationElementDefragmentizeEvent.php @@ -18,7 +18,6 @@ public function __construct( private RelationElementInterface $relationElement, private RelationInterface $cypherFragment, private ?DocumentInterface $documentFragment, - private mixed $fileFragment = null, ) { } @@ -36,9 +35,4 @@ public function getDocumentFragment(): ?DocumentInterface { return $this->documentFragment; } - - public function getFileFragment(): mixed - { - return $this->fileFragment; - } } diff --git a/src/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEvent.php b/src/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEvent.php index eae52b3e..9a1ea556 100644 --- a/src/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEvent.php +++ b/src/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEvent.php @@ -20,7 +20,6 @@ public function __construct( private NodeInterface $cypherFragment, private MongoDocumentInterface $mongoFragment, private ElasticDocumentInterface $elasticFragment, - private mixed $fileFragment, ) { } @@ -43,9 +42,4 @@ public function getElasticFragment(): ElasticDocumentInterface { return $this->elasticFragment; } - - public function getFileFragment(): mixed - { - return $this->fileFragment; - } } diff --git a/src/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEvent.php b/src/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEvent.php index 6203d6d3..c37a2907 100644 --- a/src/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEvent.php +++ b/src/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEvent.php @@ -20,7 +20,6 @@ public function __construct( private RelationInterface $cypherFragment, private MongoDocumentInterface $mongoFragment, private ElasticDocumentInterface $elasticFragment, - private mixed $fileFragment, ) { } @@ -43,9 +42,4 @@ public function getElasticFragment(): ElasticDocumentInterface { return $this->elasticFragment; } - - public function getFileFragment(): mixed - { - return $this->fileFragment; - } } diff --git a/src/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListener.php b/src/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListener.php new file mode 100644 index 00000000..d10a82fc --- /dev/null +++ b/src/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListener.php @@ -0,0 +1,51 @@ +handleEvent($event); + } + + #[AsEventListener] + public function onRelationElementFragmentizeEvent(RelationElementFragmentizeEvent $event): void + { + $this->handleEvent($event); + } + + private function handleEvent(NodeElementFragmentizeEvent|RelationElementFragmentizeEvent $event): void + { + $cypherFragment = $event->getCypherFragment(); + $mongoFragment = $event->getMongoFragment(); + $elasticFragment = $event->getElasticFragment(); + if ($event instanceof NodeElementFragmentizeEvent) { + $element = $event->getNodeElement(); + } else { + $element = $event->getRelationElement(); + } + if ($element->hasProperty('hasFile')) { + $cypherFragment->addProperty('hasFile', $element->getProperty('hasFile')); + $mongoFragment->addProperty('hasFile', $element->getProperty('hasFile')); + $elasticFragment->addProperty('hasFile', $element->getProperty('hasFile')); + } + } +} diff --git a/src/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListener.php b/src/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListener.php new file mode 100644 index 00000000..e87e1cff --- /dev/null +++ b/src/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListener.php @@ -0,0 +1,31 @@ +getChangedProperties())) { + return; + } + throw $this->client400ForbiddenPropertyExceptionFactory->createFromTemplate('file'); + } +} diff --git a/src/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListener.php b/src/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListener.php new file mode 100644 index 00000000..cdf57b9c --- /dev/null +++ b/src/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListener.php @@ -0,0 +1,29 @@ +getChangedProperties())) { + return; + } + throw $this->client400ForbiddenPropertyExceptionFactory->createFromTemplate('hasFile'); + } +} diff --git a/src/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListener.php b/src/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListener.php new file mode 100644 index 00000000..71434bcd --- /dev/null +++ b/src/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListener.php @@ -0,0 +1,21 @@ +addPropertyNameToBeKept('file'); + } +} diff --git a/src/EventSystem/ElementPropertyReset/EventListener/HasFileElementPropertyResetEventListener.php b/src/EventSystem/ElementPropertyReset/EventListener/HasFileElementPropertyResetEventListener.php new file mode 100644 index 00000000..0b131738 --- /dev/null +++ b/src/EventSystem/ElementPropertyReset/EventListener/HasFileElementPropertyResetEventListener.php @@ -0,0 +1,17 @@ +addPropertyNameToBeKept('hasFile'); + } +} diff --git a/src/EventSystem/SearchStep/EventListener/ElementHydrationSearchStepEventListener.php b/src/EventSystem/SearchStep/EventListener/ElementHydrationSearchStepEventListener.php index 16541173..eb3e4b63 100644 --- a/src/EventSystem/SearchStep/EventListener/ElementHydrationSearchStepEventListener.php +++ b/src/EventSystem/SearchStep/EventListener/ElementHydrationSearchStepEventListener.php @@ -255,9 +255,7 @@ private function getElementData(array $elementIds): array foreach ($elementIds as $elementId) { $element = $this->elementManager->getElement($elementId); if ($element) { - $elementData[] = $this->elementToRawService->elementToRaw( - $element - ); + $elementData[] = $this->elementToRawService->elementToRaw($element); } } diff --git a/src/Exception/Client416RangeNotSatisfiableException.php b/src/Exception/Client416RangeNotSatisfiableException.php new file mode 100644 index 00000000..73840eaf --- /dev/null +++ b/src/Exception/Client416RangeNotSatisfiableException.php @@ -0,0 +1,25 @@ + $additionalDetails + */ + public function __construct( + string $type, + string $title = 'Range Not Satisfiable', + int $status = 416, + string $detail = 'The requested range can not be satisfied for the target resource.', + ?string $instance = null, + ?Throwable $previous = null, + array $additionalDetails = [], + ) { + parent::__construct($type, $title, $status, $detail, $instance, $previous, $additionalDetails); + } +} diff --git a/src/Factory/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventFactory.php b/src/Factory/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventFactory.php index 37598fbf..bdcb53ba 100644 --- a/src/Factory/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventFactory.php +++ b/src/Factory/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventFactory.php @@ -18,8 +18,7 @@ public function createNodeElementFragmentizeEvent(NodeElementInterface $nodeElem $nodeElement, new Node(), new MongoDocument(), - new ElasticDocument(), - null + new ElasticDocument() ); } } diff --git a/src/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactory.php b/src/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactory.php index a3aa1d99..2720122f 100644 --- a/src/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactory.php +++ b/src/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactory.php @@ -18,8 +18,7 @@ public function createRelationElementFragmentizeEvent(RelationElementInterface $ $relationElement, new Relation(), new MongoDocument(), - new ElasticDocument(), - null + new ElasticDocument() ); } } diff --git a/src/Factory/Exception/Client416RangeNotSatisfiableExceptionFactory.php b/src/Factory/Exception/Client416RangeNotSatisfiableExceptionFactory.php new file mode 100644 index 00000000..c46c1be7 --- /dev/null +++ b/src/Factory/Exception/Client416RangeNotSatisfiableExceptionFactory.php @@ -0,0 +1,35 @@ + $additionalDetails + */ + public function createFromDetail(string $detail, array $additionalDetails = []): Client416RangeNotSatisfiableException + { + return new Client416RangeNotSatisfiableException( + $this->urlGenerator->generate( + 'exception-detail', + [ + 'code' => '416', + 'name' => 'range-not-satisfiable', + ], + UrlGeneratorInterface::ABSOLUTE_URL + ), + detail: $detail, + additionalDetails: $additionalDetails + ); + } +} diff --git a/src/Factory/Type/UploadFactory.php b/src/Factory/Type/UploadFactory.php index 8cadc871..c53d023a 100644 --- a/src/Factory/Type/UploadFactory.php +++ b/src/Factory/Type/UploadFactory.php @@ -45,7 +45,8 @@ public function createUploadFromElement(NodeElementInterface|RelationElementInte $this->propertyParseService->getAlreadyUploadedChunksFromProperties($properties), $this->propertyParseService->getUploadOwnerFromProperties($properties), $this->propertyParseService->getExtensionFromProperties($properties), - $this->propertyParseService->getExpiresFromProperties($properties) + $this->propertyParseService->getExpiresFromProperties($properties), + $this->propertyParseService->getHashStateFromProperties($properties) ); } @@ -60,11 +61,12 @@ public function markUploadAsComplete(UploadInterface $upload): UploadInterface $upload->getAlreadyUploadedChunks(), $upload->getUploadOwner(), $upload->getExtension(), - $upload->getExpires() + $upload->getExpires(), + $upload->getHashState() ); } - public function addNewChunkToUpload(UploadInterface $upload, int $chunkLength): UploadInterface + public function addNewChunkToUpload(UploadInterface $upload, int $chunkLength, ?string $hashState = null): UploadInterface { return new Upload( $upload->getId(), @@ -75,7 +77,8 @@ public function addNewChunkToUpload(UploadInterface $upload, int $chunkLength): $upload->getAlreadyUploadedChunks() + 1, $upload->getUploadOwner(), $upload->getExtension(), - $upload->getExpires() + $upload->getExpires(), + $hashState ?? $upload->getHashState() ); } } diff --git a/src/Helper/FragmentHelper.php b/src/Helper/FragmentHelper.php index 6dbb8ff8..d4bddbd9 100644 --- a/src/Helper/FragmentHelper.php +++ b/src/Helper/FragmentHelper.php @@ -15,8 +15,7 @@ public static function getFragmentGroupFromFragmentizeEvent(NodeElementFragmenti return new FragmentGroup( $event->getCypherFragment(), $event->getMongoFragment(), - $event->getElasticFragment(), - $event->getFileFragment() + $event->getElasticFragment() ); } } diff --git a/src/Service/CollectionService.php b/src/Service/CollectionService.php index 0e259feb..3a91a1b7 100755 --- a/src/Service/CollectionService.php +++ b/src/Service/CollectionService.php @@ -147,17 +147,13 @@ public function buildCollectionFromIds( foreach ($nodeIds as $nodeId) { $nodeElement = $this->elementManager->getNode($nodeId); if ($nodeElement) { - $nodeData[] = $this->elementToRawService->elementToRaw( - $nodeElement - ); + $nodeData[] = $this->elementToRawService->elementToRaw($nodeElement); } } foreach ($relationIds as $relationId) { $relationElement = $this->elementManager->getRelation($relationId); if ($relationElement) { - $relationData[] = $this->elementToRawService->elementToRaw( - $relationElement - ); + $relationData[] = $this->elementToRawService->elementToRaw($relationElement); } } @@ -201,9 +197,7 @@ public function buildElementCollectionFromIds( foreach ($elementIds as $elementId) { $element = $this->elementManager->getElement($elementId); if ($element) { - $elementData[] = $this->elementToRawService->elementToRaw( - $element - ); + $elementData[] = $this->elementToRawService->elementToRaw($element); } } diff --git a/src/Service/DigestService.php b/src/Service/DigestService.php new file mode 100644 index 00000000..bbd97d46 --- /dev/null +++ b/src/Service/DigestService.php @@ -0,0 +1,51 @@ +=::` members; only that single + * member is looked for, other (unsupported) algorithm members in the same header are ignored. + */ +class DigestService +{ + public const string ALGORITHM_LABEL = 'sha-256'; + + public function formatDigestHeaderValue(string $hexHash): string + { + return sprintf('%s=:%s:', self::ALGORITHM_LABEL, base64_encode(\Safe\hex2bin($hexHash))); + } + + /** + * Returns the hex-encoded sha-256 hash declared in the header value, or null if the header does not declare a + * usable sha-256 member (either only unsupported algorithms were offered, or the sha-256 value is malformed). + */ + public function parseSha256HexFromHeaderValue(string $headerValue): ?string + { + if (1 !== \Safe\preg_match('/(?:^|,)\s*sha-256=:([A-Za-z0-9+\/=]*):/', $headerValue, $matches)) { + return null; + } + + $base64Value = $matches[1] ?? ''; + + try { + $binary = \Safe\base64_decode($base64Value, true); + } catch (Throwable) { + return null; + } + + if (32 !== strlen($binary)) { + return null; + } + + return bin2hex($binary); + } +} diff --git a/src/Service/ElementDefragmentizeService.php b/src/Service/ElementDefragmentizeService.php index 3cf97194..e851b2a9 100755 --- a/src/Service/ElementDefragmentizeService.php +++ b/src/Service/ElementDefragmentizeService.php @@ -25,14 +25,12 @@ public function __construct( public function defragmentize( NodeInterface|RelationInterface $cypherFragment, ?DocumentInterface $documentFragment, - mixed $fileFragment, ): NodeElementInterface|RelationElementInterface { if ($cypherFragment instanceof NodeInterface) { $event = new NodeElementDefragmentizeEvent( new NodeElement(), $cypherFragment, - $documentFragment, - $fileFragment + $documentFragment ); $this->eventDispatcher->dispatch($event); @@ -41,8 +39,7 @@ public function defragmentize( $event = new RelationElementDefragmentizeEvent( new RelationElement(), $cypherFragment, - $documentFragment, - $fileFragment + $documentFragment ); $this->eventDispatcher->dispatch($event); diff --git a/src/Service/ElementManager.php b/src/Service/ElementManager.php index 70a47c52..f51d179c 100755 --- a/src/Service/ElementManager.php +++ b/src/Service/ElementManager.php @@ -171,9 +171,8 @@ public function getNode(UuidInterface $id): ?NodeElementInterface return null; } $documentFragment = $this->mongoEntityManager->getOneByIdentifier($cypherFragment->getLabels()[0], $id->toString()); - $fileFragment = null; - $node = $this->elementDefragmentizeService->defragmentize($cypherFragment, $documentFragment, $fileFragment); + $node = $this->elementDefragmentizeService->defragmentize($cypherFragment, $documentFragment); if (!($node instanceof NodeElementInterface)) { return null; } @@ -217,9 +216,8 @@ public function getRelation(UuidInterface $id): ?RelationElementInterface throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to get relationship type.'); } $documentFragment = $this->mongoEntityManager->getOneByIdentifier($type, $id->toString()); - $fileFragment = null; - $relation = $this->elementDefragmentizeService->defragmentize($cypherFragment, $documentFragment, $fileFragment); + $relation = $this->elementDefragmentizeService->defragmentize($cypherFragment, $documentFragment); if (!($relation instanceof RelationElementInterface)) { return null; } diff --git a/src/Service/EtagCalculatorService.php b/src/Service/EtagCalculatorService.php index 95e8deed..c5d3dbd0 100644 --- a/src/Service/EtagCalculatorService.php +++ b/src/Service/EtagCalculatorService.php @@ -5,11 +5,10 @@ namespace App\Service; use App\Factory\Exception\Server500LogicErrorExceptionFactory; -use App\Factory\Type\S3\FileOperationFactory; use App\Helper\DateTimeHelper; use App\Type\Etag; use App\Type\EtagCalculator; -use AsyncAws\Core\Exception\Http\ClientException; +use ArrayAccess; use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; use Laudis\Neo4j\Databags\Statement; @@ -25,8 +24,6 @@ public function __construct( private EmberNexusConfiguration $emberNexusConfiguration, private CypherEntityManager $cypherEntityManager, private ElementManager $elementManager, - private S3Service $s3Service, - private FileOperationFactory $fileOperationFactory, private LoggerInterface $logger, private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { @@ -368,22 +365,22 @@ public function calculateFileEtag(UuidInterface $elementId): ?Etag ); $element = $this->elementManager->getElementOrFail($elementId); - $fileOperation = $this->fileOperationFactory->createFileOperationFromElement($element); - try { - $fileEtag = $this->s3Service->getEtag($fileOperation); - } catch (ClientException $exception) { - $this->logger->error(sprintf( - 'Unable to calculate Etag for file of element %s.', - (string) $elementId - )); - - return null; + $rawFileProperties = $element->hasProperty('file') ? $element->getProperty('file') : null; + $fileEtag = $this->extractPreferredHashFromFileProperties($rawFileProperties); + + if (null === $fileEtag) { + // no stored hash at all (e.g. a pre-hash-rollout record): fall back to the element's own identity + // instead of an S3 round trip, since there is nothing file-specific left to distinguish it by anyway. + $fileEtag = $this->calculateElementEtag($elementId); + if (null === $fileEtag) { + return null; + } + $fileEtag = (string) $fileEtag; } - $fileProperties = $element->getProperty('file'); - $fileProperties = \Safe\json_encode($fileProperties); + $fileProperties = \Safe\json_encode($rawFileProperties); - $name = $element->getProperty('name'); + $name = $element->hasProperty('name') ? $element->getProperty('name') : null; $name = \Safe\json_encode($name); $etagCalculator = new EtagCalculator($this->emberNexusConfiguration->getCacheEtagSeed()); @@ -403,4 +400,38 @@ public function calculateFileEtag(UuidInterface $elementId): ?Etag return $etag; } + + /** + * Prefers {@see FileHashService::ALGORITHM}, since that is the algorithm this codebase actually writes; if it + * is missing but some other algorithm is present (e.g. a future 'blake3'), the alphanumerically first one is + * used instead, purely to pick deterministically among otherwise-equal options. Returns null if no usable + * hash is stored at all. + */ + private function extractPreferredHashFromFileProperties(mixed $rawFileProperties): ?string + { + if (!is_array($rawFileProperties) && !($rawFileProperties instanceof ArrayAccess)) { + return null; + } + $hash = $rawFileProperties['hash'] ?? null; + if (is_object($hash) && method_exists($hash, 'getArrayCopy')) { + $hash = $hash->getArrayCopy(); + } + if (!is_array($hash) || [] === $hash) { + return null; + } + + $preferredValue = $hash[FileHashService::ALGORITHM] ?? null; + if (is_string($preferredValue) && '' !== $preferredValue) { + return $preferredValue; + } + + ksort($hash); + foreach ($hash as $value) { + if (is_string($value) && '' !== $value) { + return $value; + } + } + + return null; + } } diff --git a/src/Service/FileHashService.php b/src/Service/FileHashService.php new file mode 100644 index 00000000..b06b1595 --- /dev/null +++ b/src/Service/FileHashService.php @@ -0,0 +1,39 @@ +: }` rather than a flat pair, specifically so a + * future algorithm can be added (or this one replaced) without a data migration. + */ +class FileHashService +{ + public const string ALGORITHM = 'sha256'; + + /** + * @param resource $resource + */ + public function calculateHashFromResource($resource): string + { + $context = hash_init(self::ALGORITHM); + hash_update_stream($context, $resource); + + return hash_final($context); + } +} diff --git a/src/Service/FileRangeService.php b/src/Service/FileRangeService.php new file mode 100644 index 00000000..83c348d2 --- /dev/null +++ b/src/Service/FileRangeService.php @@ -0,0 +1,81 @@ +-`: an explicit, inclusive range. + * - `bytes=-`: from `` to the end of the resource. + * - `bytes=-`: the last `` bytes of the resource. + * + * A syntactically invalid `Range` header, or one requesting multiple ranges, results in a 400 Bad Request: the + * server does not understand what was asked for. A syntactically valid header which can not be satisfied against + * the resource (e.g. a `start` beyond the end of the file) results in a 416 Range Not Satisfiable instead. + */ +class FileRangeService +{ + public function __construct( + private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, + private Client416RangeNotSatisfiableExceptionFactory $client416RangeNotSatisfiableExceptionFactory, + ) { + } + + public function parseRangeHeader(string $rangeHeader, int $totalContentLength): ByteRange + { + if (1 !== \Safe\preg_match('/^bytes=(\d*)-(\d*)$/', trim($rangeHeader), $matches)) { + // syntactically invalid, or multiple ranges requested (unsupported, and not planned to be) + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not parse 'Range' header: '%s'. Only a single range of the form 'bytes=-', 'bytes=-' or 'bytes=-' is supported.", $rangeHeader)); + } + + $rawStart = $matches[1] ?? ''; + $rawEnd = $matches[2] ?? ''; + + if ('' === $rawStart && '' === $rawEnd) { + throw $this->client400BadContentExceptionFactory->createFromDetail("Could not parse 'Range' header: 'bytes=-' does not specify a start or a suffix length."); + } + + if ($totalContentLength <= 0) { + throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail('Requested range can not be satisfied, as the resource is empty.', ['total-length' => $totalContentLength]); + } + + if ('' === $rawStart) { + // suffix range: last bytes + $suffixLength = (int) $rawEnd; + if ($suffixLength <= 0) { + throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail('Requested suffix range must request at least 1 byte.', ['total-length' => $totalContentLength]); + } + + $start = max(0, $totalContentLength - $suffixLength); + $end = $totalContentLength - 1; + } else { + $start = (int) $rawStart; + if ($start >= $totalContentLength) { + throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail(sprintf('Requested range start (%d) is beyond the end of the resource (%d bytes long).', $start, $totalContentLength), ['requested-start' => $start, 'total-length' => $totalContentLength]); + } + + if ('' === $rawEnd) { + // open range: from to the end of the resource ("infinity") + $end = $totalContentLength - 1; + } else { + // end can not exceed the resource's actual length, it is clamped instead of rejected + $end = min((int) $rawEnd, $totalContentLength - 1); + } + } + + if ($end < $start) { + throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail('Requested range must span at least 1 byte.', ['requested-start' => $start, 'requested-end' => $end, 'total-length' => $totalContentLength]); + } + + return new ByteRange($start, $end, $totalContentLength); + } +} diff --git a/src/Service/IncrementalHashService.php b/src/Service/IncrementalHashService.php new file mode 100644 index 00000000..e2d3f873 --- /dev/null +++ b/src/Service/IncrementalHashService.php @@ -0,0 +1,91 @@ +client409ConflictExceptionFactory->createFromDetail('Could not resume this upload\'s integrity check; please restart the upload.'); + } + + return $context; + } +} diff --git a/src/Service/PropertyParseService.php b/src/Service/PropertyParseService.php index 26e1402a..41e9db66 100644 --- a/src/Service/PropertyParseService.php +++ b/src/Service/PropertyParseService.php @@ -141,6 +141,22 @@ public function getExtensionFromProperties(mixed $properties): string return $extension; } + /** + * @param mixed[] $properties + */ + public function getHashStateFromProperties(mixed $properties): ?string + { + if (!array_key_exists('hashState', $properties)) { + return null; + } + $hashState = $properties['hashState']; + if (!is_string($hashState)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property hashState to be either string or to be absent.'); + } + + return $hashState; + } + /** * @param mixed[] $properties */ diff --git a/src/Service/QueueService.php b/src/Service/QueueService.php index 6a695161..4e9b8fca 100644 --- a/src/Service/QueueService.php +++ b/src/Service/QueueService.php @@ -25,4 +25,28 @@ public function publishEvent(RabbitMQQueueType $queueType, mixed $eventData): vo $channel->basic_publish($message, '', $queue); $channel->close(); } + + /** + * Drains all messages currently waiting in the given queue, calling $handler for each decoded message. + * Messages are only acknowledged once $handler returns without throwing, so a failure leaves the message + * queued for the next run. Returns the number of successfully processed messages. + */ + public function consumeQueue(RabbitMQQueueType $queueType, callable $handler): int + { + $channel = $this->AMQPStreamConnection->channel(); + $queue = $queueType->value; + $channel->queue_declare($queue, false, false, false, false); + + $processedMessages = 0; + while (null !== ($message = $channel->basic_get($queue))) { + $eventData = \Safe\json_decode($message->getBody(), true); + $handler($eventData); + $message->ack(); + ++$processedMessages; + } + + $channel->close(); + + return $processedMessages; + } } diff --git a/src/Service/S3Service.php b/src/Service/S3Service.php index 44a2712f..8f555a4b 100644 --- a/src/Service/S3Service.php +++ b/src/Service/S3Service.php @@ -26,7 +26,9 @@ public function __construct( private MimeTypeService $mimeTypeService, private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, + S3TechnicalLimitsValidator $s3TechnicalLimitsValidator, ) { + $s3TechnicalLimitsValidator->validate(); } /** @@ -162,15 +164,16 @@ public function uploadFileChunk(UploadFileChunkOperationInterface $uploadFileChu } /** - * todo: optimize upload for larger files using multipart-upload?, handled by https://github.com/ember-nexus/api/issues/452. + * Writes the whole file with a single PUT, which caps it at S3's 5 GiB single-object-upload limit. Requests + * reaching this are already bounded well below that by `post_max_size`, but `BackupLoadCommand` is not: it + * restores files straight from a backup archive, so a backup holding a file larger than 5 GiB fails to load. + * Lifting that needs multipart, tracked in https://github.com/ember-nexus/api/issues/452. */ public function uploadFile(UploadFileOperationInterface $uploadFileOperation): int { - // intermediate upload to "upload bucket" $uploadFileChunkOperation = $this->uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation($uploadFileOperation); $contentLength = $this->uploadFileChunk($uploadFileChunkOperation); - // transfer uploaded element to "storage bucket" $copyResult = $this->s3Client->copyObject([ 'Bucket' => $uploadFileOperation->getStorageBucket(), 'Key' => $uploadFileOperation->getStorageKey(), @@ -187,14 +190,12 @@ public function uploadFile(UploadFileOperationInterface $uploadFileOperation): i $this->s3ClientWrapper->resolveCopyObjectOutput($copyResult); $previousStorageKey = $uploadFileOperation->getPreviousStorageKey(); if (null !== $previousStorageKey && $previousStorageKey !== $uploadFileOperation->getStorageKey()) { - // delete previous uploaded element, if available $this->deleteFile(new FileOperation( $uploadFileOperation->getStorageBucket(), $previousStorageKey )); } - // clean up upload bucket $this->deleteFile(new FileOperation( $uploadFileOperation->getUploadBucket(), $uploadFileOperation->getUploadKey() @@ -246,6 +247,19 @@ public function getFile(FileOperationInterface $fileOperation): GetObjectOutput return $this->s3Client->getObject($objectConfig); } + /** + * Reads only the given inclusive byte range [$start, $end] from S3, via the `Range` request header, instead + * of downloading the whole file. + */ + public function getFileByteRange(FileOperationInterface $fileOperation, int $start, int $end): GetObjectOutput + { + return $this->s3Client->getObject([ + 'Bucket' => $fileOperation->getBucket(), + 'Key' => $fileOperation->getKey(), + 'Range' => sprintf('bytes=%d-%d', $start, $end), + ]); + } + /** * @return resource */ @@ -267,6 +281,11 @@ public function getFileAsResource(FileOperationInterface $fileOperation) public function getFileRangeAsResource(FileOperationInterface $fileOperation, int $maxContentLength) { $contentLength = $this->getContentLength($fileOperation); + if (0 === $contentLength) { + // a byte-range request against an empty object has no satisfiable range (S3 rejects it with a 416), + // and there is nothing to fetch either way - an empty resource is the correct result directly. + return \Safe\fopen('php://memory', 'r'); + } $result = $this->s3Client->getObject([ 'Bucket' => $fileOperation->getBucket(), 'Key' => $fileOperation->getKey(), @@ -315,7 +334,6 @@ public function getMimeTypeFromFile(FileOperationInterface $fileOperation): stri } /** - * Returns the MimeType of a chunked merge file operation. * Assumes that the first chunk a) exists and is b) sufficiently long to correctly determine the MimeType. This is * currently the case, as S3's minimum chunk length is 5MB - sufficient for MimeType detection. */ diff --git a/src/Service/S3TechnicalLimitsValidator.php b/src/Service/S3TechnicalLimitsValidator.php new file mode 100644 index 00000000..6dd29391 --- /dev/null +++ b/src/Service/S3TechnicalLimitsValidator.php @@ -0,0 +1,63 @@ +validateMinChunkSize(); + $this->validateMaxChunkCount(); + $this->validateMaxObjectSize(); + } + + private function validateMinChunkSize(): void + { + $configuredMinChunkSize = $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes(); + $technicalMinChunkSize = $this->s3TechnicalLimits->getMinChunkSizeInBytes(); + + if ($configuredMinChunkSize < $technicalMinChunkSize) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Configured 'file.uploadMinChunkSizeInBytes' (%d) can not be smaller than the storage backend's technical minimum chunk size (%d).", $configuredMinChunkSize, $technicalMinChunkSize)); + } + } + + private function validateMaxChunkCount(): void + { + $chunkDigitsLength = $this->emberNexusConfiguration->getFileUploadChunkDigitsLength(); + $configuredMaxChunkCount = 10 ** $chunkDigitsLength; + $technicalMaxChunkCount = $this->s3TechnicalLimits->getMaxChunkCount(); + + if ($configuredMaxChunkCount > $technicalMaxChunkCount) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Configured 'file.uploadChunkDigitsLength' (%d) allows up to %d chunks, which exceeds the storage backend's technical maximum of %d chunks.", $chunkDigitsLength, $configuredMaxChunkCount, $technicalMaxChunkCount)); + } + } + + private function validateMaxObjectSize(): void + { + $configuredMaxFileSize = $this->emberNexusConfiguration->getFileMaxFileSizeInBytes(); + $technicalMaxObjectSize = $this->s3TechnicalLimits->getMaxObjectSizeInBytes(); + + if ($configuredMaxFileSize > $technicalMaxObjectSize) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Configured 'file.maxFileSizeInBytes' (%d) can not be larger than the storage backend's technical maximum object size (%d).", $configuredMaxFileSize, $technicalMaxObjectSize)); + } + } +} diff --git a/src/Service/UploadCreationService.php b/src/Service/UploadCreationService.php index 1bd76fa9..ae33458e 100755 --- a/src/Service/UploadCreationService.php +++ b/src/Service/UploadCreationService.php @@ -35,6 +35,8 @@ public function __construct( private AuthProvider $authProvider, private EmberNexusConfiguration $emberNexusConfiguration, private S3Service $s3Service, + private IncrementalHashService $incrementalHashService, + private DigestService $digestService, private ElementManager $elementManager, private UploadFileOperationFactory $uploadFileOperationFactory, private UploadFileChunkOperationFactory $uploadFileChunkOperationFactory, @@ -57,15 +59,37 @@ public function handleUploadCreationFromRequest(UuidInterface $elementId, Reques return $this->createNewResumableUpload($resumableUploadRequest); } - return $this->setOrReplaceElementFileDirectly($element, $resumableUploadRequest); + $requestDigestHeaderValue = $request->headers->get('Repr-Digest') ?? $request->headers->get('Content-Digest'); + + return $this->setOrReplaceElementFileDirectly($element, $resumableUploadRequest, $requestDigestHeaderValue); } private function setOrReplaceElementFileDirectly( NodeElementInterface|RelationElementInterface $element, ResumableUploadRequestInterface $resumableUploadRequest, + ?string $requestDigestHeaderValue, ): Response { + // the request body is hashed once, locally, then rewound, before anything else (including + // UploadFileOperationFactory's own mime type sniffing) reads it - see IncrementalHashService for why this + // is not done via a persistently-attached stream filter instead. + $resource = $resumableUploadRequest->getContent(); + $hashContext = $this->incrementalHashService->createContext(FileHashService::ALGORITHM); + $this->incrementalHashService->updateFromResource($hashContext, $resource); + $hash = $this->incrementalHashService->finalize($hashContext); + + // building the operation only sniffs the mime type locally; it does not touch S3 - so the digest, now + // already known, can be verified before the storage bucket is ever written to. On a mismatch, this means + // an existing file at this element is never replaced/overwritten in the first place. $uploadFileOperation = $this->uploadFileOperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest); + + if (null !== $requestDigestHeaderValue) { + $this->verifyRequestDigest($requestDigestHeaderValue, $hash); + } + $this->s3Service->uploadFile($uploadFileOperation); + if (is_resource($resource)) { + \Safe\fclose($resource); + } $this->eventDispatcher->dispatch(new ElementFileReplaceEvent($resumableUploadRequest->getElementId())); @@ -73,22 +97,51 @@ private function setOrReplaceElementFileDirectly( 'contentLength' => $uploadFileOperation->getContentLength(), 'extension' => $resumableUploadRequest->getExtension(), 'mimeType' => $uploadFileOperation->getMimeType(), + 'hash' => [ + FileHashService::ALGORITHM => $hash, + ], ]); + $element->addProperty('hasFile', true); $this->elementManager->merge($element); $this->elementManager->flush(); return new CreatedResponse(); } + private function verifyRequestDigest(string $requestDigestHeaderValue, string $actualHash): void + { + $expectedHash = $this->digestService->parseSha256HexFromHeaderValue($requestDigestHeaderValue); + if (null === $expectedHash) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not verify upload: 'Repr-Digest'/'Content-Digest' header '%s' does not declare a supported digest algorithm; only 'sha-256' is supported.", $requestDigestHeaderValue)); + } + if ($expectedHash !== $actualHash) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Could not verify upload: the declared digest does not match the uploaded file\'s actual content.'); + } + } + private function createNewResumableUpload(ResumableUploadRequestInterface $resumableUploadRequest): Response { $uploadId = Uuid::uuid4(); $uploadOffset = 0; $alreadyUploadedChunks = 0; + $hashState = null; if (0 !== $resumableUploadRequest->getContentLength()) { + // the chunk is hashed once, locally, then rewound, before S3Service ever sees it; the running hash + // state is persisted on the Upload element for the next chunk to resume from, so the final hash never + // requires reading the file back from S3 at all. + $resource = $resumableUploadRequest->getContent(); + $hashContext = $this->incrementalHashService->createContext(FileHashService::ALGORITHM); + $this->incrementalHashService->updateFromResource($hashContext, $resource); + $uploadFileChunkOperation = $this->uploadFileChunkOperationFactory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId); $uploadOffset = $this->s3Service->uploadFileChunk($uploadFileChunkOperation); + if (is_resource($resource)) { + \Safe\fclose($resource); + } + + $hashState = $this->incrementalHashService->serializeContextForStorage($hashContext); + $alreadyUploadedChunks = 1; if ($uploadOffset < $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()) { /** @@ -115,7 +168,8 @@ private function createNewResumableUpload(ResumableUploadRequestInterface $resum $alreadyUploadedChunks, $this->authProvider->getUserId(), $resumableUploadRequest->getExtension(), - $expires + $expires, + $hashState ); $this->uploadService->mergeUploadElement($upload); diff --git a/src/Service/UploadService.php b/src/Service/UploadService.php index 622d2d18..c97852aa 100755 --- a/src/Service/UploadService.php +++ b/src/Service/UploadService.php @@ -7,7 +7,14 @@ use App\Contract\NodeElementInterface; use App\Contract\UploadInterface; use App\Factory\Exception\Server500LogicErrorExceptionFactory; +use App\Factory\Type\S3\FileOperationFactory; +use App\Factory\Type\UploadFactory; use App\Type\NodeElement; +use Exception; +use Laudis\Neo4j\Databags\Statement; +use Ramsey\Uuid\Rfc4122\UuidV4; +use Ramsey\Uuid\UuidInterface; +use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; /** * @SuppressWarnings("PHPMD.ExcessiveParameterList") @@ -17,6 +24,10 @@ class UploadService public function __construct( private ElementManager $elementManager, private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, + private FileOperationFactory $fileOperationFactory, + private S3Service $s3Service, + private UploadFactory $uploadFactory, + private CypherEntityManager $cypherEntityManager, ) { } @@ -44,6 +55,7 @@ public function mergeUploadElement(UploadInterface $upload): void $element->addProperty('uploadOwner', $upload->getUploadOwner()->toString()); $element->addProperty('extension', $upload->getExtension()); $element->addProperty('expires', $upload->getExpires()); + $element->addProperty('hashState', $upload->getHashState()); $this->elementManager->merge($element); } @@ -54,4 +66,75 @@ public function deleteUpload(UploadInterface $upload): void $this->elementManager->delete($element); } + + /** + * Deletes an upload's already-uploaded chunks from S3, then the `Upload` element itself. Does not flush - + * that is left to the caller, matching {@see deleteUpload()}. + */ + public function deleteUploadAndChunks(UploadInterface $upload): void + { + for ($chunk = 0; $chunk <= $upload->getAlreadyUploadedChunks(); ++$chunk) { + $deleteChunkOperation = $this->fileOperationFactory->createFileOperationFromUpload($upload, $chunk); + $this->s3Service->deleteFile($deleteChunkOperation); + } + + $this->deleteUpload($upload); + } + + /** + * Deletes any in-progress resumable upload(s) still targeting `$elementId`, and their already-uploaded S3 + * chunks - meant to be called (and flushed) *before* the target element itself is deleted. `uploadTarget` + * (see {@see mergeUploadElement()}) is a plain property, not a graph edge, so deleting an element does not + * take any upload(s) still targeting it along with it on its own; without this, they would linger - still + * HEAD-able, still occupying storage - until the `cron:delete-expired-uploads` grace period eventually + * catches up with them. + * + * Deliberately not wired up as an event listener on element deletion: an `Upload` element deleted by this + * method would itself re-enter `ElementManager::delete()`/`flush()` while the *original* deletion's own + * `flush()` for the target element is still on the call stack (event listeners fire from within `flush()`) - + * `ElementManager` and the underlying entity managers are not reentrant, and a nested `flush()` call + * corrupts the outer one's still-in-flight batch. Calling this as an explicit, separate, sequential step + * before the target's own delete+flush avoids that entirely. + */ + public function deleteUploadsTargeting(UuidInterface $elementId): void + { + foreach ($this->getUploadIdsTargeting($elementId) as $uploadId) { + $uploadElement = $this->elementManager->getElement(UuidV4::fromString($uploadId)); + if (null === $uploadElement) { + continue; // already gone (e.g. deleted or expired in the meantime), nothing left to do + } + + try { + $upload = $this->uploadFactory->createUploadFromElement($uploadElement); + } catch (Exception) { + continue; // @codeCoverageIgnore + } + + $this->deleteUploadAndChunks($upload); + } + } + + /** + * @return string[] + */ + private function getUploadIdsTargeting(UuidInterface $elementId): array + { + $queryResult = $this->cypherEntityManager->getClient()->runStatement(new Statement( + 'MATCH (u:Upload) WHERE u.uploadTarget = $elementId RETURN u.id', + [ + 'elementId' => $elementId->toString(), + ] + )); + + $uploadIds = []; + foreach ($queryResult as $queryResultLine) { + $uploadId = $queryResultLine['u.id']; + if (!is_string($uploadId)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property u.id as string, not %s.', get_debug_type($uploadId))); // @codeCoverageIgnore + } + $uploadIds[] = $uploadId; + } + + return $uploadIds; + } } diff --git a/src/Type/ByteRange.php b/src/Type/ByteRange.php new file mode 100644 index 00000000..1a0e6d5c --- /dev/null +++ b/src/Type/ByteRange.php @@ -0,0 +1,40 @@ +start; + } + + public function getEnd(): int + { + return $this->end; + } + + public function getTotalLength(): int + { + return $this->totalLength; + } + + public function getLength(): int + { + return $this->end - $this->start + 1; + } +} diff --git a/src/Type/FragmentGroup.php b/src/Type/FragmentGroup.php index 272f3688..6b78ca60 100755 --- a/src/Type/FragmentGroup.php +++ b/src/Type/FragmentGroup.php @@ -15,7 +15,6 @@ public function __construct( private NodeInterface|RelationInterface $cypherFragment, private MongoDocumentInterface $mongoFragment, private ElasticDocumentInterface $elasticFragment, - private mixed $fileFragment, ) { } @@ -54,16 +53,4 @@ public function setElasticFragment(ElasticDocumentInterface $elasticFragment): s return $this; } - - public function getFileFragment(): mixed - { - return $this->fileFragment; - } - - public function setFileFragment(mixed $fileFragment): static - { - $this->fileFragment = $fileFragment; - - return $this; - } } diff --git a/src/Type/Response/BinaryStreamResponse.php b/src/Type/Response/BinaryStreamResponse.php index 0df088dc..4fcde1d7 100644 --- a/src/Type/Response/BinaryStreamResponse.php +++ b/src/Type/Response/BinaryStreamResponse.php @@ -5,6 +5,7 @@ namespace App\Type\Response; use App\Contract\EtagCapableResponseInterface; +use App\Type\ByteRange; use App\Type\Etag; use AsyncAws\S3\Result\GetObjectOutput; use Symfony\Component\HttpFoundation\ResponseHeaderBag; @@ -12,18 +13,34 @@ class BinaryStreamResponse extends StreamedResponse implements EtagCapableResponseInterface { - public const int STREAM_CHUNK_SIZE = 8192; + public const int STREAM_CHUNK_SIZE = 65536; - public function __construct(GetObjectOutput $object, string $fileName, string $fileNameFallback) + public function __construct(GetObjectOutput $object, string $fileName, string $fileNameFallback, ?ByteRange $range = null, ?string $reprDigestHeaderValue = null, ?string $contentType = null) { parent::__construct(); $this->content = ''; $stream = $object->getBody()->getContentAsResource(); - $this->headers->set('Content-Length', (string) ($object->getContentLength() ?? 0)); + $this->headers->set('Accept-Ranges', 'bytes'); - // todo: use file's actual mime type, if available. otherwise fall back to current mime type? - $this->headers->set('Content-Type', 'application/octet-stream'); + if (null !== $range) { + $this->setStatusCode(206); + $this->headers->set('Content-Length', (string) $range->getLength()); + $this->headers->set('Content-Range', sprintf('bytes %d-%d/%d', $range->getStart(), $range->getEnd(), $range->getTotalLength())); + } else { + $this->headers->set('Content-Length', (string) ($object->getContentLength() ?? 0)); + } + + if (null !== $reprDigestHeaderValue) { + // Repr-Digest describes the full underlying resource, regardless of Range, so it applies to both full + // and partial responses alike. Content-Digest is deliberately not set: for a full response it would + // always be identical to Repr-Digest (no extra information), and for a partial response it would + // require hashing just the returned range, which is not done, so it is correctly left out rather than + // set to a value describing the wrong bytes. + $this->headers->set('Repr-Digest', $reprDigestHeaderValue); + } + + $this->headers->set('Content-Type', $contentType ?? 'application/octet-stream'); $disposition = $this->headers->makeDisposition( ResponseHeaderBag::DISPOSITION_ATTACHMENT, diff --git a/src/Type/S3/S3TechnicalLimits.php b/src/Type/S3/S3TechnicalLimits.php new file mode 100644 index 00000000..c616033f --- /dev/null +++ b/src/Type/S3/S3TechnicalLimits.php @@ -0,0 +1,34 @@ +expires; } + + public function getHashState(): ?string + { + return $this->hashState; + } } diff --git a/tests/ExampleGenerationCommand/Cron/DeleteExpiredUploadsTest.php b/tests/ExampleGenerationCommand/Cron/DeleteExpiredUploadsTest.php new file mode 100644 index 00000000..b81f9859 --- /dev/null +++ b/tests/ExampleGenerationCommand/Cron/DeleteExpiredUploadsTest.php @@ -0,0 +1,34 @@ +runCommand(sprintf( + 'APP_ENV=prod VERSION=%s php bin/console cron:delete-expired-uploads --ansi --help | aha -s --black --css "./cli-style.css"', + $this->getCurrentVersion() + )); + $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput(self::PATH_TO_ROOT, 'docs/commands/assets/cron-delete-expired-uploads-help.html', $commandOutput); + } + + public function testDeleteExpiredUploads(): void + { + $commandOutput = $this->runCommand(sprintf( + 'APP_ENV=prod VERSION=%s php bin/console cron:delete-expired-uploads --ansi | aha -s --black --css "./cli-style.css"', + $this->getCurrentVersion() + )); + $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput( + self::PATH_TO_ROOT, + 'docs/commands/assets/cron-delete-expired-uploads.html', + $commandOutput + ); + } +} diff --git a/tests/ExampleGenerationCommand/Cron/ReindexFilesTest.php b/tests/ExampleGenerationCommand/Cron/ReindexFilesTest.php new file mode 100644 index 00000000..7c6a1cf6 --- /dev/null +++ b/tests/ExampleGenerationCommand/Cron/ReindexFilesTest.php @@ -0,0 +1,34 @@ +runCommand(sprintf( + 'APP_ENV=prod VERSION=%s php bin/console cron:reindex-files --ansi --help | aha -s --black --css "./cli-style.css"', + $this->getCurrentVersion() + )); + $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput(self::PATH_TO_ROOT, 'docs/commands/assets/cron-reindex-files-help.html', $commandOutput); + } + + public function testReindexFiles(): void + { + $commandOutput = $this->runCommand(sprintf( + 'APP_ENV=prod VERSION=%s php bin/console cron:reindex-files --ansi | aha -s --black --css "./cli-style.css"', + $this->getCurrentVersion() + )); + $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput( + self::PATH_TO_ROOT, + 'docs/commands/assets/cron-reindex-files.html', + $commandOutput + ); + } +} diff --git a/tests/ExampleGenerationController/Asset/.gitkeep b/tests/ExampleGenerationController/Asset/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/ExampleGenerationController/BaseRequestTestCase.php b/tests/ExampleGenerationController/BaseRequestTestCase.php index 21df66c0..f310dcfa 100644 --- a/tests/ExampleGenerationController/BaseRequestTestCase.php +++ b/tests/ExampleGenerationController/BaseRequestTestCase.php @@ -11,7 +11,7 @@ abstract class BaseRequestTestCase extends \App\Tests\FeatureTests\BaseRequestTe /** * @var string[] IGNORED_HEADERS */ - private const array IGNORED_HEADERS = ['Date', 'Etag', 'Location']; + private const array IGNORED_HEADERS = ['Date', 'Etag', 'Location', 'Expires']; /** * @var string[] REMOVED_HEADERS diff --git a/tests/ExampleGenerationController/File/DeleteElementFileTest.php b/tests/ExampleGenerationController/File/DeleteElementFileTest.php new file mode 100644 index 00000000..2e285c96 --- /dev/null +++ b/tests/ExampleGenerationController/File/DeleteElementFileTest.php @@ -0,0 +1,105 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'id' => $id, + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + $this->assertIsCreatedResponse($response, false); + } + + public function testDeleteElementFileSuccess204(): void + { + $elementId = '6f7a8b9c-0d1e-4f2a-3b4c-5d6e7f8a9b0c'; + $this->createElement($elementId, 'delete-element-file-204'); + + $filePath = __DIR__.'/../Asset/example-delete-element-file-204.bin'; + $this->generateDeterministicFile(99999999, 2048, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $uploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsCreatedResponse($uploadResponse, false); + unlink($filePath); + + $response = $this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertNoContentResponse($response); + + $documentationHeadersPath = 'docs/api-endpoints/file/delete-element-file/204-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testDeleteElementFileFailure401(): void + { + $elementId = '7a8b9c0d-1e2f-4a3b-4c5d-6e7f8a9b0c1d'; + $this->createElement($elementId, 'delete-element-file-401'); + + $response = $this->runDeleteRequest(sprintf('/%s/file', $elementId), 'thisTokenDoesNotExist'); + $this->assertIsProblemResponse($response, 401); + + $documentationHeadersPath = 'docs/api-endpoints/file/delete-element-file/401-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/delete-element-file/401-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testDeleteElementFileFailure404(): void + { + $elementWhichDoesNotExist = 'b4117ae0-1241-479f-b363-45f290ec7fc7'; + + $response = $this->runDeleteRequest(sprintf('/%s/file', $elementWhichDoesNotExist), self::TOKEN); + $this->assertIsProblemResponse($response, 404); + + $documentationHeadersPath = 'docs/api-endpoints/file/delete-element-file/404-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/delete-element-file/404-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } +} diff --git a/tests/ExampleGenerationController/File/GetElementFileTest.php b/tests/ExampleGenerationController/File/GetElementFileTest.php new file mode 100644 index 00000000..cea21c86 --- /dev/null +++ b/tests/ExampleGenerationController/File/GetElementFileTest.php @@ -0,0 +1,128 @@ +runGetRequest(sprintf('/%s/file', self::ROSE_ID), self::TOKEN); + $this->assertIsBinaryStreamResponse($response, 'image/jpeg'); + $documentationHeadersPath = 'docs/api-endpoints/file/get-element-file/200-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testGetElementFileSuccess206(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=0-99'] + ); + $this->assertSame(206, $response->getStatusCode()); + $documentationHeadersPath = 'docs/api-endpoints/file/get-element-file/206-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testGetElementFileFailure400(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=not-a-valid-range'] + ); + $this->assertIsProblemResponse($response, 400); + $documentationHeadersPath = 'docs/api-endpoints/file/get-element-file/400-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/get-element-file/400-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testGetElementFileFailure401(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', self::ROSE_ID), 'thisTokenDoesNotExist'); + $this->assertIsProblemResponse($response, 401); + $documentationHeadersPath = 'docs/api-endpoints/file/get-element-file/401-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/get-element-file/401-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testGetElementFileFailure404(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', self::ELEMENT_WHICH_DOES_NOT_EXIST), self::TOKEN); + $this->assertIsProblemResponse($response, 404); + $documentationHeadersPath = 'docs/api-endpoints/file/get-element-file/404-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/get-element-file/404-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testGetElementFileFailure416(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=999999999-'] + ); + $this->assertIsProblemResponse($response, 416); + $documentationHeadersPath = 'docs/api-endpoints/file/get-element-file/416-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/get-element-file/416-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } +} diff --git a/tests/ExampleGenerationController/File/PostElementFileTest.php b/tests/ExampleGenerationController/File/PostElementFileTest.php new file mode 100644 index 00000000..e1a794d1 --- /dev/null +++ b/tests/ExampleGenerationController/File/PostElementFileTest.php @@ -0,0 +1,266 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'id' => $id, + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + $this->assertIsCreatedResponse($response, false); + } + + public function testPostElementFileSuccess201(): void + { + $elementId = 'a4dfda49-96db-4b58-95f4-4a6cae835674'; + $this->createElement($elementId, 'post-element-file-201'); + + $filePath = __DIR__.'/../Asset/example-post-element-file-201.bin'; + $this->generateDeterministicFile(11111111, 2048, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename=example.bin', + ] + ); + $this->assertIsCreatedResponse($response, false); + unlink($filePath); + + $documentationHeadersPath = 'docs/api-endpoints/file/post-element-file/201-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testPostElementFileSuccess204WithInitialData(): void + { + $elementId = '90db2934-3f27-4a37-8e05-a03ea3e5e5f2'; + $this->createElement($elementId, 'post-element-file-204-with-data'); + + // non-final chunks must meet the server's configured minimum chunk size (advertised via 'Upload-Limit') + $filePath = __DIR__.'/../Asset/example-post-element-file-204-with-data.bin'; + $this->generateDeterministicFile(22222222, 5 * 1024 * 1024, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename=example.bin', + ] + ); + $this->assertNoContentResponse($response, true); + unlink($filePath); + + $documentationHeadersPath = 'docs/api-endpoints/file/post-element-file/204-with-initial-data-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + /** + * A resumable upload may be created with no initial data at all, i.e. an empty body alongside + * `Upload-Complete: ?0` - explicitly allowed by the resumable upload draft standard unless disabled, which + * this API does not do. + */ + public function testPostElementFileSuccess204WithoutInitialData(): void + { + $elementId = '2b6a4c9f-2c9d-4b2a-9f1b-3b8ea3c7f6a1'; + $this->createElement($elementId, 'post-element-file-204-without-data'); + + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($response, true); + + $documentationHeadersPath = 'docs/api-endpoints/file/post-element-file/204-without-initial-data-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testPostElementFileFailure400(): void + { + $elementId = 'd15c34e0-4c8a-4d1b-9f5c-7e3a1b2c4d5e'; + $this->createElement($elementId, 'post-element-file-400'); + + $filePath = __DIR__.'/../Asset/example-post-element-file-400.bin'; + $this->generateDeterministicFile(33333333, 2048, $filePath); + + $bogusHash = str_repeat('00', 32); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($bogusHash))); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertIsProblemResponse($response, 400); + unlink($filePath); + + $documentationHeadersPath = 'docs/api-endpoints/file/post-element-file/400-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/post-element-file/400-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testPostElementFileFailure401(): void + { + $elementId = 'f3a1b2c4-5d6e-4f7a-8b9c-0d1e2f3a4b5c'; + $this->createElement($elementId, 'post-element-file-401'); + + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + 'thisTokenDoesNotExist', + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsProblemResponse($response, 401); + + $documentationHeadersPath = 'docs/api-endpoints/file/post-element-file/401-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/post-element-file/401-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testPostElementFileFailure404(): void + { + $elementWhichDoesNotExist = 'b4117ae0-1241-479f-b363-45f290ec7fc7'; + + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementWhichDoesNotExist), + '', + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsProblemResponse($response, 404); + + $documentationHeadersPath = 'docs/api-endpoints/file/post-element-file/404-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/post-element-file/404-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testPostElementFileFailure409(): void + { + $elementId = '6b1c2d3e-4f5a-4b6c-8d9e-0f1a2b3c4d5e'; + $this->createElement($elementId, 'post-element-file-409'); + + $filePath = __DIR__.'/../Asset/example-post-element-file-409.bin'; + $this->generateDeterministicFile(44444444, 2048, $filePath); + + $firstFile = \Safe\fopen($filePath, 'r'); + $firstResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $firstFile, + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsCreatedResponse($firstResponse, false); + + $secondFile = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $secondFile, + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsProblemResponse($response, 409); + unlink($filePath); + + $documentationHeadersPath = 'docs/api-endpoints/file/post-element-file/409-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/post-element-file/409-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } +} diff --git a/tests/ExampleGenerationController/File/PutElementFileTest.php b/tests/ExampleGenerationController/File/PutElementFileTest.php new file mode 100644 index 00000000..36c65aed --- /dev/null +++ b/tests/ExampleGenerationController/File/PutElementFileTest.php @@ -0,0 +1,235 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'id' => $id, + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + $this->assertIsCreatedResponse($response, false); + } + + public function testPutElementFileSuccess201(): void + { + $elementId = '1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d'; + $this->createElement($elementId, 'put-element-file-201'); + + $filePath = __DIR__.'/../Asset/example-put-element-file-201.bin'; + $this->generateDeterministicFile(55555555, 2048, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename=example.bin', + ] + ); + $this->assertIsCreatedResponse($response, false); + unlink($filePath); + + $documentationHeadersPath = 'docs/api-endpoints/file/put-element-file/201-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testPutElementFileSuccess204WithInitialData(): void + { + $elementId = '2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e'; + $this->createElement($elementId, 'put-element-file-204-with-data'); + + // non-final chunks must meet the server's configured minimum chunk size (advertised via 'Upload-Limit') + $filePath = __DIR__.'/../Asset/example-put-element-file-204-with-data.bin'; + $this->generateDeterministicFile(66666666, 5 * 1024 * 1024, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename=example.bin', + ] + ); + $this->assertNoContentResponse($response, true); + unlink($filePath); + + $documentationHeadersPath = 'docs/api-endpoints/file/put-element-file/204-with-initial-data-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + /** + * A resumable upload may be created with no initial data at all, i.e. an empty body alongside + * `Upload-Complete: ?0` - explicitly allowed by the resumable upload draft standard unless disabled, which + * this API does not do. + */ + public function testPutElementFileSuccess204WithoutInitialData(): void + { + $elementId = '3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f'; + $this->createElement($elementId, 'put-element-file-204-without-data'); + + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($response, true); + + $documentationHeadersPath = 'docs/api-endpoints/file/put-element-file/204-without-initial-data-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + /** + * PUT also allows replacing a file the element already has - here, the replacement is rejected because its + * declared digest doesn't match, which must leave the original file usable (not just return an error). + */ + public function testPutElementFileFailure400(): void + { + $elementId = '4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a'; + $this->createElement($elementId, 'put-element-file-400'); + + $originalFilePath = __DIR__.'/../Asset/example-put-element-file-400-original.bin'; + $this->generateDeterministicFile(77777777, 2048, $originalFilePath); + $originalFile = \Safe\fopen($originalFilePath, 'r'); + $originalUploadResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + $originalFile, + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsCreatedResponse($originalUploadResponse, false); + unlink($originalFilePath); + + $replacementFilePath = __DIR__.'/../Asset/example-put-element-file-400-replacement.bin'; + $this->generateDeterministicFile(88888888, 2048, $replacementFilePath); + $bogusHash = str_repeat('00', 32); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($bogusHash))); + + $replacementFile = \Safe\fopen($replacementFilePath, 'r'); + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + $replacementFile, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertIsProblemResponse($response, 400); + unlink($replacementFilePath); + + $documentationHeadersPath = 'docs/api-endpoints/file/put-element-file/400-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/put-element-file/400-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testPutElementFileFailure401(): void + { + $elementId = '5e6f7a8b-9c0d-4e1f-2a3b-4c5d6e7f8a9b'; + $this->createElement($elementId, 'put-element-file-401'); + + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + '', + 'thisTokenDoesNotExist', + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsProblemResponse($response, 401); + + $documentationHeadersPath = 'docs/api-endpoints/file/put-element-file/401-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/put-element-file/401-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testPutElementFileFailure404(): void + { + $elementWhichDoesNotExist = 'b4117ae0-1241-479f-b363-45f290ec7fc7'; + + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementWhichDoesNotExist), + '', + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsProblemResponse($response, 404); + + $documentationHeadersPath = 'docs/api-endpoints/file/put-element-file/404-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/file/put-element-file/404-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } +} diff --git a/tests/ExampleGenerationController/Upload/DeleteUploadTest.php b/tests/ExampleGenerationController/Upload/DeleteUploadTest.php new file mode 100644 index 00000000..351d70b1 --- /dev/null +++ b/tests/ExampleGenerationController/Upload/DeleteUploadTest.php @@ -0,0 +1,118 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'id' => $id, + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + $this->assertIsCreatedResponse($response, false); + } + + private function createResumableUpload(string $elementId, string $assetName): string + { + $filePath = __DIR__.'/../Asset/'.$assetName; + // non-final chunks must meet the server's configured minimum chunk size + $this->generateDeterministicFile(crc32($assetName), 5 * 1024 * 1024, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($response, true); + unlink($filePath); + + return $this->getUuidFromLocation($response); + } + + public function testDeleteUploadSuccess204(): void + { + $elementId = 'a0b1c2d3-4e5f-4a6b-7c8d-9e0f1a2b3c4d'; + $this->createElement($elementId, 'delete-upload-204'); + $uploadId = $this->createResumableUpload($elementId, 'example-delete-upload-204.bin'); + + $response = $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertNoContentResponse($response); + + $documentationHeadersPath = 'docs/api-endpoints/upload/delete-upload/204-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testDeleteUploadFailure401(): void + { + $elementId = 'b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e'; + $this->createElement($elementId, 'delete-upload-401'); + $uploadId = $this->createResumableUpload($elementId, 'example-delete-upload-401.bin'); + + $response = $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), 'thisTokenDoesNotExist'); + $this->assertIsProblemResponse($response, 401); + + $documentationHeadersPath = 'docs/api-endpoints/upload/delete-upload/401-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/upload/delete-upload/401-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testDeleteUploadFailure404(): void + { + $uploadWhichDoesNotExist = 'b4117ae0-1241-479f-b363-45f290ec7fc7'; + + $response = $this->runDeleteRequest(sprintf('/upload/%s', $uploadWhichDoesNotExist), self::TOKEN); + $this->assertIsProblemResponse($response, 404); + + $documentationHeadersPath = 'docs/api-endpoints/upload/delete-upload/404-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/upload/delete-upload/404-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } +} diff --git a/tests/ExampleGenerationController/Upload/HeadUploadTest.php b/tests/ExampleGenerationController/Upload/HeadUploadTest.php new file mode 100644 index 00000000..e30fd934 --- /dev/null +++ b/tests/ExampleGenerationController/Upload/HeadUploadTest.php @@ -0,0 +1,114 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'id' => $id, + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + $this->assertIsCreatedResponse($response, false); + } + + private function createResumableUpload(string $elementId, string $assetName): string + { + $filePath = __DIR__.'/../Asset/'.$assetName; + // non-final chunks must meet the server's configured minimum chunk size + $this->generateDeterministicFile(crc32($assetName), 5 * 1024 * 1024, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($response, true); + unlink($filePath); + + return $this->getUuidFromLocation($response); + } + + public function testHeadUploadSuccess204(): void + { + $elementId = '8b9c0d1e-2f3a-4b4c-5d6e-7f8a9b0c1d2e'; + $this->createElement($elementId, 'head-upload-204'); + $uploadId = $this->createResumableUpload($elementId, 'example-head-upload-204.bin'); + + $response = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $response->getStatusCode()); + + $documentationHeadersPath = 'docs/api-endpoints/upload/head-upload/204-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + /** + * A HEAD response never carries a body (RFC 9110, Section 9.3.2), so unlike the other endpoints' failure + * examples here, only the headers are documented/verified - there's no body to show. + */ + public function testHeadUploadFailure401(): void + { + $elementId = '9c0d1e2f-3a4b-4c5d-6e7f-8a9b0c1d2e3f'; + $this->createElement($elementId, 'head-upload-401'); + $uploadId = $this->createResumableUpload($elementId, 'example-head-upload-401.bin'); + + $response = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), 'thisTokenDoesNotExist'); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('application/problem+json; charset=utf-8', $response->getHeader('content-type')[0]); + + $documentationHeadersPath = 'docs/api-endpoints/upload/head-upload/401-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testHeadUploadFailure404(): void + { + $uploadWhichDoesNotExist = 'b4117ae0-1241-479f-b363-45f290ec7fc7'; + + $response = $this->runHeadRequest(sprintf('/upload/%s', $uploadWhichDoesNotExist), self::TOKEN); + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame('application/problem+json; charset=utf-8', $response->getHeader('content-type')[0]); + + $documentationHeadersPath = 'docs/api-endpoints/upload/head-upload/404-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } +} diff --git a/tests/ExampleGenerationController/Upload/PatchUploadTest.php b/tests/ExampleGenerationController/Upload/PatchUploadTest.php new file mode 100644 index 00000000..a8a8fe10 --- /dev/null +++ b/tests/ExampleGenerationController/Upload/PatchUploadTest.php @@ -0,0 +1,280 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'id' => $id, + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + $this->assertIsCreatedResponse($response, false); + } + + /** + * @return array{0: string, 1: string[]} the created upload's id, and the paths of the file chunks it was + * split into (two chunks: one already uploaded, one not yet) + */ + private function createResumableUploadWithOneChunkRemaining(string $elementId, string $assetName): array + { + $filePath = __DIR__.'/../Asset/'.$assetName; + $this->generateDeterministicFile(crc32($assetName), 2 * self::CHUNK_SIZE, $filePath); + $chunks = $this->splitFileToChunks($filePath, self::CHUNK_SIZE); + unlink($filePath); + + $firstChunk = \Safe\fopen($chunks[0], 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $firstChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($response, true); + + return [$this->getUuidFromLocation($response), $chunks]; + } + + public function testPatchUploadSuccess204IntermediateChunk(): void + { + $elementId = 'c2d3e4f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f'; + $this->createElement($elementId, 'patch-upload-204-intermediate'); + + // three chunks, so the second PATCH (tested here) is still an intermediate one, not the final chunk + $filePath = __DIR__.'/../Asset/example-patch-upload-204-intermediate.bin'; + $this->generateDeterministicFile(crc32('patch-upload-204-intermediate'), 3 * self::CHUNK_SIZE, $filePath); + $chunks = $this->splitFileToChunks($filePath, self::CHUNK_SIZE); + unlink($filePath); + + $firstChunk = \Safe\fopen($chunks[0], 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $firstChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $secondChunk = \Safe\fopen($chunks[1], 'r'); + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $secondChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Upload-Offset' => self::CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($response); + $this->cleanupChunks($chunks); + + $documentationHeadersPath = 'docs/api-endpoints/upload/patch-upload/204-intermediate-chunk-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testPatchUploadSuccess204FinalChunk(): void + { + $elementId = 'd3e4f5a6-7b8c-4d9e-0f1a-2b3c4d5e6f7a'; + $this->createElement($elementId, 'patch-upload-204-final'); + [$uploadId, $chunks] = $this->createResumableUploadWithOneChunkRemaining($elementId, 'example-patch-upload-204-final.bin'); + + $secondChunk = \Safe\fopen($chunks[1], 'r'); + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $secondChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => self::CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($response); + $this->cleanupChunks($chunks); + + $documentationHeadersPath = 'docs/api-endpoints/upload/patch-upload/204-final-chunk-response-header.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + } + + public function testPatchUploadFailure400(): void + { + $elementId = 'e4f5a6b7-8c9d-4e0f-1a2b-3c4d5e6f7a8b'; + $this->createElement($elementId, 'patch-upload-400'); + [$uploadId, $chunks] = $this->createResumableUploadWithOneChunkRemaining($elementId, 'example-patch-upload-400.bin'); + + $bogusHash = str_repeat('00', 32); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($bogusHash))); + + $secondChunk = \Safe\fopen($chunks[1], 'r'); + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $secondChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => self::CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertIsProblemResponse($response, 400); + $this->cleanupChunks($chunks); + + $documentationHeadersPath = 'docs/api-endpoints/upload/patch-upload/400-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/upload/patch-upload/400-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testPatchUploadFailure409(): void + { + $elementId = 'f5a6b7c8-9d0e-4f1a-2b3c-4d5e6f7a8b9c'; + $this->createElement($elementId, 'patch-upload-409'); + [$uploadId, $chunks] = $this->createResumableUploadWithOneChunkRemaining($elementId, 'example-patch-upload-409.bin'); + + $incorrectOffset = self::CHUNK_SIZE + 1; + $secondChunk = \Safe\fopen($chunks[1], 'r'); + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $secondChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => $incorrectOffset, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($response, 409); + $this->cleanupChunks($chunks); + + $documentationHeadersPath = 'docs/api-endpoints/upload/patch-upload/409-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/upload/patch-upload/409-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testPatchUploadFailure401(): void + { + $elementId = 'a6b7c8d9-0e1f-4a2b-3c4d-5e6f7a8b9c0d'; + $this->createElement($elementId, 'patch-upload-401'); + [$uploadId, $chunks] = $this->createResumableUploadWithOneChunkRemaining($elementId, 'example-patch-upload-401.bin'); + + $secondChunk = \Safe\fopen($chunks[1], 'r'); + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $secondChunk, + 'thisTokenDoesNotExist', + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => self::CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($response, 401); + $this->cleanupChunks($chunks); + + $documentationHeadersPath = 'docs/api-endpoints/upload/patch-upload/401-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/upload/patch-upload/401-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } + + public function testPatchUploadFailure404(): void + { + $uploadWhichDoesNotExist = 'b4117ae0-1241-479f-b363-45f290ec7fc7'; + + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadWhichDoesNotExist), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($response, 404); + + $documentationHeadersPath = 'docs/api-endpoints/upload/patch-upload/404-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/upload/patch-upload/404-response-body.json'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response + ); + } +} diff --git a/tests/FeatureTests/Command/BackupFetchFolderStructureTest.php b/tests/FeatureTests/Command/BackupFetchFolderStructureTest.php new file mode 100644 index 00000000..f0d12990 --- /dev/null +++ b/tests/FeatureTests/Command/BackupFetchFolderStructureTest.php @@ -0,0 +1,91 @@ +buildFixtureZip(null); + $backupName = 'backup-fetch-folder-structure-root-test'; + + $this->runBackupFetchCommand($backupName, $zipPath); + $this->assertNestedFileWasExtractedCorrectly($backupName); + + unlink($zipPath); + } + + public function testFetchFromArchiveWithBackupContentWrappedInASingleFolder(): void + { + $zipPath = $this->buildFixtureZip('wrapper-folder'); + $backupName = 'backup-fetch-folder-structure-wrapped-test'; + + $this->runBackupFetchCommand($backupName, $zipPath); + $this->assertNestedFileWasExtractedCorrectly($backupName); + + unlink($zipPath); + } + + private function runBackupFetchCommand(string $backupName, string $zipPath): void + { + $command = sprintf( + 'php bin/console backup:fetch %s %s --force', + escapeshellarg($backupName), + escapeshellarg($zipPath) + ); + $resultCode = 0; + \Safe\exec($command, result_code: $resultCode); + $this->assertSame(0, $resultCode, sprintf('Command should succeed: %s', $command)); + } + + private function assertNestedFileWasExtractedCorrectly(string $backupName): void + { + foreach (['node', 'relation', 'file'] as $topLevelFolder) { + $extractedFilePath = sprintf('var/backup/%s/%s/nested/folder/data.json', $backupName, $topLevelFolder); + $this->assertFileExists($extractedFilePath); + $this->assertSame(sprintf('{"nested":"%s"}', $topLevelFolder), file_get_contents($extractedFilePath)); + } + } + + /** + * Builds a minimal fixture ZIP containing a `summary.json`, and `node`/`relation`/`file` folders, each with a + * file nested one directory deep, optionally wrapped inside a single top level folder. + */ + private function buildFixtureZip(?string $wrapperFolder): string + { + $zipPath = sprintf('%s/backup-fetch-folder-structure-%s.zip', sys_get_temp_dir(), uniqid()); + $prefix = null === $wrapperFolder ? '' : $wrapperFolder.'/'; + + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE); + if (null !== $wrapperFolder) { + $zip->addEmptyDir($wrapperFolder); + } + $zip->addFromString($prefix.'summary.json', '{"nodeCount":1,"relationCount":1,"fileCount":0}'); + foreach (['node', 'relation', 'file'] as $topLevelFolder) { + $zip->addEmptyDir($prefix.$topLevelFolder); + $zip->addEmptyDir($prefix.$topLevelFolder.'/nested'); + $zip->addEmptyDir($prefix.$topLevelFolder.'/nested/folder'); + $zip->addFromString( + $prefix.$topLevelFolder.'/nested/folder/data.json', + sprintf('{"nested":"%s"}', $topLevelFolder) + ); + } + $zip->close(); + + return $zipPath; + } +} diff --git a/tests/FeatureTests/Endpoint/File/DeleteFileTest.php b/tests/FeatureTests/Endpoint/File/DeleteFileTest.php index 86d7d0d1..cd85d055 100644 --- a/tests/FeatureTests/Endpoint/File/DeleteFileTest.php +++ b/tests/FeatureTests/Endpoint/File/DeleteFileTest.php @@ -42,7 +42,7 @@ public function testDeleteFileFromNode(): void sprintf('%s/file', $location), self::TOKEN ); - $this->assertIsBinaryStreamResponse($getFileResponse, 'application/octet-stream'); + $this->assertIsBinaryStreamResponse($getFileResponse, 'image/jpeg'); // delete file $deleteFileResponse = $this->runDeleteRequest( @@ -115,7 +115,7 @@ public function testDeleteFileFromRelation(): void sprintf('%s/file', $location), self::TOKEN ); - $this->assertIsBinaryStreamResponse($getFileResponse, 'application/octet-stream'); + $this->assertIsBinaryStreamResponse($getFileResponse, 'image/jpeg'); // verify relation has file-property $getResponse = $this->runGetRequest( @@ -128,6 +128,9 @@ public function testDeleteFileFromRelation(): void 'contentLength' => 63933, 'extension' => 'bin', 'mimeType' => 'image/jpeg', + 'hash' => [ + 'sha256' => hash_file('sha256', __DIR__.'/../../Asset/cherry-blossoms.jpg'), + ], ], $data['file'] ); diff --git a/tests/FeatureTests/Endpoint/File/GetFileRangeTest.php b/tests/FeatureTests/Endpoint/File/GetFileRangeTest.php new file mode 100644 index 00000000..3e71c78e --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/GetFileRangeTest.php @@ -0,0 +1,149 @@ +/file` against the read-only "Rose" image from the + * "general.botanicExample" reference dataset scenario (138937 bytes). + */ +#[Group('test')] +class GetFileRangeTest extends BaseRequestTestCase +{ + private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; + private const string ROSE_ID = '0fdd52ba-55da-430c-b015-3277a231e895'; + private const int ROSE_CONTENT_LENGTH = 138937; + + private function getFullBody(): string + { + $response = $this->runGetRequest(sprintf('/%s/file', self::ROSE_ID), self::TOKEN); + $this->assertIsBinaryStreamResponse($response, 'image/jpeg'); + + return (string) $response->getBody(); + } + + public function testExplicitRangeReturnsPartialContent(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=0-99'] + ); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame(['100'], $response->getHeader('Content-Length')); + $this->assertSame([sprintf('bytes 0-99/%d', self::ROSE_CONTENT_LENGTH)], $response->getHeader('Content-Range')); + $this->assertSame(['bytes'], $response->getHeader('Accept-Ranges')); + + $body = (string) $response->getBody(); + $this->assertSame(100, strlen($body)); + $this->assertSame(substr($this->getFullBody(), 0, 100), $body); + } + + public function testOpenEndedRangeReturnsBytesUntilEndOfFile(): void + { + $start = self::ROSE_CONTENT_LENGTH - 50; + + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => sprintf('bytes=%d-', $start)] + ); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame( + [sprintf('bytes %d-%d/%d', $start, self::ROSE_CONTENT_LENGTH - 1, self::ROSE_CONTENT_LENGTH)], + $response->getHeader('Content-Range') + ); + + $body = (string) $response->getBody(); + $this->assertSame(50, strlen($body)); + $this->assertSame(substr($this->getFullBody(), $start), $body); + } + + public function testSuffixRangeReturnsLastBytesOfFile(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=-64'] + ); + + $this->assertSame(206, $response->getStatusCode()); + $expectedStart = self::ROSE_CONTENT_LENGTH - 64; + $this->assertSame( + [sprintf('bytes %d-%d/%d', $expectedStart, self::ROSE_CONTENT_LENGTH - 1, self::ROSE_CONTENT_LENGTH)], + $response->getHeader('Content-Range') + ); + + $body = (string) $response->getBody(); + $this->assertSame(64, strlen($body)); + $this->assertSame(substr($this->getFullBody(), -64), $body); + } + + public function testEndBeyondFileLengthIsClampedInsteadOfRejected(): void + { + $start = self::ROSE_CONTENT_LENGTH - 10; + + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => sprintf('bytes=%d-99999999', $start)] + ); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame( + [sprintf('bytes %d-%d/%d', $start, self::ROSE_CONTENT_LENGTH - 1, self::ROSE_CONTENT_LENGTH)], + $response->getHeader('Content-Range') + ); + $this->assertSame(10, strlen((string) $response->getBody())); + } + + public function testRangeStartBeyondEndOfFileReturnsRangeNotSatisfiable(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=999999999-'] + ); + + $this->assertIsProblemResponse($response, 416); + $body = \Safe\json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('total-length', $body); + $this->assertSame(self::ROSE_CONTENT_LENGTH, $body['total-length']); + } + + public function testMalformedRangeHeaderReturnsBadContent(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'not-a-valid-range'] + ); + + $this->assertIsProblemResponse($response, 400); + } + + public function testMultipleRangesReturnsBadContent(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=0-10,20-30'] + ); + + $this->assertIsProblemResponse($response, 400); + } + + public function testRequestWithoutRangeHeaderAdvertisesAcceptRanges(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', self::ROSE_ID), self::TOKEN); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['bytes'], $response->getHeader('Accept-Ranges')); + } +} diff --git a/tests/FeatureTests/Endpoint/File/GetFileTest.php b/tests/FeatureTests/Endpoint/File/GetFileTest.php index 53c2c5ef..1dc4d1b3 100644 --- a/tests/FeatureTests/Endpoint/File/GetFileTest.php +++ b/tests/FeatureTests/Endpoint/File/GetFileTest.php @@ -17,7 +17,7 @@ class GetFileTest extends BaseRequestTestCase public function testGetFileOnNodeWithFile(): void { $response = $this->runGetRequest(sprintf('/%s/file', self::NODE_WITH_FILE_UUID), self::TOKEN); - $this->assertIsBinaryStreamResponse($response, 'application/octet-stream'); + $this->assertIsBinaryStreamResponse($response, 'image/jpeg'); } public function testGetFileOnNodeWithoutFile(): void diff --git a/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionTest.php b/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionTest.php new file mode 100644 index 00000000..6187a0bd --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionTest.php @@ -0,0 +1,74 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'replace-file-with-different-extension', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $originalFile = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $originalUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $originalFile, + self::TOKEN, + ['Content-Disposition' => 'attachment; filename=original.jpg'] + ); + $this->assertIsCreatedResponse($originalUploadResponse, false); + + $originalDownloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($originalDownloadResponse, 'image/jpeg'); + $this->assertStringContainsString('.jpg', $originalDownloadResponse->getHeader('Content-Disposition')[0]); + + $replacementFilePath = __DIR__.'/../../Asset/replace-file-with-different-extension.bin'; + $this->generateDeterministicFile(24681357, 2048, $replacementFilePath); + $replacementContent = \Safe\file_get_contents($replacementFilePath); + $replacementFile = \Safe\fopen($replacementFilePath, 'r'); + + $replaceResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + $replacementFile, + self::TOKEN, + ['Content-Disposition' => 'attachment; filename=replacement.txt'] + ); + unlink($replacementFilePath); + $this->assertIsCreatedResponse($replaceResponse, false); + + $replacementDownloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($replacementDownloadResponse, 'text/plain'); + $this->assertStringContainsString('.txt', $replacementDownloadResponse->getHeader('Content-Disposition')[0]); + $this->assertSame($replacementContent, (string) $replacementDownloadResponse->getBody()); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadTest.php b/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadTest.php new file mode 100644 index 00000000..2d48b83b --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadTest.php @@ -0,0 +1,66 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + + return $this->getUuidFromLocation($response); + } + + public function testZeroByteFileCanBeUploadedDirectlyAndDownloadedAgain(): void + { + $elementId = $this->createElement('zero-byte-direct-upload'); + + // no 'Upload-Complete' header at all -> a direct, non-resumable upload of the (empty) request body + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsCreatedResponse($response, false); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame(200, $downloadResponse->getStatusCode()); + $this->assertSame(['0'], $downloadResponse->getHeader('Content-Length')); + $this->assertSame('', (string) $downloadResponse->getBody()); + $this->assertSame(['application/x-empty'], $downloadResponse->getHeader('Content-Type')); + + $elementResponse = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN); + $body = $this->getBody($elementResponse); + $this->assertSame(0, $body['file']['contentLength']); + $this->assertSame(64, strlen($body['file']['hash']['sha256'])); + $this->assertSame(hash('sha256', ''), $body['file']['hash']['sha256']); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/DeleteUploadTest.php b/tests/FeatureTests/Endpoint/Upload/DeleteUploadTest.php new file mode 100644 index 00000000..95f03025 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/DeleteUploadTest.php @@ -0,0 +1,85 @@ +generateDeterministicFile(self::FILE_SEED, self::FILE_SIZE, self::FILE_PATH); + $chunks = $this->splitFileToChunks(self::FILE_PATH, self::FILE_SIZE); + + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'delete-upload', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $chunk1 = \Safe\fopen($chunks[0], 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $chunk1, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Disposition' => 'inline; filename=delete-upload.bin', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + $actualOffset = (int) $createUploadResponse->getHeader('Upload-Offset')[0]; + + // sanity check: upload exists before deletion + $headResponseBeforeDelete = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponseBeforeDelete->getStatusCode()); + + $deleteUploadResponse = $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertIsDeletedResponse($deleteUploadResponse); + + $headResponseAfterDelete = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(404, $headResponseAfterDelete->getStatusCode()); + + $bogusChunkPath = __DIR__.'/../../Asset/delete-upload-chunk.bin'; + $this->generateDeterministicFile(self::FILE_SEED + 1, 1024, $bogusChunkPath); + $bogusChunk = \Safe\fopen($bogusChunkPath, 'r'); + $patchResponseAfterDelete = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $bogusChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => $actualOffset, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($patchResponseAfterDelete, 404); + + $this->cleanupChunks($chunks); + unlink(self::FILE_PATH); + unlink($bogusChunkPath); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/DeletingElementCleansUpInProgressUploadTest.php b/tests/FeatureTests/Endpoint/Upload/DeletingElementCleansUpInProgressUploadTest.php new file mode 100644 index 00000000..0d890d24 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/DeletingElementCleansUpInProgressUploadTest.php @@ -0,0 +1,108 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + + return $this->getUuidFromLocation($response); + } + + public function testDeletingElementRemovesUploadWithNoChunksUploadedYet(): void + { + $elementId = $this->createElement('delete-element-cleans-up-upload-no-chunks'); + + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $headResponseBeforeDelete = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponseBeforeDelete->getStatusCode()); + + $deleteElementResponse = $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + $this->assertIsDeletedResponse($deleteElementResponse); + + $headResponseAfterDelete = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(404, $headResponseAfterDelete->getStatusCode()); + } + + public function testDeletingElementRemovesUploadWithAnAlreadyUploadedChunk(): void + { + $elementId = $this->createElement('delete-element-cleans-up-upload-with-chunk'); + + $filePath = __DIR__.'/../../Asset/delete-element-cleans-up-upload-with-chunk.bin'; + $this->generateDeterministicFile(65432198, 5 * 1024 * 1024, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + unlink($filePath); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $deleteElementResponse = $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + $this->assertIsDeletedResponse($deleteElementResponse); + + $headResponseAfterDelete = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(404, $headResponseAfterDelete->getStatusCode()); + + // the upload resource is really gone, not just inaccessible - a further attempt to continue it must + // report the same "not found" a client would get for any other unknown upload id, not a crash + $patchResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 5 * 1024 * 1024, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($patchResponse, 404); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadIncorrectOffsetTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadIncorrectOffsetTest.php new file mode 100644 index 00000000..cdc5378a --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadIncorrectOffsetTest.php @@ -0,0 +1,80 @@ +generateDeterministicFile(self::FILE_SEED, self::FILE_SIZE, self::FILE_PATH); + $chunks = $this->splitFileToChunks(self::FILE_PATH, self::FILE_SIZE); + + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'patch-upload-incorrect-offset', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $chunk1 = \Safe\fopen($chunks[0], 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $chunk1, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Disposition' => 'inline; filename=patch-upload-incorrect-offset.bin', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + $actualOffset = (int) $createUploadResponse->getHeader('Upload-Offset')[0]; + + $incorrectOffset = $actualOffset + 1; + $bogusChunkPath = __DIR__.'/../../Asset/patch-upload-incorrect-offset-chunk.bin'; + $this->generateDeterministicFile(self::FILE_SEED + 1, 1024, $bogusChunkPath); + $bogusChunk = \Safe\fopen($bogusChunkPath, 'r'); + + $patchResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $bogusChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => $incorrectOffset, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($patchResponse, 409); + $body = $this->getBody($patchResponse); + $this->assertSame($actualOffset, $body['expected-offset']); + $this->assertSame($incorrectOffset, $body['provided-offset']); + + $this->cleanupChunks($chunks); + unlink(self::FILE_PATH); + unlink($bogusChunkPath); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadOversizedChunkTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadOversizedChunkTest.php new file mode 100644 index 00000000..0e65ca38 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadOversizedChunkTest.php @@ -0,0 +1,97 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + + return $this->getUuidFromLocation($response); + } + + private function getMaxAppendSizeInBytes(string $uploadLimitHeaderValue): int + { + $this->assertMatchesRegularExpression('/max-append-size=(\d+)/', $uploadLimitHeaderValue); + preg_match('/max-append-size=(\d+)/', $uploadLimitHeaderValue, $matches); + + return (int) $matches[1]; + } + + public function testOversizedFinalChunkIsRejected(): void + { + $elementId = $this->createElement('patch-upload-oversized-chunk'); + + $firstChunkPath = __DIR__.'/../../Asset/patch-upload-oversized-chunk-first.bin'; + $this->generateDeterministicFile(19283746, self::FIRST_CHUNK_SIZE, $firstChunkPath); + $firstChunk = \Safe\fopen($firstChunkPath, 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $firstChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + unlink($firstChunkPath); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $maxAppendSizeInBytes = $this->getMaxAppendSizeInBytes($createUploadResponse->getHeader('Upload-Limit')[0]); + + $oversizedChunkPath = __DIR__.'/../../Asset/patch-upload-oversized-chunk-second.bin'; + $this->generateDeterministicFile(56473829, $maxAppendSizeInBytes + 1, $oversizedChunkPath); + $oversizedChunk = \Safe\fopen($oversizedChunkPath, 'r'); + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $oversizedChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => self::FIRST_CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + ] + ); + unlink($oversizedChunkPath); + $this->assertIsProblemResponse($response, 400); + $body = $this->getBody($response); + $this->assertStringContainsString((string) $maxAppendSizeInBytes, $body['detail']); + + // the rejected chunk must not have advanced the upload's offset, nor marked it complete + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::FIRST_CHUNK_SIZE, $headResponse->getHeader('Upload-Offset')[0]); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadUndersizedIntermediateChunkTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadUndersizedIntermediateChunkTest.php new file mode 100644 index 00000000..993bb5e4 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadUndersizedIntermediateChunkTest.php @@ -0,0 +1,148 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + + return $this->getUuidFromLocation($response); + } + + /** + * @return array{0: string, 1: int} the created upload's id, and the offset it is now at + */ + private function createResumableUploadWithOneChunk(string $elementId, string $assetName): array + { + $filePath = __DIR__.'/../../Asset/'.$assetName; + $this->generateDeterministicFile(crc32($assetName), self::CHUNK_SIZE, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + unlink($filePath); + $this->assertNoContentResponse($response, true); + + return [$this->getUuidFromLocation($response), (int) $response->getHeader('Upload-Offset')[0]]; + } + + public function testZeroLengthIntermediateChunkIsRejected(): void + { + $elementId = $this->createElement('patch-upload-undersized-zero'); + [$uploadId, $offset] = $this->createResumableUploadWithOneChunk($elementId, 'patch-upload-undersized-zero.bin'); + + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Upload-Offset' => $offset, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($response, 400); + + // the rejected chunk must not have advanced the upload's offset + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame((string) $offset, $headResponse->getHeader('Upload-Offset')[0]); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + } + + public function testUndersizedNonZeroIntermediateChunkIsRejected(): void + { + $elementId = $this->createElement('patch-upload-undersized-nonzero'); + [$uploadId, $offset] = $this->createResumableUploadWithOneChunk($elementId, 'patch-upload-undersized-nonzero.bin'); + + $tinyChunkPath = __DIR__.'/../../Asset/patch-upload-undersized-nonzero-chunk.bin'; + $this->generateDeterministicFile(11223344, 1024, $tinyChunkPath); + $tinyChunk = \Safe\fopen($tinyChunkPath, 'r'); + + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $tinyChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Upload-Offset' => $offset, + 'Content-Type' => 'application/partial-upload', + ] + ); + unlink($tinyChunkPath); + $this->assertIsProblemResponse($response, 400); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + } + + /** + * Sanity check for the exemption itself: a zero-length chunk which *completes* the upload must still be + * accepted (already covered end-to-end by ZeroByteResumableUploadTest and ResumableUploadCreationTest, but + * checked directly against this validation here too). + */ + public function testZeroLengthFinalChunkIsStillAccepted(): void + { + $elementId = $this->createElement('patch-upload-zero-final-chunk'); + [$uploadId, $offset] = $this->createResumableUploadWithOneChunk($elementId, 'patch-upload-zero-final-chunk.bin'); + + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => $offset, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($response); + $this->assertSame('?1', $response->getHeader('Upload-Complete')[0]); + $this->assertSame((string) $offset, $response->getHeader('Upload-Offset')[0]); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/ResumableUploadCreationTest.php b/tests/FeatureTests/Endpoint/Upload/ResumableUploadCreationTest.php new file mode 100644 index 00000000..078f0ace --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/ResumableUploadCreationTest.php @@ -0,0 +1,124 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + + return $this->getUuidFromLocation($response); + } + + public function testResumableUploadCreationWithInitialData(): void + { + $elementId = $this->createElement('resumable-upload-creation-with-data'); + + $filePath = __DIR__.'/../../Asset/resumable-upload-creation-with-data.bin'; + $this->generateDeterministicFile(19283746, self::CHUNK_SIZE, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + unlink($filePath); + + $this->assertNoContentResponse($createUploadResponse, true); + $this->assertSame('?0', $createUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::CHUNK_SIZE, $createUploadResponse->getHeader('Upload-Offset')[0]); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + // the initial chunk was actually received and persisted, not just acknowledged + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::CHUNK_SIZE, $headResponse->getHeader('Upload-Offset')[0]); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + } + + public function testResumableUploadCreationWithoutInitialData(): void + { + $elementId = $this->createElement('resumable-upload-creation-without-data'); + + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + + $this->assertNoContentResponse($createUploadResponse, true); + $this->assertSame('?0', $createUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame('0', $createUploadResponse->getHeader('Upload-Offset')[0]); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + // the upload session exists and expects its first chunk at offset 0, even though no data was received yet + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + $this->assertSame('0', $headResponse->getHeader('Upload-Offset')[0]); + + // an empty-data creation is a genuinely usable upload session, not just an accepted-but-broken stub: the + // whole file can still be appended and completed from offset 0 via PATCH + $filePath = __DIR__.'/../../Asset/resumable-upload-creation-without-data.bin'; + $this->generateDeterministicFile(56473829, self::CHUNK_SIZE, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $finishUploadResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($finishUploadResponse); + $this->assertSame('?1', $finishUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::CHUNK_SIZE, $finishUploadResponse->getHeader('Upload-Offset')[0]); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $this->assertSame(self::CHUNK_SIZE, strlen((string) $downloadResponse->getBody())); + + unlink($filePath); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleTest.php b/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleTest.php new file mode 100644 index 00000000..b7cd9cc3 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleTest.php @@ -0,0 +1,93 @@ +generateDeterministicFile(self::FILE_SEED, self::FILE_SIZE, self::FILE_PATH); + $chunks = $this->splitFileToChunks(self::FILE_PATH, self::CHUNK_SIZE); + + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'resumable-upload-lifecycle', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + // create the upload with the first chunk + $chunk1 = \Safe\fopen($chunks[0], 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $chunk1, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Disposition' => 'inline; filename=resumable-upload-lifecycle.bin', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $this->assertSame('?0', $createUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::CHUNK_SIZE, $createUploadResponse->getHeader('Upload-Offset')[0]); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + // check upload status through HEAD + $headResponse1 = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse1->getStatusCode()); + $this->assertSame('?0', $headResponse1->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::CHUNK_SIZE, $headResponse1->getHeader('Upload-Offset')[0]); + + // upload the second, final chunk + $chunk2 = \Safe\fopen($chunks[1], 'r'); + $finishUploadResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $chunk2, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => self::CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($finishUploadResponse); + $this->assertSame('?1', $finishUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::FILE_SIZE, $finishUploadResponse->getHeader('Upload-Offset')[0]); + + // upload resource is gone once finished + $headResponse2 = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(404, $headResponse2->getStatusCode()); + + // verify the merged file is downloadable and matches what was uploaded + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $this->assertSame(self::FILE_SIZE, strlen((string) $downloadResponse->getBody())); + + $this->cleanupChunks($chunks); + unlink(self::FILE_PATH); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadTest.php b/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadTest.php new file mode 100644 index 00000000..5d42731e --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadTest.php @@ -0,0 +1,81 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'zero-byte-resumable-upload', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $this->assertSame('?0', $createUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame('0', $createUploadResponse->getHeader('Upload-Offset')[0]); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $finishUploadResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($finishUploadResponse); + $this->assertSame('?1', $finishUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame('0', $finishUploadResponse->getHeader('Upload-Offset')[0]); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame(200, $downloadResponse->getStatusCode()); + $this->assertSame(['0'], $downloadResponse->getHeader('Content-Length')); + $this->assertSame('', (string) $downloadResponse->getBody()); + $this->assertSame(['application/x-empty'], $downloadResponse->getHeader('Content-Type')); + + $elementResponseAfter = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN); + $body = $this->getBody($elementResponseAfter); + $this->assertSame(0, $body['file']['contentLength']); + $this->assertSame(hash('sha256', ''), $body['file']['hash']['sha256']); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/General/File/AnonymousFileAccessTest.php b/tests/FeatureTests/General/File/AnonymousFileAccessTest.php new file mode 100644 index 00000000..980a68bb --- /dev/null +++ b/tests/FeatureTests/General/File/AnonymousFileAccessTest.php @@ -0,0 +1,28 @@ +runGetRequest(sprintf('/%s', self::ANONYMOUS_DATA_ID), null); + $this->assertIsNodeResponse($elementResponse, 'Data'); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', self::ANONYMOUS_DATA_ID), null); + $this->assertIsBinaryStreamResponse($fileResponse, 'image/jpeg'); + } +} diff --git a/tests/FeatureTests/General/File/BotanicalFileTest.php b/tests/FeatureTests/General/File/BotanicalFileTest.php new file mode 100644 index 00000000..ad1fe2eb --- /dev/null +++ b/tests/FeatureTests/General/File/BotanicalFileTest.php @@ -0,0 +1,165 @@ +runGetRequest(sprintf('/%s/file', self::ROSE_ID), self::TOKEN); + $this->assertIsBinaryStreamResponse($response, 'image/jpeg'); + + // the element's 'name' property ("Rose") is used as the download's filename, with the stored file + // extension ("jpg") appended; see ElementService::getFileName(). + $this->assertStringContainsString('filename=Rose.jpg', $response->getHeader('Content-Disposition')[0]); + + $body = (string) $response->getBody(); + $this->assertSame(self::ROSE_CONTENT_LENGTH, strlen($body)); + $this->assertSame(self::ROSE_SHA256, hash('sha256', $body)); + } + + public function testDownloadedTulipImageHasCorrectHashAndLength(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', self::TULIP_ID), self::TOKEN); + $this->assertIsBinaryStreamResponse($response, 'image/jpeg'); + + $body = (string) $response->getBody(); + $this->assertSame(self::TULIP_CONTENT_LENGTH, strlen($body)); + $this->assertSame(self::TULIP_SHA256, hash('sha256', $body)); + } + + public function testReplaceOrchidImageWithNewFile(): void + { + $newFile = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $replaceResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', self::ORCHID_ID), + $newFile, + self::TOKEN, + [ + 'Content-Disposition' => 'inline; filename=cherry-blossoms.jpg', + 'Content-Type' => 'image/jpg', + ] + ); + $this->assertIsCreatedResponse($replaceResponse, false); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', self::ORCHID_ID), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'image/jpeg'); + $this->assertSame(63933, strlen((string) $downloadResponse->getBody())); + } + + public function testDeleteDaisyImage(): void + { + $deleteResponse = $this->runDeleteRequest(sprintf('/%s/file', self::DAISY_ID), self::TOKEN); + $this->assertIsDeletedResponse($deleteResponse); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', self::DAISY_ID), self::TOKEN); + $this->assertIsProblemResponse($downloadResponse, 404); + } + + public function testReplaceAzaleaImageWithResumableUpload(): void + { + $fileSeed = 91827364; + $fileSize = 6 * 1024 * 1024; + $chunkSize = 5 * 1024 * 1024; // first chunk must be at least 5MB, see uploadMinChunkSizeInBytes + $filePath = __DIR__.'/../../Asset/azalea-replacement.bin'; + + $this->generateDeterministicFile($fileSeed, $fileSize, $filePath); + $chunks = $this->splitFileToChunks($filePath, $chunkSize); + + $firstChunk = \Safe\fopen($chunks[0], 'r'); + $createUploadResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', self::AZALEA_ID), + $firstChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Disposition' => 'inline; filename=azalea-replacement.bin', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $secondChunk = \Safe\fopen($chunks[1], 'r'); + $finishUploadResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $secondChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => $chunkSize, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($finishUploadResponse); + $this->assertSame('?1', $finishUploadResponse->getHeader('Upload-Complete')[0]); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', self::AZALEA_ID), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $this->assertSame($fileSize, strlen((string) $downloadResponse->getBody())); + + $this->cleanupChunks($chunks); + unlink($filePath); + } + + public function testReplaceCherryBlossomImageWithBogusGeneratedFile(): void + { + $fileSeed = 55667788; + $fileSize = 32 * 1024; + $filePath = __DIR__.'/../../Asset/cherry-blossom-bogus.bin'; + + $this->generateDeterministicFile($fileSeed, $fileSize, $filePath); + $expectedHash = hash_file('sha256', $filePath); + + $bogusFile = \Safe\fopen($filePath, 'r'); + $replaceResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', self::CHERRY_BLOSSOM_ID), + $bogusFile, + self::TOKEN, + [ + 'Content-Disposition' => 'inline; filename=bogus.bin', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($replaceResponse, false); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', self::CHERRY_BLOSSOM_ID), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $downloadedBody = (string) $downloadResponse->getBody(); + $this->assertSame($fileSize, strlen($downloadedBody)); + $this->assertSame($expectedHash, hash('sha256', $downloadedBody)); + + unlink($filePath); + } +} diff --git a/tests/FeatureTests/General/File/FileDigestTest.php b/tests/FeatureTests/General/File/FileDigestTest.php new file mode 100644 index 00000000..ef3d42b4 --- /dev/null +++ b/tests/FeatureTests/General/File/FileDigestTest.php @@ -0,0 +1,346 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + + return $this->getUuidFromLocation($response); + } + + public function testDownloadResponseIncludesReprDigestHeader(): void + { + $elementId = $this->createElement('digest-response-headers'); + + $filePath = __DIR__.'/../../Asset/file-digest-response.bin'; + $this->generateDeterministicFile(11223344, 4096, $filePath); + $expectedHash = hash_file('sha256', $filePath); + $expectedHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($expectedHash))); + + $file = \Safe\fopen($filePath, 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + unlink($filePath); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $this->assertSame([$expectedHeaderValue], $downloadResponse->getHeader('Repr-Digest')); + // Content-Digest is deliberately not set at all: for a full response it would always equal Repr-Digest + // (no extra information), and it is never set on a partial response either (see below), so it never + // carries any information Repr-Digest doesn't already provide. + $this->assertSame([], $downloadResponse->getHeader('Content-Digest')); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testRangedResponseIncludesReprDigestButNotContentDigest(): void + { + $elementId = $this->createElement('digest-ranged-response'); + + $filePath = __DIR__.'/../../Asset/file-digest-ranged.bin'; + $this->generateDeterministicFile(55667788, 4096, $filePath); + $expectedHash = hash_file('sha256', $filePath); + $expectedHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($expectedHash))); + + $file = \Safe\fopen($filePath, 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + unlink($filePath); + + $downloadResponse = $this->runGetRequest( + sprintf('/%s/file', $elementId), + self::TOKEN, + ['Range' => 'bytes=0-99'] + ); + $this->assertSame(206, $downloadResponse->getStatusCode()); + $this->assertSame([$expectedHeaderValue], $downloadResponse->getHeader('Repr-Digest')); + $this->assertSame([], $downloadResponse->getHeader('Content-Digest')); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testUploadWithCorrectDigestSucceeds(): void + { + $elementId = $this->createElement('digest-upload-correct'); + + $filePath = __DIR__.'/../../Asset/file-digest-correct.bin'; + $this->generateDeterministicFile(99887766, 2048, $filePath); + $hash = hash_file('sha256', $filePath); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($hash))); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertIsCreatedResponse($response, false); + unlink($filePath); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testUploadWithMismatchedDigestIsRejected(): void + { + $elementId = $this->createElement('digest-upload-mismatch'); + + $filePath = __DIR__.'/../../Asset/file-digest-mismatch.bin'; + $this->generateDeterministicFile(13245768, 2048, $filePath); + + $bogusHash = str_repeat('00', 32); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($bogusHash))); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertIsProblemResponse($response, 400); + unlink($filePath); + + // the element must not end up with a file property, since the upload was rejected + $getResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsProblemResponse($getResponse, 404); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testUploadWithUnsupportedDigestAlgorithmIsRejected(): void + { + $elementId = $this->createElement('digest-upload-unsupported'); + + $filePath = __DIR__.'/../../Asset/file-digest-unsupported.bin'; + $this->generateDeterministicFile(24681012, 2048, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Repr-Digest' => 'md5=:1B2M2Y8AsgTpgAmY7PhCfg==:', + ] + ); + $this->assertIsProblemResponse($response, 400); + unlink($filePath); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testResumableUploadCompletionVerifiesDigest(): void + { + $elementId = $this->createElement('digest-resumable'); + + $fileSeed = 987654321; + $fileSize = 6 * 1024 * 1024; + $chunkSize = 5 * 1024 * 1024; + $filePath = __DIR__.'/../../Asset/file-digest-resumable.bin'; + + $this->generateDeterministicFile($fileSeed, $fileSize, $filePath); + $hash = hash_file('sha256', $filePath); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($hash))); + $chunks = $this->splitFileToChunks($filePath, $chunkSize); + + $firstChunk = \Safe\fopen($chunks[0], 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $firstChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $secondChunk = \Safe\fopen($chunks[1], 'r'); + $finishUploadResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $secondChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => $chunkSize, + 'Content-Type' => 'application/partial-upload', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertNoContentResponse($finishUploadResponse); + + $this->cleanupChunks($chunks); + unlink($filePath); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testReplacingFileWithMismatchedDigestLeavesOriginalFileIntact(): void + { + $elementId = $this->createElement('digest-replace-mismatch'); + + $originalFilePath = __DIR__.'/../../Asset/file-digest-replace-original.bin'; + $this->generateDeterministicFile(11223344, 2048, $originalFilePath); + $originalContent = \Safe\file_get_contents($originalFilePath); + + $originalFile = \Safe\fopen($originalFilePath, 'r'); + $originalUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $originalFile, + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsCreatedResponse($originalUploadResponse, false); + unlink($originalFilePath); + + $replacementFilePath = __DIR__.'/../../Asset/file-digest-replace-replacement.bin'; + $this->generateDeterministicFile(55667788, 2048, $replacementFilePath); + $bogusHash = str_repeat('00', 32); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($bogusHash))); + + $replacementFile = \Safe\fopen($replacementFilePath, 'r'); + $replacementResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + $replacementFile, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertIsProblemResponse($replacementResponse, 400); + unlink($replacementFilePath); + + // the rejected replacement must not have overwritten the original file + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame(200, $downloadResponse->getStatusCode()); + $this->assertSame($originalContent, (string) $downloadResponse->getBody()); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testResumableReplaceWithMismatchedDigestLeavesOriginalFileIntact(): void + { + $elementId = $this->createElement('digest-resumable-replace-mismatch'); + + $originalFilePath = __DIR__.'/../../Asset/file-digest-resumable-replace-original.bin'; + $this->generateDeterministicFile(99001122, 2048, $originalFilePath); + $originalContent = \Safe\file_get_contents($originalFilePath); + + $originalFile = \Safe\fopen($originalFilePath, 'r'); + $originalUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $originalFile, + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsCreatedResponse($originalUploadResponse, false); + unlink($originalFilePath); + + $replacementFileSeed = 33445566; + $replacementFileSize = 6 * 1024 * 1024; + $chunkSize = 5 * 1024 * 1024; + $replacementFilePath = __DIR__.'/../../Asset/file-digest-resumable-replace-replacement.bin'; + + $this->generateDeterministicFile($replacementFileSeed, $replacementFileSize, $replacementFilePath); + $chunks = $this->splitFileToChunks($replacementFilePath, $chunkSize); + $bogusHash = str_repeat('00', 32); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($bogusHash))); + + $firstChunk = \Safe\fopen($chunks[0], 'r'); + $createUploadResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + $firstChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $secondChunk = \Safe\fopen($chunks[1], 'r'); + $finishUploadResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $secondChunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => $chunkSize, + 'Content-Type' => 'application/partial-upload', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertIsProblemResponse($finishUploadResponse, 400); + + $this->cleanupChunks($chunks); + unlink($replacementFilePath); + + // the rejected replacement must not have overwritten the original file + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame(200, $downloadResponse->getStatusCode()); + $this->assertSame($originalContent, (string) $downloadResponse->getBody()); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/General/File/FileNameFallbackTest.php b/tests/FeatureTests/General/File/FileNameFallbackTest.php new file mode 100644 index 00000000..b2416816 --- /dev/null +++ b/tests/FeatureTests/General/File/FileNameFallbackTest.php @@ -0,0 +1,103 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $filePath = __DIR__.'/../../Asset/filename-fallback-no-name.bin'; + $this->generateDeterministicFile(13572468, 16, $filePath); + $file = \Safe\fopen($filePath, 'r'); + + // deliberately no Content-Disposition header on upload, so the extension also falls back to the default + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + unlink($filePath); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $this->assertStringContainsString( + sprintf('filename=%s.bin', $elementId), + $downloadResponse->getHeader('Content-Disposition')[0] + ); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testDownloadFilenameUsesExplicitNamePropertyWhenSet(): void + { + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'someOtherFilename', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $filePath = __DIR__.'/../../Asset/filename-fallback-explicit-name.bin'; + $this->generateDeterministicFile(24681357, 16, $filePath); + $file = \Safe\fopen($filePath, 'r'); + + // deliberately no Content-Disposition header on upload, so the extension also falls back to the default + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + unlink($filePath); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $this->assertStringContainsString( + 'filename=someOtherFilename.bin', + $downloadResponse->getHeader('Content-Disposition')[0] + ); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/General/File/FileRemovedWhenElementRemovedTest.php b/tests/FeatureTests/General/File/FileRemovedWhenElementRemovedTest.php new file mode 100644 index 00000000..aa957e34 --- /dev/null +++ b/tests/FeatureTests/General/File/FileRemovedWhenElementRemovedTest.php @@ -0,0 +1,54 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'file-removed-when-element-removed', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($postFileResponse, false); + + $getFileResponse1 = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($getFileResponse1, 'image/jpeg'); + + $deleteElementResponse = $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + $this->assertIsDeletedResponse($deleteElementResponse); + + $getElementResponse = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN); + $this->assertIsProblemResponse($getElementResponse, 404); + + $getFileResponse2 = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsProblemResponse($getFileResponse2, 404); + } +} diff --git a/tests/FeatureTests/General/File/FileTopLevelPropertyTest.php b/tests/FeatureTests/General/File/FileTopLevelPropertyTest.php new file mode 100644 index 00000000..089f66e0 --- /dev/null +++ b/tests/FeatureTests/General/File/FileTopLevelPropertyTest.php @@ -0,0 +1,229 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + $elementId = $this->getUuidFromLocation($postResponse); + + $filePath = __DIR__.'/../../Asset/file-top-level-property.bin'; + $this->generateDeterministicFile(19283746, 128, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $fileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($fileResponse, false); + unlink($filePath); + + return $elementId; + } + + public function testFileIsTopLevelAndHasFileIsInData(): void + { + $elementId = $this->createElementWithFile('top-level-file-property'); + + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN); + $body = \Safe\json_decode((string) $response->getBody(), true); + + $this->assertArrayHasKey('file', $body); + $this->assertArrayNotHasKey('file', $body['data']); + $this->assertArrayHasKey('contentLength', $body['file']); + $this->assertTrue($body['data']['hasFile']); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testCollectionListingIncludesFileAndHasFile(): void + { + // a dedicated, freshly created parent is used (instead of e.g. the shared botanicExample user's + // children) so the collection queried here is not shared with, and can not race against, any other + // (parallel) test. + $parentResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'collection-includes-file-parent', + ], + ] + ); + $parentId = $this->getUuidFromLocation($parentResponse); + + $childResponse = $this->runPostRequest( + sprintf('/%s', $parentId), + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'collection-includes-file-child', + ], + ] + ); + $elementId = $this->getUuidFromLocation($childResponse); + + $filePath = __DIR__.'/../../Asset/file-top-level-property-collection.bin'; + $this->generateDeterministicFile(56473829, 128, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $fileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($fileResponse, false); + unlink($filePath); + + $childrenResponse = $this->runGetRequest(sprintf('/%s/children', $parentId), self::TOKEN); + $childrenBody = \Safe\json_decode((string) $childrenResponse->getBody(), true); + + $this->assertCount(1, $childrenBody['nodes']); + $foundElement = $childrenBody['nodes'][0]; + $this->assertSame($elementId, $foundElement['id']); + $this->assertTrue($foundElement['data']['hasFile']); + $this->assertArrayHasKey('file', $foundElement); + $this->assertSame(128, $foundElement['file']['contentLength']); + $this->assertArrayHasKey('mimeType', $foundElement['file']); + $this->assertArrayHasKey('extension', $foundElement['file']); + $this->assertSame(64, strlen($foundElement['file']['hash']['sha256'])); + $this->assertArrayNotHasKey('file', $foundElement['data']); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $parentId), self::TOKEN); + } + + public function testHasFileIsFalseAfterFileDeletion(): void + { + $elementId = $this->createElementWithFile('has-file-false-after-delete'); + + $this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN); + + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN); + $body = \Safe\json_decode((string) $response->getBody(), true); + + $this->assertArrayNotHasKey('file', $body); + $this->assertFalse($body['data']['hasFile']); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testDirectWriteToFilePropertyIsRejectedOnCreate(): void + { + $response = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'file' => ['contentLength' => 999], + ], + ] + ); + $this->assertIsProblemResponse($response, 400); + } + + public function testDirectWriteToHasFilePropertyIsRejectedOnCreate(): void + { + $response = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'hasFile' => true, + ], + ] + ); + $this->assertIsProblemResponse($response, 400); + } + + public function testDirectWriteToFilePropertyIsRejectedOnPatch(): void + { + $elementId = $this->createElementWithFile('patch-rejects-file'); + + $response = $this->runPatchRequest( + sprintf('/%s', $elementId), + self::TOKEN, + ['file' => ['contentLength' => 999]] + ); + $this->assertIsProblemResponse($response, 400); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testDirectWriteToHasFilePropertyIsRejectedOnPatch(): void + { + $elementId = $this->createElementWithFile('patch-rejects-has-file'); + + $response = $this->runPatchRequest( + sprintf('/%s', $elementId), + self::TOKEN, + ['hasFile' => false] + ); + $this->assertIsProblemResponse($response, 400); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testPutFullReplaceDoesNotWipeFile(): void + { + $elementId = $this->createElementWithFile('put-preserves-file'); + + $putResponse = $this->runPutRequest( + sprintf('/%s', $elementId), + self::TOKEN, + ['name' => 'replaced-via-put'] + ); + $this->assertNoContentResponse($putResponse); + + $getResponse = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN); + $body = \Safe\json_decode((string) $getResponse->getBody(), true); + + $this->assertArrayHasKey('file', $body); + $this->assertTrue($body['data']['hasFile']); + $this->assertSame('replaced-via-put', $body['data']['name']); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($fileResponse, 'text/plain'); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/General/File/FileUploadUpdatesElementTest.php b/tests/FeatureTests/General/File/FileUploadUpdatesElementTest.php new file mode 100644 index 00000000..1af33606 --- /dev/null +++ b/tests/FeatureTests/General/File/FileUploadUpdatesElementTest.php @@ -0,0 +1,181 @@ +runGetRequest(sprintf('/%s', $elementId), self::TOKEN); + $etagHeader = $response->getHeader('ETag'); + $this->assertCount(1, $etagHeader, 'Expected exactly one ETag header on the element response.'); + + return $etagHeader[0]; + } + + private function getElementUpdatedTimestamp(string $elementId): string + { + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN); + $body = \Safe\json_decode((string) $response->getBody(), true); + + return $body['data']['updated']; + } + + public function testAttachingAFileUpdatesTheElement(): void + { + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'file-upload-updates-element', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $etagBeforeUpload = $this->getElementEtag($elementId); + $updatedBeforeUpload = $this->getElementUpdatedTimestamp($elementId); + + // ensure the 'updated' timestamp (second precision) has a chance to actually move forward + sleep(1); + + $filePath = __DIR__.'/../../Asset/file-upload-updates-element.bin'; + $this->generateDeterministicFile(11223344, 128, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $fileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($fileResponse, false); + unlink($filePath); + + $etagAfterUpload = $this->getElementEtag($elementId); + $updatedAfterUpload = $this->getElementUpdatedTimestamp($elementId); + + $this->assertNotSame($etagBeforeUpload, $etagAfterUpload); + $this->assertNotSame($updatedBeforeUpload, $updatedAfterUpload); + $this->assertGreaterThan( + new DateTime($updatedBeforeUpload), + new DateTime($updatedAfterUpload) + ); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testReplacingAFileUpdatesTheElement(): void + { + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'file-replace-updates-element', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $firstFilePath = __DIR__.'/../../Asset/file-replace-updates-element-1.bin'; + $this->generateDeterministicFile(22334455, 128, $firstFilePath); + $firstFile = \Safe\fopen($firstFilePath, 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $firstFile, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + unlink($firstFilePath); + + $etagBeforeReplace = $this->getElementEtag($elementId); + + sleep(1); + + $secondFilePath = __DIR__.'/../../Asset/file-replace-updates-element-2.bin'; + $this->generateDeterministicFile(33445566, 128, $secondFilePath); + $secondFile = \Safe\fopen($secondFilePath, 'r'); + $putFileResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + $secondFile, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($putFileResponse, false); + unlink($secondFilePath); + + $etagAfterReplace = $this->getElementEtag($elementId); + $this->assertNotSame($etagBeforeReplace, $etagAfterReplace); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testDeletingAFileUpdatesTheElement(): void + { + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'file-delete-updates-element', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $filePath = __DIR__.'/../../Asset/file-delete-updates-element.bin'; + $this->generateDeterministicFile(44556677, 128, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + unlink($filePath); + + $etagBeforeDelete = $this->getElementEtag($elementId); + + sleep(1); + + $deleteFileResponse = $this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsDeletedResponse($deleteFileResponse); + + $etagAfterDelete = $this->getElementEtag($elementId); + $this->assertNotSame($etagBeforeDelete, $etagAfterDelete); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php b/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php index 497d404a..a5af4aea 100644 --- a/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php +++ b/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php @@ -112,6 +112,9 @@ public function testMultiPartUploadLifecycle(): void 'contentLength' => 24117248, 'extension' => 'txt', 'mimeType' => 'text/plain', + 'hash' => [ + 'sha256' => hash_file('sha256', self::FILE_PATH), + ], ], $getNodeResponseData2['file'] ); @@ -121,7 +124,7 @@ public function testMultiPartUploadLifecycle(): void sprintf('/%s/file', $elementId), self::TOKEN ); - $this->assertIsBinaryStreamResponse($getFileResponse2, 'application/octet-stream'); + $this->assertIsBinaryStreamResponse($getFileResponse2, 'text/plain'); $this->cleanupChunks($chunks); } diff --git a/tests/FeatureTests/General/File/SimpleFileInteractivityTest.php b/tests/FeatureTests/General/File/SimpleFileInteractivityTest.php new file mode 100644 index 00000000..2a5f3227 --- /dev/null +++ b/tests/FeatureTests/General/File/SimpleFileInteractivityTest.php @@ -0,0 +1,85 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'simple-file-interactivity', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + // post file directly + $firstFile = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $firstFile, + self::TOKEN, + [ + 'Content-Disposition' => 'inline; filename=cherry-blossoms.jpg', + 'Content-Type' => 'image/jpg', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + + $getFileResponse1 = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($getFileResponse1, 'image/jpeg'); + $this->assertSame(63933, strlen((string) $getFileResponse1->getBody())); + + // put replace file + $secondFilePath = __DIR__.'/../../Asset/simple-file-interactivity-replacement.bin'; + $this->generateDeterministicFile(24681012, 16 * 1024, $secondFilePath); + $secondFile = \Safe\fopen($secondFilePath, 'r'); + $putFileResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + $secondFile, + self::TOKEN, + [ + 'Content-Disposition' => 'inline; filename=simple-file-interactivity-replacement.bin', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($putFileResponse, false); + + $getFileResponse2 = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($getFileResponse2, 'text/plain'); + $this->assertNotSame( + (string) $getFileResponse1->getBody(), + (string) $getFileResponse2->getBody() + ); + + unlink($secondFilePath); + + // delete file + $deleteFileResponse = $this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsDeletedResponse($deleteFileResponse); + + // get file -> 404 + $getFileResponse3 = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsProblemResponse($getFileResponse3, 404); + } +} diff --git a/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php b/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php index 5a3dbb24..b12ea332 100644 --- a/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php +++ b/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php @@ -69,6 +69,9 @@ public function testSinglePartUploadLifecycle(): void 'contentLength' => 63933, 'extension' => 'jpg', 'mimeType' => 'image/jpeg', + 'hash' => [ + 'sha256' => hash_file('sha256', __DIR__.'/../../Asset/cherry-blossoms.jpg'), + ], ], $getNodeResponseData2['file'] ); @@ -78,7 +81,7 @@ public function testSinglePartUploadLifecycle(): void sprintf('/%s/file', $elementId), self::TOKEN ); - $this->assertIsBinaryStreamResponse($getFileResponse2, 'application/octet-stream'); + $this->assertIsBinaryStreamResponse($getFileResponse2, 'image/jpeg'); // delete file ------------------------------------------------------------------------------------------------- $deleteFileResponse = $this->runDeleteRequest( diff --git a/tests/FeatureTests/Security/FilePermissionTest.php b/tests/FeatureTests/Security/FilePermissionTest.php new file mode 100644 index 00000000..5c438f70 --- /dev/null +++ b/tests/FeatureTests/Security/FilePermissionTest.php @@ -0,0 +1,38 @@ +runGetRequest(sprintf('/%s', self::DATA_ID), self::TOKEN_USER_A); + $this->assertIsNodeResponse($elementResponse, 'Data'); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', self::DATA_ID), self::TOKEN_USER_A); + $this->assertIsBinaryStreamResponse($fileResponse, 'image/jpeg'); + } + + public function testUserWithoutAccessGetsNotFoundForElementAndFile(): void + { + $elementResponse = $this->runGetRequest(sprintf('/%s', self::DATA_ID), self::TOKEN_USER_B); + $this->assertIsProblemResponse($elementResponse, 404); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', self::DATA_ID), self::TOKEN_USER_B); + $this->assertIsProblemResponse($fileResponse, 404); + } +} diff --git a/tests/FeatureTests/Security/_03_01_GroupImmediateNodeOwnershipTest.php b/tests/FeatureTests/Security/_03_01_GroupImmediateNodeOwnershipTest.php index c6d8318a..650076f4 100644 --- a/tests/FeatureTests/Security/_03_01_GroupImmediateNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/_03_01_GroupImmediateNodeOwnershipTest.php @@ -30,6 +30,12 @@ public function testGetAllowedNodes(): void $this->assertIsNodeResponse($response, 'Data'); } + public function testGetAllowedNodeFile(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', self::DATA), self::TOKEN); + $this->assertIsBinaryStreamResponse($response, 'image/jpeg'); + } + public function testGetAllowedRelations(): void { $response = $this->runGetRequest(sprintf('/%s', self::IS_IN_GROUP), self::TOKEN); diff --git a/tests/FeatureTests/Security/_99_02_OwningGroupsGiveDirectAccessButNotToRelatedGroupsTest.php b/tests/FeatureTests/Security/_99_02_OwningGroupsGiveDirectAccessButNotToRelatedGroupsTest.php index 38c58223..cbe54dcd 100644 --- a/tests/FeatureTests/Security/_99_02_OwningGroupsGiveDirectAccessButNotToRelatedGroupsTest.php +++ b/tests/FeatureTests/Security/_99_02_OwningGroupsGiveDirectAccessButNotToRelatedGroupsTest.php @@ -35,9 +35,21 @@ public function testGetAllowedNodes(): void $this->assertIsNodeResponse($response, 'Data'); } + public function testGetAllowedNodeFile(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', self::DATA_1), self::TOKEN); + $this->assertIsBinaryStreamResponse($response, 'image/jpeg'); + } + public function testGetForbiddenNode(): void { $response = $this->runGetRequest(sprintf('/%s', self::DATA_3), self::TOKEN); $this->assertIsProblemResponse($response, 404); } + + public function testGetForbiddenNodeFile(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', self::DATA_3), self::TOKEN); + $this->assertIsProblemResponse($response, 404); + } } diff --git a/tests/UnitTests/Command/Cron/DeleteExpiredUploadsCommandTest.php b/tests/UnitTests/Command/Cron/DeleteExpiredUploadsCommandTest.php new file mode 100644 index 00000000..72d78f5f --- /dev/null +++ b/tests/UnitTests/Command/Cron/DeleteExpiredUploadsCommandTest.php @@ -0,0 +1,269 @@ +> $expiredUploadRows + */ + private function buildCommand( + bool $isCronDisabled = false, + array $expiredUploadRows = [], + ?ElementManager $elementManager = null, + ?UploadFactory $uploadFactory = null, + ?UploadService $uploadService = null, + int $expiredUploadCanBeDeletedAfterExpirationInSeconds = 3600, + ?ClientInterface $client = null, + ): DeleteExpiredUploadsCommand { + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn($isCronDisabled); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileExpiredUploadCanBeDeletedAfterExpirationInSeconds() + ->willReturn($expiredUploadCanBeDeletedAfterExpirationInSeconds); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + if ($isCronDisabled) { + $cypherEntityManager->getClient()->shouldNotBeCalled(); + } elseif (null !== $client) { + $cypherEntityManager->getClient()->willReturn($client); + } else { + $clientProphecy = $this->prophesize(ClientInterface::class); + $summaryReference = null; + $rows = array_map(static fn (array $row) => new CypherMap($row), $expiredUploadRows); + $clientProphecy->runStatement(Argument::any())->willReturn(new SummarizedResult($summaryReference, $rows)); + $cypherEntityManager->getClient()->willReturn($clientProphecy->reveal()); + } + + return new DeleteExpiredUploadsCommand( + $bag->reveal(), + $emberNexusConfiguration->reveal(), + $cypherEntityManager->reveal(), + $elementManager ?? $this->prophesize(ElementManager::class)->reveal(), + $uploadFactory ?? $this->prophesize(UploadFactory::class)->reveal(), + $uploadService ?? $this->prophesize(UploadService::class)->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + } + + public function testCommandStopsEarlyIfCronIsDisabled(): void + { + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any())->shouldNotBeCalled(); + + $command = $this->buildCommand(isCronDisabled: true, elementManager: $elementManager->reveal()); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Cron is disabled', $commandTester->getDisplay()); + } + + public function testCommandThrowsIfCronDisabledParameterIsNotBoolean(): void + { + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn('not-a-boolean'); + + $command = new DeleteExpiredUploadsCommand( + $bag->reveal(), + $this->prophesize(EmberNexusConfiguration::class)->reveal(), + $this->prophesize(CypherEntityManager::class)->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(UploadFactory::class)->reveal(), + $this->prophesize(UploadService::class)->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + + $this->expectException(LogicException::class); + (new CommandTester($command))->execute([]); + } + + public function testCommandDoesNothingIfNoUploadsAreExpired(): void + { + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any())->shouldNotBeCalled(); + $uploadService = $this->prophesize(UploadService::class); + $uploadService->deleteUploadAndChunks(Argument::any())->shouldNotBeCalled(); + + $command = $this->buildCommand( + expiredUploadRows: [], + elementManager: $elementManager->reveal(), + uploadService: $uploadService->reveal() + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('No expired uploads found.', $commandTester->getDisplay()); + } + + public function testCommandDeletesSingleExpiredUploadIncludingItsChunks(): void + { + $uploadId = Uuid::fromString('7d7c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d1'); + $uploadTarget = Uuid::fromString('8f8c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d2'); + $uploadOwner = Uuid::fromString('9a9c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d3'); + + $upload = new Upload( + $uploadId, + null, + 5 * 1024 * 1024, + false, + $uploadTarget, + 2, + $uploadOwner, + 'bin', + (new DateTime())->modify('-1 hour') + ); + + $uploadElement = (new NodeElement())->setId($uploadId)->setLabel('Upload'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $uploadId->toString())) + ->shouldBeCalledOnce() + ->willReturn($uploadElement); + $elementManager->flush()->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + + $uploadFactory = $this->prophesize(UploadFactory::class); + $uploadFactory->createUploadFromElement(Argument::is($uploadElement))->willReturn($upload); + + $uploadService = $this->prophesize(UploadService::class); + $uploadService->deleteUploadAndChunks(Argument::is($upload))->shouldBeCalledOnce(); + + $command = $this->buildCommand( + expiredUploadRows: [['u.id' => $uploadId->toString()]], + elementManager: $elementManager->reveal(), + uploadFactory: $uploadFactory->reveal(), + uploadService: $uploadService->reveal() + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Deleted 1 expired upload(s).', $commandTester->getDisplay()); + } + + public function testCommandSkipsUploadWhichWasAlreadyDeletedInTheMeantime(): void + { + $uploadId = Uuid::fromString('1c1c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d1'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any())->willReturn(null); + + $uploadService = $this->prophesize(UploadService::class); + $uploadService->deleteUploadAndChunks(Argument::any())->shouldNotBeCalled(); + + $command = $this->buildCommand( + expiredUploadRows: [['u.id' => $uploadId->toString()]], + elementManager: $elementManager->reveal(), + uploadService: $uploadService->reveal() + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Deleted 1 expired upload(s).', $commandTester->getDisplay()); + } + + public function testCommandAppliesGracePeriodToDeletionThreshold(): void + { + $gracePeriodInSeconds = 1800; + $summaryReference = null; + + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::that(function (Statement $statement) use ($gracePeriodInSeconds) { + $parameters = $statement->getParameters(); + if (!isset($parameters['deletionThreshold']) || !$parameters['deletionThreshold'] instanceof DateTime) { + return false; + } + $expectedThreshold = (new DateTime())->modify(sprintf('-%d seconds', $gracePeriodInSeconds)); + $actualThreshold = $parameters['deletionThreshold']; + + // allow a small delta to account for test execution time + return abs($expectedThreshold->getTimestamp() - $actualThreshold->getTimestamp()) <= 2; + }))->shouldBeCalledOnce()->willReturn(new SummarizedResult($summaryReference, [])); + + $command = $this->buildCommand( + expiredUploadCanBeDeletedAfterExpirationInSeconds: $gracePeriodInSeconds, + client: $client->reveal() + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + } + + public function testCommandDeletesMultipleExpiredUploads(): void + { + $uploadId1 = Uuid::fromString('2d2c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d1'); + $uploadId2 = Uuid::fromString('3e3c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d2'); + + $buildUpload = fn ($id) => new Upload($id, null, 0, false, $id, 0, $id, 'bin', (new DateTime())->modify('-1 hour')); + + $uploadElement1 = (new NodeElement())->setId($uploadId1)->setLabel('Upload'); + $uploadElement2 = (new NodeElement())->setId($uploadId2)->setLabel('Upload'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $uploadId1->toString()))->willReturn($uploadElement1); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $uploadId2->toString()))->willReturn($uploadElement2); + $elementManager->flush()->willReturn($elementManager->reveal()); + + $uploadFactory = $this->prophesize(UploadFactory::class); + $uploadFactory->createUploadFromElement(Argument::is($uploadElement1))->willReturn($buildUpload($uploadId1)); + $uploadFactory->createUploadFromElement(Argument::is($uploadElement2))->willReturn($buildUpload($uploadId2)); + + $uploadService = $this->prophesize(UploadService::class); + $uploadService->deleteUploadAndChunks(Argument::any())->shouldBeCalledTimes(2); + + $command = $this->buildCommand( + expiredUploadRows: [ + ['u.id' => $uploadId1->toString()], + ['u.id' => $uploadId2->toString()], + ], + elementManager: $elementManager->reveal(), + uploadFactory: $uploadFactory->reveal(), + uploadService: $uploadService->reveal() + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertStringContainsString('Deleted 2 expired upload(s).', $commandTester->getDisplay()); + } +} diff --git a/tests/UnitTests/Command/Cron/ReindexFilesCommandTest.php b/tests/UnitTests/Command/Cron/ReindexFilesCommandTest.php new file mode 100644 index 00000000..5c06bbd5 --- /dev/null +++ b/tests/UnitTests/Command/Cron/ReindexFilesCommandTest.php @@ -0,0 +1,148 @@ +prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn($isCronDisabled); + + return new ReindexFilesCommand( + $bag->reveal(), + $queueService ?? $this->prophesize(QueueService::class)->reveal(), + $elementManager ?? $this->prophesize(ElementManager::class)->reveal() + ); + } + + public function testCommandStopsEarlyIfCronIsDisabled(): void + { + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::cetera())->shouldNotBeCalled(); + + $command = $this->buildCommand(isCronDisabled: true, queueService: $queueService->reveal()); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Cron is disabled', $commandTester->getDisplay()); + } + + public function testCommandThrowsIfCronDisabledParameterIsNotBoolean(): void + { + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn('not-a-boolean'); + + $command = new ReindexFilesCommand( + $bag->reveal(), + $this->prophesize(QueueService::class)->reveal(), + $this->prophesize(ElementManager::class)->reveal() + ); + + $this->expectException(LogicException::class); + (new CommandTester($command))->execute([]); + } + + public function testCommandDoesNothingIfQueueIsEmpty(): void + { + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE), Argument::type('callable')) + ->shouldBeCalledOnce() + ->willReturn(0); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any())->shouldNotBeCalled(); + + $command = $this->buildCommand(queueService: $queueService->reveal(), elementManager: $elementManager->reveal()); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Reindexed 0 element file(s).', $commandTester->getDisplay()); + } + + public function testCommandResyncsElementForEachQueuedMessage(): void + { + $elementId = Uuid::fromString('4f4c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d4'); + $element = (new NodeElement())->setId($elementId)->setLabel('Data'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $elementId->toString())) + ->shouldBeCalledOnce() + ->willReturn($element); + $elementManager->merge(Argument::is($element))->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + $elementManager->flush()->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE), Argument::type('callable')) + ->will(function ($args) use ($elementId) { + /** @var callable $handler */ + $handler = $args[1]; + $handler(['elementId' => $elementId->toString()]); + + return 1; + }); + + $command = $this->buildCommand(queueService: $queueService->reveal(), elementManager: $elementManager->reveal()); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Reindexed 1 element file(s).', $commandTester->getDisplay()); + } + + public function testCommandSkipsMessageIfElementWasAlreadyDeleted(): void + { + $elementId = Uuid::fromString('5a5c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d5'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any())->willReturn(null); + $elementManager->merge(Argument::any())->shouldNotBeCalled(); + + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE), Argument::type('callable')) + ->will(function ($args) use ($elementId) { + /** @var callable $handler */ + $handler = $args[1]; + $handler(['elementId' => $elementId->toString()]); + + return 1; + }); + + $command = $this->buildCommand(queueService: $queueService->reveal(), elementManager: $elementManager->reveal()); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + } +} diff --git a/tests/UnitTests/Command/CronCommandTest.php b/tests/UnitTests/Command/CronCommandTest.php new file mode 100644 index 00000000..5d96ed2f --- /dev/null +++ b/tests/UnitTests/Command/CronCommandTest.php @@ -0,0 +1,130 @@ +prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn($isCronDisabled); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileExpiredUploadCanBeDeletedAfterExpirationInSeconds()->willReturn(3600); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + if (!$isCronDisabled) { + $summaryReference = null; + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->willReturn(new SummarizedResult($summaryReference, [])); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + } + + return new DeleteExpiredUploadsCommand( + $bag->reveal(), + $emberNexusConfiguration->reveal(), + $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(UploadFactory::class)->reveal(), + $this->prophesize(UploadService::class)->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + } + + private function buildReindexFilesCommand(bool $isCronDisabled): ReindexFilesCommand + { + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn($isCronDisabled); + + $queueService = $this->prophesize(QueueService::class); + if (!$isCronDisabled) { + $queueService->consumeQueue(Argument::any(), Argument::any())->willReturn(0); + } + + return new ReindexFilesCommand( + $bag->reveal(), + $queueService->reveal(), + $this->prophesize(ElementManager::class)->reveal() + ); + } + + private function buildCommand(bool $isCronDisabled): CronCommand + { + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn($isCronDisabled); + + return new CronCommand( + $bag->reveal(), + $this->buildDeleteExpiredUploadsCommand($isCronDisabled), + $this->buildReindexFilesCommand($isCronDisabled) + ); + } + + public function testCommandStopsEarlyIfCronIsDisabled(): void + { + $command = $this->buildCommand(isCronDisabled: true); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Cron is disabled', $commandTester->getDisplay()); + } + + public function testCommandThrowsIfCronDisabledParameterIsNotBoolean(): void + { + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn('not-a-boolean'); + + $command = new CronCommand( + $bag->reveal(), + $this->buildDeleteExpiredUploadsCommand(true), + $this->buildReindexFilesCommand(true) + ); + + $this->expectException(LogicException::class); + (new CommandTester($command))->execute([]); + } + + public function testCommandDispatchesBothSubCommandsWhenEnabled(): void + { + $command = $this->buildCommand(isCronDisabled: false); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $display = $commandTester->getDisplay(); + $this->assertStringContainsString('No expired uploads found.', $display); + $this->assertStringContainsString('Reindexed 0 element file(s).', $display); + $this->assertStringContainsString('Finished.', $display); + } +} diff --git a/tests/UnitTests/EventSystem/ElementDefragmentize/Event/NodeElementDefragmentizeEventTest.php b/tests/UnitTests/EventSystem/ElementDefragmentize/Event/NodeElementDefragmentizeEventTest.php index 51d05365..54075f3f 100644 --- a/tests/UnitTests/EventSystem/ElementDefragmentize/Event/NodeElementDefragmentizeEventTest.php +++ b/tests/UnitTests/EventSystem/ElementDefragmentize/Event/NodeElementDefragmentizeEventTest.php @@ -21,13 +21,11 @@ public function testNodeElementDefragmentizeEvent(): void $nodeElement = new NodeElement(); $cypherFragment = new Node(); $documentFragment = new Document(); - $fileFragment = null; - $nodeElementDefragmentizeEvent = new NodeElementDefragmentizeEvent($nodeElement, $cypherFragment, $documentFragment, $fileFragment); + $nodeElementDefragmentizeEvent = new NodeElementDefragmentizeEvent($nodeElement, $cypherFragment, $documentFragment); $this->assertSame($nodeElement, $nodeElementDefragmentizeEvent->getNodeElement()); $this->assertSame($cypherFragment, $nodeElementDefragmentizeEvent->getCypherFragment()); $this->assertSame($documentFragment, $nodeElementDefragmentizeEvent->getDocumentFragment()); - $this->assertSame($fileFragment, $nodeElementDefragmentizeEvent->getFileFragment()); } } diff --git a/tests/UnitTests/EventSystem/ElementDefragmentize/Event/RelationElementDefragmentizeEventTest.php b/tests/UnitTests/EventSystem/ElementDefragmentize/Event/RelationElementDefragmentizeEventTest.php index 50c38771..5466a464 100644 --- a/tests/UnitTests/EventSystem/ElementDefragmentize/Event/RelationElementDefragmentizeEventTest.php +++ b/tests/UnitTests/EventSystem/ElementDefragmentize/Event/RelationElementDefragmentizeEventTest.php @@ -21,13 +21,11 @@ public function testRelationElementDefragmentizeEvent(): void $relationElement = new RelationElement(); $cypherFragment = new Relation(); $documentFragment = new Document(); - $fileFragment = null; - $relationElementDefragmentizeEvent = new RelationElementDefragmentizeEvent($relationElement, $cypherFragment, $documentFragment, $fileFragment); + $relationElementDefragmentizeEvent = new RelationElementDefragmentizeEvent($relationElement, $cypherFragment, $documentFragment); $this->assertSame($relationElement, $relationElementDefragmentizeEvent->getRelationElement()); $this->assertSame($cypherFragment, $relationElementDefragmentizeEvent->getCypherFragment()); $this->assertSame($documentFragment, $relationElementDefragmentizeEvent->getDocumentFragment()); - $this->assertSame($fileFragment, $relationElementDefragmentizeEvent->getFileFragment()); } } diff --git a/tests/UnitTests/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventTest.php b/tests/UnitTests/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventTest.php index e18738f4..b79efd9d 100644 --- a/tests/UnitTests/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventTest.php +++ b/tests/UnitTests/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventTest.php @@ -23,20 +23,17 @@ public function testNodeElementFragmentizeEvent(): void $cypherFragment = new Node(); $mongoFragment = new MongoDocument(); $elasticFragment = new ElasticDocument(); - $fileFragment = null; $nodeElementFragmentizeEvent = new NodeElementFragmentizeEvent( $nodeElement, $cypherFragment, $mongoFragment, - $elasticFragment, - $fileFragment + $elasticFragment ); $this->assertSame($nodeElement, $nodeElementFragmentizeEvent->getNodeElement()); $this->assertSame($cypherFragment, $nodeElementFragmentizeEvent->getCypherFragment()); $this->assertSame($mongoFragment, $nodeElementFragmentizeEvent->getMongoFragment()); $this->assertSame($elasticFragment, $nodeElementFragmentizeEvent->getElasticFragment()); - $this->assertSame($fileFragment, $nodeElementFragmentizeEvent->getFileFragment()); } } diff --git a/tests/UnitTests/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventTest.php b/tests/UnitTests/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventTest.php index 27d06797..bbc7048d 100644 --- a/tests/UnitTests/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventTest.php +++ b/tests/UnitTests/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventTest.php @@ -23,20 +23,17 @@ public function testRelationElementFragmentizeEvent(): void $cypherFragment = new Relation(); $mongoFragment = new MongoDocument(); $elasticFragment = new ElasticDocument(); - $fileFragment = null; $relationElementFragmentizeEvent = new RelationElementFragmentizeEvent( $relationElement, $cypherFragment, $mongoFragment, - $elasticFragment, - $fileFragment + $elasticFragment ); $this->assertSame($relationElement, $relationElementFragmentizeEvent->getRelationElement()); $this->assertSame($cypherFragment, $relationElementFragmentizeEvent->getCypherFragment()); $this->assertSame($mongoFragment, $relationElementFragmentizeEvent->getMongoFragment()); $this->assertSame($elasticFragment, $relationElementFragmentizeEvent->getElasticFragment()); - $this->assertSame($fileFragment, $relationElementFragmentizeEvent->getFileFragment()); } } diff --git a/tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListenerTest.php b/tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListenerTest.php new file mode 100644 index 00000000..f7f80f2a --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListenerTest.php @@ -0,0 +1,48 @@ + 'Test']); + $eventListener = new FileElementPropertyChangeEventListener( + $this->prophesize(Client400ForbiddenPropertyExceptionFactory::class)->reveal() + ); + $eventListener->onElementPropertyChangeEvent($event); + } + + public function testElementWithFilePropertyTriggersException(): void + { + $event = new ElementPropertyChangeEvent('Test', null, ['file' => ['contentLength' => 1]]); + $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate(Argument::cetera()) + ->willReturn('http://example.com/url-to-error'); + $eventListener = new FileElementPropertyChangeEventListener( + new Client400ForbiddenPropertyExceptionFactory( + $urlGenerator->reveal() + ) + ); + $this->expectException(Exception::class); + $eventListener->onElementPropertyChangeEvent($event); + } +} diff --git a/tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListenerTest.php b/tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListenerTest.php new file mode 100644 index 00000000..b99401c4 --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListenerTest.php @@ -0,0 +1,48 @@ + 'Test']); + $eventListener = new HasFileElementPropertyChangeEventListener( + $this->prophesize(Client400ForbiddenPropertyExceptionFactory::class)->reveal() + ); + $eventListener->onElementPropertyChangeEvent($event); + } + + public function testElementWithHasFilePropertyTriggersException(): void + { + $event = new ElementPropertyChangeEvent('Test', null, ['hasFile' => true]); + $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate(Argument::cetera()) + ->willReturn('http://example.com/url-to-error'); + $eventListener = new HasFileElementPropertyChangeEventListener( + new Client400ForbiddenPropertyExceptionFactory( + $urlGenerator->reveal() + ) + ); + $this->expectException(Exception::class); + $eventListener->onElementPropertyChangeEvent($event); + } +} diff --git a/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventFactoryTest.php b/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventFactoryTest.php index ea9e6b76..467655c4 100644 --- a/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventFactoryTest.php +++ b/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/NodeElementFragmentizeEventFactoryTest.php @@ -27,6 +27,5 @@ public function testCreateNodeElementFragmentizeEvent(): void $this->assertNotNull($event->getCypherFragment()); $this->assertNotNull($event->getElasticFragment()); $this->assertNotNull($event->getMongoFragment()); - $this->assertNull($event->getFileFragment()); } } diff --git a/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactoryTest.php b/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactoryTest.php index 2d91af2f..fafcb982 100644 --- a/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactoryTest.php +++ b/tests/UnitTests/Factory/EventSystem/ElementFragmentize/Event/RelationElementFragmentizeEventFactoryTest.php @@ -27,6 +27,5 @@ public function testCreateRelationElementFragmentizeEvent(): void $this->assertNotNull($event->getCypherFragment()); $this->assertNotNull($event->getElasticFragment()); $this->assertNotNull($event->getMongoFragment()); - $this->assertNull($event->getFileFragment()); } } diff --git a/tests/UnitTests/Factory/Type/UploadFactoryTest.php b/tests/UnitTests/Factory/Type/UploadFactoryTest.php index 179fd80a..846b9aaf 100644 --- a/tests/UnitTests/Factory/Type/UploadFactoryTest.php +++ b/tests/UnitTests/Factory/Type/UploadFactoryTest.php @@ -58,6 +58,7 @@ public function testCreateUploadFromElement(): void $propertyParseService->getUploadOwnerFromProperties(Argument::is($properties))->willReturn($uploadOwner); $propertyParseService->getExtensionFromProperties(Argument::is($properties))->willReturn('some-ext'); $propertyParseService->getExpiresFromProperties(Argument::is($properties))->willReturn($expires); + $propertyParseService->getHashStateFromProperties(Argument::is($properties))->willReturn('some-hash-state'); $uploadFactory = $this->buildUploadFactory( propertyParseService: $propertyParseService->reveal() @@ -74,6 +75,7 @@ public function testCreateUploadFromElement(): void $this->assertSame($uploadOwner, $upload->getUploadOwner()); $this->assertSame('some-ext', $upload->getExtension()); $this->assertSame($expires, $upload->getExpires()); + $this->assertSame('some-hash-state', $upload->getHashState()); } public function testCreateUploadFromElementThrowsWhenElementIsNotANode(): void @@ -150,6 +152,7 @@ public function testMarkUploadAsComplete(): void $upload->getUploadOwner()->shouldBeCalledOnce()->willReturn($uploadOwner); $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); + $upload->getHashState()->shouldBeCalledOnce()->willReturn('some-hash-state'); $uploadFactory = $this->buildUploadFactory(); @@ -164,6 +167,7 @@ public function testMarkUploadAsComplete(): void $this->assertSame($uploadOwner, $result->getUploadOwner()); $this->assertSame('ext', $result->getExtension()); $this->assertSame($expires, $result->getExpires()); + $this->assertSame('some-hash-state', $result->getHashState()); } public function testAddNewChunkToUpload(): void @@ -183,6 +187,7 @@ public function testAddNewChunkToUpload(): void $upload->getUploadOwner()->shouldBeCalledOnce()->willReturn($uploadOwner); $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); + $upload->getHashState()->shouldBeCalledOnce()->willReturn('some-hash-state'); $uploadFactory = $this->buildUploadFactory(); @@ -197,5 +202,32 @@ public function testAddNewChunkToUpload(): void $this->assertSame($uploadOwner, $result->getUploadOwner()); $this->assertSame('ext', $result->getExtension()); $this->assertSame($expires, $result->getExpires()); + $this->assertSame('some-hash-state', $result->getHashState()); + } + + public function testAddNewChunkToUploadUsesProvidedHashStateInsteadOfExistingUploadHashState(): void + { + $uploadId = Uuid::fromString('7f27bcda-3d08-4790-b6f0-a5727fbc2bde'); + $uploadTarget = Uuid::fromString('f253379f-3533-4719-8337-871df7d336f6'); + $uploadOwner = Uuid::fromString('2be74c20-89d7-4f61-9c5b-c47a7ae11b5e'); + $expires = new DateTime(); + + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); + $upload->getUploadLength()->shouldBeCalledOnce()->willReturn(20000); + $upload->getUploadOffset()->shouldBeCalledOnce()->willReturn(10000); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); + $upload->getUploadTarget()->shouldBeCalledOnce()->willReturn($uploadTarget); + $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); + $upload->getUploadOwner()->shouldBeCalledOnce()->willReturn($uploadOwner); + $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); + $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); + $upload->getHashState()->shouldNotBeCalled(); + + $uploadFactory = $this->buildUploadFactory(); + + $result = $uploadFactory->addNewChunkToUpload($upload->reveal(), 9999, 'new-hash-state'); + + $this->assertSame('new-hash-state', $result->getHashState()); } } diff --git a/tests/UnitTests/Service/DigestServiceTest.php b/tests/UnitTests/Service/DigestServiceTest.php new file mode 100644 index 00000000..cd416195 --- /dev/null +++ b/tests/UnitTests/Service/DigestServiceTest.php @@ -0,0 +1,86 @@ +assertSame( + sprintf('sha-256=:%s:', self::BASE64_DIGEST), + $service->formatDigestHeaderValue(self::HEX_HASH) + ); + } + + public function testParseSha256HexFromHeaderValue(): void + { + $service = new DigestService(); + + $hash = $service->parseSha256HexFromHeaderValue(sprintf('sha-256=:%s:', self::BASE64_DIGEST)); + + $this->assertSame(self::HEX_HASH, $hash); + } + + public function testRoundTripsThroughFormatAndParse(): void + { + $service = new DigestService(); + + $headerValue = $service->formatDigestHeaderValue(self::HEX_HASH); + $parsedHash = $service->parseSha256HexFromHeaderValue($headerValue); + + $this->assertSame(self::HEX_HASH, $parsedHash); + } + + public function testReturnsNullForUnsupportedAlgorithmOnly(): void + { + $service = new DigestService(); + + $this->assertNull($service->parseSha256HexFromHeaderValue('md5=:1B2M2Y8AsgTpgAmY7PhCfg==:')); + } + + public function testPicksSha256AmongMultipleAlgorithms(): void + { + $service = new DigestService(); + + $hash = $service->parseSha256HexFromHeaderValue(sprintf('md5=:1B2M2Y8AsgTpgAmY7PhCfg==:, sha-256=:%s:', self::BASE64_DIGEST)); + + $this->assertSame(self::HEX_HASH, $hash); + } + + public function testReturnsNullForMalformedHeaderValue(): void + { + $service = new DigestService(); + + $this->assertNull($service->parseSha256HexFromHeaderValue('not a digest header at all')); + } + + public function testReturnsNullForInvalidBase64(): void + { + $service = new DigestService(); + + $this->assertNull($service->parseSha256HexFromHeaderValue('sha-256=:not-valid-base64!!!:')); + } + + public function testReturnsNullForWrongLengthDigest(): void + { + $service = new DigestService(); + + // valid base64, but decodes to fewer than 32 bytes + $this->assertNull($service->parseSha256HexFromHeaderValue('sha-256=:dG9vc2hvcnQ=:')); + } +} diff --git a/tests/UnitTests/Service/ElementDefragmentizeServiceTest.php b/tests/UnitTests/Service/ElementDefragmentizeServiceTest.php index e00bf9a6..93c84771 100644 --- a/tests/UnitTests/Service/ElementDefragmentizeServiceTest.php +++ b/tests/UnitTests/Service/ElementDefragmentizeServiceTest.php @@ -27,18 +27,16 @@ public function testDefragmentizeNode(): void { $cypherFragment = new CypherNode(); $documentFragment = new MongoDocument(); - $fileFragment = null; $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); $eventDispatcher ->dispatch(Argument::type(NodeElementDefragmentizeEvent::class)) ->shouldBeCalledOnce() - ->will(function ($args) use ($cypherFragment, $documentFragment, $fileFragment) { + ->will(function ($args) use ($cypherFragment, $documentFragment) { $event = $args[0]; /** @var NodeElementDefragmentizeEvent $event */ TestCase::assertSame($cypherFragment, $event->getCypherFragment()); TestCase::assertSame($documentFragment, $event->getDocumentFragment()); - TestCase::assertSame($fileFragment, $event->getFileFragment()); $event->getNodeElement()->addProperty('key', 'value'); @@ -47,7 +45,7 @@ public function testDefragmentizeNode(): void $elementFragmentizeService = new ElementDefragmentizeService($eventDispatcher->reveal()); - $nodeElement = $elementFragmentizeService->defragmentize($cypherFragment, $documentFragment, $fileFragment); + $nodeElement = $elementFragmentizeService->defragmentize($cypherFragment, $documentFragment); $this->assertSame('value', $nodeElement->getProperty('key')); } @@ -55,18 +53,16 @@ public function testDefragmentizeRelation(): void { $cypherFragment = new CypherRelation(); $documentFragment = new MongoDocument(); - $fileFragment = null; $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); $eventDispatcher ->dispatch(Argument::type(RelationElementDefragmentizeEvent::class)) ->shouldBeCalledOnce() - ->will(function ($args) use ($cypherFragment, $documentFragment, $fileFragment) { + ->will(function ($args) use ($cypherFragment, $documentFragment) { $event = $args[0]; /** @var RelationElementDefragmentizeEvent $event */ TestCase::assertSame($cypherFragment, $event->getCypherFragment()); TestCase::assertSame($documentFragment, $event->getDocumentFragment()); - TestCase::assertSame($fileFragment, $event->getFileFragment()); $event->getRelationElement()->addProperty('key', 'value'); @@ -75,7 +71,7 @@ public function testDefragmentizeRelation(): void $elementFragmentizeService = new ElementDefragmentizeService($eventDispatcher->reveal()); - $relationElement = $elementFragmentizeService->defragmentize($cypherFragment, $documentFragment, $fileFragment); + $relationElement = $elementFragmentizeService->defragmentize($cypherFragment, $documentFragment); $this->assertSame('value', $relationElement->getProperty('key')); } } diff --git a/tests/UnitTests/Service/EtagCalculatorServiceTest.php b/tests/UnitTests/Service/EtagCalculatorServiceTest.php index 73d72ad0..0115a0bb 100644 --- a/tests/UnitTests/Service/EtagCalculatorServiceTest.php +++ b/tests/UnitTests/Service/EtagCalculatorServiceTest.php @@ -5,13 +5,9 @@ namespace App\Tests\UnitTests\Service; use App\Factory\Exception\Server500LogicErrorExceptionFactory; -use App\Factory\Type\S3\FileOperationFactory; use App\Service\ElementManager; use App\Service\EtagCalculatorService; -use App\Service\S3Service; use App\Type\NodeElement; -use App\Type\S3\FileOperation; -use AsyncAws\Core\Exception\Http\ClientException; use Beste\Psr\Log\TestLogger; use EmberNexusBundle\Service\EmberNexusConfiguration; use Laudis\Neo4j\Contracts\ClientInterface; @@ -80,8 +76,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -147,8 +141,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -203,8 +195,6 @@ public function testCalculateElementEtagForElementWhichDoesNotExist(): void $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -249,8 +239,6 @@ public function testCalculateElementEtagWithEdgecaseWhereDifferentObjectIsReturn $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -316,8 +304,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -396,8 +382,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -472,8 +456,6 @@ public function testCalculateChildrenCollectionEtagWithTooManyElements(): void $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -527,8 +509,6 @@ public function testCalculateChildrenCollectionEtagDifferentObjectIsReturned(): $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -569,8 +549,6 @@ public function testCalculateChildrenCollectionEtagWhereNoDataIsReturned(): void $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -636,8 +614,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -716,8 +692,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -792,8 +766,6 @@ public function testCalculateParentsCollectionEtagWithTooManyElements(): void $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -847,8 +819,6 @@ public function testCalculateParentsCollectionEtagDifferentObjectIsReturned(): v $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -889,8 +859,6 @@ public function testCalculateParentsCollectionEtagWhereNoDataIsReturned(): void $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -956,8 +924,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1035,8 +1001,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1110,8 +1074,6 @@ public function testCalculateRelatedCollectionEtagWithTooManyElements(): void $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1165,8 +1127,6 @@ public function testCalculateRelatedCollectionEtagDifferentObjectIsReturned(): v $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1207,8 +1167,6 @@ public function testCalculateRelatedCollectionEtagWhereNoDataIsReturned(): void $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1274,8 +1232,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1353,8 +1309,6 @@ function ($internalStatement) use (&$statement) { $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1428,8 +1382,6 @@ public function testCalculateIndexCollectionEtagWithTooManyElements(): void $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1483,8 +1435,6 @@ public function testCalculateIndexCollectionEtagDifferentObjectIsReturned(): voi $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1525,8 +1475,6 @@ public function testCalculateIndexCollectionEtagWhereNoDataIsReturned(): void $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), - $this->prophesize(S3Service::class)->reveal(), - $this->prophesize(FileOperationFactory::class)->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); @@ -1540,18 +1488,23 @@ public function testCalculateIndexCollectionEtagWhereNoDataIsReturned(): void $this->assertTrue($logger->records->includeMessagesContaining('Calculating Etag for index collection.')); } - public function testCalculateFileEtag(): void + public function testCalculateFileEtagUsesStoredSha256Hash(): void { // setup variables $id = Uuid::fromString('544e0cf6-d351-435c-828f-7a0762240ce6'); $element = new NodeElement(); $element - ->addProperty('file', ['size' => 1024, 'mimetype' => 'image/png']) + ->addProperty('file', [ + 'contentLength' => 1024, + 'extension' => 'png', + 'mimeType' => 'image/png', + 'hash' => [ + 'sha256' => str_repeat('a', 64), + ], + ]) ->addProperty('name', 'some name'); - $fileOperation = new FileOperation('someBucket', 'someKey'); - // setup service dependencies $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); $emberNexusConfiguration->getCacheEtagSeed()->shouldBeCalledOnce()->willReturn('seed'); @@ -1559,77 +1512,224 @@ public function testCalculateFileEtag(): void $elementManager = $this->prophesize(ElementManager::class); $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); - $fileOperationFactory = $this->prophesize(FileOperationFactory::class); - $fileOperationFactory->createFileOperationFromElement(Argument::is($element))->shouldBeCalledOnce()->willReturn($fileOperation); - - $s3Service = $this->prophesize(S3Service::class); - $s3Service->getEtag(Argument::is($fileOperation))->shouldBeCalledOnce()->willReturn('some s3 etag'); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->shouldNotBeCalled(); $logger = TestLogger::create(); // setup service $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), - $this->prophesize(CypherEntityManager::class)->reveal(), + $cypherEntityManager->reveal(), $elementManager->reveal(), - $s3Service->reveal(), - $fileOperationFactory->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); - // run service method + // run service method; no Cypher query (i.e. no calculateElementEtag() fallback) should have happened at + // all, per the prophecy expectation above $etag = $etagCalculatorService->calculateFileEtag($id); - $this->assertSame('dYDnWUfIMVL', (string) $etag); + $this->assertSame('K6XEZuifVAv', (string) $etag); // assert logs $this->assertTrue($logger->records->includeMessagesContaining('Calculated Etag for file.')); } - public function testCalculateFileEtagCapturesExceptionIfS3FileIsMissing(): void + public function testCalculateFileEtagFallsBackToAlphabeticallyFirstHashWhenSha256Missing(): void { // setup variables - $id = Uuid::fromString('662a045f-7d90-4fa2-85f8-9f972f2bdbd3'); + $id = Uuid::fromString('7f8e9d0c-1b2a-4c3d-9e8f-0a1b2c3d4e5f'); $element = new NodeElement(); $element - ->addProperty('file', ['size' => 1024, 'mimetype' => 'image/png']) + ->addProperty('file', [ + 'contentLength' => 1024, + 'extension' => 'png', + 'mimeType' => 'image/png', + 'hash' => [ + 'md5' => str_repeat('b', 32), + 'blake3' => str_repeat('c', 64), + ], + ]) ->addProperty('name', 'some name'); - $fileOperation = new FileOperation('someBucket', 'someKey'); + // setup service dependencies + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getCacheEtagSeed()->shouldBeCalledOnce()->willReturn('seed'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->shouldNotBeCalled(); + + $logger = TestLogger::create(); + + // setup service + $etagCalculatorService = new EtagCalculatorService( + $emberNexusConfiguration->reveal(), + $cypherEntityManager->reveal(), + $elementManager->reveal(), + $logger, + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + + // run service method; 'blake3' sorts before 'md5' alphanumerically, so it is used. The Cypher client + // prophecy above (shouldNotBeCalled) proves that a hash was actually picked here, rather than falling + // back to calculateElementEtag(). + $etag = $etagCalculatorService->calculateFileEtag($id); + $this->assertNotNull($etag); + } + + public function testCalculateFileEtagFallsBackToElementEtagWhenNoHashIsPresent(): void + { + // setup variables + $id = Uuid::fromString('9c1a0c0e-6b8f-4b3d-9b1e-2b1a0c0e6b8f'); + + $element = new NodeElement(); + $element + ->addProperty('file', ['contentLength' => 1024, 'extension' => 'png', 'mimeType' => 'image/png']); + + $null = null; + $queryResult = new SummarizedResult( + $null, + [ + new CypherMap([ + 'node.updated' => new DateTimeZoneId(1705772003, 646811000, 'UTC'), + 'relation.updated' => null, + ]), + ] + ); // setup service dependencies $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getCacheEtagSeed()->shouldBeCalledTimes(2)->willReturn('seed'); $elementManager = $this->prophesize(ElementManager::class); $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); - $fileOperationFactory = $this->prophesize(FileOperationFactory::class); - $fileOperationFactory->createFileOperationFromElement(Argument::is($element))->shouldBeCalledOnce()->willReturn($fileOperation); + $clientInterface = $this->prophesize(ClientInterface::class); + $clientInterface->runStatement(Argument::any())->shouldBeCalledOnce()->willReturn($queryResult); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->shouldBeCalledOnce()->willReturn($clientInterface->reveal()); + + $logger = TestLogger::create(); + + // setup service + $etagCalculatorService = new EtagCalculatorService( + $emberNexusConfiguration->reveal(), + $cypherEntityManager->reveal(), + $elementManager->reveal(), + $logger, + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + + // run service method; falls back to calculateElementEtag(), i.e. a Cypher query, not an S3 call + $etag = $etagCalculatorService->calculateFileEtag($id); + $this->assertNotNull($etag); + + // assert logs + $this->assertTrue($logger->records->includeMessagesContaining('Calculated Etag for element.')); + $this->assertTrue($logger->records->includeMessagesContaining('Calculated Etag for file.')); + } - $exception = $this->prophesize(ClientException::class)->reveal(); + public function testCalculateFileEtagFallsBackToElementEtagWhenNoFilePropertyIsPresent(): void + { + // an S3 object can exist without the element ever having its 'file' property set, e.g. when an upload is + // rejected after the object was already written (a mismatched Content-Digest); this must not crash. + + // setup variables + $id = Uuid::fromString('1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d'); - $s3Service = $this->prophesize(S3Service::class); - $s3Service->getEtag(Argument::is($fileOperation))->shouldBeCalledOnce()->willThrow($exception); + $element = new NodeElement(); + + $null = null; + $queryResult = new SummarizedResult( + $null, + [ + new CypherMap([ + 'node.updated' => new DateTimeZoneId(1705772003, 646811000, 'UTC'), + 'relation.updated' => null, + ]), + ] + ); + + // setup service dependencies + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getCacheEtagSeed()->shouldBeCalledTimes(2)->willReturn('seed'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); + + $clientInterface = $this->prophesize(ClientInterface::class); + $clientInterface->runStatement(Argument::any())->shouldBeCalledOnce()->willReturn($queryResult); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->shouldBeCalledOnce()->willReturn($clientInterface->reveal()); $logger = TestLogger::create(); // setup service $etagCalculatorService = new EtagCalculatorService( $emberNexusConfiguration->reveal(), - $this->prophesize(CypherEntityManager::class)->reveal(), + $cypherEntityManager->reveal(), $elementManager->reveal(), - $s3Service->reveal(), - $fileOperationFactory->reveal(), $logger, $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); - // run service method + // run service method; must not throw despite the element having no 'file' property $etag = $etagCalculatorService->calculateFileEtag($id); - $this->assertNull($etag); + $this->assertNotNull($etag); // assert logs - $this->assertTrue($logger->records->includeMessagesContaining('Unable to calculate Etag for file of element 662a045f-7d90-4fa2-85f8-9f972f2bdbd3.')); + $this->assertTrue($logger->records->includeMessagesContaining('Calculated Etag for file.')); + } + + public function testCalculateFileEtagReturnsNullWhenElementEtagFallbackAlsoReturnsNull(): void + { + // setup variables + $id = Uuid::fromString('662a045f-7d90-4fa2-85f8-9f972f2bdbd3'); + + $element = new NodeElement(); + + $null = null; + $queryResult = new SummarizedResult( + $null, + [ + new CypherMap([ + 'node.updated' => null, + 'relation.updated' => null, + ]), + ] + ); + + // setup service dependencies + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); + + $clientInterface = $this->prophesize(ClientInterface::class); + $clientInterface->runStatement(Argument::any())->shouldBeCalledOnce()->willReturn($queryResult); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->shouldBeCalledOnce()->willReturn($clientInterface->reveal()); + + $logger = TestLogger::create(); + + // setup service + $etagCalculatorService = new EtagCalculatorService( + $emberNexusConfiguration->reveal(), + $cypherEntityManager->reveal(), + $elementManager->reveal(), + $logger, + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + + // run service method; neither a hash nor an updated timestamp is available anywhere + $etag = $etagCalculatorService->calculateFileEtag($id); + $this->assertNull($etag); } } diff --git a/tests/UnitTests/Service/FileHashServiceTest.php b/tests/UnitTests/Service/FileHashServiceTest.php new file mode 100644 index 00000000..7cafbf9c --- /dev/null +++ b/tests/UnitTests/Service/FileHashServiceTest.php @@ -0,0 +1,39 @@ +calculateHashFromResource($resource); + + $this->assertSame(hash('sha256', $content), $hash); + $this->assertSame(64, strlen($hash)); + } + + public function testCalculatesCorrectHashForEmptyResource(): void + { + $resource = \Safe\fopen('php://memory', 'r+'); + + $service = new FileHashService(); + $hash = $service->calculateHashFromResource($resource); + + $this->assertSame(hash('sha256', ''), $hash); + } +} diff --git a/tests/UnitTests/Service/FileRangeServiceTest.php b/tests/UnitTests/Service/FileRangeServiceTest.php new file mode 100644 index 00000000..6058c5dc --- /dev/null +++ b/tests/UnitTests/Service/FileRangeServiceTest.php @@ -0,0 +1,144 @@ +prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate('exception-detail', [ + 'code' => '400', + 'name' => 'bad-content', + ], UrlGeneratorInterface::ABSOLUTE_URL)->willReturn('http://localhost/error/400/bad-content'); + $urlGenerator->generate('exception-detail', [ + 'code' => '416', + 'name' => 'range-not-satisfiable', + ], UrlGeneratorInterface::ABSOLUTE_URL)->willReturn('http://localhost/error/416/range-not-satisfiable'); + + return new FileRangeService( + new Client400BadContentExceptionFactory($urlGenerator->reveal()), + new Client416RangeNotSatisfiableExceptionFactory($urlGenerator->reveal()) + ); + } + + public function testThrowsBadContentForMalformedRangeHeader(): void + { + $service = $this->buildService(); + + $this->expectException(Client400BadContentException::class); + $service->parseRangeHeader('not-a-range', 100); + } + + public function testThrowsBadContentForMultipleRanges(): void + { + $service = $this->buildService(); + + $this->expectException(Client400BadContentException::class); + $service->parseRangeHeader('bytes=0-10,20-30', 100); + } + + public function testThrowsBadContentForEmptyRange(): void + { + $service = $this->buildService(); + + $this->expectException(Client400BadContentException::class); + $service->parseRangeHeader('bytes=-', 100); + } + + public function testParsesExplicitRange(): void + { + $service = $this->buildService(); + + $range = $service->parseRangeHeader('bytes=0-99', 1000); + + $this->assertSame(0, $range->getStart()); + $this->assertSame(99, $range->getEnd()); + $this->assertSame(1000, $range->getTotalLength()); + $this->assertSame(100, $range->getLength()); + } + + public function testParsesOpenEndedRange(): void + { + $service = $this->buildService(); + + $range = $service->parseRangeHeader('bytes=900-', 1000); + + $this->assertSame(900, $range->getStart()); + $this->assertSame(999, $range->getEnd()); + $this->assertSame(100, $range->getLength()); + } + + public function testParsesSuffixRange(): void + { + $service = $this->buildService(); + + $range = $service->parseRangeHeader('bytes=-100', 1000); + + $this->assertSame(900, $range->getStart()); + $this->assertSame(999, $range->getEnd()); + $this->assertSame(100, $range->getLength()); + } + + public function testSuffixRangeLongerThanFileIsClampedToFullFile(): void + { + $service = $this->buildService(); + + $range = $service->parseRangeHeader('bytes=-10000', 1000); + + $this->assertSame(0, $range->getStart()); + $this->assertSame(999, $range->getEnd()); + $this->assertSame(1000, $range->getLength()); + } + + public function testEndIsClampedToLastByteOfFile(): void + { + $service = $this->buildService(); + + $range = $service->parseRangeHeader('bytes=990-999999', 1000); + + $this->assertSame(990, $range->getStart()); + $this->assertSame(999, $range->getEnd()); + } + + public function testThrowsIfStartIsBeyondEndOfFile(): void + { + $service = $this->buildService(); + + $this->expectException(Client416RangeNotSatisfiableException::class); + $service->parseRangeHeader('bytes=1000-', 1000); + } + + public function testThrowsIfFileIsEmpty(): void + { + $service = $this->buildService(); + + $this->expectException(Client416RangeNotSatisfiableException::class); + $service->parseRangeHeader('bytes=0-10', 0); + } + + public function testThrowsForZeroLengthSuffixRange(): void + { + $service = $this->buildService(); + + $this->expectException(Client416RangeNotSatisfiableException::class); + $service->parseRangeHeader('bytes=-0', 1000); + } +} diff --git a/tests/UnitTests/Service/IncrementalHashServiceTest.php b/tests/UnitTests/Service/IncrementalHashServiceTest.php new file mode 100644 index 00000000..97a16302 --- /dev/null +++ b/tests/UnitTests/Service/IncrementalHashServiceTest.php @@ -0,0 +1,127 @@ +prophesize(Client409ConflictExceptionFactory::class)->reveal(), + ); + } + + /** + * @return resource + */ + private function createResourceWithContent(string $content) + { + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($resource, $content); + rewind($resource); + + return $resource; + } + + public function testFinalizeReturnsSha256HashOfContentFedInAsASingleChunk(): void + { + $content = 'some deterministic content used to verify the calculated hash'; + $resource = $this->createResourceWithContent($content); + + $service = $this->buildService(); + $context = $service->createContext('sha256'); + $service->updateFromResource($context, $resource); + $hash = $service->finalize($context); + + $this->assertSame(hash('sha256', $content), $hash); + } + + public function testUpdateFromResourceRewindsResourceAfterHashing(): void + { + $content = 'content that must still be fully readable afterwards'; + $resource = $this->createResourceWithContent($content); + + $service = $this->buildService(); + $context = $service->createContext('sha256'); + $service->updateFromResource($context, $resource); + + $this->assertSame(0, \Safe\ftell($resource)); + $this->assertSame($content, \Safe\stream_get_contents($resource)); + } + + public function testHashCanBeAccumulatedAcrossMultipleChunksMatchingASingleShotHash(): void + { + $chunks = ['first chunk of data, ', 'second chunk of data, ', 'and the final chunk.']; + + $service = $this->buildService(); + $context = $service->createContext('sha256'); + foreach ($chunks as $chunk) { + $resource = $this->createResourceWithContent($chunk); + $service->updateFromResource($context, $resource); + } + $hash = $service->finalize($context); + + $this->assertSame(hash('sha256', implode('', $chunks)), $hash); + } + + public function testSerializedContextCanBeResumedAndProducesTheSameHashAsAnUninterruptedRun(): void + { + $firstChunk = 'first chunk of a resumed upload, '; + $secondChunk = 'and its second, final chunk.'; + + $service = $this->buildService(); + + $context = $service->createContext('sha256'); + $service->updateFromResource($context, $this->createResourceWithContent($firstChunk)); + $serialized = $service->serializeContextForStorage($context); + + $resumedContext = $service->unserializeContextFromStorage($serialized); + $service->updateFromResource($resumedContext, $this->createResourceWithContent($secondChunk)); + $hash = $service->finalize($resumedContext); + + $this->assertSame(hash('sha256', $firstChunk.$secondChunk), $hash); + } + + public function testUnserializeContextFromStorageThrowsConflictExceptionOnGarbageInput(): void + { + $exception = $this->prophesize(Client409ConflictException::class)->reveal(); + + $client409ConflictExceptionFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $client409ConflictExceptionFactory->createFromDetail(Argument::type('string'))->shouldBeCalledOnce()->willReturn($exception); + + $service = $this->buildService($client409ConflictExceptionFactory->reveal()); + + $this->expectException(Client409ConflictException::class); + + $service->unserializeContextFromStorage('this is not valid base64-encoded serialized data!!!'); + } + + public function testUnserializeContextFromStorageThrowsConflictExceptionWhenDecodedValueIsNotAHashContext(): void + { + $exception = $this->prophesize(Client409ConflictException::class)->reveal(); + + $client409ConflictExceptionFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $client409ConflictExceptionFactory->createFromDetail(Argument::type('string'))->shouldBeCalledOnce()->willReturn($exception); + + $service = $this->buildService($client409ConflictExceptionFactory->reveal()); + + $this->expectException(Client409ConflictException::class); + + $service->unserializeContextFromStorage(base64_encode(serialize('just a string, not a HashContext'))); + } +} diff --git a/tests/UnitTests/Service/QueueServiceTest.php b/tests/UnitTests/Service/QueueServiceTest.php new file mode 100644 index 00000000..c77b8cb8 --- /dev/null +++ b/tests/UnitTests/Service/QueueServiceTest.php @@ -0,0 +1,93 @@ +prophesize(AMQPChannel::class); + $channel->queue_declare('ELASTICSEARCH_REINDEX_FILE', false, false, false, false)->shouldBeCalledOnce(); + $channel->basic_publish( + Argument::that(fn (AMQPMessage $message) => '{"elementId":"abc"}' === $message->getBody()), + '', + 'ELASTICSEARCH_REINDEX_FILE' + )->shouldBeCalledOnce(); + $channel->close()->shouldBeCalledOnce(); + + $connection = $this->prophesize(AMQPStreamConnection::class); + $connection->channel()->willReturn($channel->reveal()); + + $queueService = new QueueService($connection->reveal()); + $queueService->publishEvent(RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, ['elementId' => 'abc']); + } + + public function testConsumeQueueReturnsZeroForEmptyQueueWithoutCallingHandler(): void + { + $channel = $this->prophesize(AMQPChannel::class); + $channel->queue_declare('ELASTICSEARCH_REINDEX_FILE', false, false, false, false)->shouldBeCalledOnce(); + $channel->basic_get('ELASTICSEARCH_REINDEX_FILE')->shouldBeCalledOnce()->willReturn(null); + $channel->close()->shouldBeCalledOnce(); + + $connection = $this->prophesize(AMQPStreamConnection::class); + $connection->channel()->willReturn($channel->reveal()); + + $queueService = new QueueService($connection->reveal()); + $processedMessages = $queueService->consumeQueue( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + function (): void { + $this->fail('Handler should not be called for an empty queue.'); + } + ); + + $this->assertSame(0, $processedMessages); + } + + public function testConsumeQueueDrainsAllMessagesAndAcknowledgesEach(): void + { + $message1 = new AMQPMessage('{"elementId":"1"}'); + $message2 = new AMQPMessage('{"elementId":"2"}'); + + $channel = $this->prophesize(AMQPChannel::class); + $channel->queue_declare('ELASTICSEARCH_REINDEX_FILE', false, false, false, false)->shouldBeCalledOnce(); + $channel->basic_get('ELASTICSEARCH_REINDEX_FILE')->willReturn($message1, $message2, null); + $channel->close()->shouldBeCalledOnce(); + + $connection = $this->prophesize(AMQPStreamConnection::class); + $connection->channel()->willReturn($channel->reveal()); + + $message1->setChannel($channel->reveal()); + $message2->setChannel($channel->reveal()); + $channel->basic_ack(Argument::any(), Argument::any())->shouldBeCalledTimes(2); + + $handledElementIds = []; + + $queueService = new QueueService($connection->reveal()); + $processedMessages = $queueService->consumeQueue( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + function (array $eventData) use (&$handledElementIds): void { + $handledElementIds[] = $eventData['elementId']; + } + ); + + $this->assertSame(2, $processedMessages); + $this->assertSame(['1', '2'], $handledElementIds); + } +} diff --git a/tests/UnitTests/Service/S3ServiceTest.php b/tests/UnitTests/Service/S3ServiceTest.php index 3ecb1ebc..29d5ee0b 100644 --- a/tests/UnitTests/Service/S3ServiceTest.php +++ b/tests/UnitTests/Service/S3ServiceTest.php @@ -15,6 +15,7 @@ use App\Factory\Type\S3\UploadFileChunkOperationFactory; use App\Service\MimeTypeService; use App\Service\S3Service; +use App\Service\S3TechnicalLimitsValidator; use App\Wrapper\S3ClientWrapper; use AsyncAws\Core\Stream\ResultStream; use AsyncAws\S3\Result\CopyObjectOutput; @@ -48,7 +49,10 @@ private function buildS3Service( ?MimeTypeService $mimeTypeService = null, ?Client400BadContentExceptionFactory $client400BadContentExceptionFactory = null, ?Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory = null, + ?S3TechnicalLimitsValidator $s3TechnicalLimitsValidator = null, ): S3Service { + $s3TechnicalLimitsValidator ??= $this->prophesize(S3TechnicalLimitsValidator::class)->reveal(); + return new S3Service( $s3Client ?? $this->prophesize(S3Client::class)->reveal(), $fileChunkOperationFactory ?? $this->prophesize(UploadFileChunkOperationFactory::class)->reveal(), @@ -56,6 +60,7 @@ private function buildS3Service( $mimeTypeService ?? $this->prophesize(MimeTypeService::class)->reveal(), $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), $server500LogicErrorExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + $s3TechnicalLimitsValidator, ); } @@ -631,6 +636,99 @@ public function testMergeFileChunksDeletesPreviousFileIfAvailable(): void $this->assertSame(12345678, $contentLength); } + /** + * A replace which keeps the same extension reuses the same storage key for the new file as the previous one + * (see FileService::getStorageBucketKey()), so `getPreviousStorageKey()` equals `getStorageKey()` - deleting + * it would delete the file that was just written, not an orphan. Complements + * testMergeFileChunksDeletesPreviousFileIfAvailable() (different key: gets deleted) and testMergeFileChunks() + * (no previous key at all: nothing to delete). + */ + public function testMergeFileChunksDoesNotDeletePreviousFileWhenItIsTheSameAsTheNewOne(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledTimes(4)->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(5)->willReturn('storage-key'); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledTimes(2)->willReturn(['upload-key-0001']); + $mergeFileChunksOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); + $createMultipartUploadOutput->getUploadId()->shouldBeCalledOnce()->willReturn('upload-id'); + $createMultipartUploadOutput = $createMultipartUploadOutput->reveal(); + + $copyPartResult1 = new CopyPartResult(['ETag' => 'etag-1']); + $uploadPartCopyOutput1 = $this->prophesize(UploadPartCopyOutput::class); + $uploadPartCopyOutput1->getCopyPartResult()->shouldBeCalledOnce()->willReturn($copyPartResult1); + + $headObjectOutput1 = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput1->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput1 = $this->prophesize(GetObjectOutput::class); + $getObjectOutput1->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $headObjectOutput2 = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput2->getContentLength()->shouldBeCalledOnce()->willReturn(12345678); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce()->willReturn($createMultipartUploadOutput); + $s3Client->uploadPartCopy(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'PartNumber' => 1, + 'CopySource' => 'upload-bucket/upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($uploadPartCopyOutput1->reveal()); + $s3Client->completeMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'upload-id', + 'MultipartUpload' => [ + 'Parts' => [ + [ + 'PartNumber' => 1, + 'ETag' => 'etag-1', + ], + ], + ], + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput1->reveal()); + $s3Client->getObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key-0001', + 'Range' => 'bytes=0-12', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput1->reveal()); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput2->reveal()); + // the just-written file must never be probed for existence or deleted just because it happens to equal + // the previous key + $s3Client->objectExists(Argument::any())->shouldNotBeCalled(); + $s3Client->deleteObject(Argument::any())->shouldNotBeCalled(); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $contentLength = $s3Service->mergeFileChunks($mergeFileChunksOperation); + $this->assertSame(12345678, $contentLength); + } + public function testDeleteFileChunks(): void { $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); @@ -1031,6 +1129,94 @@ public function testUploadFileDeletesPreviousFileIfItExists(): void $this->assertSame(1234, $contentLength); } + /** + * A replace which keeps the same extension reuses the same storage key for the new file as the previous one + * (see FileService::getStorageBucketKey()), so `getPreviousStorageKey()` equals `getStorageKey()` - deleting + * it would delete the file that was just written, not an orphan. Complements + * testUploadFileDeletesPreviousFileIfItExists() (different key: gets deleted) and testUploadFile() (no + * previous key at all: nothing to delete). The intermediate upload-bucket object is still cleaned up + * regardless, since that cleanup is unconditional. + */ + public function testUploadFileDoesNotDeletePreviousFileWhenItIsTheSameAsTheNewOne(): void + { + $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $uploadFileOperation->getStorageKey()->shouldBeCalledTimes(2)->willReturn('storage-key'); + $uploadFileOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $uploadFileOperation = $uploadFileOperation->reveal(); + + $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); + $uploadFileChunkOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $uploadFileChunkOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); + $uploadFileChunkOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $uploadFileChunkOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + $uploadFileChunkOperation->getContentLength()->shouldBeCalledOnce()->willReturn(null); + + $copyObjectOutput = $this->prophesize(CopyObjectOutput::class)->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1234); + + $objectExistsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->putObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + 'Body' => 'some content', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce(); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + $s3Client->copyObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'CopySource' => 'upload-bucket/upload-key', + 'ContentType' => 'text/plain', + 'MetadataDirective' => 'REPLACE', + ]))->shouldBeCalledOnce()->willReturn($copyObjectOutput); + // only the intermediate upload-bucket object is ever probed/deleted - the just-written storage object + // must never be, just because it happens to equal the previous key + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'upload-bucket', + 'Key' => 'upload-key', + ]))->shouldBeCalledOnce(); + $s3Client->objectExists(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + ]))->shouldNotBeCalled(); + $s3Client->deleteObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + ]))->shouldNotBeCalled(); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledTimes(2)->willReturn(true, false); + $s3ClientWrapper->resolveCopyObjectOutput(Argument::is($copyObjectOutput))->shouldBeCalledOnce(); + + $uploadFileChunkOperationFactory = $this->prophesize(UploadFileChunkOperationFactory::class); + $uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) + ->shouldBeCalledOnce()->willReturn($uploadFileChunkOperation); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + fileChunkOperationFactory: $uploadFileChunkOperationFactory->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $contentLength = $s3Service->uploadFile($uploadFileOperation); + $this->assertSame(1234, $contentLength); + } + public function testUploadFileRethrowsExceptionDuringUpload(): void { $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); @@ -1248,6 +1434,34 @@ public function testGetFileRangeAsResourceClipsResourceLengthToActualContentLeng $this->assertSame('some content', $resource); } + public function testGetFileRangeAsResourceReturnsEmptyResourceWithoutCallingS3WhenContentLengthIsZero(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledOnce()->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(0); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + // a byte-range request against an empty object is invalid (S3 rejects it with a 416), so getObject must + // not be called at all here + $s3Client->getObject(Argument::any())->shouldNotBeCalled(); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $resource = $s3Service->getFileRangeAsResource($fileOperation, 5000000); + $this->assertIsResource($resource); + $this->assertSame('', \Safe\stream_get_contents($resource)); + } + public function testGetFileRangeAsResourceThrowsWhenContentLengthIsNotDeterminable(): void { $fileOperation = $this->prophesize(FileOperationInterface::class); diff --git a/tests/UnitTests/Service/S3TechnicalLimitsValidatorTest.php b/tests/UnitTests/Service/S3TechnicalLimitsValidatorTest.php new file mode 100644 index 00000000..0c50881f --- /dev/null +++ b/tests/UnitTests/Service/S3TechnicalLimitsValidatorTest.php @@ -0,0 +1,130 @@ +prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()->willReturn($configuredMinChunkSize); + $emberNexusConfiguration->getFileUploadChunkDigitsLength()->willReturn($configuredChunkDigitsLength); + $emberNexusConfiguration->getFileMaxFileSizeInBytes()->willReturn($configuredMaxFileSize); + + $s3TechnicalLimits = $this->prophesize(S3TechnicalLimitsInterface::class); + $s3TechnicalLimits->getMinChunkSizeInBytes()->willReturn($technicalMinChunkSize); + $s3TechnicalLimits->getMaxChunkCount()->willReturn($technicalMaxChunkCount); + $s3TechnicalLimits->getMaxObjectSizeInBytes()->willReturn($technicalMaxObjectSize); + + return new S3TechnicalLimitsValidator( + $emberNexusConfiguration->reveal(), + $s3TechnicalLimits->reveal(), + $server500LogicErrorExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + } + + public function testValidConfigurationPasses(): void + { + $validator = $this->buildValidator( + configuredMinChunkSize: 5 * 1024 * 1024, + configuredChunkDigitsLength: 4, + configuredMaxFileSize: 10 * 1024 * 1024 * 1024, + ); + + $validator->validate(); + $this->addToAssertionCount(1); + } + + public function testThrowsIfConfiguredMinChunkSizeIsBelowTechnicalMinimum(): void + { + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate(Argument::containingString('uploadMinChunkSizeInBytes')) + ->shouldBeCalledOnce() + ->willReturn($exception); + + $validator = $this->buildValidator( + configuredMinChunkSize: 1024 * 1024, + configuredChunkDigitsLength: 4, + configuredMaxFileSize: 10 * 1024 * 1024 * 1024, + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal(), + ); + + $this->expectException(Server500LogicErrorException::class); + $validator->validate(); + } + + public function testThrowsIfConfiguredChunkDigitsLengthAllowsMoreChunksThanTechnicallySupported(): void + { + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate(Argument::containingString('uploadChunkDigitsLength')) + ->shouldBeCalledOnce() + ->willReturn($exception); + + $validator = $this->buildValidator( + configuredMinChunkSize: 5 * 1024 * 1024, + configuredChunkDigitsLength: 5, // allows up to 100,000 chunks, exceeds the 10,000 technical limit + configuredMaxFileSize: 10 * 1024 * 1024 * 1024, + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal(), + ); + + $this->expectException(Server500LogicErrorException::class); + $validator->validate(); + } + + public function testThrowsIfConfiguredMaxFileSizeExceedsTechnicalMaxObjectSize(): void + { + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate(Argument::containingString('maxFileSizeInBytes')) + ->shouldBeCalledOnce() + ->willReturn($exception); + + $validator = $this->buildValidator( + configuredMinChunkSize: 5 * 1024 * 1024, + configuredChunkDigitsLength: 4, + configuredMaxFileSize: 6 * 1024 * 1024 * 1024 * 1024, + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal(), + ); + + $this->expectException(Server500LogicErrorException::class); + $validator->validate(); + } + + public function testExactlyAtTechnicalLimitsIsAllowed(): void + { + $validator = $this->buildValidator( + configuredMinChunkSize: 5 * 1024 * 1024, + configuredChunkDigitsLength: 4, // 10,000 chunks, exactly the technical maximum + configuredMaxFileSize: 5 * 1024 * 1024 * 1024 * 1024, + ); + + $validator->validate(); + $this->addToAssertionCount(1); + } +} diff --git a/tests/UnitTests/Type/FragmentGroupTest.php b/tests/UnitTests/Type/FragmentGroupTest.php index 77983dc6..8c61bdb4 100644 --- a/tests/UnitTests/Type/FragmentGroupTest.php +++ b/tests/UnitTests/Type/FragmentGroupTest.php @@ -21,33 +21,27 @@ public function testFragmentGroup(): void $cypherFragment1 = new Node(); $mongoFragment1 = new MongoDocument(); $elasticFragment1 = new ElasticDocument(); - $fileFragment1 = null; $fragmentGroup = new FragmentGroup( $cypherFragment1, $mongoFragment1, - $elasticFragment1, - $fileFragment1 + $elasticFragment1 ); $this->assertSame($cypherFragment1, $fragmentGroup->getCypherFragment()); $this->assertSame($mongoFragment1, $fragmentGroup->getMongoFragment()); $this->assertSame($elasticFragment1, $fragmentGroup->getElasticFragment()); - $this->assertSame($fileFragment1, $fragmentGroup->getFileFragment()); $cypherFragment2 = new Node(); $mongoFragment2 = new MongoDocument(); $elasticFragment2 = new ElasticDocument(); - $fileFragment2 = 'somethingElse'; $fragmentGroup->setCypherFragment($cypherFragment2); $fragmentGroup->setMongoFragment($mongoFragment2); $fragmentGroup->setElasticFragment($elasticFragment2); - $fragmentGroup->setFileFragment($fileFragment2); $this->assertSame($cypherFragment2, $fragmentGroup->getCypherFragment()); $this->assertSame($mongoFragment2, $fragmentGroup->getMongoFragment()); $this->assertSame($elasticFragment2, $fragmentGroup->getElasticFragment()); - $this->assertSame($fileFragment2, $fragmentGroup->getFileFragment()); } } diff --git a/tests/UnitTests/Type/S3/S3TechnicalLimitsTest.php b/tests/UnitTests/Type/S3/S3TechnicalLimitsTest.php new file mode 100644 index 00000000..a7970bdc --- /dev/null +++ b/tests/UnitTests/Type/S3/S3TechnicalLimitsTest.php @@ -0,0 +1,24 @@ +assertSame(5 * 1024 * 1024, $limits->getMinChunkSizeInBytes()); + $this->assertSame(10_000, $limits->getMaxChunkCount()); + $this->assertSame(5 * 1024 * 1024 * 1024 * 1024, $limits->getMaxObjectSizeInBytes()); + } +} diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml index 67d127c6..36675a7f 100644 --- a/tools/docker-compose.yml +++ b/tools/docker-compose.yml @@ -14,6 +14,17 @@ services: ports: - "80:80" + reference-dataset-server: + container_name: reference-dataset-server + image: caddy:latest + volumes: + - ../../reference-dataset-local-backup:/usr/share/caddy + ports: + - "8090:80" + # temporary, local-only static file server used to serve a locally modified copy of the + # reference-dataset backup to the `api` container, bypassing the GitHub release used by + # `bin/test-feature-prepare`. Not part of the regular dev stack. + docs: container_name: docs image: sujaykumarh/docsify:latest From 7ada205f57be1416b4d532522a840be91bbe167a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Klein?= Date: Wed, 23 Sep 2026 23:40:03 +0200 Subject: [PATCH 4/7] wip --- .env | 2 +- .github/CODEOWNERS | 1 + .../ci-check-upstream-dependency-alpine.yml | 2 +- .../ci-check-upstream-dependency-php.yml | 2 +- .github/workflows/ci-release-package.yml | 4 +- .github/workflows/ci-test.yml | 210 +- CHANGELOG.md | 53 + README.md | 14 +- TODO-BACKUP-LARGE-FILES.md | 42 - TODO-NEXT.md | 107 - TODO.md | 101 - bin/test-feature-command-prepare | 6 +- bin/test-server | 82 + bin/with-heartbeat | 27 + composer.json | 65 +- composer.lock | 2658 ++++++++--------- config/packages/flysystem.yaml | 3 +- config/packages/monolog.yaml | 17 +- config/packages/web_profiler.yaml | 1 - config/reference.php | 206 +- config/routes/framework.yaml | 2 +- config/routes/web_profiler.yaml | 4 +- config/services.yaml | 5 +- docker/.env.docker | 2 +- docker/Caddyfile | 75 + docker/Caddyfile.dev | 107 + docker/Dockerfile | 11 +- docker/docker-entrypoint.sh | 6 + docker/php-embed/php.dev.ini | 6 +- docker/php-embed/php.prod.ini | 26 +- docs/_sidebar.md | 2 + .../delete-element/204-response-header.txt | 4 +- .../delete-element/401-response-body.json | 1 + .../delete-element/401-response-header.txt | 3 +- .../delete-element/404-response-body.json | 1 + .../delete-element/404-response-header.txt | 4 +- .../delete-element/412-response-body.json | 1 + .../delete-element/412-response-header.txt | 4 +- .../delete-element/429-response-body.json | 1 + .../get-children/200-response-header.txt | 6 +- .../get-children/304-response-header.txt | 6 +- .../get-children/401-response-body.json | 1 + .../get-children/401-response-header.txt | 3 +- .../get-children/404-response-body.json | 1 + .../get-children/404-response-header.txt | 4 +- .../get-children/412-response-body.json | 1 + .../get-children/412-response-header.txt | 4 +- .../get-children/429-response-body.json | 1 + .../get-element/200-node-response-header.txt | 6 +- .../200-relation-response-header.txt | 6 +- .../get-element/304-response-header.txt | 6 +- .../get-element/401-response-body.json | 1 + .../get-element/401-response-header.txt | 3 +- .../get-element/404-response-body.json | 1 + .../get-element/404-response-header.txt | 4 +- .../get-element/412-response-body.json | 1 + .../get-element/412-response-header.txt | 4 +- .../get-element/429-response-body.json | 1 + .../element/get-index/200-response-header.txt | 6 +- .../element/get-index/304-response-header.txt | 6 +- .../element/get-index/401-response-body.json | 1 + .../element/get-index/401-response-header.txt | 3 +- .../element/get-index/412-response-body.json | 1 + .../element/get-index/412-response-header.txt | 4 +- .../element/get-index/429-response-body.json | 1 + .../get-parents/200-response-header.txt | 6 +- .../get-parents/304-response-header.txt | 6 +- .../get-parents/401-response-body.json | 1 + .../get-parents/401-response-header.txt | 3 +- .../get-parents/404-response-body.json | 1 + .../get-parents/404-response-header.txt | 4 +- .../get-parents/412-response-body.json | 1 + .../get-parents/412-response-header.txt | 4 +- .../get-parents/429-response-body.json | 1 + .../get-related/200-response-header.txt | 6 +- .../get-related/304-response-header.txt | 6 +- .../get-related/401-response-body.json | 1 + .../get-related/401-response-header.txt | 3 +- .../get-related/404-response-body.json | 1 + .../get-related/404-response-header.txt | 4 +- .../get-related/412-response-body.json | 1 + .../get-related/412-response-header.txt | 4 +- .../get-related/429-response-body.json | 1 + .../patch-element/204-response-header.txt | 4 +- .../patch-element/401-response-body.json | 1 + .../patch-element/401-response-header.txt | 3 +- .../patch-element/404-response-body.json | 1 + .../patch-element/404-response-header.txt | 4 +- .../patch-element/412-response-body.json | 1 + .../patch-element/412-response-header.txt | 4 +- .../patch-element/429-response-body.json | 1 + .../post-element/204-response-header.txt | 6 +- .../post-element/400-response-body.json | 1 + .../post-element/400-response-header.txt | 4 +- .../post-element/401-response-body.json | 1 + .../post-element/401-response-header.txt | 3 +- .../post-element/429-response-body.json | 1 + .../post-index/204-response-header.txt | 6 +- .../element/post-index/400-response-body.json | 1 + .../post-index/400-response-header.txt | 4 +- .../element/post-index/401-response-body.json | 1 + .../post-index/401-response-header.txt | 3 +- .../element/post-index/404-response-body.json | 1 + .../post-index/404-response-header.txt | 4 +- .../element/post-index/429-response-body.json | 1 + .../put-element/204-response-header.txt | 4 +- .../put-element/401-response-body.json | 1 + .../put-element/401-response-header.txt | 3 +- .../put-element/404-response-body.json | 1 + .../put-element/404-response-header.txt | 4 +- .../put-element/412-response-body.json | 1 + .../put-element/412-response-header.txt | 4 +- .../put-element/429-response-body.json | 1 + .../200-response-header.txt | 4 +- .../200-response-header.txt | 4 +- .../200-response-header.txt | 4 +- .../200-response-header.txt | 4 +- .../200-response-header.txt | 4 +- .../200-response-header.txt | 4 +- .../get-403-forbidden/200-response-header.txt | 4 +- .../get-404-not-found/200-response-header.txt | 4 +- .../error/get-408-request-timeout.md | 21 + .../200-response-body.txt | 16 + .../200-response-header.txt} | 6 +- .../200-response-header.txt | 4 +- .../200-response-header.txt | 4 +- .../200-response-header.txt | 4 +- .../error/get-503-service-unavailable.md | 21 + .../200-response-body.txt | 16 + .../200-response-header.txt} | 6 +- .../204-response-header.txt | 4 +- .../401-response-body.json | 1 + .../401-response-header.txt | 3 +- .../404-response-body.json | 1 + .../404-response-header.txt | 4 +- .../200-if-range-mismatch-response-body.txt | 1 + .../200-if-range-mismatch-response-header.txt | 15 + .../get-element-file/200-response-header.txt | 8 +- .../206-if-range-match-response-body.txt | 1 + .../206-if-range-match-response-header.txt | 16 + .../get-element-file/206-response-header.txt | 8 +- .../get-element-file/400-response-body.json | 1 + .../get-element-file/400-response-header.txt | 4 +- .../get-element-file/401-response-body.json | 1 + .../get-element-file/401-response-header.txt | 3 +- .../get-element-file/404-response-body.json | 1 + .../get-element-file/404-response-header.txt | 4 +- .../get-element-file/416-response-body.json | 5 +- .../get-element-file/416-response-header.txt | 5 +- .../post-element-file/201-response-header.txt | 4 +- .../204-with-initial-data-response-header.txt | 8 +- ...4-without-initial-data-response-header.txt | 8 +- .../post-element-file/400-response-body.json | 3 +- .../post-element-file/400-response-header.txt | 4 +- .../post-element-file/401-response-body.json | 1 + .../post-element-file/401-response-header.txt | 3 +- .../post-element-file/404-response-body.json | 1 + .../post-element-file/404-response-header.txt | 4 +- .../post-element-file/409-response-body.json | 1 + .../post-element-file/409-response-header.txt | 4 +- .../put-element-file/201-response-header.txt | 4 +- .../204-with-initial-data-response-header.txt | 8 +- ...4-without-initial-data-response-header.txt | 8 +- .../put-element-file/400-response-body.json | 3 +- .../put-element-file/400-response-header.txt | 4 +- .../put-element-file/401-response-body.json | 1 + .../put-element-file/401-response-header.txt | 3 +- .../put-element-file/404-response-body.json | 1 + .../put-element-file/404-response-header.txt | 4 +- .../200-response-body.json | 1 + .../200-response-header.txt | 4 +- .../403-response-body.json | 3 +- .../403-response-header.txt | 6 +- .../200-response-header.txt | 4 +- .../403-response-body.json | 3 +- .../403-response-header.txt | 6 +- .../200-response-header.txt | 4 +- docs/api-endpoints/upload/delete-upload.md | 12 +- .../delete-upload/204-response-header.txt | 4 +- .../delete-upload/401-response-body.json | 1 + .../delete-upload/401-response-header.txt | 3 +- .../delete-upload/404-response-body.json | 1 + .../delete-upload/404-response-header.txt | 4 +- docs/api-endpoints/upload/head-upload.md | 8 + .../head-upload/204-response-header.txt | 6 +- .../head-upload/401-response-header.txt | 3 +- .../head-upload/404-response-header.txt | 4 +- docs/api-endpoints/upload/patch-upload.md | 8 + .../204-final-chunk-response-header.txt | 6 +- ...204-intermediate-chunk-response-header.txt | 6 +- .../patch-upload/400-response-body.json | 1 + .../patch-upload/400-response-header.txt | 4 +- .../patch-upload/401-response-body.json | 1 + .../patch-upload/401-response-header.txt | 3 +- .../patch-upload/404-response-body.json | 1 + .../patch-upload/404-response-header.txt | 4 +- .../patch-upload/409-response-body.json | 5 +- .../patch-upload/409-response-header.txt | 4 +- .../user/delete-token/204-response-header.txt | 4 +- .../user/delete-token/401-response-body.json | 1 + .../user/delete-token/401-response-header.txt | 3 +- .../user/delete-token/429-response-body.json | 1 + .../user/get-me/200-response-header.txt | 4 +- .../user/get-me/401-response-body.json | 1 + .../user/get-me/401-response-header.txt | 3 +- .../user/get-me/429-response-body.json | 1 + .../user/get-token/200-response-header.txt | 4 +- .../user/get-token/401-response-body.json | 1 + .../user/get-token/401-response-header.txt | 3 +- .../user/get-token/403-response-body.json | 1 + .../user/get-token/403-response-header.txt | 4 +- .../user/post-change-password-old.md | 204 -- .../400-response-body.json | 6 - .../401-response-body.json | 6 - .../403-response-body.json | 6 - .../429-response-body.json | 6 - .../429-response-header.txt | 9 - .../user/post-change-password.md | 5 - .../204-response-header.txt | 4 +- .../400-response-body.json | 1 + .../400-response-header.txt | 4 +- .../401-response-body.json | 1 + .../401-response-header.txt | 4 +- .../403-response-body.json | 1 + .../403-response-header.txt | 4 +- .../429-response-body.json | 1 + docs/api-endpoints/user/post-register-old.md | 169 -- .../post-register-old/201-response-header.txt | 11 - .../post-register-old/400-response-body.json | 6 - .../post-register-old/403-response-body.json | 6 - .../post-register-old/403-response-header.txt | 10 - docs/api-endpoints/user/post-register.md | 5 - .../post-register/201-response-header.txt | 6 +- .../user/post-register/400-response-body.json | 1 + .../post-register/400-response-header.txt | 4 +- .../user/post-register/403-response-body.json | 3 +- .../post-register/403-response-header.txt | 6 +- docs/api-endpoints/user/post-token-old.md | 164 - .../post-token-old/201-response-body.json | 4 - .../post-token-old/400-response-body.json | 6 - .../post-token-old/400-response-header.txt | 10 - .../post-token-old/401-response-body.json | 6 - .../post-token-old/401-response-header.txt | 10 - docs/api-endpoints/user/post-token.md | 5 - .../user/post-token/201-response-header.txt | 4 +- .../user/post-token/400-response-body.json | 1 + .../user/post-token/400-response-header.txt | 4 +- .../user/post-token/401-response-body.json | 1 + .../user/post-token/401-response-header.txt | 4 +- docs/commands/assets/backup-create-help.html | 22 + docs/commands/assets/backup-create.html | 47 +- docs/commands/assets/backup-fetch.html | 6 +- docs/commands/assets/backup-list.html | 5 +- docs/commands/assets/backup-load-help.html | 3 +- docs/commands/assets/backup-load.html | 6 +- docs/commands/assets/token-create.html | 4 +- docs/commands/assets/token-revoke.html | 24 +- docs/commands/assets/user-create.html | 2 +- docs/concepts/caching.md | 4 + docs/example/default-parameters.yaml | 6 - .../examples/NotFoundProblemExample.json | 1 + docs/open-api/schemas/Problem.json | 29 +- docs/open-api/swagger.json | 30 +- docs/open-api/swagger_old.yml | 2 +- .../find-nested-elements/response-body.json | 200 +- .../find-nested-elements/response-header.txt | 4 +- .../find-tagged-elements/response-body.json | 160 +- .../find-tagged-elements/response-header.txt | 4 +- .../response-body.json | 207 +- .../response-header.txt | 4 +- .../keyword-search/response-body.json | 29 +- .../keyword-search/response-header.txt | 4 +- .../response-body.json | 63 +- .../response-header.txt | 4 +- .../query-string-search/response-body.json | 122 +- .../query-string-search/response-header.txt | 4 +- .../response-body.json | 244 +- .../response-header.txt | 4 +- .../text-prefix-search/response-body.json | 35 +- .../text-prefix-search/response-header.txt | 4 +- .../wildcard-search/response-header.txt | 4 +- .../response-body.json | 57 +- .../response-header.txt | 4 +- .../response-body.json | 244 +- .../response-header.txt | 4 +- .../response-body.json | 244 +- .../response-header.txt | 4 +- .../response-body.json | 57 +- .../response-header.txt | 4 +- .../response-body.json | 187 +- .../response-header.txt | 4 +- .../response-body.json | 496 +-- .../response-header.txt | 4 +- docs/security/access.md | 6 +- .../no-free-php-thread/503-response-body.json | 7 + .../503-response-header.txt} | 5 +- .../php-boot-failure/500-response-body.json | 7 + .../php-boot-failure/500-response-header.txt} | 5 +- .../php-out-of-memory/500-response-body.json | 7 + .../500-response-header.txt} | 6 +- .../408-response-body.json | 7 + .../408-response-header.txt} | 7 +- .../408-response-body.json | 7 + .../408-response-header.txt | 11 + .../src/DependencyInjection/Configuration.php | 11 - .../src/Service/EmberNexusConfiguration.php | 27 +- public/index.php | 3 +- src/Command/BackupCreateCommand.php | 51 +- src/Command/BackupFetchCommand.php | 16 +- src/Command/BackupListCommand.php | 2 + src/Command/BackupLoadCommand.php | 149 +- .../Cron/DeleteExpiredUploadsCommand.php | 111 +- src/Command/Cron/ReindexFilesCommand.php | 11 +- src/Command/Cron/UpdateOwnershipCommand.php | 233 +- src/Command/CronCommand.php | 41 +- src/Command/DatabaseDropCommand.php | 67 +- .../S3/S3TechnicalLimitsInterface.php | 19 +- src/Contract/UploadInterface.php | 23 +- .../Element/DeleteElementController.php | 8 +- .../Element/GetChildrenController.php | 2 +- .../Element/GetParentsController.php | 2 +- .../Element/GetRelatedController.php | 2 +- .../Element/PatchElementController.php | 4 +- .../Element/PostElementController.php | 4 +- .../Element/PostIndexController.php | 4 +- .../Element/PutElementController.php | 4 +- .../assets/error-405-method-not-allowed.txt | 7 + .../assets/error-408-request-timeout.txt | 9 + .../assets/error-503-service-unavailable.txt | 9 + .../File/DeleteElementFileController.php | 20 +- .../File/GetElementFileController.php | 23 +- .../File/PostElementFileController.php | 34 +- .../File/PutElementFileController.php | 9 + .../Search/PostSearchController.php | 4 +- .../Upload/DeleteUploadController.php | 22 +- .../Upload/HeadUploadController.php | 36 +- .../Upload/PatchUploadController.php | 196 +- src/Controller/User/DeleteTokenController.php | 12 + .../User/PostChangePasswordController.php | 7 +- .../User/PostRegisterController.php | 10 +- src/Controller/User/PostTokenController.php | 7 +- .../IfMatchControllerEventListener.php | 23 +- .../IfNoneMatchControllerEventListener.php | 12 +- ...ropertyElementFragmentizeEventListener.php | 2 +- ...ropertyElementFragmentizeEventListener.php | 9 +- ...FileElementPropertyChangeEventListener.php | 4 +- ...FileElementPropertyChangeEventListener.php | 2 +- ...loadElementPropertyChangeEventListener.php | 32 + .../FileElementPropertyResetEventListener.php | 3 +- .../CalculateSearchAccessEventListener.php | 73 +- ...CancelUploadsOnAccessLossEventListener.php | 75 + .../ExpireEtagOnChangeEventListener.php | 36 + .../OwnershipChangeEventListener.php | 48 +- .../EventListener/ExceptionEventListener.php | 29 +- .../NoRouteFoundExceptionEventListener.php | 6 + .../AllowHeaderResponseEventListener.php | 97 + .../Client408RequestTimeoutException.php | 21 + src/Exception/Client409ConflictException.php | 6 +- .../Client416RangeNotSatisfiableException.php | 6 +- src/Exception/ProblemJsonException.php | 22 + ...lient408RequestTimeoutExceptionFactory.php | 45 + .../Client409ConflictExceptionFactory.php | 6 +- ...416RangeNotSatisfiableExceptionFactory.php | 14 +- src/Factory/Type/RedisKeyFactory.php | 24 + .../Request/PartialUploadRequestFactory.php | 11 +- .../Request/ResumableUploadRequestFactory.php | 5 +- .../Response/NoContentResponseFactory.php | 6 +- src/Factory/Type/S3/FileOperationFactory.php | 48 - .../S3/MergeFileChunksOperationFactory.php | 56 - src/Factory/Type/S3/S3OperationFactory.php | 176 ++ .../S3/UploadFileChunkOperationFactory.php | 63 - .../Type/S3/UploadFileOperationFactory.php | 85 - src/Factory/Type/UploadFactory.php | 17 +- src/Logger/TerminalConsoleHandler.php | 98 + src/Security/AccessChecker.php | 26 +- .../CreateElementFromRawDataService.php | 3 +- src/Service/CronExecutionGateService.php | 36 + src/Service/DigestService.php | 12 +- src/Service/ElementFileDeletionService.php | 133 + src/Service/ElementService.php | 20 + src/Service/ElementToRawService.php | 19 +- src/Service/EtagCalculatorService.php | 41 +- src/Service/EtagService.php | 22 + .../ExpiredUploadDeletionAttemptService.php | 94 + src/Service/FileCreationLockService.php | 52 + src/Service/FileHashService.php | 87 +- src/Service/FilePropertyService.php | 11 +- src/Service/FileRangeService.php | 50 +- src/Service/FileService.php | 43 +- src/Service/FileSizeLimitService.php | 39 + src/Service/HeaderParseService.php | 8 +- src/Service/IncrementalHashService.php | 36 +- src/Service/PropertyParseService.php | 49 +- src/Service/QueueService.php | 145 +- src/Service/RawToElementService.php | 2 - src/Service/RequestContentService.php | 33 + src/Service/RequestIdService.php | 30 +- src/Service/RequestUtilService.php | 66 +- src/Service/S3Service.php | 211 +- src/Service/S3TechnicalLimitsValidator.php | 17 +- src/Service/SearchAccessCalculatorService.php | 73 + src/Service/TtyDetectorService.php | 25 + src/Service/UploadAccessService.php | 63 + src/Service/UploadAppendService.php | 154 + src/Service/UploadBodyLimitService.php | 71 + src/Service/UploadCancellationService.php | 107 + src/Service/UploadChunkValidator.php | 69 + src/Service/UploadConsistencyService.php | 67 + src/Service/UploadCreationService.php | 124 +- src/Service/UploadFinalizationService.php | 183 ++ src/Service/UploadLockService.php | 46 + src/Service/UploadService.php | 102 +- src/Style/EmberNexusStyle.php | 14 +- src/Type/RedisPrefixType.php | 4 + src/Type/Response/BinaryStreamResponse.php | 12 +- src/Type/S3/S3TechnicalLimits.php | 6 + src/Type/Upload.php | 32 +- src/Wrapper/S3ClientWrapper.php | 6 + taskfile.yml | 2 +- .../Backup/BackupFetchTest.php | 28 +- .../Backup/BackupFilesTest.php | 173 ++ .../Backup/BackupLoadTest.php | 4 +- .../BaseCommandTestCase.php | 21 +- .../Cache/CacheClearEtagTest.php | 4 +- .../Database/DatabaseDropTest.php | 4 +- .../ConfigDumpReferenceEmberNexusTest.php | 2 +- .../docker-compose.yml | 16 +- .../BaseRequestTestCase.php | 162 +- .../Element/GetChildrenTest.php | 9 +- .../Element/GetParentsTest.php | 9 +- .../Element/GetRelatedTest.php | 9 +- .../Error/Get408RequestTimeoutTest.php | 30 + .../Error/Get503ServiceUnavailableTest.php | 30 + .../File/GetElementFileTest.php | 85 + .../User/PostChangePasswordOldTest.php | 127 - .../User/PostRegisterOldTest.php | 65 - .../User/PostTokenOldTest.php | 96 - .../docker-compose.verify-tmp.yml | 136 + .../docker-compose.yml | 58 +- .../File/MaxFileSizeTest.php | 154 + .../configuration.yml | 2 + tests/FeatureTests/BaseRequestTestCase.php | 81 + .../BackupFetchFolderStructureTest.php | 8 +- .../FeatureTests/Command/BaseCronTestCase.php | 121 + .../Command/CronDeleteExpiredUploadsTest.php | 133 + .../Command/CronReferenceDatasetTest.php | 60 + .../Command/CronReindexFilesTest.php | 153 + .../Command/CronUpdateOwnershipTest.php | 388 +++ .../Endpoint/Element/PostReservedTypeTest.php | 92 + .../Endpoint/Error/ProblemResponsesTest.php | 191 ++ .../Endpoint/File/DeleteFileTest.php | 24 +- .../Endpoint/File/FileEtagTest.php | 117 + .../File/GetFileRangeOnRelationTest.php | 228 ++ .../Endpoint/File/GetFileRangeTest.php | 104 +- .../Endpoint/File/GetFileTest.php | 99 +- .../File/PostFileCreationLockTest.php | 159 + .../File/PostFileEtagOnRelationTest.php | 76 + .../Endpoint/File/PostFileEtagTest.php | 101 + .../File/PostFileInSingleRequestTest.php | 4 - .../File/PutFileInSingleRequestTest.php | 4 - ...leWithDifferentExtensionOnRelationTest.php | 60 + .../ReplaceFileWithDifferentExtensionTest.php | 10 +- .../Endpoint/File/WildcardFileEtagTest.php | 102 + .../File/ZeroByteFileUploadOnRelationTest.php | 41 + .../Endpoint/File/ZeroByteFileUploadTest.php | 9 +- .../Endpoint/System/MethodNotAllowedTest.php | 56 + .../Upload/CancelUploadOnAccessLossTest.php | 160 + .../Endpoint/Upload/DeleteUploadTest.php | 2 - ...ingElementCleansUpInProgressUploadTest.php | 63 +- .../PatchUploadErrorsOnRelationTest.php | 153 + .../Upload/PatchUploadIncorrectOffsetTest.php | 6 +- .../PatchUploadMissingUploadCompleteTest.php | 85 + .../Upload/PatchUploadOversizedChunkTest.php | 9 +- .../Upload/PatchUploadStateErrorsTest.php | 253 ++ ...hUploadUndersizedIntermediateChunkTest.php | 26 +- .../Upload/PatchUploadZeroLengthChunkTest.php | 174 ++ .../Upload/ResumableUploadCreationTest.php | 11 +- .../ResumableUploadFinalizeRaceTest.php | 81 + ...ResumableUploadLifecycleOnRelationTest.php | 118 + .../Upload/ResumableUploadLifecycleTest.php | 2 - .../ResumableUploadUnfinalizedStateTest.php | 146 + ...tBeModifiedThroughGenericEndpointsTest.php | 101 + .../Upload/UploadLengthEnforcementTest.php | 163 + .../Endpoint/Upload/UploadLimitHeaderTest.php | 98 + .../ZeroByteResumableUploadOnRelationTest.php | 61 + .../Upload/ZeroByteResumableUploadTest.php | 12 +- .../User/PostChangePasswordOldTest.php | 184 -- .../Endpoint/User/PostRegisterOldTest.php | 125 - .../Endpoint/User/PostTokenOldTest.php | 135 - .../General/EtagAccessControlTest.php | 100 + .../Exception/ProblemJsonInstanceTest.php | 53 + .../General/File/AnonymousFileAccessTest.php | 7 +- .../General/File/BotanicalFileTest.php | 11 +- .../General/File/FileDigestOnRelationTest.php | 178 ++ .../General/File/FileDigestTest.php | 10 +- .../File/FileDigestVerificationTest.php | 350 +++ .../General/File/FileNameFallbackTest.php | 164 +- .../FileRemovedWhenElementRemovedTest.php | 28 +- .../FileRemovedWhenRelationRemovedTest.php | 81 + .../FileTopLevelPropertyOnRelationTest.php | 319 ++ .../General/File/FileTopLevelPropertyTest.php | 15 +- .../File/FileUploadUpdatesElementTest.php | 6 +- .../File/MultiPartUploadLifecycleTest.php | 2 - .../File/SimpleFileInteractivityTest.php | 7 - .../File/SinglePartUploadLifecycleTest.php | 2 - .../IfMatchAndIfNoneMatchCombinedTest.php | 161 + tests/FeatureTests/General/IfMatchTest.php | 98 +- .../FeatureTests/General/IfNoneMatchTest.php | 100 +- .../FeatureTests/General/WildcardEtagTest.php | 105 + ...FileAndUploadWriteAccessOnRelationTest.php | 127 + .../FileEndpointAccessControlTest.php | 136 + .../Security/FilePermissionOnRelationTest.php | 43 + .../Security/FilePermissionTest.php | 2 - .../OwnerOnElementWithoutFileTest.php | 82 + .../_01_01_NoConnectionTest.php | 22 +- .../_01_02_NoRelevantConnectionTest.php | 22 +- ...itherStartOrEndNodeOfRelationshipsTest.php | 44 +- .../_02_01_ImmediateNodeOwnershipTest.php | 143 - .../UploadEndpointAccessControlTest.php | 151 + .../docker-compose-neo4j-5.20.yml | 39 +- .../FeatureTests/docker-compose-neo4j-5.7.yml | 39 +- .../docker-compose-neo4j-latest.yml | 39 +- tests/ServerTests/BaseServerTestCase.php | 161 + tests/ServerTests/PhpBootFailureTest.php | 29 + tests/ServerTests/PhpOutOfMemoryTest.php | 48 + tests/ServerTests/PhpThreadExhaustionTest.php | 46 + tests/ServerTests/RawHttpRequest.php | 121 + tests/ServerTests/RequestBodyTimeoutTest.php | 75 + tests/ServerTests/docker-compose.yml | 124 + .../ServerTests/ini/low-memory/low-memory.ini | 2 + .../Command/BackupCreateCommandTest.php | 220 ++ .../Command/BackupLoadCommandTest.php | 319 ++ .../Cron/DeleteExpiredUploadsCommandTest.php | 148 +- .../Command/Cron/ReindexFilesCommandTest.php | 16 +- .../Cron/UpdateOwnershipCommandTest.php | 456 +++ tests/UnitTests/Command/CronCommandTest.php | 105 +- .../Command/DatabaseDropCommandTest.php | 295 ++ .../Command/HealthcheckCommandS3Test.php | 90 + .../HealthcheckCommandVersionsTest.php | 138 + .../File/PostElementFileControllerTest.php | 143 + .../File/PutElementFileControllerTest.php | 92 + .../Upload/DeleteUploadControllerTest.php | 84 + .../Upload/HeadUploadControllerTest.php | 39 + .../Upload/PatchUploadControllerTest.php | 131 + .../User/DeleteTokenControllerTest.php | 64 + .../User/PostChangePasswordControllerTest.php | 3 + .../User/PostRegisterControllerTest.php | 6 +- .../User/PostTokenControllerTest.php | 5 +- .../DependencyInjection/ConfigurationTest.php | 115 + .../EtagControllerEventListenerTest.php | 4 +- .../IfMatchControllerEventListenerTest.php | 94 +- ...IfNoneMatchControllerEventListenerTest.php | 91 +- ...yElementDefragmentizeEventListenerTest.php | 93 + ...asticsearchFileDeleteEventListenerTest.php | 35 + ...sticsearchFileReplaceEventListenerTest.php | 35 + ...rtyElementFragmentizeEventListenerTest.php | 92 + ...rtyElementFragmentizeEventListenerTest.php | 115 + ...ElementPropertyChangeEventListenerTest.php | 66 + ...eElementPropertyResetEventListenerTest.php | 30 + ...eElementPropertyResetEventListenerTest.php | 30 + ...elUploadsOnAccessLossEventListenerTest.php | 115 + .../ExpireEtagOnChangeEventListenerTest.php | 341 +++ .../OwnershipChangeEventListenerTest.php | 194 ++ .../EtagListenerPriorityTest.php | 56 + .../ExceptionEventListenerTest.php | 165 +- ...NoRouteFoundExceptionEventListenerTest.php | 41 +- ...alizedValueToRawValueEventListenerTest.php | 47 + .../AllowHeaderResponseEventListenerTest.php | 120 + ...ent416RangeNotSatisfiableExceptionTest.php | 68 + .../Exception/ProblemJsonExceptionTest.php | 9 + ...t408RequestTimeoutExceptionFactoryTest.php | 45 + .../Client409ConflictExceptionFactoryTest.php | 6 +- ...angeNotSatisfiableExceptionFactoryTest.php | 75 + .../UnitTests/Factory/S3ClientFactoryTest.php | 23 + .../Factory/Type/RedisKeyFactoryTest.php | 18 + .../PartialUploadRequestFactoryTest.php | 102 +- .../ResumableUploadRequestFactoryTest.php | 53 +- .../Response/NoContentResponseFactoryTest.php | 56 +- .../Type/S3/FileOperationFactoryTest.php | 115 - .../MergeFileChunksOperationFactoryTest.php | 158 - .../Type/S3/S3OperationFactoryTest.php | 531 ++++ .../UploadFileChunkOperationFactoryTest.php | 149 - .../S3/UploadFileOperationFactoryTest.php | 229 -- .../Factory/Type/UploadFactoryTest.php | 21 +- .../Logger/TerminalConsoleHandlerTest.php | 138 + .../Service/CronExecutionGateServiceTest.php | 76 + .../ElementFileDeletionServiceTest.php | 240 ++ .../ElementManagerGetElementOrFailTest.php | 111 + .../UnitTests/Service/ElementServiceTest.php | 168 ++ .../Service/ElementToRawServiceTest.php | 97 + .../Service/EtagCalculatorServiceTest.php | 81 +- tests/UnitTests/Service/EtagServiceTest.php | 106 +- ...xpiredUploadDeletionAttemptServiceTest.php | 107 + .../Service/FileCreationLockServiceTest.php | 78 + .../UnitTests/Service/FileHashServiceTest.php | 81 +- .../Service/FilePropertyServiceTest.php | 133 + .../Service/FileRangeServiceTest.php | 44 + tests/UnitTests/Service/FileServiceTest.php | 104 +- .../Service/FileSizeLimitServiceTest.php | 78 + .../Service/HeaderParseServiceTest.php | 28 +- .../Service/IncrementalHashServiceTest.php | 16 + .../Service/PropertyParseServiceTest.php | 291 ++ tests/UnitTests/Service/QueueServiceTest.php | 155 +- .../Service/RequestContentServiceTest.php | 61 + .../Service/RequestIdServiceTest.php | 62 +- .../Service/RequestUtilServiceTest.php | 158 +- tests/UnitTests/Service/S3ServiceTest.php | 638 +++- .../S3TechnicalLimitsValidatorTest.php | 2 + .../Service/TtyDetectorServiceTest.php | 28 + .../Service/UploadAccessServiceTest.php | 132 + .../Service/UploadAppendServiceTest.php | 329 ++ .../Service/UploadBodyLimitServiceTest.php | 118 + .../Service/UploadCancellationServiceTest.php | 164 + .../Service/UploadChunkValidatorTest.php | 155 + .../Service/UploadConsistencyServiceTest.php | 156 + .../Service/UploadCreationServiceTest.php | 537 ++++ .../Service/UploadFinalizationServiceTest.php | 362 +++ .../Service/UploadLockServiceTest.php | 61 + tests/UnitTests/Service/UploadServiceTest.php | 427 +++ tests/UnitTests/Type/ByteRangeTest.php | 45 + tests/UnitTests/Type/FilePropertyTest.php | 47 + .../Response/BinaryStreamResponseTest.php | 158 + .../Response/CollectionResponseTest.php | 2 +- .../Response/CreatedResponseTest.php | 2 +- .../Response/ElementResponseTest.php | 2 +- .../{ => Type}/Response/JsonResponseTest.php | 2 +- .../Response/NoContentResponseTest.php | 2 +- .../Response/NotModifiedResponseTest.php | 2 +- .../Response/ProblemJsonResponseTest.php | 2 +- .../{ => Type}/Response/TextResponseTest.php | 2 +- tests/UnitTests/Type/UploadTest.php | 25 +- .../Wrapper/ContentDispositionWrapperTest.php | 30 + tests/phpunit.feature.xml.dist | 1 + tests/phpunit.server.xml.dist | 16 + tests/phpunit.xml.dist | 1 + tests/psalm.xml | 2 + tools/docker-compose.yml | 17 +- 637 files changed, 26404 insertions(+), 7178 deletions(-) delete mode 100644 TODO-BACKUP-LARGE-FILES.md delete mode 100644 TODO-NEXT.md delete mode 100644 TODO.md create mode 100755 bin/test-server create mode 100755 bin/with-heartbeat create mode 100644 docker/Caddyfile.dev create mode 100644 docs/api-endpoints/error/get-408-request-timeout.md create mode 100644 docs/api-endpoints/error/get-408-request-timeout/200-response-body.txt rename docs/api-endpoints/{user/post-change-password-old/204-response-header.txt => error/get-408-request-timeout/200-response-header.txt} (67%) create mode 100644 docs/api-endpoints/error/get-503-service-unavailable.md create mode 100644 docs/api-endpoints/error/get-503-service-unavailable/200-response-body.txt rename docs/api-endpoints/{user/post-token-old/201-response-header.txt => error/get-503-service-unavailable/200-response-header.txt} (63%) create mode 100644 docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-body.txt create mode 100644 docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-header.txt create mode 100644 docs/api-endpoints/file/get-element-file/206-if-range-match-response-body.txt create mode 100644 docs/api-endpoints/file/get-element-file/206-if-range-match-response-header.txt delete mode 100644 docs/api-endpoints/user/post-change-password-old.md delete mode 100644 docs/api-endpoints/user/post-change-password-old/400-response-body.json delete mode 100644 docs/api-endpoints/user/post-change-password-old/401-response-body.json delete mode 100644 docs/api-endpoints/user/post-change-password-old/403-response-body.json delete mode 100644 docs/api-endpoints/user/post-change-password-old/429-response-body.json delete mode 100644 docs/api-endpoints/user/post-change-password-old/429-response-header.txt delete mode 100644 docs/api-endpoints/user/post-register-old.md delete mode 100644 docs/api-endpoints/user/post-register-old/201-response-header.txt delete mode 100644 docs/api-endpoints/user/post-register-old/400-response-body.json delete mode 100644 docs/api-endpoints/user/post-register-old/403-response-body.json delete mode 100644 docs/api-endpoints/user/post-register-old/403-response-header.txt delete mode 100644 docs/api-endpoints/user/post-token-old.md delete mode 100644 docs/api-endpoints/user/post-token-old/201-response-body.json delete mode 100644 docs/api-endpoints/user/post-token-old/400-response-body.json delete mode 100644 docs/api-endpoints/user/post-token-old/400-response-header.txt delete mode 100644 docs/api-endpoints/user/post-token-old/401-response-body.json delete mode 100644 docs/api-endpoints/user/post-token-old/401-response-header.txt create mode 100644 docs/server/no-free-php-thread/503-response-body.json rename docs/{api-endpoints/user/post-change-password-old/400-response-header.txt => server/no-free-php-thread/503-response-header.txt} (67%) create mode 100644 docs/server/php-boot-failure/500-response-body.json rename docs/{api-endpoints/user/post-change-password-old/401-response-header.txt => server/php-boot-failure/500-response-header.txt} (67%) create mode 100644 docs/server/php-out-of-memory/500-response-body.json rename docs/{api-endpoints/user/post-change-password-old/403-response-header.txt => server/php-out-of-memory/500-response-header.txt} (67%) create mode 100644 docs/server/request-body-timeout/408-response-body.json rename docs/{api-endpoints/user/post-register-old/400-response-header.txt => server/request-body-timeout/408-response-header.txt} (67%) create mode 100644 docs/server/upload-request-body-timeout/408-response-body.json create mode 100644 docs/server/upload-request-body-timeout/408-response-header.txt create mode 100644 src/Controller/Error/assets/error-405-method-not-allowed.txt create mode 100644 src/Controller/Error/assets/error-408-request-timeout.txt create mode 100644 src/Controller/Error/assets/error-503-service-unavailable.txt create mode 100644 src/EventSystem/ElementPropertyChange/EventListener/UploadElementPropertyChangeEventListener.php create mode 100644 src/EventSystem/EntityManager/EventListener/CancelUploadsOnAccessLossEventListener.php create mode 100644 src/EventSystem/Response/EventListener/AllowHeaderResponseEventListener.php create mode 100644 src/Exception/Client408RequestTimeoutException.php create mode 100644 src/Factory/Exception/Client408RequestTimeoutExceptionFactory.php delete mode 100644 src/Factory/Type/S3/FileOperationFactory.php delete mode 100644 src/Factory/Type/S3/MergeFileChunksOperationFactory.php create mode 100644 src/Factory/Type/S3/S3OperationFactory.php delete mode 100644 src/Factory/Type/S3/UploadFileChunkOperationFactory.php delete mode 100644 src/Factory/Type/S3/UploadFileOperationFactory.php create mode 100644 src/Logger/TerminalConsoleHandler.php create mode 100644 src/Service/CronExecutionGateService.php create mode 100644 src/Service/ElementFileDeletionService.php create mode 100644 src/Service/ExpiredUploadDeletionAttemptService.php create mode 100644 src/Service/FileCreationLockService.php create mode 100644 src/Service/FileSizeLimitService.php create mode 100644 src/Service/RequestContentService.php create mode 100644 src/Service/SearchAccessCalculatorService.php create mode 100644 src/Service/TtyDetectorService.php create mode 100644 src/Service/UploadAccessService.php create mode 100644 src/Service/UploadAppendService.php create mode 100644 src/Service/UploadBodyLimitService.php create mode 100644 src/Service/UploadCancellationService.php create mode 100644 src/Service/UploadChunkValidator.php create mode 100644 src/Service/UploadConsistencyService.php create mode 100644 src/Service/UploadFinalizationService.php create mode 100644 src/Service/UploadLockService.php create mode 100644 tests/ExampleGenerationCommand/Backup/BackupFilesTest.php create mode 100644 tests/ExampleGenerationController/Error/Get408RequestTimeoutTest.php create mode 100644 tests/ExampleGenerationController/Error/Get503ServiceUnavailableTest.php delete mode 100644 tests/ExampleGenerationController/User/PostChangePasswordOldTest.php delete mode 100644 tests/ExampleGenerationController/User/PostRegisterOldTest.php delete mode 100644 tests/ExampleGenerationController/User/PostTokenOldTest.php create mode 100644 tests/ExampleGenerationController/docker-compose.verify-tmp.yml create mode 100644 tests/ExampleGenerationControllerWithDifferentConfiguration/File/MaxFileSizeTest.php create mode 100644 tests/FeatureTests/Command/BaseCronTestCase.php create mode 100644 tests/FeatureTests/Command/CronDeleteExpiredUploadsTest.php create mode 100644 tests/FeatureTests/Command/CronReferenceDatasetTest.php create mode 100644 tests/FeatureTests/Command/CronReindexFilesTest.php create mode 100644 tests/FeatureTests/Command/CronUpdateOwnershipTest.php create mode 100644 tests/FeatureTests/Endpoint/Element/PostReservedTypeTest.php create mode 100644 tests/FeatureTests/Endpoint/Error/ProblemResponsesTest.php create mode 100644 tests/FeatureTests/Endpoint/File/FileEtagTest.php create mode 100644 tests/FeatureTests/Endpoint/File/GetFileRangeOnRelationTest.php create mode 100644 tests/FeatureTests/Endpoint/File/PostFileCreationLockTest.php create mode 100644 tests/FeatureTests/Endpoint/File/PostFileEtagOnRelationTest.php create mode 100644 tests/FeatureTests/Endpoint/File/PostFileEtagTest.php create mode 100644 tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionOnRelationTest.php create mode 100644 tests/FeatureTests/Endpoint/File/WildcardFileEtagTest.php create mode 100644 tests/FeatureTests/Endpoint/File/ZeroByteFileUploadOnRelationTest.php create mode 100644 tests/FeatureTests/Endpoint/System/MethodNotAllowedTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/CancelUploadOnAccessLossTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/PatchUploadErrorsOnRelationTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/PatchUploadMissingUploadCompleteTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/PatchUploadStateErrorsTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/PatchUploadZeroLengthChunkTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/ResumableUploadFinalizeRaceTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleOnRelationTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/ResumableUploadUnfinalizedStateTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/UploadElementCanNotBeModifiedThroughGenericEndpointsTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/UploadLengthEnforcementTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/UploadLimitHeaderTest.php create mode 100644 tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadOnRelationTest.php delete mode 100644 tests/FeatureTests/Endpoint/User/PostChangePasswordOldTest.php delete mode 100644 tests/FeatureTests/Endpoint/User/PostRegisterOldTest.php delete mode 100644 tests/FeatureTests/Endpoint/User/PostTokenOldTest.php create mode 100644 tests/FeatureTests/General/EtagAccessControlTest.php create mode 100644 tests/FeatureTests/General/Exception/ProblemJsonInstanceTest.php create mode 100644 tests/FeatureTests/General/File/FileDigestOnRelationTest.php create mode 100644 tests/FeatureTests/General/File/FileDigestVerificationTest.php create mode 100644 tests/FeatureTests/General/File/FileRemovedWhenRelationRemovedTest.php create mode 100644 tests/FeatureTests/General/File/FileTopLevelPropertyOnRelationTest.php create mode 100644 tests/FeatureTests/General/IfMatchAndIfNoneMatchCombinedTest.php create mode 100644 tests/FeatureTests/General/WildcardEtagTest.php create mode 100644 tests/FeatureTests/Security/FileAndUploadWriteAccessOnRelationTest.php create mode 100644 tests/FeatureTests/Security/FileEndpointAccessControlTest.php create mode 100644 tests/FeatureTests/Security/FilePermissionOnRelationTest.php create mode 100644 tests/FeatureTests/Security/OwnerOnElementWithoutFileTest.php create mode 100644 tests/FeatureTests/Security/UploadEndpointAccessControlTest.php create mode 100644 tests/ServerTests/BaseServerTestCase.php create mode 100644 tests/ServerTests/PhpBootFailureTest.php create mode 100644 tests/ServerTests/PhpOutOfMemoryTest.php create mode 100644 tests/ServerTests/PhpThreadExhaustionTest.php create mode 100644 tests/ServerTests/RawHttpRequest.php create mode 100644 tests/ServerTests/RequestBodyTimeoutTest.php create mode 100644 tests/ServerTests/docker-compose.yml create mode 100644 tests/ServerTests/ini/low-memory/low-memory.ini create mode 100644 tests/UnitTests/Command/BackupCreateCommandTest.php create mode 100644 tests/UnitTests/Command/BackupLoadCommandTest.php create mode 100644 tests/UnitTests/Command/Cron/UpdateOwnershipCommandTest.php create mode 100644 tests/UnitTests/Command/DatabaseDropCommandTest.php create mode 100644 tests/UnitTests/Command/HealthcheckCommandS3Test.php create mode 100644 tests/UnitTests/Command/HealthcheckCommandVersionsTest.php create mode 100644 tests/UnitTests/Controller/File/PostElementFileControllerTest.php create mode 100644 tests/UnitTests/Controller/File/PutElementFileControllerTest.php create mode 100644 tests/UnitTests/Controller/Upload/DeleteUploadControllerTest.php create mode 100644 tests/UnitTests/Controller/Upload/HeadUploadControllerTest.php create mode 100644 tests/UnitTests/Controller/Upload/PatchUploadControllerTest.php create mode 100644 tests/UnitTests/Controller/User/DeleteTokenControllerTest.php create mode 100644 tests/UnitTests/EmberNexusBundle/DependencyInjection/ConfigurationTest.php create mode 100644 tests/UnitTests/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/ElementFileDelete/EventListener/ElasticsearchFileDeleteEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/ElementFileReplace/EventListener/ElasticsearchFileReplaceEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/UploadElementPropertyChangeEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/ElementPropertyReset/EventListener/HasFileElementPropertyResetEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/EntityManager/EventListener/CancelUploadsOnAccessLossEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/EntityManager/EventListener/OwnershipChangeEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/Etag/EventListener/EtagListenerPriorityTest.php create mode 100644 tests/UnitTests/EventSystem/NormalizedValueToRawValue/EventListener/MongoDBNormalizedValueToRawValueEventListenerTest.php create mode 100644 tests/UnitTests/EventSystem/Response/EventListener/AllowHeaderResponseEventListenerTest.php create mode 100644 tests/UnitTests/Exception/Client416RangeNotSatisfiableExceptionTest.php create mode 100644 tests/UnitTests/Factory/Exception/Client408RequestTimeoutExceptionFactoryTest.php create mode 100644 tests/UnitTests/Factory/Exception/Client416RangeNotSatisfiableExceptionFactoryTest.php create mode 100644 tests/UnitTests/Factory/S3ClientFactoryTest.php delete mode 100644 tests/UnitTests/Factory/Type/S3/FileOperationFactoryTest.php delete mode 100644 tests/UnitTests/Factory/Type/S3/MergeFileChunksOperationFactoryTest.php create mode 100644 tests/UnitTests/Factory/Type/S3/S3OperationFactoryTest.php delete mode 100644 tests/UnitTests/Factory/Type/S3/UploadFileChunkOperationFactoryTest.php delete mode 100644 tests/UnitTests/Factory/Type/S3/UploadFileOperationFactoryTest.php create mode 100644 tests/UnitTests/Logger/TerminalConsoleHandlerTest.php create mode 100644 tests/UnitTests/Service/CronExecutionGateServiceTest.php create mode 100644 tests/UnitTests/Service/ElementFileDeletionServiceTest.php create mode 100644 tests/UnitTests/Service/ElementManagerGetElementOrFailTest.php create mode 100644 tests/UnitTests/Service/ElementServiceTest.php create mode 100644 tests/UnitTests/Service/ElementToRawServiceTest.php create mode 100644 tests/UnitTests/Service/ExpiredUploadDeletionAttemptServiceTest.php create mode 100644 tests/UnitTests/Service/FileCreationLockServiceTest.php create mode 100644 tests/UnitTests/Service/FilePropertyServiceTest.php create mode 100644 tests/UnitTests/Service/FileSizeLimitServiceTest.php create mode 100644 tests/UnitTests/Service/PropertyParseServiceTest.php create mode 100644 tests/UnitTests/Service/RequestContentServiceTest.php create mode 100644 tests/UnitTests/Service/TtyDetectorServiceTest.php create mode 100644 tests/UnitTests/Service/UploadAccessServiceTest.php create mode 100644 tests/UnitTests/Service/UploadAppendServiceTest.php create mode 100644 tests/UnitTests/Service/UploadBodyLimitServiceTest.php create mode 100644 tests/UnitTests/Service/UploadCancellationServiceTest.php create mode 100644 tests/UnitTests/Service/UploadChunkValidatorTest.php create mode 100644 tests/UnitTests/Service/UploadConsistencyServiceTest.php create mode 100644 tests/UnitTests/Service/UploadCreationServiceTest.php create mode 100644 tests/UnitTests/Service/UploadFinalizationServiceTest.php create mode 100644 tests/UnitTests/Service/UploadLockServiceTest.php create mode 100644 tests/UnitTests/Service/UploadServiceTest.php create mode 100644 tests/UnitTests/Type/ByteRangeTest.php create mode 100644 tests/UnitTests/Type/FilePropertyTest.php create mode 100644 tests/UnitTests/Type/Response/BinaryStreamResponseTest.php rename tests/UnitTests/{ => Type}/Response/CollectionResponseTest.php (97%) rename tests/UnitTests/{ => Type}/Response/CreatedResponseTest.php (95%) rename tests/UnitTests/{ => Type}/Response/ElementResponseTest.php (96%) rename tests/UnitTests/{ => Type}/Response/JsonResponseTest.php (95%) rename tests/UnitTests/{ => Type}/Response/NoContentResponseTest.php (92%) rename tests/UnitTests/{ => Type}/Response/NotModifiedResponseTest.php (95%) rename tests/UnitTests/{ => Type}/Response/ProblemJsonResponseTest.php (96%) rename tests/UnitTests/{ => Type}/Response/TextResponseTest.php (95%) create mode 100644 tests/UnitTests/Wrapper/ContentDispositionWrapperTest.php create mode 100644 tests/phpunit.server.xml.dist diff --git a/.env b/.env index dd6b2623..36e860c7 100755 --- a/.env +++ b/.env @@ -32,4 +32,4 @@ S3_ENDPOINT=http://minio:9000 S3_ACCESS_KEY_ID=accessKey S3_SECRET_ACCESS_KEY=secretKey -REFERENCE_DATASET_VERSION=0.0.30 +REFERENCE_DATASET_VERSION=0.0.33 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 498b5bbb..7728dc1c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,4 @@ /.github/ @ember-nexus/secops /docker/ @ember-nexus/secops *.env @ember-nexus/secops +docker-compose*.yml @ember-nexus/secops diff --git a/.github/workflows/ci-check-upstream-dependency-alpine.yml b/.github/workflows/ci-check-upstream-dependency-alpine.yml index 8c3030a7..f10786bc 100644 --- a/.github/workflows/ci-check-upstream-dependency-alpine.yml +++ b/.github/workflows/ci-check-upstream-dependency-alpine.yml @@ -8,7 +8,7 @@ jobs: check-upstream-dependency-alpine: name: 'Check upstream dependency Alpine' timeout-minutes: 5 - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Checkout repo diff --git a/.github/workflows/ci-check-upstream-dependency-php.yml b/.github/workflows/ci-check-upstream-dependency-php.yml index 66a241a4..7cb90e1a 100644 --- a/.github/workflows/ci-check-upstream-dependency-php.yml +++ b/.github/workflows/ci-check-upstream-dependency-php.yml @@ -8,7 +8,7 @@ jobs: check-upstream-dependency-php: name: 'Check upstream dependency PHP' timeout-minutes: 5 - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Checkout repo diff --git a/.github/workflows/ci-release-package.yml b/.github/workflows/ci-release-package.yml index 28c809db..8a0b272f 100644 --- a/.github/workflows/ci-release-package.yml +++ b/.github/workflows/ci-release-package.yml @@ -11,7 +11,7 @@ jobs: checkout-repo-and-bump-version: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: Check out Repo and Bump Version timeout-minutes: 10 steps: @@ -192,7 +192,7 @@ jobs: publish-release: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: Publish release timeout-minutes: 10 needs: [build-docker-image-amd, build-docker-image-arm] diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index d77f46fb..a6e73594 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -6,7 +6,7 @@ on: jobs: yml-lint: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'YML lint' timeout-minutes: 3 steps: @@ -15,7 +15,7 @@ jobs: - run: docker run --rm -t -v $(pwd):/data cytopia/yamllint --config-file=tests/.yamllint . markdown-lint: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'Markdown lint' timeout-minutes: 3 steps: @@ -78,7 +78,7 @@ jobs: retention-days: 7 cs-lint: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'CS Lint' timeout-minutes: 5 needs: @@ -96,7 +96,7 @@ jobs: docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && PHP_CS_FIXER_IGNORE_ENV=1 composer cs:list" psalm: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'Psalm' timeout-minutes: 5 needs: @@ -111,10 +111,10 @@ jobs: - name: Psalm run: | docker load < /tmp/docker/api-dev-amd.tar.gz - docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && composer psalm" + docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && composer psalm -- --no-progress" phpstan: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'Phpstan' timeout-minutes: 5 needs: @@ -129,10 +129,10 @@ jobs: - name: Phpstan run: | docker load < /tmp/docker/api-dev-amd.tar.gz - docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && composer phpstan" + docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && composer phpstan -- --no-progress" test-unit: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'Unit Test' timeout-minutes: 5 needs: @@ -150,9 +150,9 @@ jobs: docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && composer test:unit" test-mutant: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'Mutant Test' - timeout-minutes: 5 + timeout-minutes: 10 needs: - build-docker-image continue-on-error: true @@ -166,29 +166,31 @@ jobs: - name: Mutant Test run: | docker load < /tmp/docker/api-dev-amd.tar.gz - docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && composer test:mutant" + docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && ./bin/with-heartbeat composer test:mutant -- --no-progress" - test-mess-detector: - runs-on: ubuntu-latest - name: 'Mess Detector' - timeout-minutes: 5 - needs: - - build-docker-image - continue-on-error: true - steps: - - uses: actions/checkout@v5 - - run: mkdir -p /tmp/docker - - uses: actions/download-artifact@v8 - with: - name: docker-image-api-dev-amd - path: /tmp/docker - - name: Mess Detector - run: | - docker load < /tmp/docker/api-dev-amd.tar.gz - docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && composer mess" + # phpmd is temporarily deactivated: it was removed from the dev dependencies, as its last release is too old for + # the current dependencies. Reactivate once a new phpmd release is available. +# test-mess-detector: +# runs-on: ubuntu-26.04 +# name: 'Mess Detector' +# timeout-minutes: 5 +# needs: +# - build-docker-image +# continue-on-error: true +# steps: +# - uses: actions/checkout@v5 +# - run: mkdir -p /tmp/docker +# - uses: actions/download-artifact@v8 +# with: +# name: docker-image-api-dev-amd +# path: /tmp/docker +# - name: Mess Detector +# run: | +# docker load < /tmp/docker/api-dev-amd.tar.gz +# docker run -v $(pwd):/var/www/html --user "$(id -u):$(id -g)" api:dev-amd sh -c "composer install --ignore-platform-req=php --quiet && composer mess" test-feature: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'Feature Test' timeout-minutes: 15 needs: @@ -196,7 +198,7 @@ jobs: - phpstan - psalm - cs-lint - - test-mess-detector +# - test-mess-detector strategy: matrix: dockerCompose: @@ -224,8 +226,8 @@ jobs: chmod 777 ./bin/test-feature-prepare docker load < /tmp/docker/api-dev-amd.tar.gz docker load < /tmp/docker/api-prod-amd.tar.gz - docker compose -f ./tests/FeatureTests/docker-compose-${{ matrix.dockerCompose }}.yml up --quiet-pull -d - echo "waiting for containers to star tup (10s)" + docker compose -f ./tests/FeatureTests/docker-compose-${{ matrix.dockerCompose }}.yml up --quiet-pull -d --wait + echo "giving containers a moment to settle (10s)" sleep 10 echo "finished waiting" docker compose -f ./tests/FeatureTests/docker-compose-${{ matrix.dockerCompose }}.yml ps @@ -233,10 +235,37 @@ jobs: docker exec --user "$(id -u):$(id -g)" api-dev bash -c "php bin/console healthcheck" - name: Feature Test run: | - docker exec --user "$(id -u):$(id -g)" api-dev bash -c "export API_DOMAIN=\"http://api-prod\" && echo \$API_DOMAIN && composer test:feature && composer test:feature:deprecated" + docker exec --user "$(id -u):$(id -g)" api-dev bash -c "export API_DOMAIN=\"http://api-prod\" && echo \$API_DOMAIN && composer test:feature" + - name: Run cron once manually + # DISABLE_CRON=true is set on the containers so the scheduler never fires cron during the test run above; + # this manually runs it once, in its own stage, after the main tests. A plain `docker exec` has no tty, so + # under DISABLE_CRON=true it would just no-op ("Cron is disabled"); `-t` allocates one so the command + # actually executes despite DISABLE_CRON. + run: | + docker exec -t --user "$(id -u):$(id -g)" api-prod php bin/console cron + - name: Collect docker logs + if: always() + run: | + mkdir -p docker-logs + COMPOSE="docker compose -f ./tests/FeatureTests/docker-compose-${{ matrix.dockerCompose }}.yml" + $COMPOSE ps -a > docker-logs/_ps.txt 2>&1 || true + for svc in $($COMPOSE config --services); do + $COMPOSE logs --no-color --timestamps "$svc" > "docker-logs/$svc.log" 2>&1 || true + done + # only name / exit code / OOM flag / error - never dump full inspect output (contains container env) + docker inspect $(docker ps -aq) --format '{{.Name}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} {{.State.Error}}' > docker-logs/_state.txt 2>&1 || true + uname -a > docker-logs/_host.txt + - name: Upload docker logs + if: always() + uses: actions/upload-artifact@v6 + with: + name: docker-logs-feature-${{ matrix.dockerCompose }} + path: docker-logs/ + if-no-files-found: ignore + retention-days: 7 test-example-generation-controller: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'Controller Example Generation Test' timeout-minutes: 15 needs: @@ -244,7 +273,7 @@ jobs: - phpstan - psalm - cs-lint - - test-mess-detector +# - test-mess-detector steps: - uses: actions/checkout@v5 - run: mkdir -p /tmp/docker @@ -266,8 +295,8 @@ jobs: chmod 777 ./bin/test-feature-prepare docker load < /tmp/docker/api-dev-amd.tar.gz docker load < /tmp/docker/api-prod-amd.tar.gz - docker compose -f ./tests/ExampleGenerationController/docker-compose.yml up --quiet-pull -d - echo "waiting for containers to star tup (10s)" + docker compose -f ./tests/ExampleGenerationController/docker-compose.yml up --quiet-pull -d --wait + echo "giving containers a moment to settle (10s)" sleep 10 echo "finished waiting" docker compose -f ./tests/ExampleGenerationController/docker-compose.yml ps @@ -275,11 +304,31 @@ jobs: docker exec --user "$(id -u):$(id -g)" api-dev bash -c "php bin/console healthcheck" - name: Controller Example Generation Test run: | - docker exec --user "$(id -u):$(id -g)" api-dev bash -c "export API_DOMAIN=\"http://api\" && echo \$API_DOMAIN && composer test:example-generation-controller && composer test:example-generation-controller:with-different-configuration && composer test:example-generation-controller:deprecated" + docker exec --user "$(id -u):$(id -g)" api-dev bash -c "export API_DOMAIN=\"http://api\" && echo \$API_DOMAIN && composer test:example-generation-controller && composer test:example-generation-controller:with-different-configuration" + - name: Collect docker logs + if: always() + run: | + mkdir -p docker-logs + COMPOSE="docker compose -f ./tests/ExampleGenerationController/docker-compose.yml" + $COMPOSE ps -a > docker-logs/_ps.txt 2>&1 || true + for svc in $($COMPOSE config --services); do + $COMPOSE logs --no-color --timestamps "$svc" > "docker-logs/$svc.log" 2>&1 || true + done + # only name / exit code / OOM flag / error - never dump full inspect output (contains container env) + docker inspect $(docker ps -aq) --format '{{.Name}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} {{.State.Error}}' > docker-logs/_state.txt 2>&1 || true + uname -a > docker-logs/_host.txt + - name: Upload docker logs + if: always() + uses: actions/upload-artifact@v6 + with: + name: docker-logs-example-generation-controller + path: docker-logs/ + if-no-files-found: ignore + retention-days: 7 test-example-generation-command: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: 'Command Example Generation Test' timeout-minutes: 15 needs: @@ -287,7 +336,7 @@ jobs: - phpstan - psalm - cs-lint - - test-mess-detector +# - test-mess-detector steps: - uses: actions/checkout@v5 - run: mkdir -p /tmp/docker @@ -304,8 +353,8 @@ jobs: run: | chmod 777 ./bin/test-feature-command-prepare docker load < /tmp/docker/api-dev-amd.tar.gz - docker compose -f ./tests/ExampleGenerationCommand/docker-compose.yml up --quiet-pull -d - echo "waiting for containers to star tup (10s)" + docker compose -f ./tests/ExampleGenerationCommand/docker-compose.yml up --quiet-pull -d --wait + echo "giving containers a moment to settle (10s)" sleep 10 echo "finished waiting" docker compose -f ./tests/ExampleGenerationCommand/docker-compose.yml ps @@ -314,3 +363,78 @@ jobs: - name: Command Example Generation Test run: | docker exec --user "$(id -u):$(id -g)" api bash -c "BACKUP_FOLDER_CAN_BE_RESET=1 composer test:example-generation-command" + - name: Collect docker logs + if: always() + run: | + mkdir -p docker-logs + COMPOSE="docker compose -f ./tests/ExampleGenerationCommand/docker-compose.yml" + $COMPOSE ps -a > docker-logs/_ps.txt 2>&1 || true + for svc in $($COMPOSE config --services); do + $COMPOSE logs --no-color --timestamps "$svc" > "docker-logs/$svc.log" 2>&1 || true + done + # only name / exit code / OOM flag / error - never dump full inspect output (contains container env) + docker inspect $(docker ps -aq) --format '{{.Name}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} {{.State.Error}}' > docker-logs/_state.txt 2>&1 || true + uname -a > docker-logs/_host.txt + - name: Upload docker logs + if: always() + uses: actions/upload-artifact@v6 + with: + name: docker-logs-example-generation-command + path: docker-logs/ + if-no-files-found: ignore + retention-days: 7 + + test-server: + runs-on: ubuntu-26.04 + name: 'Server Test' + timeout-minutes: 20 + needs: + - test-unit + - phpstan + - psalm + - cs-lint + steps: + - uses: actions/checkout@v5 + - run: mkdir -p /tmp/docker + - uses: actions/download-artifact@v8 + with: + name: docker-image-api-dev-amd + path: /tmp/docker + - uses: actions/download-artifact@v8 + with: + name: docker-image-api-prod-amd + path: /tmp/docker + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Load Docker images + run: | + docker load < /tmp/docker/api-dev-amd.tar.gz + docker load < /tmp/docker/api-prod-amd.tar.gz + # the scenarios run one after another and restart the production container with lower limits, see bin/test-server + - name: Server Test + run: | + chmod +x ./bin/test-server + KEEP_STACK=1 ./bin/test-server + - name: Collect docker logs + if: always() + run: | + mkdir -p docker-logs + COMPOSE="docker compose -p enx-server-tests -f ./tests/ServerTests/docker-compose.yml" + $COMPOSE ps -a > docker-logs/_ps.txt 2>&1 || true + for svc in $($COMPOSE config --services); do + $COMPOSE logs --no-color --timestamps "$svc" > "docker-logs/$svc.log" 2>&1 || true + done + # only name / exit code / OOM flag / error - never dump full inspect output (contains container env) + docker inspect $(docker ps -aq) --format '{{.Name}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} {{.State.Error}}' > docker-logs/_state.txt 2>&1 || true + uname -a > docker-logs/_host.txt + - name: Upload docker logs + if: always() + uses: actions/upload-artifact@v6 + with: + name: docker-logs-server + path: docker-logs/ + if-no-files-found: ignore + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc69d691..715c2856 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,27 +6,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +- Add file endpoints `GET`/`POST`/`PUT`/`DELETE //file` and resumable, chunked upload endpoints + `POST`/`PATCH`/`DELETE`/`HEAD //upload`, backed by S3 / object storage, closes #119. +- Add `ETag`, `If-Match`, `If-None-Match` and `If-Range` support to file endpoints (not upload endpoints, decided as + final), related to #119. +- Add byte-range (`Range`/`Content-Range`) support to `GET //file`, related to #119. +- Add `cron:update-ownership` command, recalculating direct group/user access after `OWNS`, `HAS_SEARCH_ACCESS` and + `IS_IN_GROUP` relations change, closes #438. - Add taskfile.dev development environment into the API repository itself, closes #461. - Add S3 status check to healthcheck command, closes #200. - Add support to delete files in S3 / object storage through the `php bin/console database:drop` command, closes #309. - Add support to back up files in S3 / object storage through the `php bin/console backup:create` command, closes #308. +- Verify file contents against the stored `file.hash.sha256` values during `php bin/console backup:load`; files with + mismatching or missing hashes are skipped with a warning. Verification can be disabled with `--skip-verify`, related + to #119. - Add support for `Content-Disposition` HTTP header, including transliteration of non-latin-characters, closes #447. - Add dependency to PHP library `cardinalby/content-disposition`. - Add 'reserved type' exception; nodes of type 'User', 'Token' and 'Upload' can not be manually created through generic API endpoints. - Store value `true` as placeholder for non-scalar properties in Neo4j. Does not change API responses, but is available for Neo4j based queries, e.g. to check whether a non-scalar property exists on the element. +- Add mimetype detection for multipart / chunked uploads. +- Add `dozzle` to the local development Docker setup for log viewing. +- Add support for running cron jobs inside CI feature and example-generation containers, via a dedicated + `CronExecutionGateService`, related to #119. ### Changed - Upgrade FrankenPHP to 1.12.1, related to #457 and #461. - Upgrade PHP to 8.5.4, related to #457 and #461. - Upgrade PHP dependencies, related to #457 and #461. +- Upgrade Symfony from 7.4 to 8.1, and `symfony/monolog-bundle` from 3 to 4; fix related deprecations. +- Temporarily deactivate `composer mess` (phpmd) in CI and remove it from the dev dependencies, as its last release is + not compatible with current dependencies. - Upgrade GitHub Actions in CI/CD. - Increase max post limit from 2 MB (PHP default) to 101 MiB, related to #119. - Change header `Access-Control-Allow-Headers` to `*`, due to growing number of supported headers. +- Change unsupported HTTP methods on existing routes to answer `405 Method Not Allowed` instead of `500`, and add a + per-endpoint `Allow` header (including on `405` responses), related to #119. +- Add `instance` (`urn:uuid:`) to every `problem+json` response, matching the request id used in logs and + the Caddy `request_id`, related to #119. +- Change `problem+json` extension members to camelCase (`expectedOffset`, `providedOffset`, `totalLength`, + `requestedStart`, `requestedEnd`), related to #119. +- Add `X-Content-Type-Options: nosniff` header to file download responses, related to #119. +- `DELETE //upload` answers `404` instead of `409`/`204` after access to the upload has been revoked, related to + #119. +- `Upload-Complete` header is now mandatory on `PATCH //upload`, related to #119. +- Reduce CLI command log output: only printed when attached to a real terminal, notice level and up by default + (`-v` for more). +- Remove the version string from CLI output in development mode. +- Rework the production Docker image so the compiled Symfony cache is no longer baked in at build time; it is + regenerated from whatever configuration is mounted at container start, fixing stale configuration when + `config/packages/ember_nexus.yaml` is overridden (`api-different-configuration`). +- Pin MinIO Docker images used in CI and local development instead of tracking `latest`. +- Pass required environment variables directly to unmounted API instances in CI / example-generation Docker Compose + files, instead of relying on a mounted `.env` file. ### Fixed - Fix bug with deserialization of MongoDB documents. +- Fix priority race condition between the `If-Match` and `If-None-Match` event listeners: `If-Match` is now evaluated + before `If-None-Match` when both are sent together (RFC 9110 Section 13.2.2). A failing `If-Match` combined with a + matching `If-None-Match` used to incorrectly answer `304`/`412` from `If-None-Match` instead of the `412` that + `If-Match` should give, related to #119. +- Fix missing exception guard when the underlying file is missing during ETag calculation. +- Fix the `onCreatedByUser` access restriction (and the equivalent implicit rule used by `GET .../children`, + `.../parents` and `.../related`) never actually matching, because the checked relation type (`CREATED_BY`) did not + match the one actually created on element creation (`CREATED`). +- Fix the exception log line always missing its `detail` (e.g. the received/announced byte counts of a `408` request + timeout), since it logged the always-empty `Exception::getMessage()` instead. + +### Removed +- Remove `POST /token`, `POST /register` and `POST /change-password` v1 request-body compatibility support, and the + `featureFlag.280_OldUniqueUserIdentifierDisabled` feature flag used to toggle it. This is a breaking change: only + the current request body shape is accepted now. +- Remove queue `REBUILD_SEARCH_DOCUMENT`, replaced by the durable, dead-letter-backed `ELASTICSEARCH_UPDATE_OWNERSHIP` + queue, related to #438. ## 0.1.31 - 2026-02-10 ### Changed diff --git a/README.md b/README.md index 47a60f05..0ba1ddb1 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Discord](https://img.shields.io/discord/1135243882360221787?logo=discord&label=Discord&color=%235865f2)](https://discord.gg/qbQFBrJrRC) Ember Nexus is a knowledge graph API, which means that it is specialized in storing and retrieving your data. -It can be used as an alternative to Obsidian, Notion, your companies ERP or content management systems or as a blank +It can be used as an alternative to Obsidian, Notion, your company's ERP or content management systems or as a blank database for your own projects. As long as you need to store data and are interested in easy to use cutting edge features - Ember Nexus will be useful for you! :D @@ -23,12 +23,12 @@ Ember Nexus is built on three principles: The "secret sauce" is the combination of databases Ember Nexus uses: - **Neo4j**: A graph database, which stores relationships and core properties of your data elements. -- **MongoDB**: A document database, stores more complex properties which Neo4j can not handle itself. +- **MongoDB**: A document database, which stores more complex properties Neo4j can not handle itself. - **Elasticsearch**: A search database, used for full text search, vector search and more. -- **S3 compatible object store**: Used to store binary files, up to 1 TB by default. +- **S3 compatible object store**: Used to store binary files, up to 10 GiB by default. -Instead of using multiple specialized databases yourself, simply use Ember Nexus, and benefit of the exposed features -and without having to worry about data consistency, security aspects and more. +Instead of using multiple specialized databases yourself, simply use Ember Nexus and benefit from their features +without having to worry about data consistency, security aspects and more. It just works :D @@ -38,8 +38,8 @@ It just works :D - [API Documentation](https://ember-nexus.github.io/api/#/): For everyone who wants to develop own apps on top of Ember Nexus. - [Docker Hub](https://hub.docker.com/r/embernexus/api): Ready to use container images for AMD and ARM architectures. -- [Demo API](https://reference-dataset.ember-nexus.dev/): Instance which can be used for quick testing. Is being reset - every day, and uses the [reference dataset](https://github.com/ember-nexus/reference-dataset) as its data, which +- [Demo API](https://reference-dataset.ember-nexus.dev/): Instance which can be used for quick testing. It is reset + every day and uses the [reference dataset](https://github.com/ember-nexus/reference-dataset) as its data, which includes eternal tokens. ## Is there a graphical user interface (GUI)? diff --git a/TODO-BACKUP-LARGE-FILES.md b/TODO-BACKUP-LARGE-FILES.md deleted file mode 100644 index 2cdec184..00000000 --- a/TODO-BACKUP-LARGE-FILES.md +++ /dev/null @@ -1,42 +0,0 @@ -# TODO: `BackupLoadCommand` cannot restore files larger than 5 GiB - -Development note, not permanent project documentation. Fold into `docs/` or into -https://github.com/ember-nexus/api/issues/452 once actioned. - -## Problem - -`S3Service::uploadFile()` writes the whole file with a single `putObject`, which caps it at S3's **5 GiB -single-object-upload limit**. It has two callers, and they are not equally bounded: - -| Caller | Bound on input size | -| --- | --- | -| `UploadCreationService::setOrReplaceElementFileDirectly()` (`src/Service/UploadCreationService.php:89`) | `post_max_size` / `upload_max_filesize` — ~101 MB (`docker/php-embed/php.dev.ini:6-7`, `php.prod.ini:6-7`) | -| `BackupLoadCommand::loadFiles()` (`src/Command/BackupLoadCommand.php:216`) | **none** | - -`BackupLoadCommand` streams files straight out of the backup archive (`$this->backupStorage->readStream($path)`) -into `uploadFile()`, with no PHP request limits involved. A backup containing a file larger than 5 GiB therefore -fails to restore. - -The sharp edge: the default `file.maxFileSizeInBytes` is **10 GiB** (`docs/example/default-parameters.yaml:83`), -and files that large are accepted fine through the resumable upload path — which builds a real multipart upload in -`S3Service::mergeFileChunks()`. So the API will happily accept a file it cannot later restore from its own backup. -The failure only surfaces at restore time, which is the worst possible moment to discover it. - -## Fix - -Give `uploadFile()` a multipart path for large resources, rather than leaving multipart exclusive to -`mergeFileChunks()`. The machinery already exists in `S3Service` -(`createMultipartUploadFromMergeFileChunksOperation()`, `createUploadPartsFromMergeFileChunksOperation()`, -`completeMultipartUpload`, with `abortMultipartUpload` on failure) and needs generalising away from -`MergeFileChunksOperationInterface` so a plain resource can use it too. - -Threshold: switch to multipart above some part-size boundary (S3's minimum part size is 5 MB, its single-PUT -maximum is 5 GiB), so ordinary API uploads keep taking the cheap single-PUT path. - -Tracked in https://github.com/ember-nexus/api/issues/452. - -## Test gap - -No test covers a backup round trip with a file over the single-PUT limit — understandably, since a >5 GiB fixture -is not something to put in CI. Worth at least a unit-level test that the multipart branch is selected above the -threshold, rather than a real large-file integration test. diff --git a/TODO-NEXT.md b/TODO-NEXT.md deleted file mode 100644 index c826b83b..00000000 --- a/TODO-NEXT.md +++ /dev/null @@ -1,107 +0,0 @@ -# TODO (next) - -Follow-up work surfaced while reviewing the file/upload branch, intentionally not addressed there. Same status as -`TODO.md`: development notes, not permanent project documentation. Fold into `docs/` or into issues once actioned. - -## Carried over from `TODO.md` (still open) - -### Enforce `file.maxFileSizeInBytes` at upload completion - -`EmberNexusConfiguration::getFileMaxFileSizeInBytes()` is advertised to clients — via the `max-size` field of the -`Upload-Limit` response header (`NoContentResponseFactory.php`) and via `/instance-configuration` -(`GetInstanceConfigurationController.php`) — and is validated at startup against the storage backend's technical -maximum object size (`S3TechnicalLimitsValidator.php`). But nothing enforces it at request time: neither a direct -upload (`POST`/`PUT //file`) nor a completing resumable upload chunk (`PatchUploadController.php`) rejects a -file whose size exceeds `maxFileSizeInBytes`. The public API docs (`/reference/upload`) already describe a -completing upload that exceeds `max-size` as failing with `400 Bad Request`, so this needs a real check — most -naturally in `UploadCreationService::setOrReplaceElementFileDirectly()` for direct uploads and in -`PatchUploadController::createFile()` for resumable ones, before the file/merge is written. - -### High-performance BLAKE3 for file hashing - -`file.hash` (`src/Service/FileHashService.php`) uses SHA-256. BLAKE3 is preferred long-term, but the only real -pure-PHP implementation (`tourze/blake3-php`) benchmarks at roughly 0.04 MB/s — about 3000x too slow for a 1 Gb/s -uplink. Real BLAKE3 performance requires either: - -- a Docker image change adding a build stage that compiles the official BLAKE3 C reference implementation into a - shared library, enabling the `ffi` PHP extension, and binding to it via FFI, or -- a compiled native PHP extension (e.g. `cypherbits/php-blake3`) added to the image build — smaller effort, but - currently a low-adoption, single-maintainer project, a real supply-chain trust tradeoff for a crypto primitive - that would need vetting first. - -`file.hash` is stored as `{: }` (not a flat pair), so adding or switching to `file.hash.blake3` -later needs no data migration — existing `file.hash.sha256` values stay valid alongside it. - -Note: SHA-256 is *not* to be consolidated with the `sha3-256` used in `TokenGenerator.php`. Those are unrelated use -cases with opposite constraints, and RFC 9530's digest registry (which `DigestService` implements) has no entry for -SHA-3 — switching file hashing to SHA-3 would break `Repr-Digest`/`Content-Digest` interoperability. - -### S3 upload bucket lifecycle policy - -`cron:delete-expired-uploads` (and its `file.expiredUploadCanBeDeletedAfterExpirationInSeconds` grace period) is an -application-level cleanup of expired uploads and their S3 chunks. As defense in depth, the S3/MinIO **upload** -bucket should also carry an object lifecycle policy expiring objects after a short time (e.g. 24-48h), in case the -cron job is disabled, not running, or falling behind. Infrastructure/ops concern — bucket provisioning lives -outside this repository. - -## New - -### `BackupLoadCommand` cannot restore files larger than 5 GiB - -Moved to its own note: see `TODO-BACKUP-LARGE-FILES.md`. - -### `file` properties in collection and search responses — DONE - -`file` is now returned everywhere an element is serialised. `ElementToRawService::elementToRaw()` lost its -`$includeFile` parameter, and the four call sites that passed `false` (`CollectionService` ×3, -`ElementHydrationSearchStepEventListener`) now get the same shape as the single-element endpoint. This also -resolves file properties in search results, with no new search step and no new query parameters. - -Rationale: `file` is an ordinary element property in the MongoDB document fragment, which -`ElementManager::getNode()`/`getRelation()` fetch unconditionally — so `includeFile: false` never saved a database -round trip, only ~160 bytes of payload per file-bearing element. The property is exactly four keys, written in only -two places (`UploadCreationService.php:96`, `PatchUploadController.php:179`). - -Accepted constraint: `file` is now part of the collection contract, so it must stay a small, fixed summary. -Thumbnails are expected to work like file downloads do today (implicit, link-free), further hash algorithms are -cheap to add, and storage keys / version history are explicitly out of scope. Reduced-payload responses can be -added later if the community asks. - -Caching needed no change: both file-setting paths and the delete path call `merge()` + `flush()`, which bumps -`updated` via `UpdatedElementPreWriteEventListener` (collection etags hash `children.updated`, -`EtagCalculatorService.php:91`) and fires `ElementPostMergeEvent`, expiring the element plus all related collection -etag keys in `ExpireEtagOnChangeEventListener`. - -### Null properties are removed from Neo4j but linger in MongoDB and Elasticsearch - -Found while checking whether `DeleteElementFileController.php:63` (`addProperty('file', null)`) removes the -property. Verdict per store: - -| Store | Write | Null removed? | -| --- | --- | --- | -| Neo4j | `SET node += $properties` (`NodeMergeToStatementEventListener.php:48`) | **Yes** — Cypher's `+=` deletes keys set to null | -| MongoDB | `$set` with the whole property array (`mongo-entity-manager/src/Type/EntityManager.php:110-119`) | **No** — stores an explicit `null`; would need `$unset` | -| Elasticsearch | `update` with `doc` (`elastic-entity-manager/src/Type/EntityManager.php:113-117`) | **No** — `null` stays in `_source`, though it is not indexed | - -The API is nonetheless correct today: `GenericPropertyElementDefragmentizeEventListener` ends by stripping every -null property from the element after defragmentizing, so a stored `file: null` never reaches `elementToRaw()` and -`hasProperty('file')` is false. That is why the always-include-`file` change above needed no null guard. - -What remains is storage cruft, not an API bug: deleted properties linger as explicit nulls in the Mongo document -and the ES `_source`. Fixing it properly means teaching `syndesi/mongo-entity-manager` to split null-valued -properties into an `$unset` clause (and the ES equivalent), which is an upstream change in those libraries rather -than something to work around here. - -### Dead `$fileFragment` plumbing — DONE - -Removed. `$fileFragment` was threaded through `FragmentGroup`, `FragmentHelper`, `ElementDefragmentizeService` and -all four fragmentize/defragmentize events, and was never read or written: the event factories hardcoded `null`, no -listener set it, `FragmentGroup::getFileFragment()` had no callers, `ElementManager::getNode()/getRelation()` -hardcoded `$fileFragment = null`, and none of the three defragmentize listeners read it. It predated this branch -(already present at the merge base). - -It looks like a placeholder for treating S3 as a fourth backing store alongside Cypher/Mongo/Elastic. The -implementation went a different way: file metadata rides along as an ordinary `file` property in the Mongo -document, and S3 I/O happens explicitly in `S3Service`/`UploadCreationService` *outside* merge/flush — deliberately -so, per the reentrancy note in `UploadService::deleteUploadsTargeting()`. If S3 writes should ever join the flush -batch, reintroduce the seam deliberately and with a real type rather than `mixed`. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index e4e13a99..00000000 --- a/TODO.md +++ /dev/null @@ -1,101 +0,0 @@ -# TODO - -Notes for follow-up work which is intentionally not addressed yet in this session. Not meant as permanent -project documentation; fold into `docs/` (or delete) once actioned. - -## Enforce `file.maxFileSizeInBytes` at upload completion - -`EmberNexusConfiguration::getFileMaxFileSizeInBytes()` is advertised to clients — via the `max-size` field of the -`Upload-Limit` response header (`NoContentResponseFactory.php`) and via `/instance-configuration` -(`GetInstanceConfigurationController.php`) — and is validated at startup against the storage backend's technical -maximum object size (`S3TechnicalLimitsValidator.php`). But nothing actually enforces it at request time: neither -a direct upload ([`POST`](src/Service/UploadCreationService.php)/`PUT //file`) nor a completing resumable -upload chunk (`PatchUploadController.php`) rejects a file whose size exceeds `maxFileSizeInBytes`. The public API -docs (`/reference/upload` on the docs site) now describe a completing upload that exceeds `max-size` as failing -with `400 Bad Request` — since that's the intended behavior, this needs an actual check (most naturally in -`UploadCreationService::setOrReplaceElementFileDirectly()` for direct uploads, and in -`PatchUploadController::createFile()` for resumable ones, before the file/merge is written) so the documented -behavior becomes true rather than aspirational. - -## High-performance BLAKE3 for file hashing - -`file.hash` (`src/Service/FileHashService.php`) uses SHA-256 for now. BLAKE3 is the preferred long-term -algorithm, but the only real pure-PHP implementation (`tourze/blake3-php`) benchmarks at roughly 0.04 MB/s — -about 3000x too slow for a 1 Gb/s uplink — so it was not used. Getting real BLAKE3 performance requires either: - -- A Docker image change adding a build stage that compiles the official BLAKE3 C reference implementation into - a shared library, enabling the `ffi` PHP extension, and binding to it via FFI, or -- A compiled native PHP extension (e.g. `cypherbits/php-blake3`) added to the image build — smaller effort, but - currently a low-adoption, single-maintainer project, a real supply-chain trust tradeoff for a crypto - primitive that would need vetting first. - -The `file.hash` property is already stored as `{: }` (not a flat pair) so adding/switching to -`file.hash.blake3` later needs no data migration — existing `file.hash.sha256` values simply stay valid -alongside it. - -## S3 upload bucket lifecycle policy - -`cron:delete-expired-uploads` (and its `file.expiredUploadCanBeDeletedAfterExpirationInSeconds` grace period) -is an application-level cleanup of expired uploads and their S3 chunks. As defense in depth, the S3/MinIO -**upload** bucket itself should also be configured with an object lifecycle policy that expires objects after a -reasonably short time (e.g. 24-48h), in case the cron job is disabled, not running, or falls behind. This is an -infrastructure/ops concern (bucket provisioning lives outside this repository) and is not implemented here. - - - ---- - -GetElementFileController: - - // older records (predating the 'mimeType' rename) use 'mimetype' instead - $mimeType = $fileProperty['mimeType'] ?? $fileProperty['mimetype'] ?? null; - if (!is_string($mimeType) || '' === $mimeType) { - return null; - } - -pls remove the "old" property. the file feature as a whole unit is solely built in this branch -> no "old" logic exists in teh wild. - -furthermore pls look through commits on this branch since august and check whether similar "old" fallback logic exists somewhere else. - ---- - -CollectionService currently ignores file related special properties from response collections. -I think there are pros and cons for enabling and keeping them disabled. what you think? what would be better? -note: search endpoints can already be used to filter only elements with files. -does the search endpoint allow returning file properties directly, i.e. element collection with file? their element hydration search step is a bit special / behaves differently iirc. - ---- - -iirc some code in this codebase uses sha3, while new file related stuff uses sha 256. -should I consolidate towards a single hash algorithm? if so, sha3? - ---- - -pls remove all mentions of todo.md from the codebase. the todo.md file is solely used for development and temporary notes inside this branch; it should not be referenced by code later merged towards main. - ---- - -s3service: - - /** - * todo: optimize upload for larger files using multipart-upload?, handled by https://github.com/ember-nexus/api/issues/452. - */ - public function uploadFile(UploadFileOperationInterface $uploadFileOperation): int - { - -should this be implemented now? - ---- - -class BinaryStreamResponse extends StreamedResponse implements EtagCapableResponseInterface -{ -public const int STREAM_CHUNK_SIZE = 8192; - -is the stream chunk size of 8k ok? like is it performant? should it be increased? does it fit into common mtus etc.? - ---- - -reduce code comments made in this branch since august to a minimum; remove them completely if they are just paraphrasing existing logic. - ---- - diff --git a/bin/test-feature-command-prepare b/bin/test-feature-command-prepare index 0c35bbc9..8d6abe3e 100755 --- a/bin/test-feature-command-prepare +++ b/bin/test-feature-command-prepare @@ -8,8 +8,10 @@ if [ -z "$BACKUP_FOLDER_CAN_BE_RESET" ]; then exit 1 fi -TERM_WIDTH=$(stty size | awk '{print $2}') -if [ "$TERM_WIDTH" -ne 80 ]; then +TERM_WIDTH=$(stty size 2>/dev/null | awk '{print $2}') +if [ -z "$TERM_WIDTH" ]; then + echo "No terminal detected, skipping terminal width check (80 chars are expected)." +elif [ "$TERM_WIDTH" -ne 80 ]; then echo "Tests must be run in an terminal with 80 chars width, due to limitations in CI." exit 1 fi diff --git a/bin/test-server b/bin/test-server new file mode 100755 index 00000000..88a7dade --- /dev/null +++ b/bin/test-server @@ -0,0 +1,82 @@ +#!/bin/bash + +# Runs the server tests (tests/ServerTests) one scenario after another against the production image, which is +# recreated with lower limits for every scenario, see docker/Caddyfile. Requires the images api:dev-amd and api:prod-amd. +# Destructive: all databases of the started stack are reset, the stack is removed afterwards (KEEP_STACK=1 keeps it). +# Update expected responses in docs/server with FIX_CONTROLLER_OUTPUT=1. + +set -u + +cd "$(dirname "$0")/.." + +COMPOSE="docker compose -p enx-server-tests -f ./tests/ServerTests/docker-compose.yml" +EXEC_DEV="$COMPOSE exec -T --user $(id -u):$(id -g) api-dev" + +cleanup() { + if [ -z "${KEEP_STACK:-}" ]; then + $COMPOSE down -v --remove-orphans >/dev/null 2>&1 + fi +} +trap cleanup EXIT + +wait_for() { + local description="$1" timeout="$2" + shift 2 + local end=$((SECONDS + timeout)) + until "$@" >/dev/null 2>&1; do + if [ "$SECONDS" -ge "$end" ]; then + echo "Timeout while waiting for: $description" + return 1 + fi + sleep 2 + done +} + +echo "Starting stack" +$COMPOSE up --quiet-pull -d || exit 1 +$EXEC_DEV bash -c "composer install --ignore-platform-req=php --quiet" || exit 1 +wait_for "databases" 240 $EXEC_DEV bash -c "php bin/console healthcheck" || exit 1 + +echo "Preparing databases and test user" +$EXEC_DEV bash -c "php bin/console database:drop -f" > /dev/null || exit 1 +$EXEC_DEV bash -c "php bin/console user:create server-test@localhost.dev 1234" > /dev/null || exit 1 +TOKEN=$($EXEC_DEV bash -c "php bin/console token:create server-test@localhost.dev 1234" | sed -n 's/.*\(secret-token:[A-Za-z0-9]*\).*/\1/p' | head -n 1) +if [ -z "$TOKEN" ]; then + echo "Unable to create test token" + exit 1 +fi + +FAILED=0 + +# name, group, expected ready-check HTTP status and environment variables of the production container. The ready +# check must wait for the exact status the booted container will answer with: plain "curl -s" (or "curl -sf") also +# treats a 500 returned while the app is still starting up (e.g. warming its cache, connecting to Neo4j) as "ready", +# so the scenario's tests would start racing a container that looks up but is not actually serving yet; for the +# boot-failure scenario the container never leaves 500, so that status itself is what "ready" means there. +run_scenario() { + local group="$1" ready_status="$2" + shift 2 + echo + echo "=== Scenario $group ($*)" + env "$@" $COMPOSE up -d --force-recreate --no-deps api-prod || { FAILED=1; return; } + if ! wait_for "api-prod ready (HTTP $ready_status)" 120 bash -c "status=\$($COMPOSE exec -T api-prod curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null); [ \"\$status\" = \"$ready_status\" ]"; then + FAILED=1 + return + fi + $COMPOSE exec -T --user "$(id -u):$(id -g)" \ + -e API_DOMAIN=http://api-prod -e SERVER_TEST_TOKEN="$TOKEN" ${FIX_CONTROLLER_OUTPUT:+-e FIX_CONTROLLER_OUTPUT=1} \ + api-dev bash -c "composer test:server -- --group $group" || FAILED=1 +} + +run_scenario server-timeouts 200 API_REQUEST_BODY_TIMEOUT=10s API_UPLOAD_REQUEST_BODY_TIMEOUT=20s +run_scenario server-threads 200 FRANKENPHP_NUM_THREADS=1 FRANKENPHP_MAX_THREADS=1 FRANKENPHP_MAX_WAIT_TIME=2s API_UPLOAD_REQUEST_BODY_TIMEOUT=20s +run_scenario server-php-failure 200 +run_scenario server-php-boot-failure 500 SERVER_TEST_INI_DIR=/server-test-ini/low-memory + +if [ "$FAILED" -ne 0 ]; then + echo + echo "Server tests failed." + exit 1 +fi +echo +echo "Server tests finished successfully." diff --git a/bin/with-heartbeat b/bin/with-heartbeat new file mode 100755 index 00000000..dd490e52 --- /dev/null +++ b/bin/with-heartbeat @@ -0,0 +1,27 @@ +#!/bin/bash + +# Runs the given command and prints a "still running" line every HEARTBEAT_INTERVAL seconds (default 10), so long +# running commands without a tty (e.g. in ci) do not look stuck. Exits with the exit code of the command. + +INTERVAL="${HEARTBEAT_INTERVAL:-10}" +START=$(date +%s) + +"$@" & +COMMAND_PID=$! + +trap 'kill "$COMMAND_PID" 2>/dev/null' INT TERM + +( + while sleep "$INTERVAL"; do + echo "[heartbeat] still running... ($(( $(date +%s) - START ))s)" + done +) & +HEARTBEAT_PID=$! + +wait "$COMMAND_PID" +EXIT_CODE=$? + +kill "$HEARTBEAT_PID" 2>/dev/null +wait "$HEARTBEAT_PID" 2>/dev/null + +exit "$EXIT_CODE" diff --git a/composer.json b/composer.json index 5939c3cc..8c0adc18 100644 --- a/composer.json +++ b/composer.json @@ -9,31 +9,31 @@ "ext-ctype": "*", "ext-fileinfo": "*", "ext-iconv": "*", - "antlr/antlr4-php-runtime": "^0.9.1", - "async-aws/s3": "^3.1", + "antlr/antlr4-php-runtime": "^0.10.0", + "async-aws/s3": "^3.5", "cardinalby/content-disposition": "^1.1", - "guzzlehttp/guzzle": "^7.8", - "league/flysystem": "^3.23", - "league/flysystem-bundle": "^3.3", + "guzzlehttp/guzzle": "^7.10", + "league/flysystem": "^3.36", + "league/flysystem-bundle": "^3.7", "league/flysystem-ziparchive": "^3.23", "mongodb/mongodb": "^2.1", "php-amqplib/php-amqplib": "^3.6", - "predis/predis": "^3.4", - "ramsey/uuid": "^4.7", - "symfony/asset": "7.4.*", - "symfony/console": "7.4.*", - "symfony/dotenv": "7.4.*", - "symfony/emoji": "7.4.*", - "symfony/event-dispatcher": "7.4.*", - "symfony/filesystem": "7.4.*", + "predis/predis": "^3.6", + "ramsey/uuid": "^4.9", + "symfony/asset": "8.1.*", + "symfony/console": "8.1.*", + "symfony/dotenv": "8.1.*", + "symfony/emoji": "8.1.*", + "symfony/event-dispatcher": "8.1.*", + "symfony/filesystem": "8.1.*", "symfony/flex": "^2.4", - "symfony/framework-bundle": "7.4.*", - "symfony/monolog-bundle": "^3.10", - "symfony/process": "7.4.*", - "symfony/runtime": "7.4.*", - "symfony/stopwatch": "7.4.*", - "symfony/string": "7.4.*", - "symfony/yaml": "7.4.*", + "symfony/framework-bundle": "8.1.*", + "symfony/monolog-bundle": "^4.1", + "symfony/process": "8.1.*", + "symfony/runtime": "8.1.*", + "symfony/stopwatch": "8.1.*", + "symfony/string": "8.1.*", + "symfony/yaml": "8.1.*", "syndesi/cypher-entity-manager": "^0.2.0", "syndesi/elastic-entity-manager": "^0.1.0", "syndesi/mongo-entity-manager": "^0.2.0", @@ -42,20 +42,19 @@ }, "require-dev": { "beste/psr-testlogger": "^1.0", - "brianium/paratest": "^7.22", - "friendsofphp/php-cs-fixer": "^3.48", - "infection/infection": "^0.32", + "brianium/paratest": "^7.24", + "friendsofphp/php-cs-fixer": "^3.95", + "infection/infection": "^0.35", "phpbench/phpbench": "^1.2", - "phpmd/phpmd": "^2.15", "phpspec/prophecy-phpunit": "^2.1", "phpstan/phpstan": "^2.1", "phpunit/php-code-coverage": "^14", "phpunit/phpunit": "^13", "symfony/maker-bundle": "^1.53", "symfony/phpunit-bridge": "^7.2", - "symfony/web-profiler-bundle": "7.4.*", + "symfony/web-profiler-bundle": "8.1.*", "thecodingmachine/phpstan-safe-rule": "^1.4", - "vimeo/psalm": "^6.14" + "vimeo/psalm": "^6.18" }, "config": { "allow-plugins": { @@ -103,16 +102,15 @@ ], "test:unit": "php vendor/bin/paratest -p 6 --configuration tests/phpunit.xml.dist", "test:unit:test": "php vendor/bin/paratest -p 6 --configuration tests/phpunit.xml.dist --group test", - "test:feature": "php bin/console cache:clear && ./bin/test-feature-prepare && php vendor/bin/paratest -p 6 --configuration tests/phpunit.feature.xml.dist ./tests/FeatureTests --exclude-group command --exclude-group deprecated && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/FeatureTests --group command", - "test:feature:deprecated": "php bin/console cache:clear && ./bin/test-feature-prepare && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/FeatureTests --group deprecated", + "test:feature": "php bin/console cache:clear && ./bin/test-feature-prepare && php vendor/bin/paratest -p 6 --configuration tests/phpunit.feature.xml.dist ./tests/FeatureTests --exclude-group command && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/FeatureTests --group command", "test:feature:test": "php bin/console cache:clear && ./bin/test-feature-prepare && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/FeatureTests --group test", "test:feature:test-fast": "php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/FeatureTests --group test", - "test:example-generation-controller": "php bin/console cache:clear && ./bin/test-feature-prepare && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationController --exclude-group deprecated && php bin/console cache:clear", - "test:example-generation-controller:with-different-configuration": "cp -f ./config/packages/ember_nexus.yaml ./config/packages/ember_nexus_orig.yaml.dis && cp -f ./tests/ExampleGenerationControllerWithDifferentConfiguration/configuration.yml ./config/packages/ember_nexus.yaml && php bin/console cache:clear && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationControllerWithDifferentConfiguration && mv -f ./config/packages/ember_nexus_orig.yaml.dis ./config/packages/ember_nexus.yaml && php bin/console cache:clear", - "test:example-generation-controller:deprecated": "php bin/console cache:clear && ./bin/test-feature-prepare && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationController --group deprecated && php bin/console cache:clear", + "test:server": "php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.server.xml.dist ./tests/ServerTests", + "test:example-generation-controller": "php bin/console cache:clear && ./bin/test-feature-prepare && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationController && php bin/console cache:clear", + "test:example-generation-controller:with-different-configuration": "API_DOMAIN=${API_DOMAIN_DIFFERENT_CONFIGURATION:-http://api-different-configuration} php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationControllerWithDifferentConfiguration", "test:example-generation-controller:test-orig": "php bin/console cache:clear && ./bin/test-feature-prepare && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationController --group test && php bin/console cache:clear", "test:example-generation-controller:test": "php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationController --group test", - "test:example-generation-command": "php bin/console cache:clear && ./bin/test-feature-command-prepare && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationCommand --exclude-group deprecated && php bin/console cache:clear", + "test:example-generation-command": "php bin/console cache:clear && ./bin/test-feature-command-prepare && php vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.feature.xml.dist ./tests/ExampleGenerationCommand && php bin/console cache:clear", "test:coverage:xml": "export XDEBUG_MODE=coverage && php ./vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.xml.dist --coverage-clover coverage.xml", "test:coverage:html": "export XDEBUG_MODE=coverage && php ./vendor/phpunit/phpunit/phpunit --configuration tests/phpunit.xml.dist --coverage-html ./tmp", "test:mutant": "export XDEBUG_MODE=coverage && php vendor/infection/infection/bin/infection --configuration=tests/infection.json5 --threads=max", @@ -122,7 +120,6 @@ "psalm:fix-dry": "php vendor/bin/psalm --config=tests/psalm.xml --alter --issues=MissingParamType,MissingReturnType,InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType --dry-run", "psalm:fix-apply": "php vendor/bin/psalm --config=tests/psalm.xml --alter --issues=MissingParamType,MissingReturnType,InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType", "phpstan": "vendor/bin/phpstan --configuration=tests/phpstan.neon", - "mess": "vendor/bin/phpmd src,lib,tests text tests/phpmd.xml", "yml:lint": "docker run --rm -it -v \"$(pwd):/data\" cytopia/yamllint --config-file=tests/.yamllint .", "markdown:lint": "docker run --rm -it -v \"$(pwd):/work\" tmknom/markdownlint '**/*.md' --config tests/.markdownlintrc --ignore vendor --ignore CHANGELOG.md --ignore var --ignore tmp", "build:antlr": "./bin/build-antlr.sh", @@ -134,7 +131,7 @@ "extra": { "symfony": { "allow-contrib": false, - "require": "7.4.*" + "require": "8.1.*" } } } diff --git a/composer.lock b/composer.lock index 6188d11a..7ec6b44a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4f2d062a8dff390133ae8a81e1d39211", + "content-hash": "01af1eb6795091e873214683965f3a73", "packages": [ { "name": "antlr/antlr4-php-runtime", - "version": "0.9.1", + "version": "0.10.0", "source": { "type": "git", "url": "https://github.com/antlr/antlr-php-runtime.git", - "reference": "3afe41b19f8ea3d6b10400397712cb8181da5e25" + "reference": "396cbea30b6b45f97511a37effde24d98de918c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antlr/antlr-php-runtime/zipball/3afe41b19f8ea3d6b10400397712cb8181da5e25", - "reference": "3afe41b19f8ea3d6b10400397712cb8181da5e25", + "url": "https://api.github.com/repos/antlr/antlr-php-runtime/zipball/396cbea30b6b45f97511a37effde24d98de918c6", + "reference": "396cbea30b6b45f97511a37effde24d98de918c6", "shasum": "" }, "require": { @@ -57,22 +57,22 @@ ], "support": { "issues": "https://github.com/antlr/antlr-php-runtime/issues", - "source": "https://github.com/antlr/antlr-php-runtime/tree/0.9.1" + "source": "https://github.com/antlr/antlr-php-runtime/tree/0.10.0" }, - "time": "2023-09-04T20:27:34+00:00" + "time": "2026-08-15T19:53:16+00:00" }, { "name": "async-aws/core", - "version": "1.28.1", + "version": "1.30.0", "source": { "type": "git", "url": "https://github.com/async-aws/core.git", - "reference": "e8b02ac30b17afaf1352cbd352dceb789d792d39" + "reference": "dc0c26832e44db34466dd68d450293d573e732d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/async-aws/core/zipball/e8b02ac30b17afaf1352cbd352dceb789d792d39", - "reference": "e8b02ac30b17afaf1352cbd352dceb789d792d39", + "url": "https://api.github.com/repos/async-aws/core/zipball/dc0c26832e44db34466dd68d450293d573e732d1", + "reference": "dc0c26832e44db34466dd68d450293d573e732d1", "shasum": "" }, "require": { @@ -98,7 +98,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.28-dev" + "dev-master": "1.30-dev" } }, "autoload": { @@ -119,7 +119,7 @@ "sts" ], "support": { - "source": "https://github.com/async-aws/core/tree/1.28.1" + "source": "https://github.com/async-aws/core/tree/1.30.0" }, "funding": [ { @@ -131,20 +131,20 @@ "type": "github" } ], - "time": "2026-02-16T10:24:54+00:00" + "time": "2026-09-09T11:10:02+00:00" }, { "name": "async-aws/s3", - "version": "3.1.0", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/async-aws/s3.git", - "reference": "68664f2db5d990b7e8863624791366d6e8925398" + "reference": "43b1f00d16f2c5c05b37337ccdc2aa1b1a24a18a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/async-aws/s3/zipball/68664f2db5d990b7e8863624791366d6e8925398", - "reference": "68664f2db5d990b7e8863624791366d6e8925398", + "url": "https://api.github.com/repos/async-aws/s3/zipball/43b1f00d16f2c5c05b37337ccdc2aa1b1a24a18a", + "reference": "43b1f00d16f2c5c05b37337ccdc2aa1b1a24a18a", "shasum": "" }, "require": { @@ -163,7 +163,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-master": "3.5-dev" } }, "autoload": { @@ -184,7 +184,7 @@ "sdk" ], "support": { - "source": "https://github.com/async-aws/s3/tree/3.1.0" + "source": "https://github.com/async-aws/s3/tree/3.5.0" }, "funding": [ { @@ -196,28 +196,28 @@ "type": "github" } ], - "time": "2026-02-16T10:24:54+00:00" + "time": "2026-09-09T11:10:02+00:00" }, { "name": "brick/math", - "version": "0.14.8", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", - "phpstan/phpstan": "2.1.22", + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", "phpunit/phpunit": "^11.5" }, "type": "library", @@ -248,7 +248,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.8" + "source": "https://github.com/brick/math/tree/1.0.0" }, "funding": [ { @@ -256,7 +256,7 @@ "type": "github" } ], - "time": "2026-02-10T14:33:43+00:00" + "time": "2026-09-12T10:28:18+00:00" }, { "name": "cardinalby/content-disposition", @@ -483,25 +483,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -509,9 +510,10 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -589,7 +591,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -605,28 +607,29 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -672,7 +675,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -688,27 +691,29 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "95e7828100de18b4e269fb1703be530082d5166d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -716,9 +721,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", + "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -789,7 +794,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.13.1" }, "funding": [ { @@ -805,20 +810,20 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2026-08-24T09:13:11+00:00" }, { "name": "laudis/neo4j-php-client", - "version": "3.4.3", + "version": "3.6.1", "source": { "type": "git", "url": "https://github.com/neo4j-php/neo4j-php-client.git", - "reference": "f97858445e322b37424c52b3636069ec20c2df16" + "reference": "31e9f3329cdeee761f66eedc7d30b699c4fc338d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/neo4j-php/neo4j-php-client/zipball/f97858445e322b37424c52b3636069ec20c2df16", - "reference": "f97858445e322b37424c52b3636069ec20c2df16", + "url": "https://api.github.com/repos/neo4j-php/neo4j-php-client/zipball/31e9f3329cdeee761f66eedc7d30b699c4fc338d", + "reference": "31e9f3329cdeee761f66eedc7d30b699c4fc338d", "shasum": "" }, "require": { @@ -832,14 +837,13 @@ "psr/http-factory": "^1.0", "psr/http-message": "^1.0|^2.0", "psr/simple-cache": ">=2.0", - "stefanak-michal/bolt": "^7.1.4", - "symfony/polyfill-php80": "^1.2" + "stefanak-michal/bolt": "^7.4.0" }, "provide": { "psr/simple-cache-implementation": "2.0|3.0" }, "require-dev": { - "cache/integration-tests": "dev-master", + "cache/integration-tests": "0.17.0", "friendsofphp/php-cs-fixer": "3.68.5", "kriswallsmith/buzz": "^1.2", "kubawerlos/php-cs-fixer-custom-fixers": "3.13.*", @@ -891,9 +895,9 @@ ], "support": { "issues": "https://github.com/neo4j-php/neo4j-php-client/issues", - "source": "https://github.com/neo4j-php/neo4j-php-client/tree/3.4.3" + "source": "https://github.com/neo4j-php/neo4j-php-client/tree/3.6.1" }, - "time": "2026-03-20T09:53:10+00:00" + "time": "2026-09-21T08:48:57+00:00" }, { "name": "laudis/typed-enum", @@ -943,16 +947,16 @@ }, { "name": "league/flysystem", - "version": "3.33.0", + "version": "3.36.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "570b8871e0ce693764434b29154c54b434905350" + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", - "reference": "570b8871e0ce693764434b29154c54b434905350", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", "shasum": "" }, "require": { @@ -1020,22 +1024,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" }, - "time": "2026-03-25T07:59:30+00:00" + "time": "2026-09-02T08:00:27+00:00" }, { "name": "league/flysystem-bundle", - "version": "3.7.0", + "version": "3.7.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-bundle.git", - "reference": "5eb41be38fc3759f74c9e458a6a5f0ef5f49284a" + "reference": "9cccb6862c9fcd16e6d8a0fbd62254abb8017c99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-bundle/zipball/5eb41be38fc3759f74c9e458a6a5f0ef5f49284a", - "reference": "5eb41be38fc3759f74c9e458a6a5f0ef5f49284a", + "url": "https://api.github.com/repos/thephpleague/flysystem-bundle/zipball/9cccb6862c9fcd16e6d8a0fbd62254abb8017c99", + "reference": "9cccb6862c9fcd16e6d8a0fbd62254abb8017c99", "shasum": "" }, "require": { @@ -1090,22 +1094,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem-bundle/issues", - "source": "https://github.com/thephpleague/flysystem-bundle/tree/3.7.0" + "source": "https://github.com/thephpleague/flysystem-bundle/tree/3.7.1" }, - "time": "2026-03-28T22:14:56+00:00" + "time": "2026-08-10T15:55:07+00:00" }, { "name": "league/flysystem-local", - "version": "3.31.0", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -1139,9 +1143,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/flysystem-ziparchive", @@ -1194,16 +1198,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -1213,7 +1217,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -1234,7 +1238,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -1246,20 +1250,20 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "mongodb/mongodb", - "version": "2.1.2", + "version": "v2.1.x-dev", "source": { "type": "git", "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67" + "reference": "96117fd745ebac8ff47e4f3be3ca448fd281f732" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67", - "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/96117fd745ebac8ff47e4f3be3ca448fd281f732", + "reference": "96117fd745ebac8ff47e4f3be3ca448fd281f732", "shasum": "" }, "require": { @@ -1275,7 +1279,7 @@ "require-dev": { "doctrine/coding-standard": "^12.0", "phpunit/phpunit": "^10.5.35", - "rector/rector": "^2.1.4", + "rector/rector": "^2.3.4", "squizlabs/php_codesniffer": "^3.7", "vimeo/psalm": "6.5.*" }, @@ -1321,22 +1325,22 @@ ], "support": { "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2" + "source": "https://github.com/mongodb/mongo-php-library/tree/v2.1" }, - "time": "2025-10-06T12:12:40+00:00" + "time": "2026-02-04T15:11:14+00:00" }, { "name": "monolog/monolog", - "version": "3.10.0", + "version": "3.12.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "reference": "72c534fc0ab181ef52d92a68382318631e301608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/72c534fc0ab181ef52d92a68382318631e301608", + "reference": "72c534fc0ab181ef52d92a68382318631e301608", "shasum": "" }, "require": { @@ -1362,6 +1366,7 @@ "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^10.5.17 || ^11.0.7", "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", "rollbar/rollbar": "^4.0", "ruflin/elastica": "^7 || ^8", "symfony/mailer": "^5.4 || ^6", @@ -1380,6 +1385,7 @@ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", "rollbar/rollbar": "Allow sending log messages to Rollbar", "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, @@ -1414,7 +1420,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "source": "https://github.com/Seldaek/monolog/tree/3.12.0" }, "funding": [ { @@ -1426,20 +1432,20 @@ "type": "tidelift" } ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2026-09-09T08:34:20+00:00" }, { "name": "open-telemetry/api", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559", "shasum": "" }, "require": { @@ -1496,7 +1502,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-02-25T13:24:05+00:00" + "time": "2026-07-06T12:28:04+00:00" }, { "name": "open-telemetry/context", @@ -2016,16 +2022,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.51", + "version": "3.0.57", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" + "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4", + "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4", "shasum": "" }, "require": { @@ -2106,7 +2112,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.57" }, "funding": [ { @@ -2122,20 +2128,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T01:33:53+00:00" + "time": "2026-08-26T12:13:21+00:00" }, { "name": "predis/predis", - "version": "v3.4.2", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "2033429520d8997a7815a2485f56abe6d2d0e075" + "reference": "e2db96312ecf57a01a3477bf192d1ebc0ad7e9fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075", - "reference": "2033429520d8997a7815a2485f56abe6d2d0e075", + "url": "https://api.github.com/repos/predis/predis/zipball/e2db96312ecf57a01a3477bf192d1ebc0ad7e9fb", + "reference": "e2db96312ecf57a01a3477bf192d1ebc0ad7e9fb", "shasum": "" }, "require": { @@ -2177,7 +2183,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v3.4.2" + "source": "https://github.com/predis/predis/tree/v3.6.1" }, "funding": [ { @@ -2185,7 +2191,7 @@ "type": "github" } ], - "time": "2026-03-09T20:33:04+00:00" + "time": "2026-09-17T20:41:00+00:00" }, { "name": "psr/cache", @@ -2722,20 +2728,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -2794,9 +2800,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2025-12-14T04:43:48+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "stefanak-michal/bolt", @@ -2869,28 +2875,25 @@ }, { "name": "symfony/asset", - "version": "v7.4.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/asset.git", - "reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856" + "reference": "4bd4d143b7e53f40d45877df52eb2b18282bdac4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", - "reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", + "url": "https://api.github.com/repos/symfony/asset/zipball/4bd4d143b7e53f40d45877df52eb2b18282bdac4", + "reference": "4bd4d143b7e53f40d45877df52eb2b18282bdac4", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "conflict": { - "symfony/http-foundation": "<6.4" + "php": ">=8.4.1" }, "require-dev": { - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0" + "symfony/http-client": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -2918,7 +2921,7 @@ "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset/tree/v7.4.8" + "source": "https://github.com/symfony/asset/tree/v8.1.0" }, "funding": [ { @@ -2938,38 +2941,33 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/cache", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "467464da294734b0fb17e853e5712abc8470f819" + "reference": "47616ab077b5aff4d0f718b96680a48599fc1a3f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/467464da294734b0fb17e853e5712abc8470f819", - "reference": "467464da294734b0fb17e853e5712abc8470f819", + "url": "https://api.github.com/repos/symfony/cache/zipball/47616ab077b5aff4d0f718b96680a48599fc1a3f", + "reference": "47616ab077b5aff4d0f718b96680a48599fc1a3f", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/cache": "^2.0|^3.0", "psr/log": "^1.1|^2|^3", "symfony/cache-contracts": "^3.6", - "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/var-exporter": "^8.1" }, "conflict": { - "doctrine/dbal": "<3.6", "ext-redis": "<6.1", - "ext-relay": "<0.12.1", - "symfony/dependency-injection": "<6.4", - "symfony/http-kernel": "<6.4", - "symfony/var-dumper": "<6.4" + "ext-relay": "<0.12.1" }, "provide": { "psr/cache-implementation": "2.0|3.0", @@ -2977,17 +2975,17 @@ "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/dbal": "^3.6|^4", + "cache/integration-tests": "^1.0.3", + "doctrine/dbal": "^4.3", "predis/predis": "^1.1|^2.0", "psr/simple-cache": "^1.0|^2.0|^3.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3022,7 +3020,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.8" + "source": "https://github.com/symfony/cache/tree/v8.1.7" }, "funding": [ { @@ -3042,20 +3040,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:15:47+00:00" + "time": "2026-09-08T13:39:13+00:00" }, { "name": "symfony/cache-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868" + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/5d68a57d66910405e5c0b63d6f0af941e66fc868", - "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { @@ -3069,7 +3067,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3102,7 +3100,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" }, "funding": [ { @@ -3113,43 +3111,46 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-13T15:25:07+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/config", - "version": "v7.4.8", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b" + "reference": "ec711a6c14ae287d9618fbd2c9de4e223a1a4b02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/2d19dde43fa2ff720b9a40763ace7226594f503b", - "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b", + "url": "https://api.github.com/repos/symfony/config/zipball/ec711a6c14ae287d9618fbd2c9de4e223a1a4b02", + "reference": "ec711a6c14ae287d9618fbd2c9de4e223a1a4b02", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^7.1|^8.0", - "symfony/polyfill-ctype": "~1.8" + "symfony/filesystem": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/finder": "<6.4", "symfony/service-contracts": "<2.5" }, "require-dev": { - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3177,7 +3178,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.8" + "source": "https://github.com/symfony/config/tree/v8.1.5" }, "funding": [ { @@ -3197,51 +3198,53 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-08-20T09:59:12+00:00" }, { "name": "symfony/console", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", - "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "url": "https://api.github.com/repos/symfony/console/zipball/29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" + "symfony/string": "^7.4.6|^8.0.6" }, "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3275,7 +3278,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.8" + "source": "https://github.com/symfony/console/tree/v8.1.7" }, "funding": [ { @@ -3295,43 +3298,40 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:54:39+00:00" + "time": "2026-09-13T10:55:57+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f7025fd7b687c240426562f86ada06a93b1e771d" + "reference": "3fd96e4b54e2372c4ce019cee9d5026add6e7f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f7025fd7b687c240426562f86ada06a93b1e771d", - "reference": "f7025fd7b687c240426562f86ada06a93b1e771d", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/3fd96e4b54e2372c4ce019cee9d5026add6e7f38", + "reference": "3fd96e4b54e2372c4ce019cee9d5026add6e7f38", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^3.6", - "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + "symfony/var-exporter": "^8.1" }, "conflict": { - "ext-psr": "<1.1|>=2", - "symfony/config": "<6.4", - "symfony/finder": "<6.4", - "symfony/yaml": "<6.4" + "ext-psr": "<1.1|>=2" }, "provide": { "psr/container-implementation": "1.1|2.0", "symfony/service-implementation": "1.1|2.0|3.0" }, "require-dev": { - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3359,7 +3359,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.8" + "source": "https://github.com/symfony/dependency-injection/tree/v8.1.7" }, "funding": [ { @@ -3379,20 +3379,20 @@ "type": "tidelift" } ], - "time": "2026-03-31T06:50:29+00:00" + "time": "2026-09-11T15:21:30+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -3405,7 +3405,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3430,7 +3430,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -3441,37 +3441,37 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/dotenv", - "version": "v7.4.8", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "5df79f11350166125fe754c85b87f7e13d735314" + "reference": "4ea87b35c75f7052309ec9735c0eb5c1fd7d2182" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/5df79f11350166125fe754c85b87f7e13d735314", - "reference": "5df79f11350166125fe754c85b87f7e13d735314", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/4ea87b35c75f7052309ec9735c0eb5c1fd7d2182", + "reference": "4ea87b35c75f7052309ec9735c0eb5c1fd7d2182", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "conflict": { - "symfony/console": "<6.4", - "symfony/process": "<6.4" + "php": ">=8.4.1" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3504,7 +3504,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.4.8" + "source": "https://github.com/symfony/dotenv/tree/v8.1.6" }, "funding": [ { @@ -3524,30 +3524,30 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-07-26T10:31:34+00:00" }, { "name": "symfony/emoji", - "version": "v7.4.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/emoji.git", - "reference": "b40b8ad11110a84f69e6751356a7ef4f4e962dc6" + "reference": "c297a56939dfdcfb256fa1ce6ae11b4ff9ec489e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/emoji/zipball/b40b8ad11110a84f69e6751356a7ef4f4e962dc6", - "reference": "b40b8ad11110a84f69e6751356a7ef4f4e962dc6", + "url": "https://api.github.com/repos/symfony/emoji/zipball/c297a56939dfdcfb256fa1ce6ae11b4ff9ec489e", + "reference": "c297a56939dfdcfb256fa1ce6ae11b4ff9ec489e", "shasum": "" }, "require": { "ext-intl": "*", - "php": ">=8.2" + "php": ">=8.4.1" }, "require-dev": { - "symfony/filesystem": "^7.1|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3584,7 +3584,7 @@ "unicode" ], "support": { - "source": "https://github.com/symfony/emoji/tree/v7.4.8" + "source": "https://github.com/symfony/emoji/tree/v8.1.0" }, "funding": [ { @@ -3604,37 +3604,36 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.8", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/var-dumper": "^7.4|^8.0" }, "conflict": { - "symfony/deprecation-contracts": "<2.5", - "symfony/http-kernel": "<6.4" + "symfony/deprecation-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -3666,7 +3665,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + "source": "https://github.com/symfony/error-handler/tree/v8.1.5" }, "funding": [ { @@ -3686,28 +3685,29 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.8", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f57b899fa736fd71121168ef268f23c206083f0a" + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f57b899fa736fd71121168ef268f23c206083f0a", - "reference": "f57b899fa736fd71121168ef268f23c206083f0a", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -3716,14 +3716,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3751,7 +3751,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.8" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" }, "funding": [ { @@ -3771,20 +3771,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:54:39+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -3798,7 +3798,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3831,7 +3831,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -3842,34 +3842,39 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v7.4.8", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5" + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5", - "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0|^8.0" + "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3897,7 +3902,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.8" + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -3917,27 +3922,27 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/finder", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e0be088d22278583a82da281886e8c3592fbf149" + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", - "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "url": "https://api.github.com/repos/symfony/finder/zipball/4fbe46a3eb64abf8a57f0364075b91f4a233e062", + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3965,7 +3970,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.8" + "source": "https://github.com/symfony/finder/tree/v8.1.7" }, "funding": [ { @@ -3985,20 +3990,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-09-10T19:14:39+00:00" }, { "name": "symfony/flex", - "version": "v2.10.0", + "version": "v2.11.0", "source": { "type": "git", "url": "https://github.com/symfony/flex.git", - "reference": "9cd384775973eabbf6e8b05784dda279fc67c28d" + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/flex/zipball/9cd384775973eabbf6e8b05784dda279fc67c28d", - "reference": "9cd384775973eabbf6e8b05784dda279fc67c28d", + "url": "https://api.github.com/repos/symfony/flex/zipball/4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99", "shasum": "" }, "require": { @@ -4011,9 +4016,9 @@ }, "require-dev": { "composer/composer": "^2.1", - "symfony/dotenv": "^6.4|^7.4|^8.0", + "phpunit/phpunit": "^12.4", + "symfony/dotenv": "^6.4.41|^7.4.13|^8.0.13", "symfony/filesystem": "^6.4|^7.4|^8.0", - "symfony/phpunit-bridge": "^6.4|^7.4|^8.0", "symfony/process": "^6.4|^7.4|^8.0" }, "type": "composer-plugin", @@ -4038,7 +4043,7 @@ "description": "Composer plugin for Symfony", "support": { "issues": "https://github.com/symfony/flex/issues", - "source": "https://github.com/symfony/flex/tree/v2.10.0" + "source": "https://github.com/symfony/flex/tree/v2.11.0" }, "funding": [ { @@ -4058,117 +4063,103 @@ "type": "tidelift" } ], - "time": "2025-11-16T09:38:19+00:00" + "time": "2026-05-29T17:25:22+00:00" }, { "name": "symfony/framework-bundle", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "180533cfbac2144349044267db31d5d3df9957cb" + "reference": "b5d67475e1c444bb71f879f79d01ff10ae980662" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/180533cfbac2144349044267db31d5d3df9957cb", - "reference": "180533cfbac2144349044267db31d5d3df9957cb", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/b5d67475e1c444bb71f879f79d01ff10ae980662", + "reference": "b5d67475e1c444bb71f879f79d01ff10ae980662", "shasum": "" }, "require": { "composer-runtime-api": ">=2.1", "ext-xml": "*", - "php": ">=8.2", - "symfony/cache": "^6.4.12|^7.0|^8.0", + "php": ">=8.4.1", + "symfony/cache": "^7.4|^8.0", "symfony/config": "^7.4.4|^8.0.4", - "symfony/dependency-injection": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^8.1.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^7.3|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/filesystem": "^7.1|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php85": "^1.32", - "symfony/routing": "^7.4|^8.0" + "symfony/http-kernel": "^8.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.33", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^3.7.1", + "symfony/var-exporter": "^8.1" }, "conflict": { "doctrine/persistence": "<1.3", "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", - "symfony/asset": "<6.4", - "symfony/asset-mapper": "<6.4", - "symfony/clock": "<6.4", - "symfony/console": "<6.4", - "symfony/dom-crawler": "<6.4", - "symfony/dotenv": "<6.4", + "symfony/console": "<8.1.2", "symfony/form": "<7.4", - "symfony/http-client": "<6.4", - "symfony/lock": "<6.4", - "symfony/mailer": "<6.4", - "symfony/messenger": "<7.4", - "symfony/mime": "<6.4", - "symfony/property-access": "<6.4", - "symfony/property-info": "<6.4", - "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", - "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", - "symfony/security-core": "<6.4", - "symfony/security-csrf": "<7.2", - "symfony/serializer": "<7.2.5", - "symfony/stopwatch": "<6.4", - "symfony/translation": "<7.3", - "symfony/twig-bridge": "<6.4", - "symfony/twig-bundle": "<6.4", - "symfony/validator": "<6.4", - "symfony/web-profiler-bundle": "<6.4", - "symfony/webhook": "<7.2", + "symfony/json-streamer": "<7.4", + "symfony/mailer": "<7.4.17|>=8.0,<8.1.5", + "symfony/messenger": "<7.4.10|>=8.0,<8.0.10", + "symfony/mime": "<7.4.9|>=8.0,<8.0.9", + "symfony/security-csrf": "<7.4", + "symfony/serializer": "<7.4", + "symfony/translation": "<7.4", + "symfony/webhook": "<7.4", "symfony/workflow": "<7.4" }, "require-dev": { "doctrine/persistence": "^1.3|^2|^3", "dragonmantank/cron-expression": "^3.1", "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", - "symfony/asset": "^6.4|^7.0|^8.0", - "symfony/asset-mapper": "^6.4|^7.0|^8.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/dotenv": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/asset": "^7.4|^8.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/console": "^8.1.2", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", "symfony/form": "^7.4|^8.0", - "symfony/html-sanitizer": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/json-streamer": "^7.3|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/mailer": "^6.4|^7.0|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/notifier": "^6.4|^7.0|^8.0", - "symfony/object-mapper": "^7.3|^8.0", - "symfony/polyfill-intl-icu": "~1.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/runtime": "^6.4.13|^7.1.6|^8.0", - "symfony/scheduler": "^6.4.4|^7.0.4|^8.0", - "symfony/security-bundle": "^6.4|^7.0|^8.0", - "symfony/semaphore": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.2.5|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/string": "^6.4|^7.0|^8.0", - "symfony/translation": "^7.3|^8.0", - "symfony/twig-bundle": "^6.4|^7.0|^8.0", - "symfony/type-info": "^7.1.8|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/json-streamer": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/mailer": "^7.4|^8.0", + "symfony/messenger": "^7.4.10|^8.0.10", + "symfony/mime": "^7.4.9|^8.0.9", + "symfony/notifier": "^7.4|^8.0", + "symfony/object-mapper": "^7.4.9|^8.0.9", + "symfony/polyfill-intl-icu": "^1.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/scheduler": "^7.4|^8.0", + "symfony/security-bundle": "^7.4|^8.0", + "symfony/semaphore": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "symfony/type-info": "^7.4.1|^8.0.1", + "symfony/uid": "^7.4|^8.0", "symfony/validator": "^7.4|^8.0", - "symfony/web-link": "^6.4|^7.0|^8.0", - "symfony/webhook": "^7.2|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/webhook": "^7.4|^8.0", "symfony/workflow": "^7.4|^8.0", - "symfony/yaml": "^7.3|^8.0", - "twig/twig": "^3.12" + "symfony/yaml": "^7.4|^8.0" }, "type": "symfony-bundle", "autoload": { @@ -4196,7 +4187,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.4.8" + "source": "https://github.com/symfony/framework-bundle/tree/v8.1.7" }, "funding": [ { @@ -4216,35 +4207,32 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-09-14T09:21:44+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "01933e626c3de76bea1e22641e205e78f6a34342" + "reference": "099aae7d3619bffb5e68b33dbbadf6649c6d49c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", - "reference": "01933e626c3de76bea1e22641e205e78f6a34342", + "url": "https://api.github.com/repos/symfony/http-client/zipball/099aae7d3619bffb5e68b33dbbadf6649c6d49c5", + "reference": "099aae7d3619bffb5e68b33dbbadf6649c6d49c5", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-client-contracts": "~3.4.4|^3.5.2", - "symfony/polyfill-php83": "^1.29", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/http-client-contracts": "^3.7", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "amphp/amp": "<2.5", - "amphp/socket": "<1.1", - "php-http/discovery": "<1.15", - "symfony/http-foundation": "<6.4" + "amphp/amp": "<3", + "php-http/discovery": "<1.15" }, "provide": { "php-http/async-client-implementation": "*", @@ -4253,20 +4241,19 @@ "symfony/http-client-implementation": "3.0" }, "require-dev": { - "amphp/http-client": "^4.2.1|^5.0", - "amphp/http-tunnel": "^1.0|^2.0", - "guzzlehttp/promises": "^1.4|^2.0", + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/guzzle": "^7.10|^8.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", - "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4297,7 +4284,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.8" + "source": "https://github.com/symfony/http-client/tree/v8.1.7" }, "funding": [ { @@ -4317,20 +4304,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-09-08T13:39:13+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", "shasum": "" }, "require": { @@ -4343,7 +4330,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4379,7 +4366,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" }, "funding": [ { @@ -4390,46 +4377,49 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-08-04T08:41:16+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab" + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "doctrine/dbal": "<3.6", - "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + "doctrine/dbal": "<4.3" }, "require-dev": { - "doctrine/dbal": "^3.6|^4", + "doctrine/dbal": "^4.3", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4457,7 +4447,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.7" }, "funding": [ { @@ -4477,78 +4467,69 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-09-14T17:47:24+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "017e76ad089bac281553389269e259e155935e1a" + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/017e76ad089bac281553389269e259e155935e1a", - "reference": "017e76ad089bac281553389269e259e155935e1a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<6.4", - "symfony/cache": "<6.4", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<6.4", + "symfony/dependency-injection": "<8.1", "symfony/flex": "<2.10", - "symfony/form": "<6.4", - "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/translation": "<6.4", + "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<6.4", - "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.4", - "twig/twig": "<3.12" + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^7.1|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.1|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21|^4.0" }, "type": "library", "autoload": { @@ -4576,7 +4557,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.8" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.7" }, "funding": [ { @@ -4596,42 +4577,37 @@ "type": "tidelift" } ], - "time": "2026-03-31T20:57:01+00:00" + "time": "2026-09-15T07:12:52+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v7.4.8", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "b52aeb44645a9a84a1795b973cc5c77a76df0720" + "reference": "90747e82447b92c0fc701bfaf99bac0d54127039" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/b52aeb44645a9a84a1795b973cc5c77a76df0720", - "reference": "b52aeb44645a9a84a1795b973cc5c77a76df0720", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/90747e82447b92c0fc701bfaf99bac0d54127039", + "reference": "90747e82447b92c0fc701bfaf99bac0d54127039", "shasum": "" }, "require": { "monolog/monolog": "^3", - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", + "php": ">=8.4.1", + "symfony/http-kernel": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3" }, - "conflict": { - "symfony/console": "<6.4", - "symfony/http-foundation": "<6.4", - "symfony/security-core": "<6.4" - }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/mailer": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/security-core": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mailer": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/notifier": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "symfony-bridge", "autoload": { @@ -4659,7 +4635,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.8" + "source": "https://github.com/symfony/monolog-bridge/tree/v8.1.6" }, "funding": [ { @@ -4679,37 +4655,36 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:54:39+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "symfony/monolog-bundle", - "version": "v3.11.2", + "version": "v4.1.0", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bundle.git", - "reference": "d87468010570b2ec766152184918ee8d267c7411" + "reference": "a22de65db405738364af77310761fdd0dce7a40e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/d87468010570b2ec766152184918ee8d267c7411", - "reference": "d87468010570b2ec766152184918ee8d267c7411", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/a22de65db405738364af77310761fdd0dce7a40e", + "reference": "a22de65db405738364af77310761fdd0dce7a40e", "shasum": "" }, "require": { "composer-runtime-api": "^2.0", - "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0", - "php": ">=8.1", - "symfony/config": "^6.4 || ^7.0", - "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/http-kernel": "^6.4 || ^7.0", - "symfony/monolog-bridge": "^6.4 || ^7.0", + "monolog/monolog": "^3.12", + "php": ">=8.2", + "symfony/config": "^7.3 || ^8.0", + "symfony/dependency-injection": "^7.3 || ^8.0", + "symfony/http-kernel": "^7.3 || ^8.0", + "symfony/monolog-bridge": "^7.3 || ^8.0", "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "symfony/console": "^6.4 || ^7.0", - "symfony/phpunit-bridge": "^7.3.3", - "symfony/yaml": "^6.4 || ^7.0" + "phpunit/phpunit": "^11.5.41 || ^12.3", + "symfony/console": "^7.3 || ^8.0", + "symfony/yaml": "^7.3 || ^8.0" }, "type": "symfony-bundle", "autoload": { @@ -4739,7 +4714,7 @@ ], "support": { "issues": "https://github.com/symfony/monolog-bundle/issues", - "source": "https://github.com/symfony/monolog-bundle/tree/v3.11.2" + "source": "https://github.com/symfony/monolog-bundle/tree/v4.1.0" }, "funding": [ { @@ -4759,24 +4734,24 @@ "type": "tidelift" } ], - "time": "2026-04-02T18:23:01+00:00" + "time": "2026-09-10T19:32:07+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.4.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", - "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -4810,7 +4785,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" }, "funding": [ { @@ -4830,27 +4805,30 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.36.0", + "name": "symfony/polyfill-deepclone", + "version": "v1.42.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/70ba0627efc68e97ea392843458a2dd9d6dbd156", + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" }, "suggest": { - "ext-intl": "For best performance" + "ext-deepclone": "For best performance" }, "type": "library", "extra": { @@ -4864,8 +4842,11 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } + "Symfony\\Polyfill\\DeepClone\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4881,18 +4862,17 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "Symfony polyfill for the deepclone extension", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "grapheme", - "intl", + "deepclone", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.42.0" }, "funding": [ { @@ -4912,20 +4892,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.36.0", + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -4946,11 +4926,8 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4966,18 +4943,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Symfony polyfill for intl's grapheme_* functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "grapheme", "intl", - "normalizer", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -4997,31 +4974,27 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.36.0", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { - "ext-iconv": "*", "php": ">=7.2" }, - "provide": { - "ext-mbstring": "*" - }, "suggest": { - "ext-mbstring": "For best performance" + "ext-intl": "For best performance" }, "type": "library", "extra": { @@ -5035,8 +5008,11 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5052,17 +5028,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Symfony polyfill for intl's Normalizer class and related functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "mbstring", + "intl", + "normalizer", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -5082,25 +5059,32 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { - "name": "symfony/polyfill-php82", - "version": "v1.36.0", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", - "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, "type": "library", "extra": { "thanks": { @@ -5113,11 +5097,8 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php82\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5133,16 +5114,17 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "mbstring", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -5162,20 +5144,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "v1.36.0", + "name": "symfony/polyfill-php82", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", "shasum": "" }, "require": { @@ -5193,7 +5175,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Polyfill\\Php82\\": "" }, "classmap": [ "Resources/stubs" @@ -5213,7 +5195,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -5222,7 +5204,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" }, "funding": [ { @@ -5242,20 +5224,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.36.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -5302,7 +5284,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -5322,20 +5304,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.36.0", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -5382,7 +5364,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -5402,24 +5384,24 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:50:15+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/process", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", + "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -5447,7 +5429,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.8" + "source": "https://github.com/symfony/process/tree/v8.1.7" }, "funding": [ { @@ -5467,38 +5449,33 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-09-02T12:40:29+00:00" }, { "name": "symfony/routing", - "version": "v7.4.8", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b" + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", - "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", + "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, - "conflict": { - "symfony/config": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/yaml": "<6.4" - }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5532,7 +5509,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.8" + "source": "https://github.com/symfony/routing/tree/v8.1.6" }, "funding": [ { @@ -5552,35 +5529,36 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-08-17T13:18:34+00:00" }, { "name": "symfony/runtime", - "version": "v7.4.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e" + "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/6d792a64fec1eae2f011cfe9ab5978a9eab3071e", - "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e", + "url": "https://api.github.com/repos/symfony/runtime/zipball/b7ea1abe04561e814b3134db0f56c287cedb35cc", + "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc", "shasum": "" }, "require": { "composer-plugin-api": "^1.0|^2.0", - "php": ">=8.2" + "php": ">=8.4.1" }, "conflict": { - "symfony/dotenv": "<6.4" + "symfony/error-handler": "<7.4" }, "require-dev": { "composer/composer": "^2.6", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dotenv": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" }, "type": "composer-plugin", "extra": { @@ -5615,7 +5593,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.4.8" + "source": "https://github.com/symfony/runtime/tree/v8.1.0" }, "funding": [ { @@ -5635,20 +5613,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -5666,7 +5644,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5702,7 +5680,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -5722,24 +5700,24 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/stopwatch", - "version": "v7.4.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" + "reference": "21c07b026905d596e8379caeb115d87aa479499d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", - "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/service-contracts": "^2.5|^3" }, "type": "library", @@ -5768,7 +5746,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" + "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" }, "funding": [ { @@ -5788,39 +5766,38 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/string", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "114ac57257d75df748eda23dd003878080b8e688" + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688", - "reference": "114ac57257d75df748eda23dd003878080b8e688", + "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.33", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5859,7 +5836,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.8" + "source": "https://github.com/symfony/string/tree/v8.1.7" }, "funding": [ { @@ -5879,36 +5856,36 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-09-11T14:51:16+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/741a8c4c948b0bb9bd3653daacd3644c73c40ea5", + "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -5946,7 +5923,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.7" }, "funding": [ { @@ -5966,30 +5943,31 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-09-03T16:02:27+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.4.8", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "398907e89a2a56fe426f7955c6fa943ec0c77225" + "reference": "802362f41128c430fd6685491e1fb72127fae78a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/398907e89a2a56fe426f7955c6fa943ec0c77225", - "reference": "398907e89a2a56fe426f7955c6fa943ec0c77225", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/802362f41128c430fd6685491e1fb72127fae78a", + "reference": "802362f41128c430fd6685491e1fb72127fae78a", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.40" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6014,11 +5992,12 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", "homepage": "https://symfony.com", "keywords": [ "clone", "construct", + "deep-clone", "export", "hydrate", "instantiate", @@ -6027,7 +6006,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.8" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.6" }, "funding": [ { @@ -6047,32 +6026,32 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-08-29T06:30:03+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.8", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883" + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883", - "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -6103,7 +6082,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.8" + "source": "https://github.com/symfony/yaml/tree/v8.1.6" }, "funding": [ { @@ -6123,7 +6102,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "syndesi/cypher-data-structures", @@ -6690,16 +6669,16 @@ "packages-dev": [ { "name": "amphp/amp", - "version": "v3.1.1", + "version": "v3.1.3", "source": { "type": "git", "url": "https://github.com/amphp/amp.git", - "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", - "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "url": "https://api.github.com/repos/amphp/amp/zipball/73c38b323ff8d790abf0f76c56fcc892bda4111a", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a", "shasum": "" }, "require": { @@ -6709,7 +6688,7 @@ "require-dev": { "amphp/php-cs-fixer-config": "^2", "phpunit/phpunit": "^9", - "psalm/phar": "5.23.1" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -6759,7 +6738,7 @@ ], "support": { "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/v3.1.1" + "source": "https://github.com/amphp/amp/tree/v3.1.3" }, "funding": [ { @@ -6767,7 +6746,7 @@ "type": "github" } ], - "time": "2025-08-27T21:42:00+00:00" + "time": "2026-07-19T17:59:20+00:00" }, { "name": "amphp/byte-stream", @@ -6911,16 +6890,16 @@ }, { "name": "amphp/dns", - "version": "v2.4.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/amphp/dns.git", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "url": "https://api.github.com/repos/amphp/dns/zipball/ec5948bfafac808f410406e18bc7b52a2d4629b7", + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7", "shasum": "" }, "require": { @@ -6939,7 +6918,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "5.20" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -6988,7 +6967,7 @@ ], "support": { "issues": "https://github.com/amphp/dns/issues", - "source": "https://github.com/amphp/dns/tree/v2.4.0" + "source": "https://github.com/amphp/dns/tree/v2.4.1" }, "funding": [ { @@ -6996,20 +6975,20 @@ "type": "github" } ], - "time": "2025-01-19T15:43:40+00:00" + "time": "2026-07-26T17:53:54+00:00" }, { "name": "amphp/parallel", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/amphp/parallel.git", - "reference": "296b521137a54d3a02425b464e5aee4c93db2c60" + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/parallel/zipball/296b521137a54d3a02425b464e5aee4c93db2c60", - "reference": "296b521137a54d3a02425b464e5aee4c93db2c60", + "url": "https://api.github.com/repos/amphp/parallel/zipball/37f5b2754fadc229c00f9416bd68fb8d04529a81", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81", "shasum": "" }, "require": { @@ -7029,7 +7008,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -7072,7 +7051,7 @@ ], "support": { "issues": "https://github.com/amphp/parallel/issues", - "source": "https://github.com/amphp/parallel/tree/v2.3.3" + "source": "https://github.com/amphp/parallel/tree/v2.4.0" }, "funding": [ { @@ -7080,7 +7059,7 @@ "type": "github" } ], - "time": "2025-11-15T06:23:42+00:00" + "time": "2026-05-16T16:54:01+00:00" }, { "name": "amphp/parser", @@ -7146,16 +7125,16 @@ }, { "name": "amphp/pipeline", - "version": "v1.2.3", + "version": "v1.2.7", "source": { "type": "git", "url": "https://github.com/amphp/pipeline.git", - "reference": "7b52598c2e9105ebcddf247fc523161581930367" + "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", - "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17", + "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17", "shasum": "" }, "require": { @@ -7167,7 +7146,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -7201,7 +7180,7 @@ ], "support": { "issues": "https://github.com/amphp/pipeline/issues", - "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + "source": "https://github.com/amphp/pipeline/tree/v1.2.7" }, "funding": [ { @@ -7209,20 +7188,20 @@ "type": "github" } ], - "time": "2025-03-16T16:33:53+00:00" + "time": "2026-07-26T14:50:43+00:00" }, { "name": "amphp/process", - "version": "v2.0.3", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/amphp/process.git", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + "reference": "583959df17d00304ad7b0b32285373f985935643" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643", + "reference": "583959df17d00304ad7b0b32285373f985935643", "shasum": "" }, "require": { @@ -7236,7 +7215,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -7269,7 +7248,7 @@ "homepage": "https://amphp.org/process", "support": { "issues": "https://github.com/amphp/process/issues", - "source": "https://github.com/amphp/process/tree/v2.0.3" + "source": "https://github.com/amphp/process/tree/v2.1.0" }, "funding": [ { @@ -7277,7 +7256,7 @@ "type": "github" } ], - "time": "2024-04-19T03:13:44+00:00" + "time": "2026-05-31T15:11:55+00:00" }, { "name": "amphp/serialization", @@ -7348,16 +7327,16 @@ }, { "name": "amphp/socket", - "version": "v2.3.1", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/amphp/socket.git", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "url": "https://api.github.com/repos/amphp/socket/zipball/b347be5aff6b2cc025208bb4d896607eb470c018", + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018", "shasum": "" }, "require": { @@ -7366,17 +7345,17 @@ "amphp/dns": "^2", "ext-openssl": "*", "kelunik/certificate": "^1.1", - "league/uri": "^6.5 | ^7", - "league/uri-interfaces": "^2.3 | ^7", + "league/uri": "^7", + "league/uri-interfaces": "^7", "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" + "revolt/event-loop": "^1" }, "require-dev": { "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "amphp/process": "^2", "phpunit/phpunit": "^9", - "psalm/phar": "5.20" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -7420,7 +7399,7 @@ ], "support": { "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.3.1" + "source": "https://github.com/amphp/socket/tree/v2.4.1" }, "funding": [ { @@ -7428,7 +7407,7 @@ "type": "github" } ], - "time": "2024-04-21T14:33:03+00:00" + "time": "2026-08-22T18:13:20+00:00" }, { "name": "amphp/sync", @@ -7570,16 +7549,16 @@ }, { "name": "brianium/paratest", - "version": "v7.22.3", + "version": "v7.24.1", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "1648aa798a5791babe1ddea6ca3b1aea37e0c5ee" + "reference": "c29bc43a5cdd0124b7eff959064ae83ea902b49a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/1648aa798a5791babe1ddea6ca3b1aea37e0c5ee", - "reference": "1648aa798a5791babe1ddea6ca3b1aea37e0c5ee", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/c29bc43a5cdd0124b7eff959064ae83ea902b49a", + "reference": "c29bc43a5cdd0124b7eff959064ae83ea902b49a", "shasum": "" }, "require": { @@ -7590,24 +7569,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^14", + "phpunit/php-code-coverage": "^14.3.1", "phpunit/php-file-iterator": "^7", "phpunit/php-timer": "^9", - "phpunit/phpunit": "^13.1.3", - "sebastian/environment": "^9.2.0", - "symfony/console": "^7.4.8 || ^8.0.8", - "symfony/process": "^7.4.8 || ^8.0.8" + "phpunit/phpunit": "^13.3.1", + "sebastian/environment": "^9.3.2", + "symfony/console": "^7.4.8 || ^8.1.2", + "symfony/process": "^7.4.8 || ^8.1.0" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.47", - "phpstan/phpstan-deprecation-rules": "^2.0.4", - "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.10", - "symfony/filesystem": "^7.4.8 || ^8.0.8" + "phpstan/phpstan": "^2.2.8", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.0.18", + "phpstan/phpstan-strict-rules": "^2.0.12", + "symfony/filesystem": "^7.4.8 || ^8.1.2" }, "bin": [ "bin/paratest", @@ -7647,7 +7626,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.22.3" + "source": "https://github.com/paratestphp/paratest/tree/v7.24.1" }, "funding": [ { @@ -7659,7 +7638,7 @@ "type": "paypal" } ], - "time": "2026-04-15T05:24:54+00:00" + "time": "2026-08-17T06:31:26+00:00" }, { "name": "clue/ndjson-react", @@ -7815,28 +7794,29 @@ }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -7874,7 +7854,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -7884,13 +7864,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "composer/semver", @@ -8454,27 +8430,25 @@ }, { "name": "doctrine/lexer", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c", + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.18.3", - "vimeo/psalm": "^5.21" + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" }, "type": "library", "autoload": { @@ -8511,7 +8485,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.1" + "source": "https://github.com/doctrine/lexer/tree/3.0.2" }, "funding": [ { @@ -8527,46 +8501,46 @@ "type": "tidelift" } ], - "time": "2024-02-05T11:56:58+00:00" + "time": "2026-06-14T20:44:06+00:00" }, { "name": "ergebnis/agent-detector", - "version": "1.1.1", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/ergebnis/agent-detector.git", - "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64" + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64", - "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", "shasum": "" }, "require": { "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/composer-normalize": "^2.51.0", "ergebnis/license": "^2.7.0", "ergebnis/php-cs-fixer-config": "^6.60.2", "ergebnis/phpstan-rules": "^2.13.1", "ergebnis/phpunit-slow-test-detector": "^2.24.0", - "ergebnis/rector-rules": "^1.16.0", + "ergebnis/rector-rules": "^1.18.1", "fakerphp/faker": "^1.24.1", "infection/infection": "^0.26.6", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.46", + "phpstan/phpstan": "^2.1.54", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.10", "phpunit/phpunit": "^9.6.34", - "rector/rector": "^2.4.1" + "rector/rector": "^2.4.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.0-dev" + "dev-main": "1.2-dev" }, "composer-normalize": { "indent-size": 2, @@ -8596,7 +8570,7 @@ "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/agent-detector" }, - "time": "2026-04-10T13:45:13+00:00" + "time": "2026-05-07T08:19:07+00:00" }, { "name": "evenement/evenement", @@ -8764,23 +8738,23 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.1", + "version": "v3.95.27", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065" + "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a9727678fbd12997f1d9de8f4a37824ed9df1065", - "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/1d0c68ee5f37446d8ad5970b82384cd1bb0a3317", + "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317", "shasum": "" }, "require": { "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.5", - "ergebnis/agent-detector": "^1.1.1", + "ergebnis/agent-detector": "^1.2", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", @@ -8791,32 +8765,31 @@ "react/event-loop": "^1.5", "react/socket": "^1.16", "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", - "symfony/polyfill-mbstring": "^1.33", - "symfony/polyfill-php80": "^1.33", - "symfony/polyfill-php81": "^1.33", - "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.8.0", - "infection/infection": "^0.32.6", - "justinrainbow/json-schema": "^6.8.0", + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.12.0", "keradus/cli-executor": "^2.3", - "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", - "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", - "symfony/polyfill-php85": "^1.33", - "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8" + "phpunit/phpunit": "^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.35 || ^13.3.3", + "symfony/polyfill-php85": "^1.41", + "symfony/var-dumper": "^5.4.48 || ^6.4.45 || ^7.4.18 || ^8.1.7", + "symfony/yaml": "^5.4.53 || ^6.4.45 || ^7.4.18 || ^8.1.6" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -8857,7 +8830,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.1" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.27" }, "funding": [ { @@ -8865,29 +8838,31 @@ "type": "github" } ], - "time": "2026-04-12T17:00:09+00:00" + "time": "2026-09-22T09:54:55+00:00" }, { "name": "infection/abstract-testframework-adapter", - "version": "0.5.0", + "version": "0.5.1", "source": { "type": "git", "url": "https://github.com/infection/abstract-testframework-adapter.git", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b" + "reference": "b24bf3e850f70cd20a10621f08c3cef66f147ac8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/abstract-testframework-adapter/zipball/18925e20d15d1a5995bb85c9dc09e8751e1e069b", - "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b", + "url": "https://api.github.com/repos/infection/abstract-testframework-adapter/zipball/b24bf3e850f70cd20a10621f08c3cef66f147ac8", + "reference": "b24bf3e850f70cd20a10621f08c3cef66f147ac8", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": "^8.3" }, "require-dev": { - "ergebnis/composer-normalize": "^2.8", - "friendsofphp/php-cs-fixer": "^2.17", - "phpunit/phpunit": "^9.5" + "ergebnis/composer-normalize": "^2.18", + "fidry/makefile": "^1.0", + "friendsofphp/php-cs-fixer": "^3.95.2", + "phpunit/phpunit": "^12.0 || ^13.0", + "rector/rector": "^2.4.5" }, "type": "library", "autoload": { @@ -8908,7 +8883,7 @@ "description": "Abstract Test Framework Adapter for Infection", "support": { "issues": "https://github.com/infection/abstract-testframework-adapter/issues", - "source": "https://github.com/infection/abstract-testframework-adapter/tree/0.5.0" + "source": "https://github.com/infection/abstract-testframework-adapter/tree/0.5.1" }, "funding": [ { @@ -8920,7 +8895,7 @@ "type": "open_collective" } ], - "time": "2021-08-17T18:49:12+00:00" + "time": "2026-05-28T19:10:29+00:00" }, { "name": "infection/extension-installer", @@ -8990,21 +8965,21 @@ }, { "name": "infection/include-interceptor", - "version": "0.2.5", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/infection/include-interceptor.git", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7" + "reference": "c083331bc0cd1cd319ac1b7a08e3ad4a34aa7992" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/include-interceptor/zipball/0cc76d95a79d9832d74e74492b0a30139904bdf7", - "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7", + "url": "https://api.github.com/repos/infection/include-interceptor/zipball/c083331bc0cd1cd319ac1b7a08e3ad4a34aa7992", + "reference": "c083331bc0cd1cd319ac1b7a08e3ad4a34aa7992", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.16", - "infection/infection": "^0.15.0", + "infection/infection": "^0.19.0", "phan/phan": "^2.4 || ^3", "php-coveralls/php-coveralls": "^2.2", "phpstan/phpstan": "^0.12.8", @@ -9030,7 +9005,7 @@ "description": "Stream Wrapper: Include Interceptor. Allows to replace included (autoloaded) file with another one.", "support": { "issues": "https://github.com/infection/include-interceptor/issues", - "source": "https://github.com/infection/include-interceptor/tree/0.2.5" + "source": "https://github.com/infection/include-interceptor/tree/1.0.0" }, "funding": [ { @@ -9042,20 +9017,20 @@ "type": "open_collective" } ], - "time": "2021-08-09T10:03:57+00:00" + "time": "2024-05-03T21:48:06+00:00" }, { "name": "infection/infection", - "version": "0.32.6", + "version": "0.35.4", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "4ed769947eaf2ecf42203027301bad2bedf037e5" + "reference": "487bf060228e1272c6a9653468b87a4fe29436df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/4ed769947eaf2ecf42203027301bad2bedf037e5", - "reference": "4ed769947eaf2ecf42203027301bad2bedf037e5", + "url": "https://api.github.com/repos/infection/infection/zipball/487bf060228e1272c6a9653468b87a4fe29436df", + "reference": "487bf060228e1272c6a9653468b87a4fe29436df", "shasum": "" }, "require": { @@ -9069,46 +9044,50 @@ "fidry/cpu-core-counter": "^1.0", "infection/abstract-testframework-adapter": "^0.5.0", "infection/extension-installer": "^0.1.0", - "infection/include-interceptor": "^0.2.5", + "infection/include-interceptor": "^0.2.5 || ^1.0.0", "infection/mutator": "^0.4", "justinrainbow/json-schema": "^6.0", "nikic/php-parser": "^5.6.2", "ondram/ci-detector": "^4.1.0", - "php": "^8.2", + "php": "^8.3", "psr/log": "^2.0 || ^3.0", - "sanmai/di-container": "^0.1.12", + "sanmai/di-container": "^0.1.23 || ^0.2", "sanmai/duoclock": "^0.1.0", "sanmai/later": "^0.1.7", "sanmai/pipeline": "^7.2", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", - "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^6.4 || ^7.4 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.4 || ^8.0", + "symfony/finder": "^6.4 || ^7.4 || ^8.0", "symfony/polyfill-php85": "^1.33", - "symfony/process": "^6.4 || ^7.0 || ^8.0", + "symfony/process": "^6.4 || ^7.4 || ^8.0", "thecodingmachine/safe": "^v3.0", "webmozart/assert": "^1.11 || ^2.0" }, "conflict": { "antecedent/patchwork": "<2.1.25", - "dg/bypass-finals": "<1.4.1" + "dg/bypass-finals": "<1.4.1", + "marc-mabe/php-enum": "<4.4" }, "require-dev": { + "carthage-software/mago": "^1.20", "ext-simplexml": "*", "fidry/makefile": "^1.0", "fig/log-test": "^1.2", + "phpat/phpat": "^0.12.4", "phpbench/phpbench": "^1.4", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-webmozart-assert": "^2.0", - "phpunit/phpunit": "^11.5.27", + "phpunit/phpunit": "^12.5.29", "rector/rector": "^2.2.4", - "shipmonk/dead-code-detector": "^0.14.0", + "shipmonk/dead-code-detector": "^1.3", "shipmonk/name-collision-detector": "^2.1", "sidz/phpstan-rules": "^0.5.1", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.4 || ^8.0", "thecodingmachine/phpstan-safe-rule": "^1.4", "webmozarts/strict-phpunit": "^7.15" }, @@ -9166,7 +9145,7 @@ ], "support": { "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.32.6" + "source": "https://github.com/infection/infection/tree/0.35.4" }, "funding": [ { @@ -9178,7 +9157,7 @@ "type": "open_collective" } ], - "time": "2026-02-26T14:34:26+00:00" + "time": "2026-09-02T13:30:25+00:00" }, { "name": "infection/mutator", @@ -9295,26 +9274,27 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.8.0", + "version": "6.13.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "89ac92bcfe5d0a8a4433c7b89d394553ae7250cc" + "reference": "30de5e9b3fa04253af272a7d932a38cd026c311b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/89ac92bcfe5d0a8a4433c7b89d394553ae7250cc", - "reference": "89ac92bcfe5d0a8a4433c7b89d394553ae7250cc", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/30de5e9b3fa04253af272a7d932a38cd026c311b", + "reference": "30de5e9b3fa04253af272a7d932a38cd026c311b", "shasum": "" }, "require": { + "ext-filter": "*", "ext-json": "*", "marc-mabe/php-enum": "^4.4", "php": "^7.2 || ^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", + "json-schema/json-schema-test-suite": "dev-main", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -9340,20 +9320,9 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "name": "Danny van der Sluijs", + "email": "danny.vandersluijs@icloud.com", + "role": "Maintainer" } ], "description": "A library to validate a json schema.", @@ -9364,9 +9333,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.13.0" }, - "time": "2026-04-02T12:43:11+00:00" + "time": "2026-09-23T19:46:42+00:00" }, { "name": "kelunik/certificate", @@ -9683,20 +9652,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -9731,15 +9700,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "netresearch/jsonmapper", @@ -9794,20 +9763,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -9846,9 +9814,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "ondram/ci-detector", @@ -9928,69 +9896,6 @@ }, "time": "2024-03-12T13:22:30+00:00" }, - { - "name": "pdepend/pdepend", - "version": "2.16.2", - "source": { - "type": "git", - "url": "https://github.com/pdepend/pdepend.git", - "reference": "f942b208dc2a0868454d01b29f0c75bbcfc6ed58" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/pdepend/pdepend/zipball/f942b208dc2a0868454d01b29f0c75bbcfc6ed58", - "reference": "f942b208dc2a0868454d01b29f0c75bbcfc6ed58", - "shasum": "" - }, - "require": { - "php": ">=5.3.7", - "symfony/config": "^2.3.0|^3|^4|^5|^6.0|^7.0", - "symfony/dependency-injection": "^2.3.0|^3|^4|^5|^6.0|^7.0", - "symfony/filesystem": "^2.3.0|^3|^4|^5|^6.0|^7.0", - "symfony/polyfill-mbstring": "^1.19" - }, - "require-dev": { - "easy-doc/easy-doc": "0.0.0|^1.2.3", - "gregwar/rst": "^1.0", - "squizlabs/php_codesniffer": "^2.0.0" - }, - "bin": [ - "src/bin/pdepend" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "PDepend\\": "src/main/php/PDepend" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "Official version of pdepend to be handled with Composer", - "keywords": [ - "PHP Depend", - "PHP_Depend", - "dev", - "pdepend" - ], - "support": { - "issues": "https://github.com/pdepend/pdepend/issues", - "source": "https://github.com/pdepend/pdepend/tree/2.16.2" - }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/pdepend/pdepend", - "type": "tidelift" - } - ], - "time": "2023-12-17T18:09:59+00:00" - }, { "name": "phar-io/manifest", "version": "2.0.4", @@ -10162,16 +10067,16 @@ }, { "name": "phpbench/phpbench", - "version": "1.6.1", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/phpbench/phpbench.git", - "reference": "661c8c6abbc7734986cf7bc6062c237fbb450461" + "reference": "3d13c0d5dcf8730a67b70fa7fb03b4556b5cc0fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/661c8c6abbc7734986cf7bc6062c237fbb450461", - "reference": "661c8c6abbc7734986cf7bc6062c237fbb450461", + "url": "https://api.github.com/repos/phpbench/phpbench/zipball/3d13c0d5dcf8730a67b70fa7fb03b4556b5cc0fe", + "reference": "3d13c0d5dcf8730a67b70fa7fb03b4556b5cc0fe", "shasum": "" }, "require": { @@ -10196,14 +10101,14 @@ "require-dev": { "dantleech/invoke": "^2.0", "ergebnis/composer-normalize": "^2.39", - "jangregor/phpstan-prophecy": "^1.0", + "jangregor/phpstan-prophecy": "^2.0", "php-cs-fixer/shim": "^3.9", "phpspec/prophecy": "^1.22", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^11.5", - "rector/rector": "^1.2", + "rector/rector": "^2.2", "sebastian/exporter": "^6.3.2", "symfony/error-handler": "^6.1 || ^7.0 || ^8.0", "symfony/var-dumper": "^6.1 || ^7.0 || ^8.0" @@ -10249,7 +10154,7 @@ ], "support": { "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.6.1" + "source": "https://github.com/phpbench/phpbench/tree/1.7.0" }, "funding": [ { @@ -10257,7 +10162,7 @@ "type": "github" } ], - "time": "2026-03-22T10:27:20+00:00" + "time": "2026-06-08T19:09:20+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -10435,89 +10340,6 @@ }, "time": "2026-01-06T21:53:42+00:00" }, - { - "name": "phpmd/phpmd", - "version": "2.15.0", - "source": { - "type": "git", - "url": "https://github.com/phpmd/phpmd.git", - "reference": "74a1f56e33afad4128b886e334093e98e1b5e7c0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpmd/phpmd/zipball/74a1f56e33afad4128b886e334093e98e1b5e7c0", - "reference": "74a1f56e33afad4128b886e334093e98e1b5e7c0", - "shasum": "" - }, - "require": { - "composer/xdebug-handler": "^1.0 || ^2.0 || ^3.0", - "ext-xml": "*", - "pdepend/pdepend": "^2.16.1", - "php": ">=5.3.9" - }, - "require-dev": { - "easy-doc/easy-doc": "0.0.0 || ^1.3.2", - "ext-json": "*", - "ext-simplexml": "*", - "gregwar/rst": "^1.0", - "mikey179/vfsstream": "^1.6.8", - "squizlabs/php_codesniffer": "^2.9.2 || ^3.7.2" - }, - "bin": [ - "src/bin/phpmd" - ], - "type": "library", - "autoload": { - "psr-0": { - "PHPMD\\": "src/main/php" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Manuel Pichler", - "email": "github@manuel-pichler.de", - "homepage": "https://github.com/manuelpichler", - "role": "Project Founder" - }, - { - "name": "Marc Würth", - "email": "ravage@bluewin.ch", - "homepage": "https://github.com/ravage84", - "role": "Project Maintainer" - }, - { - "name": "Other contributors", - "homepage": "https://github.com/phpmd/phpmd/graphs/contributors", - "role": "Contributors" - } - ], - "description": "PHPMD is a spin-off project of PHP Depend and aims to be a PHP equivalent of the well known Java tool PMD.", - "homepage": "https://phpmd.org/", - "keywords": [ - "dev", - "mess detection", - "mess detector", - "pdepend", - "phpmd", - "pmd" - ], - "support": { - "irc": "irc://irc.freenode.org/phpmd", - "issues": "https://github.com/phpmd/phpmd/issues", - "source": "https://github.com/phpmd/phpmd/tree/2.15.0" - }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/phpmd/phpmd", - "type": "tidelift" - } - ], - "time": "2023-12-11T08:22:20+00:00" - }, { "name": "phpspec/prophecy", "version": "v1.26.1", @@ -10646,16 +10468,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "shasum": "" }, "require": { @@ -10687,17 +10509,17 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-08-31T16:05:28+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.50", + "version": "2.2.15", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", - "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7", + "reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7", "shasum": "" }, "require": { @@ -10720,6 +10542,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -10742,38 +10575,39 @@ "type": "github" } ], - "time": "2026-04-17T13:10:32+00:00" + "time": "2026-09-23T12:23:07+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "14.1.3", + "version": "14.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "24dc6fcf9f2a983de5b3f1199fb01e88d68e7474" + "reference": "f8640c238e930b914d0098a1edbad6652e61a64a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/24dc6fcf9f2a983de5b3f1199fb01e88d68e7474", - "reference": "24dc6fcf9f2a983de5b3f1199fb01e88d68e7474", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f8640c238e930b914d0098a1edbad6652e61a64a", + "reference": "f8640c238e930b914d0098a1edbad6652e61a64a", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", + "ext-mbstring": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", + "nikic/php-parser": "^5.8.0", "php": ">=8.4", "phpunit/php-text-template": "^6.0", "sebastian/complexity": "^6.0", - "sebastian/environment": "^9.2", + "sebastian/environment": "^9.3.2", "sebastian/git-state": "^1.0", - "sebastian/lines-of-code": "^5.0", + "sebastian/lines-of-code": "^5.0.2", "sebastian/version": "^7.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.1" + "phpunit/phpunit": "^13.3.3" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10782,7 +10616,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "14.1.x-dev" + "dev-main": "14.3.x-dev" } }, "autoload": { @@ -10811,7 +10645,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.3.3" }, "funding": [ { @@ -10831,27 +10665,27 @@ "type": "tidelift" } ], - "time": "2026-04-18T05:41:54+00:00" + "time": "2026-09-10T13:08:32+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "7.0.0", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/9bb4e6c58b62c1e043be995c66abec7c97307aae", + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.1" }, "type": "library", "extra": { @@ -10884,7 +10718,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.2" }, "funding": [ { @@ -10904,7 +10738,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:33:26+00:00" + "time": "2026-08-25T14:47:43+00:00" }, { "name": "phpunit/php-invoker", @@ -11128,44 +10962,45 @@ }, { "name": "phpunit/phpunit", - "version": "13.1.7", + "version": "13.3.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd" + "reference": "d19d90cf860efb7e4d96cee8243e06a248578085" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddd6401641861cdef94b922ef10d484f436e8dcd", - "reference": "ddd6401641861cdef94b922ef10d484f436e8dcd", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d19d90cf860efb7e4d96cee8243e06a248578085", + "reference": "d19d90cf860efb7e4d96cee8243e06a248578085", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.4", + "myclabs/deep-copy": "^1.14.0", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.1.3", - "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-code-coverage": "^14.3.3", + "phpunit/php-file-iterator": "^7.0.2", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", "phpunit/php-timer": "^9.0.0", - "sebastian/cli-parser": "^5.0.0", - "sebastian/comparator": "^8.1.2", - "sebastian/diff": "^8.1.0", - "sebastian/environment": "^9.3.0", - "sebastian/exporter": "^8.0.2", + "sebastian/cli-parser": "^5.0.1", + "sebastian/comparator": "^8.4", + "sebastian/diff": "^9.0.1", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.2.1", + "sebastian/file-filter": "^1.0", "sebastian/git-state": "^1.0", - "sebastian/global-state": "^9.0.0", - "sebastian/object-enumerator": "^8.0.0", - "sebastian/recursion-context": "^8.0.0", - "sebastian/type": "^7.0.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.1.0", + "sebastian/recursion-context": "^8.0.1", + "sebastian/type": "^7.0.2", "sebastian/version": "^7.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -11175,7 +11010,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "13.1-dev" + "dev-main": "13.3-dev" } }, "autoload": { @@ -11207,7 +11042,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.7" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.4" }, "funding": [ { @@ -11215,7 +11050,7 @@ "type": "other" } ], - "time": "2026-04-18T06:14:52+00:00" + "time": "2026-09-15T07:37:05+00:00" }, { "name": "psr/clock", @@ -11793,16 +11628,16 @@ }, { "name": "revolt/event-loop", - "version": "v1.0.8", + "version": "v1.0.9", "source": { "type": "git", "url": "https://github.com/revoltphp/event-loop.git", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + "reference": "44061cf513e53c6200372fc935ac42271566295d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", "shasum": "" }, "require": { @@ -11812,7 +11647,7 @@ "ext-json": "*", "jetbrains/phpstorm-stubs": "^2019.3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.15" + "psalm/phar": "6.16.*" }, "type": "library", "extra": { @@ -11859,28 +11694,28 @@ ], "support": { "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" }, - "time": "2025-08-27T21:33:23+00:00" + "time": "2026-05-16T17:55:38+00:00" }, { "name": "sanmai/di-container", - "version": "0.1.12", + "version": "0.2", "source": { "type": "git", "url": "https://github.com/sanmai/di-container.git", - "reference": "8b9ad72f6ac1f9e185e5bd060dc9479cb5191d8b" + "reference": "c14ca816bb5527e2361e3dc554a20829674c7ee1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/di-container/zipball/8b9ad72f6ac1f9e185e5bd060dc9479cb5191d8b", - "reference": "8b9ad72f6ac1f9e185e5bd060dc9479cb5191d8b", + "url": "https://api.github.com/repos/sanmai/di-container/zipball/c14ca816bb5527e2361e3dc554a20829674c7ee1", + "reference": "c14ca816bb5527e2361e3dc554a20829674c7ee1", "shasum": "" }, "require": { "php": ">=8.2", "psr/container": "^1.1.2 || ^2.0", - "sanmai/pipeline": "^6.17 || ^7.0" + "sanmai/pipeline": "^7.10" }, "require-dev": { "ergebnis/composer-normalize": "^2.8", @@ -11932,7 +11767,7 @@ ], "support": { "issues": "https://github.com/sanmai/di-container/issues", - "source": "https://github.com/sanmai/di-container/tree/0.1.12" + "source": "https://github.com/sanmai/di-container/tree/0.2" }, "funding": [ { @@ -11940,7 +11775,7 @@ "type": "github" } ], - "time": "2026-01-27T08:25:46+00:00" + "time": "2026-08-11T06:42:18+00:00" }, { "name": "sanmai/duoclock", @@ -12007,16 +11842,16 @@ }, { "name": "sanmai/later", - "version": "0.1.7", + "version": "0.1.8", "source": { "type": "git", "url": "https://github.com/sanmai/later.git", - "reference": "72a82d783864bca90412d8a26c1878f8981fee97" + "reference": "c56aeb8fa7fdf81eda2640a68b51884685963d13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/later/zipball/72a82d783864bca90412d8a26c1878f8981fee97", - "reference": "72a82d783864bca90412d8a26c1878f8981fee97", + "url": "https://api.github.com/repos/sanmai/later/zipball/c56aeb8fa7fdf81eda2640a68b51884685963d13", + "reference": "c56aeb8fa7fdf81eda2640a68b51884685963d13", "shasum": "" }, "require": { @@ -12059,7 +11894,7 @@ "description": "Later: deferred wrapper object", "support": { "issues": "https://github.com/sanmai/later/issues", - "source": "https://github.com/sanmai/later/tree/0.1.7" + "source": "https://github.com/sanmai/later/tree/0.1.8" }, "funding": [ { @@ -12067,20 +11902,20 @@ "type": "github" } ], - "time": "2025-05-11T01:48:00+00:00" + "time": "2026-06-29T07:24:33+00:00" }, { "name": "sanmai/pipeline", - "version": "7.9", + "version": "7.10", "source": { "type": "git", "url": "https://github.com/sanmai/pipeline.git", - "reference": "d7046ecce91ae57fca403be694888371a21250eb" + "reference": "a8e4e57a6031efc7a92defd13fed9c1c4c73fc71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/pipeline/zipball/d7046ecce91ae57fca403be694888371a21250eb", - "reference": "d7046ecce91ae57fca403be694888371a21250eb", + "url": "https://api.github.com/repos/sanmai/pipeline/zipball/a8e4e57a6031efc7a92defd13fed9c1c4c73fc71", + "reference": "a8e4e57a6031efc7a92defd13fed9c1c4c73fc71", "shasum": "" }, "require": { @@ -12095,7 +11930,7 @@ "php-coveralls/php-coveralls": "^2.4.1", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2", - "phpunit/phpunit": ">=9.4 <12", + "phpunit/phpunit": "^11 || ^12", "sanmai/phpstan-rules": "^0.3.11", "sanmai/phpunit-double-colon-syntax": "^0.1.1", "vimeo/psalm": ">=2" @@ -12127,7 +11962,7 @@ "description": "General-purpose collections pipeline", "support": { "issues": "https://github.com/sanmai/pipeline/issues", - "source": "https://github.com/sanmai/pipeline/tree/7.9" + "source": "https://github.com/sanmai/pipeline/tree/7.10" }, "funding": [ { @@ -12135,27 +11970,27 @@ "type": "github" } ], - "time": "2026-01-16T11:54:05+00:00" + "time": "2026-08-03T04:56:22+00:00" }, { "name": "sebastian/cli-parser", - "version": "5.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.2.6" }, "type": "library", "extra": { @@ -12184,7 +12019,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1" }, "funding": [ { @@ -12204,31 +12039,31 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:39:44+00:00" + "time": "2026-08-01T04:27:14+00:00" }, { "name": "sebastian/comparator", - "version": "8.1.2", + "version": "8.4.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1" + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", - "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/3b070e608146cba00fd6fd1f0ffba89e5a8897fb", + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", "php": ">=8.4", - "sebastian/diff": "^8.1", - "sebastian/exporter": "^8.0" + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.2" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -12236,7 +12071,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "8.4-dev" } }, "autoload": { @@ -12276,7 +12111,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/8.1.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.4.0" }, "funding": [ { @@ -12296,7 +12131,7 @@ "type": "tidelift" } ], - "time": "2026-04-14T08:24:42+00:00" + "time": "2026-08-07T07:23:13+00:00" }, { "name": "sebastian/complexity", @@ -12370,29 +12205,29 @@ }, { "name": "sebastian/diff", - "version": "8.1.0", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "9c957d730257f49c873f3761674559bd90098a7d" + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/9c957d730257f49c873f3761674559bd90098a7d", - "reference": "9c957d730257f49c873f3761674559bd90098a7d", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^13.3.1", + "symfony/process": "^7.4.17" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -12425,7 +12260,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/8.1.0" + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.1" }, "funding": [ { @@ -12445,27 +12280,27 @@ "type": "tidelift" } ], - "time": "2026-04-05T12:02:33+00:00" + "time": "2026-08-25T15:38:55+00:00" }, { "name": "sebastian/environment", - "version": "9.3.0", + "version": "9.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "6767059a30e4277ac95ee034809e793528464768" + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6767059a30e4277ac95ee034809e793528464768", - "reference": "6767059a30e4277ac95ee034809e793528464768", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.1.11" }, "suggest": { "ext-posix": "*" @@ -12501,7 +12336,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/9.3.0" + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" }, "funding": [ { @@ -12521,34 +12356,34 @@ "type": "tidelift" } ], - "time": "2026-04-15T12:14:03+00:00" + "time": "2026-05-25T13:41:38+00:00" }, { "name": "sebastian/exporter", - "version": "8.0.2", + "version": "8.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb" + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/9cee180ebe62259e3ed48df2212d1fc8cfd971bb", - "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/24a3b69bba4a12ab615fca9d34680c5598d9ab7a", + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.4", - "sebastian/recursion-context": "^8.0" + "sebastian/recursion-context": "^8.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.2-dev" } }, "autoload": { @@ -12591,7 +12426,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/8.2.1" }, "funding": [ { @@ -12611,7 +12446,76 @@ "type": "tidelift" } ], - "time": "2026-04-15T12:38:05+00:00" + "time": "2026-08-07T07:22:06+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" }, { "name": "sebastian/git-state", @@ -12684,16 +12588,16 @@ }, { "name": "sebastian/global-state", - "version": "9.0.0", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7" + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7", - "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", "shasum": "" }, "require": { @@ -12703,7 +12607,7 @@ }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.1.13" }, "type": "library", "extra": { @@ -12734,7 +12638,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0" + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" }, "funding": [ { @@ -12754,28 +12658,28 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:45:13+00:00" + "time": "2026-06-01T15:11:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "5.0.0", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471" + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", - "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.8.0", "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.2.4" }, "type": "library", "extra": { @@ -12804,7 +12708,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" }, "funding": [ { @@ -12824,34 +12728,33 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:45:54+00:00" + "time": "2026-07-09T08:42:34+00:00" }, { "name": "sebastian/object-enumerator", - "version": "8.0.0", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/511064ecde82bd747e2ba2fab3dda8d977b59576", + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576", "shasum": "" }, "require": { "php": ">=8.4", - "sebastian/object-reflector": "^6.0", - "sebastian/recursion-context": "^8.0" + "sebastian/recursion-context": "^8.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -12874,7 +12777,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.1.0" }, "funding": [ { @@ -12894,27 +12797,27 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:46:36+00:00" + "time": "2026-08-13T07:05:05+00:00" }, { "name": "sebastian/object-reflector", - "version": "6.0.0", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f71bbcdc4f95456b4622810bec64eb06372e25b2", + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { @@ -12942,7 +12845,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.1.0" }, "funding": [ { @@ -12962,27 +12865,27 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:47:13+00:00" + "time": "2026-08-13T06:34:36+00:00" }, { "name": "sebastian/recursion-context", - "version": "8.0.0", + "version": "8.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.2.6" }, "type": "library", "extra": { @@ -13018,7 +12921,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1" }, "funding": [ { @@ -13038,27 +12941,27 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:51:28+00:00" + "time": "2026-08-03T05:58:12+00:00" }, { "name": "sebastian/type", - "version": "7.0.0", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "42412224607bd3931241bbd17f38e0f972f5a916" + "reference": "bd1df467864cb95140414059a535b2d906173fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916", - "reference": "42412224607bd3931241bbd17f38e0f972f5a916", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/bd1df467864cb95140414059a535b2d906173fcf", + "reference": "bd1df467864cb95140414059a535b2d906173fcf", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { @@ -13087,7 +12990,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/type/tree/7.0.2" }, "funding": [ { @@ -13107,7 +13010,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:52:09+00:00" + "time": "2026-08-10T08:00:57+00:00" }, { "name": "sebastian/version", @@ -13177,16 +13080,16 @@ }, { "name": "seld/jsonlint", - "version": "1.11.0", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2" + "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/1748aaf847fc731cfad7725aec413ee46f0cc3a2", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9a90eb5d32d5a500296bf43f946d60246444d5f7", + "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7", "shasum": "" }, "require": { @@ -13225,7 +13128,7 @@ ], "support": { "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" + "source": "https://github.com/Seldaek/jsonlint/tree/1.12.1" }, "funding": [ { @@ -13237,7 +13140,7 @@ "type": "tidelift" } ], - "time": "2024-07-11T14:55:45+00:00" + "time": "2026-06-12T11:32:29+00:00" }, { "name": "spatie/array-to-xml", @@ -13361,32 +13264,32 @@ }, { "name": "symfony/maker-bundle", - "version": "v1.67.0", + "version": "1.68.0", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" + "reference": "1b0fb459594fdeb1d183065451c5e954babeb16c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", - "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/1b0fb459594fdeb1d183065451c5e954babeb16c", + "reference": "1b0fb459594fdeb1d183065451c5e954babeb16c", "shasum": "" }, "require": { "composer-runtime-api": "^2.1", "doctrine/inflector": "^2.0", "nikic/php-parser": "^5.0", - "php": ">=8.1", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "php": ">=8.2", + "symfony/config": "^7.4|^8.1", + "symfony/console": "^7.4|^8.1", + "symfony/dependency-injection": "^7.4|^8.1", "symfony/deprecation-contracts": "^2.2|^3", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.1", + "symfony/finder": "^7.4|^8.1", + "symfony/framework-bundle": "^7.4|^8.1", + "symfony/http-kernel": "^7.4|^8.1", + "symfony/process": "^7.4|^8.1" }, "conflict": { "doctrine/doctrine-bundle": "<2.10", @@ -13397,11 +13300,12 @@ "doctrine/doctrine-bundle": "^2.10|^3.0", "doctrine/orm": "^2.15|^3", "doctrine/persistence": "^3.1|^4.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0", - "symfony/security-core": "^6.4|^7.0|^8.0", - "symfony/security-http": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0", + "symfony/doctrine-bridge": "^7.4|^8.1", + "symfony/http-client": "^7.4|^8.1", + "symfony/phpunit-bridge": "^7.4|^8.1", + "symfony/security-core": "^7.4|^8.1", + "symfony/security-http": "^7.4|^8.1", + "symfony/yaml": "^7.4|^8.1", "twig/twig": "^3.0|^4.x-dev" }, "type": "symfony-bundle", @@ -13436,7 +13340,7 @@ ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" + "source": "https://github.com/symfony/maker-bundle/tree/1.68.0" }, "funding": [ { @@ -13456,20 +13360,20 @@ "type": "tidelift" } ], - "time": "2026-03-18T13:39:06+00:00" + "time": "2026-09-12T21:13:48+00:00" }, { "name": "symfony/phpunit-bridge", - "version": "v7.4.8", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d" + "reference": "ff3a78aceb731b24b256a2845a8ad6e6851f942b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/140bbbe1cd1c21a084494ccddeee33f3c3381d7d", - "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/ff3a78aceb731b24b256a2845a8ad6e6851f942b", + "reference": "ff3a78aceb731b24b256a2845a8ad6e6851f942b", "shasum": "" }, "require": { @@ -13521,7 +13425,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.8" + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.18" }, "funding": [ { @@ -13541,20 +13445,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-08-30T18:28:44+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -13567,7 +13471,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -13603,7 +13507,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -13623,71 +13527,64 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/twig-bridge", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c" + "reference": "7d9f1e128b44e37170f424046d23a48a3e545ad4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/ac43e7e59298ed1ce98c8d228b651d46e907d02c", - "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/7d9f1e128b44e37170f424046d23a48a3e545ad4", + "reference": "7d9f1e128b44e37170f424046d23a48a3e545ad4", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/translation-contracts": "^2.5|^3", - "twig/twig": "^3.21" + "twig/twig": "^3.25|^4.0" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", - "symfony/console": "<6.4", - "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4", - "symfony/http-foundation": "<6.4", - "symfony/http-kernel": "<6.4", - "symfony/mime": "<6.4.36|>7,<7.4.8|>8.0,<8.0.8", - "symfony/serializer": "<6.4", - "symfony/translation": "<6.4", - "symfony/workflow": "<6.4" + "symfony/form": "<7.4.4|>8.0,<8.0.4", + "symfony/mime": "<7.4.9|>8.0,<8.0.9" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "symfony/asset": "^6.4|^7.0|^8.0", - "symfony/asset-mapper": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/emoji": "^7.1|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/form": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", - "symfony/html-sanitizer": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^7.3|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4.36|^7.4.8|^8.0.8", - "symfony/polyfill-intl-icu": "~1.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/asset": "^7.4|^8.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4.4|^8.0.4", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/mime": "^7.4.9|^8.0.9", + "symfony/polyfill-intl-icu": "^1.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", "symfony/security-acl": "^2.8|^3.0", - "symfony/security-core": "^6.4|^7.0|^8.0", - "symfony/security-csrf": "^6.4|^7.0|^8.0", - "symfony/security-http": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/web-link": "^6.4|^7.0|^8.0", - "symfony/workflow": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/security-http": "^7.4|^8.0", + "symfony/serializer": "^7.4.17|^8.1.5", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0", "twig/cssinliner-extra": "^3", "twig/inky-extra": "^3", "twig/markdown-extra": "^3" @@ -13718,7 +13615,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.4.8" + "source": "https://github.com/symfony/twig-bridge/tree/v8.1.7" }, "funding": [ { @@ -13738,49 +13635,43 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:17:09+00:00" + "time": "2026-09-14T12:20:25+00:00" }, { "name": "symfony/twig-bundle", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95" + "reference": "71648fac591a675b91ae408f03a3d95a3f98b436" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", - "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/71648fac591a675b91ae408f03a3d95a3f98b436", + "reference": "71648fac591a675b91ae408f03a3d95a3f98b436", "shasum": "" }, "require": { "composer-runtime-api": ">=2.1", - "php": ">=8.2", + "php": ">=8.4.1", "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", - "symfony/twig-bridge": "^7.3|^8.0", - "twig/twig": "^3.12" + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" }, - "conflict": { - "symfony/framework-bundle": "<6.4", - "symfony/translation": "<6.4" - }, - "require-dev": { - "symfony/asset": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/form": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/runtime": "^6.4.13|^7.1.6", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", - "symfony/web-link": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "require-dev": { + "symfony/asset": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "symfony-bundle", "autoload": { @@ -13808,7 +13699,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v7.4.8" + "source": "https://github.com/symfony/twig-bundle/tree/v8.1.7" }, "funding": [ { @@ -13828,46 +13719,41 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-09-11T14:51:16+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v7.4.8", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "79f039096c67cc1cc3f607d2ba72af86cd27e6a4" + "reference": "0b70c60cf63518375b59b0907712de79fa8162b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/79f039096c67cc1cc3f607d2ba72af86cd27e6a4", - "reference": "79f039096c67cc1cc3f607d2ba72af86cd27e6a4", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/0b70c60cf63518375b59b0907712de79fa8162b7", + "reference": "0b70c60cf63518375b59b0907712de79fa8162b7", "shasum": "" }, "require": { "composer-runtime-api": ">=2.1", - "php": ">=8.2", - "symfony/config": "^7.3|^8.0", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", - "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/twig-bundle": "^6.4|^7.0|^8.0", - "twig/twig": "^3.15" + "php": ">=8.4.1", + "symfony/config": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^8.1", + "symfony/routing": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0" }, "conflict": { - "symfony/form": "<6.4", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/serializer": "<7.2", - "symfony/workflow": "<7.3" + "symfony/serializer": "<7.4", + "symfony/workflow": "<7.4" }, "require-dev": { - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/runtime": "^6.4.13|^7.1.6|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/browser-kit": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" }, "type": "symfony-bundle", "autoload": { @@ -13898,7 +13784,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.8" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.7" }, "funding": [ { @@ -13918,27 +13804,27 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-09-04T10:14:04+00:00" }, { "name": "thecodingmachine/phpstan-safe-rule", - "version": "v1.4.3", + "version": "v1.4.7", "source": { "type": "git", "url": "https://github.com/thecodingmachine/phpstan-safe-rule.git", - "reference": "5c804889253ce9498ef185e108e9f94b6023208e" + "reference": "51fa2a35a270f683fc9ea53384a03e892b4d7b51" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/phpstan-safe-rule/zipball/5c804889253ce9498ef185e108e9f94b6023208e", - "reference": "5c804889253ce9498ef185e108e9f94b6023208e", + "url": "https://api.github.com/repos/thecodingmachine/phpstan-safe-rule/zipball/51fa2a35a270f683fc9ea53384a03e892b4d7b51", + "reference": "51fa2a35a270f683fc9ea53384a03e892b4d7b51", "shasum": "" }, "require": { "nikic/php-parser": "^5", "php": "^8.1", - "phpstan/phpstan": "^2.1.11", - "thecodingmachine/safe": "^1.2 || ^2.0 || ^3.0" + "phpstan/phpstan": "^2.2.2", + "thecodingmachine/safe": "^3.1" }, "require-dev": { "php-coveralls/php-coveralls": "^2.1", @@ -13974,9 +13860,9 @@ "description": "A PHPStan rule to detect safety issues. Must be used in conjunction with thecodingmachine/safe", "support": { "issues": "https://github.com/thecodingmachine/phpstan-safe-rule/issues", - "source": "https://github.com/thecodingmachine/phpstan-safe-rule/tree/v1.4.3" + "source": "https://github.com/thecodingmachine/phpstan-safe-rule/tree/v1.4.7" }, - "time": "2025-11-21T09:41:49+00:00" + "time": "2026-06-21T07:55:55+00:00" }, { "name": "theseer/tokenizer", @@ -14030,16 +13916,16 @@ }, { "name": "twig/twig", - "version": "v3.24.0", + "version": "v3.29.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/45a3c6e9224c3377a39c7b150bb29d5d97d2c75d", + "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d", "shasum": "" }, "require": { @@ -14094,7 +13980,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + "source": "https://github.com/twigphp/Twig/tree/v3.29.0" }, "funding": [ { @@ -14106,20 +13992,20 @@ "type": "tidelift" } ], - "time": "2026-03-17T21:31:11+00:00" + "time": "2026-09-18T09:10:14+00:00" }, { "name": "vimeo/psalm", - "version": "6.16.1", + "version": "6.18.0", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac" + "reference": "536dd6236b39a5115385b0307b42d6d1ad431a02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", - "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/536dd6236b39a5115385b0307b42d6d1ad431a02", + "reference": "536dd6236b39a5115385b0307b42d6d1ad431a02", "shasum": "" }, "require": { @@ -14141,9 +14027,9 @@ "felixfbecker/language-server-protocol": "^1.5.3", "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0", "netresearch/jsonmapper": "^5.0", - "nikic/php-parser": "^5.0.0", + "nikic/php-parser": "^5.2.0", "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0", "spatie/array-to-xml": "^2.17.0 || ^3.0", "symfony/console": "^6.0 || ^7.0 || ^8.0", "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0", @@ -14224,20 +14110,20 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2026-03-19T10:56:09+00:00" + "time": "2026-09-21T08:32:57+00:00" }, { "name": "webmozart/assert", - "version": "2.3.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -14253,7 +14139,11 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { + "dev-master": "2.0-dev", "dev-feature/2-0": "2.0-dev" } }, @@ -14284,9 +14174,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.3.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-04-11T10:33:05+00:00" + "time": "2026-06-15T15:31:57+00:00" }, { "name": "webmozart/glob", diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index 4a4bc08f..517c0bac 100755 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -2,6 +2,5 @@ flysystem: storages: backup.storage: - adapter: 'local' - options: + local: directory: '%kernel.project_dir%/var/backup' diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 2ec4e814..311c8ceb 100755 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -11,9 +11,10 @@ when@dev: level: debug channels: ["!event", "!request"] formatter: monolog_json_formatter + # only active for commands which run in a terminal, regular requests never use it console: - type: console - process_psr_3_messages: false + type: service + id: App\Logger\TerminalConsoleHandler channels: ["!event", "!doctrine", "!console"] when@test: @@ -33,6 +34,12 @@ when@test: when@prod: monolog: handlers: + # declared first: for commands which run in a terminal it handles the records and stops them from also reaching + # the JSON handlers below; without a terminal (cron, CI) or for regular requests it is inactive + console: + type: service + id: App\Logger\TerminalConsoleHandler + channels: ["!event", "!doctrine", "!deprecation"] main: type: fingers_crossed action_level: error @@ -44,11 +51,9 @@ when@prod: path: "php://stderr" level: debug formatter: monolog.formatter.json - console: - type: console - process_psr_3_messages: false - channels: ["!event", "!doctrine", "!deprecation"] deprecation: type: stream path: "php://stderr" level: error + # without the channel restriction every error would be written a second time in the line format + channels: ["deprecation"] diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml index ec1d51da..9c6c8368 100755 --- a/config/packages/web_profiler.yaml +++ b/config/packages/web_profiler.yaml @@ -6,7 +6,6 @@ when@dev: framework: profiler: only_exceptions: false - collect_serializer_data: true when@test: web_profiler: diff --git a/config/reference.php b/config/reference.php index acef2f43..5b1252c6 100644 --- a/config/reference.php +++ b/config/reference.php @@ -31,7 +31,7 @@ * @psalm-type ImportsConfig = list * @psalm-type ParametersConfig = array|Param|null>|Param|null> * @psalm-type ArgumentsType = list|array @@ -78,6 +78,7 @@ * tags?: TagsType, * resource_tags?: TagsType, * decorates?: string, + * decorates_tag?: string, * decoration_inner_name?: string, * decoration_priority?: int, * decoration_on_invalid?: 'exception'|'ignore'|null, @@ -118,17 +119,22 @@ * stack: list>, * public?: bool, * deprecated?: DeprecationType, + * decorates?: string, + * decorates_tag?: string, + * decoration_inner_name?: string, + * decoration_priority?: int, + * decoration_on_invalid?: 'exception'|'ignore'|null, * } * @psalm-type ServicesConfig = array{ * _defaults?: DefaultsType, - * _instanceof?: InstanceofType, + * _instanceof?: array, * ... * } * @psalm-type ExtensionType = array * @psalm-type FrameworkConfig = array{ * secret?: scalar|Param|null, * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false - * allowed_http_method_override?: list|null, + * allowed_http_method_override?: null|list, * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" * test?: bool|Param, @@ -136,9 +142,9 @@ * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false * enabled_locales?: list, - * trusted_hosts?: list, + * trusted_hosts?: Param|string|list, * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] - * trusted_headers?: list, + * trusted_headers?: Param|string|list, * error_controller?: scalar|Param|null, // Default: "error_controller" * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true * csrf_protection?: bool|array{ @@ -168,7 +174,7 @@ * allow_revalidate?: bool|Param, * stale_while_revalidate?: int|Param, * stale_if_error?: int|Param, - * terminate_on_cache_hit?: bool|Param, + * terminate_on_cache_hit?: bool|Param, // Deprecated: Setting the "framework.http_cache.terminate_on_cache_hit.terminate_on_cache_hit" configuration option is deprecated. It will be removed in version 9.0. * }, * esi?: bool|array{ // ESI configuration * enabled?: bool|Param, // Default: false @@ -188,7 +194,7 @@ * only_exceptions?: bool|Param, // Default: false * only_main_requests?: bool|Param, // Default: false * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" - * collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false + * collect_serializer_data?: true|Param, // Deprecated: Setting the "framework.profiler.collect_serializer_data.collect_serializer_data" configuration option is deprecated. It will be removed in version 9.0. // Default: true * }, * workflows?: bool|array{ * enabled?: bool|Param, // Default: false @@ -202,23 +208,23 @@ * property?: scalar|Param|null, * service?: scalar|Param|null, * }, - * supports?: list, + * supports?: Param|string|list, * definition_validators?: list, * support_strategy?: scalar|Param|null, - * initial_marking?: list, - * events_to_dispatch?: list|null, - * places?: list, + * events_to_dispatch?: null|list, + * places?: Param|string|list, * }>, * transitions?: list, - * to?: list, @@ -232,7 +238,6 @@ * enabled?: bool|Param, // Default: false * resource?: scalar|Param|null, * type?: scalar|Param|null, - * cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null * http_port?: scalar|Param|null, // Default: 80 * https_port?: scalar|Param|null, // Default: 443 @@ -256,12 +261,10 @@ * gc_maxlifetime?: scalar|Param|null, * save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 - * sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. - * sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. * }, * request?: bool|array{ // Request configuration * enabled?: bool|Param, // Default: false - * formats?: array>, + * formats?: array>, * }, * assets?: bool|array{ // Assets configuration * enabled?: bool|Param, // Default: true @@ -271,7 +274,7 @@ * version_format?: scalar|Param|null, // Default: "%%s?%%s" * json_manifest_path?: scalar|Param|null, // Default: null * base_path?: scalar|Param|null, // Default: "" - * base_urls?: list, + * base_urls?: Param|string|list, * packages?: array, + * base_urls?: Param|string|list, * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration * enabled?: bool|Param, // Default: false - * paths?: array, + * paths?: Param|string|array, * excluded_patterns?: list, * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true @@ -303,7 +306,7 @@ * }, * translator?: bool|array{ // Translator configuration * enabled?: bool|Param, // Default: false - * fallbacks?: list, + * fallbacks?: Param|string|list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" @@ -322,7 +325,7 @@ * domains?: list, * locales?: list, * }>, - * globals?: array, @@ -331,11 +334,10 @@ * }, * validation?: bool|array{ // Validation configuration * enabled?: bool|Param, // Default: false - * cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. * enable_attributes?: bool|Param, // Default: true - * static_method?: list, + * static_method?: Param|string|list, * translation_domain?: scalar|Param|null, // Default: "validators" - * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" + * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|Param, // Default: "html5" * mapping?: array{ * paths?: list, * }, @@ -344,13 +346,11 @@ * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null * }, * disable_translation?: bool|Param, // Default: false + * property_metadata_existence_check?: bool|Param, // When enabled, validateProperty() and validatePropertyValue() throw an exception if no metadata is found for the given property. // Default: false * auto_mapping?: array, * }>, * }, - * annotations?: bool|array{ - * enabled?: bool|Param, // Default: false - * }, * serializer?: bool|array{ // Serializer configuration * enabled?: bool|Param, // Default: false * enable_attributes?: bool|Param, // Default: true @@ -382,7 +382,7 @@ * }, * property_info?: bool|array{ // Property info configuration * enabled?: bool|Param, // Default: false - * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. + * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. // Default: true * }, * cache?: array{ // Cache configuration * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" @@ -396,13 +396,14 @@ * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" * default_pdo_provider?: scalar|Param|null, // Default: null * pools?: array, + * adapters?: Param|string|list, * tags?: scalar|Param|null, // Default: null * public?: bool|Param, // Default: false * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. * early_expiration_message_bus?: scalar|Param|null, * clearer?: scalar|Param|null, + * marshaller?: scalar|Param|null, // The marshaller service to use for this pool. * }>, * }, * php_errors?: array{ // PHP errors handling configuration @@ -417,19 +418,17 @@ * web_link?: bool|array{ // Web links configuration * enabled?: bool|Param, // Default: false * }, - * lock?: bool|string|array{ // Lock configuration + * lock?: Param|bool|string|array{ // Lock configuration * enabled?: bool|Param, // Default: false - * resources?: array>, + * resources?: Param|string|array>, * }, - * semaphore?: bool|string|array{ // Semaphore configuration + * semaphore?: Param|bool|string|array{ // Semaphore configuration * enabled?: bool|Param, // Default: false - * resources?: array, + * resources?: Param|string|array, * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: false - * routing?: array, - * }>, + * routing?: array>, * serializer?: array{ * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" * symfony_serializer?: array{ @@ -437,12 +436,12 @@ * context?: array, * }, * }, - * transports?: array, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null - * retry_strategy?: string|array{ + * retry_strategy?: Param|string|array{ * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -453,15 +452,15 @@ * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null * }>, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null - * stop_worker_on_signals?: list, + * stop_worker_on_signals?: Param|int|string|list, * default_bus?: scalar|Param|null, // Default: null * buses?: array, * }>, @@ -505,14 +504,14 @@ * enabled?: bool|Param, // Default: false * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true - * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. // Default: 86400 * }, * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * methods?: Param|string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -521,8 +520,8 @@ * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 * }, * }, - * mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. - * scoped_clients?: array, * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null * caching?: bool|array{ // Caching configuration. * enabled?: bool|Param, // Default: false * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true - * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. // Default: 86400 * }, * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * methods?: Param|string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -582,10 +582,10 @@ * transports?: array, * envelope?: array{ // Mailer Envelope configuration * sender?: scalar|Param|null, - * recipients?: list, - * allowed_recipients?: list, + * recipients?: Param|string|list, + * allowed_recipients?: Param|string|list, * }, - * headers?: array, * dkim_signer?: bool|array{ // DKIM signer configuration @@ -622,7 +622,7 @@ * chatter_transports?: array, * texter_transports?: array, * notification_on_failed_messages?: bool|Param, // Default: false - * channel_policy?: array>, + * channel_policy?: array>, * admin_recipients?: list, + * limiters?: Param|string|list, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 * }, + * anchor_at?: scalar|Param|null, // Aligns the "fixed_window" policy to a calendar (e.g. "2024-01-05 00:00:00 UTC" combined with `interval: 1 month` resets the counter on the 5th of each month). UTC if not specified. // Default: null * }>, * }, * uid?: bool|array{ // Uid configuration @@ -651,47 +652,58 @@ * name_based_uuid_namespace?: scalar|Param|null, * time_based_uuid_version?: 7|6|1|Param, // Default: 7 * time_based_uuid_node?: scalar|Param|null, + * uuid47_secret?: scalar|Param|null, // A high-entropy secret used by the "uuid47_transformer" service. Defaults to "kernel.secret". // Default: null * }, * html_sanitizer?: bool|array{ // HtmlSanitizer configuration * enabled?: bool|Param, // Default: false * sanitizers?: array, - * block_elements?: list, - * drop_elements?: list, + * block_elements?: Param|string|list, + * drop_elements?: Param|string|list, * allow_attributes?: array, * drop_attributes?: array, * force_attributes?: array>, * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false - * allowed_link_schemes?: list, - * allowed_link_hosts?: list|null, + * allowed_link_schemes?: Param|string|list, + * allowed_link_hosts?: Param|null|string|list, * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false - * allowed_media_schemes?: list, - * allowed_media_hosts?: list|null, + * allowed_media_schemes?: Param|string|list, + * allowed_media_hosts?: Param|null|string|list, * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false - * with_attribute_sanitizers?: list, - * without_attribute_sanitizers?: list, + * with_attribute_sanitizers?: Param|string|list, + * without_attribute_sanitizers?: Param|string|list, * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 * }>, * }, * webhook?: bool|array{ // Webhook configuration * enabled?: bool|Param, // Default: false * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" + * event_header_name?: scalar|Param|null, // Default: "Webhook-Event" + * id_header_name?: scalar|Param|null, // Default: "Webhook-Id" + * signature_header_name?: scalar|Param|null, // Default: "Webhook-Signature" + * signing_algorithm?: scalar|Param|null, // Default: "sha256" * routing?: array, * }, - * remote-event?: bool|array{ // RemoteEvent configuration + * remote_event?: bool|array{ // RemoteEvent configuration * enabled?: bool|Param, // Default: false * }, * json_streamer?: bool|array{ // JSON streamer configuration * enabled?: bool|Param, // Default: false + * default_options?: array{ + * include_null_properties?: bool|Param, // Encode the properties with null value // Default: false + * ... + * }, * }, * } * @psalm-type MonologConfig = array{ * use_microseconds?: scalar|Param|null, // Default: true + * timezone?: string|Param, // The timezone used for the timestamp of every log record (e.g. "UTC" or "Europe/Paris"). Defaults to the PHP default timezone. // Default: null * channels?: list, * handlers?: array * }, * path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log" * file_permission?: scalar|Param|null, // Default: null * use_locking?: bool|Param, // Default: false * filename_format?: scalar|Param|null, // Default: "{filename}-{date}" * date_format?: scalar|Param|null, // Default: "Y-m-d" - * ident?: scalar|Param|null, // Default: false + * ident?: scalar|Param|null, // Default: "php" * logopts?: scalar|Param|null, // Default: 1 * facility?: scalar|Param|null, // Default: "user" * max_files?: scalar|Param|null, // Default: 0 @@ -722,7 +735,6 @@ * activation_strategy?: scalar|Param|null, // Default: null * stop_buffering?: bool|Param, // Default: true * passthru_level?: scalar|Param|null, // Default: null - * excluded_404s?: list, * excluded_http_codes?: list, @@ -736,9 +748,6 @@ * url?: scalar|Param|null, * exchange?: scalar|Param|null, * exchange_name?: scalar|Param|null, // Default: "log" - * room?: scalar|Param|null, - * message_format?: scalar|Param|null, // Default: "text" - * api_version?: scalar|Param|null, // Default: null * channel?: scalar|Param|null, // Default: null * bot_name?: scalar|Param|null, // Default: "Monolog" * use_attachment?: scalar|Param|null, // Default: true @@ -747,9 +756,6 @@ * icon_emoji?: scalar|Param|null, // Default: null * webhook_url?: scalar|Param|null, * exclude_fields?: list, - * team?: scalar|Param|null, - * notify?: scalar|Param|null, // Default: false - * nickname?: scalar|Param|null, // Default: "Monolog" * token?: scalar|Param|null, * region?: scalar|Param|null, * source?: scalar|Param|null, @@ -758,6 +764,7 @@ * title?: scalar|Param|null, // Default: null * host?: scalar|Param|null, // Default: null * port?: scalar|Param|null, // Default: 514 + * rfc?: scalar|Param|null, // Default: 1 * config?: list, * members?: list, * connection_string?: scalar|Param|null, @@ -767,13 +774,8 @@ * store?: scalar|Param|null, // Default: null * connection_timeout?: scalar|Param|null, * persistent?: bool|Param, - * dsn?: scalar|Param|null, - * hub_id?: scalar|Param|null, // Default: null - * client_id?: scalar|Param|null, // Default: null - * auto_log_stacks?: scalar|Param|null, // Default: false - * release?: scalar|Param|null, // Default: null - * environment?: scalar|Param|null, // Default: null * message_type?: scalar|Param|null, // Default: 0 + * expand_newlines?: bool|Param, // Default: false * parse_mode?: scalar|Param|null, // Default: null * disable_webpage_preview?: bool|Param|null, // Default: null * disable_notification?: bool|Param|null, // Default: null @@ -781,28 +783,18 @@ * delay_between_messages?: bool|Param, // Default: false * topic?: int|Param, // Default: null * factor?: int|Param, // Default: 1 - * tags?: list, - * console_formater_options?: mixed, // Deprecated: "monolog.handlers..console_formater_options.console_formater_options" is deprecated, use "monolog.handlers..console_formater_options.console_formatter_options" instead. + * tags?: Param|string|list, * console_formatter_options?: mixed, // Default: [] * formatter?: scalar|Param|null, * nested?: bool|Param, // Default: false - * publisher?: string|array{ + * publisher?: Param|string|array{ * id?: scalar|Param|null, * hostname?: scalar|Param|null, * port?: scalar|Param|null, // Default: 12201 * chunk_size?: scalar|Param|null, // Default: 1420 * encoder?: "json"|"compressed_json"|Param, * }, - * mongo?: string|array{ - * id?: scalar|Param|null, - * host?: scalar|Param|null, - * port?: scalar|Param|null, // Default: 27017 - * user?: scalar|Param|null, - * pass?: scalar|Param|null, - * database?: scalar|Param|null, // Default: "monolog" - * collection?: scalar|Param|null, // Default: "logs" - * }, - * mongodb?: string|array{ + * mongodb?: Param|string|array{ * id?: scalar|Param|null, // ID of a MongoDB\Client service * uri?: scalar|Param|null, * username?: scalar|Param|null, @@ -810,7 +802,7 @@ * database?: scalar|Param|null, // Default: "monolog" * collection?: scalar|Param|null, // Default: "logs" * }, - * elasticsearch?: string|array{ + * elasticsearch?: Param|string|array{ * id?: scalar|Param|null, * hosts?: list, * host?: scalar|Param|null, @@ -822,7 +814,7 @@ * index?: scalar|Param|null, // Default: "monolog" * document_type?: scalar|Param|null, // Default: "logs" * ignore_error?: scalar|Param|null, // Default: false - * redis?: string|array{ + * redis?: Param|string|array{ * id?: scalar|Param|null, * host?: scalar|Param|null, * password?: scalar|Param|null, // Default: null @@ -830,21 +822,21 @@ * database?: scalar|Param|null, // Default: 0 * key_name?: scalar|Param|null, // Default: "monolog_redis" * }, - * predis?: string|array{ + * predis?: Param|string|array{ // Deprecated: The "predis" option is deprecated and ignored, use the "redis" option to configure the Predis client. * id?: scalar|Param|null, * host?: scalar|Param|null, * }, * from_email?: scalar|Param|null, - * to_email?: list, + * to_email?: Param|string|list, * subject?: scalar|Param|null, * content_type?: scalar|Param|null, // Default: null * headers?: list, + * parameters?: list, * mailer?: scalar|Param|null, // Default: null - * email_prototype?: string|array{ + * email_prototype?: Param|string|array{ * id?: scalar|Param|null, * method?: scalar|Param|null, // Default: null * }, - * lazy?: bool|Param, // Default: true * verbosity_levels?: array{ * VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR" * VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING" @@ -852,9 +844,10 @@ * VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO" * VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG" * }, - * channels?: string|array{ + * channels?: Param|string|array{ * type?: scalar|Param|null, * elements?: list, + * ... * }, * }>, * } @@ -864,10 +857,10 @@ * id?: scalar|Param|null, * type?: scalar|Param|null, * value?: mixed, + * ... * }>, * autoescape_service?: scalar|Param|null, // Default: null * autoescape_service_method?: scalar|Param|null, // Default: null - * base_template_class?: scalar|Param|null, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated. * cache?: scalar|Param|null, // Default: true * charset?: scalar|Param|null, // Default: "%kernel.charset%" * debug?: bool|Param, // Default: "%kernel.debug%" @@ -875,7 +868,7 @@ * auto_reload?: scalar|Param|null, * optimizations?: int|Param, * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" - * file_name_pattern?: list, + * file_name_pattern?: Param|string|list, * paths?: array, * date?: array{ // The default format options used by the date filter. * format?: scalar|Param|null, // Default: "F j, Y H:i" @@ -936,7 +929,7 @@ * timeout?: int|Param, // Connection timeout in seconds // Default: 90 * ignore_passive_address?: scalar|Param|null, // Ignore passive address // Default: null * utf8?: bool|Param, // Enable UTF8 mode // Default: false - * transfer_mode?: scalar|Param|null, // Transfer mode (FTP_ASCII or FTP_BINARY constante on ftp extension) // Default: null + * transfer_mode?: scalar|Param|null, // Transfer mode (FTP_ASCII or FTP_BINARY constant on ftp extension) // Default: null * system_type?: null|"windows"|"unix"|Param, // FTP system type // Default: null * timestamps_on_unix_listings_enabled?: bool|Param, // Enable timestamps on Unix listings // Default: false * recurse_manually?: bool|Param, // Recurse directories manually // Default: true @@ -1031,8 +1024,8 @@ * disable_asserts?: bool|Param, // Deprecated: The "disable_asserts" option is deprecated and will be removed in 4.0. // Default: false * public_url?: list, * path_normalizer?: scalar|Param|null, // Path normalizer service name (should implement League\Flysystem\PathNormalizer) // Default: null - * public_url_generator?: scalar|Param|null, // For adapter that do not provide public URLs or override adapter capabilities and public_url option, a public URL generator service name can be configured in the main Filesystem configuration (should implement League\Flysystem\PublicUrlGenerator) // Default: null - * temporary_url_generator?: scalar|Param|null, // For adapter that do not provide public URLs or override adapter capabilities, a temporary URL generator service name can be configured in the main Filesystem configuration (should implement League\Flysystem\TemporaryUrlGenerator) // Default: null + * public_url_generator?: scalar|Param|null, // For adapter that do not provide public URLs or override adapter capabilities and public_url option, a public URL generator service name can be configured in the main Filesystem configuration (should implement League\Flysystem\UrlGeneration\PublicUrlGenerator) // Default: null + * temporary_url_generator?: scalar|Param|null, // For adapter that do not provide public URLs or override adapter capabilities, a temporary URL generator service name can be configured in the main Filesystem configuration (should implement League\Flysystem\UrlGeneration\TemporaryUrlGenerator) // Default: null * read_only?: bool|Param, // Converts a file system to read-only // Default: false * }>, * } @@ -1066,9 +1059,6 @@ * etagSeed?: scalar|Param|null, // Seed used to generate Etag values. Changing it invalidates all data cached by clients. // Default: "" * etagUpperLimitInCollectionEndpoints?: scalar|Param|null, // Maximum number of items in a collection for which to generate Etags. Bigger collection do not contain Etags due to performance problems. // Default: 100 * }, - * featureFlag?: array{ // Configures feature flags. - * 280_OldUniqueUserIdentifierDisabled?: bool|Param, // If true, the original behavior of some user endpoints in versions before 0.1.6 will no longer be supported. Old behavior will be dropped with version 0.2.0. See GitHub issue #280. // Default: false - * }, * file?: array{ // Configures behavior related to files. * maxFileSizeInBytes?: int|Param, // Maximum supported file size in bytes. Note: Upstream services like S3 have limits of their own (5TB). // Default: 10737418240 * uploadExpiresInSecondsAfterFirstRequest?: int|Param, // Number of seconds after which unfinished uploads are expired. // Default: 10800 diff --git a/config/routes/framework.yaml b/config/routes/framework.yaml index c0bfda32..aa5c3c43 100755 --- a/config/routes/framework.yaml +++ b/config/routes/framework.yaml @@ -1,4 +1,4 @@ when@dev: _errors: - resource: '@FrameworkBundle/Resources/config/routing/errors.xml' + resource: '@FrameworkBundle/Resources/config/routing/errors.php' prefix: /_error diff --git a/config/routes/web_profiler.yaml b/config/routes/web_profiler.yaml index 4f744d5f..3d84c092 100755 --- a/config/routes/web_profiler.yaml +++ b/config/routes/web_profiler.yaml @@ -1,8 +1,8 @@ when@dev: web_profiler_wdt: - resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' + resource: '@WebProfilerBundle/Resources/config/routing/wdt.php' prefix: /_wdt web_profiler_profiler: - resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' + resource: '@WebProfilerBundle/Resources/config/routing/profiler.php' prefix: /_profiler diff --git a/config/services.yaml b/config/services.yaml index d784d199..4a2b98e2 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -18,10 +18,7 @@ services: - '../src/DependencyInjection/' - '../src/Kernel.php' - # Only one storage backend/connection is supported today, so this is a plain alias to its one implementation. - # Once multiple connections with differing technical limits are supported, this alias is the extension point: - # replace it with a factory/registry service (still implementing S3TechnicalLimitsInterface) resolving the - # right implementation per connection, without any consumer of the interface needing to change. + # only one S3 connection is supported for now; replace with a per-connection factory once there are more App\Contract\S3\S3TechnicalLimitsInterface: '@App\Type\S3\S3TechnicalLimits' # library services diff --git a/docker/.env.docker b/docker/.env.docker index 22baf851..902f93be 100644 --- a/docker/.env.docker +++ b/docker/.env.docker @@ -19,4 +19,4 @@ APP_ENV=prod APP_SECRET=f762ce98c98b7579d2d6a6c0e5eb1d39 ###< symfony/framework-bundle ### -ANONYMOUS_USER_UUID=1f875943-5a12-42bb-b093-1826a8e1a34d \ No newline at end of file +ANONYMOUS_USER_UUID=2d376349-c5e2-42c8-8ce0-d6f525256cf7 \ No newline at end of file diff --git a/docker/Caddyfile b/docker/Caddyfile index 8bde6e5a..85250c9b 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -3,16 +3,44 @@ admin off servers { protocols h1 + timeouts { + # default for all endpoints, upload endpoints are extended below + read_body {$API_REQUEST_BODY_TIMEOUT:30s} + } } log { output stdout format json } + frankenphp { + # upload requests hold a PHP thread while the client sends the request body, so more threads than the default + # (2x cores) may be started under load; both values are calculated by the entrypoint, the fallbacks are only used + # if it did not run. `num_threads` has to be set explicitly, as it drops to 1 as soon as `max_threads` is set. + num_threads {$FRANKENPHP_NUM_THREADS:16} + max_threads {$FRANKENPHP_MAX_THREADS:auto} + # do not queue requests forever if all threads are busy + max_wait_time {$FRANKENPHP_MAX_WAIT_TIME:30s} + } supervisor { supercronic -json /etc/crontabs/supercronic } } +# headers of problem json responses which are created by the server instead of the application, they have to be +# identical to the headers of the application's error responses, see public/index.php and ProblemJsonResponse; +# errors of the web server have no route, therefore no `Allow` header +(problem_json_headers) { + header { + Access-Control-Allow-Headers "*" + Access-Control-Allow-Methods "GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK" + Access-Control-Allow-Origin "*" + Access-Control-Expose-Headers "ETag, Location" + Cache-Control "no-cache, private" + Content-Type "application/problem+json; charset=utf-8" + X-Powered-By "Ember Nexus API" + } +} + :80 { root * /var/www/html/public encode zstd br gzip @@ -20,9 +48,56 @@ -Server -Set-Cookie } + # identifies the request in the access log below and, via the request header, in the logs of the application; it is + # not part of the response headers, but of the `instance` of every problem json response (`urn:uuid:`); always + # set by the server, so that clients can not spoof it in the logs + request_header X-Request-Id "{http.request.uuid}" + log_append request_id {http.request.uuid} request_body { max_size 101MiB } + # only endpoints which receive upload data from the client may take up to 15 minutes to receive it: single request + # uploads (POST/PUT //file) and resumable upload chunks (PATCH /upload/); slow clients have to send + # smaller chunks + @uploadFile { + method POST PUT + path_regexp ^/[0-9a-fA-F-]{36}/file$ + } + @uploadChunk { + method PATCH + path_regexp ^/upload/[0-9a-fA-F-]{36}$ + } + request_body @uploadFile { + read_timeout {$API_UPLOAD_REQUEST_BODY_TIMEOUT:15m} + } + request_body @uploadChunk { + read_timeout {$API_UPLOAD_REQUEST_BODY_TIMEOUT:15m} + } + # server errors which do not come from the application as problem json still get a problem json body, in the same + # format as the application's errors (compact json, member order type, title, status, instance, detail, `type` links to the error page, but as relative reference + # without scheme and host, which clients resolve against the request URI, see RFC 9457): + # - PHP failures before the application handles them (e.g. PHP can not start the application at all) answer with + # an empty body and the default html content type -> 500 + # - FrankenPHP answers by itself, without content type, if it did not get a free PHP thread in time + # (`max_wait_time`) -> 503; this is the only such case, other server errors are not rewritten + intercept { + @phpFailure { + status 5xx + header Content-Type text/html* + } + handle_response @phpFailure { + import problem_json_headers + respond `{"type":"/error/500/internal-server-error","title":"Internal server error","status":500,"instance":"urn:uuid:{http.request.uuid}","detail":"Internal server error, see log."}` 500 + } + @noFreePhpThread { + status 503 + header !Content-Type + } + handle_response @noFreePhpThread { + import problem_json_headers + respond `{"type":"/error/503/service-unavailable","title":"Service unavailable","status":503,"instance":"urn:uuid:{http.request.uuid}","detail":"The service itself or an internal component is currently unavailable."}` 503 + } + } log { no_hostname output stdout diff --git a/docker/Caddyfile.dev b/docker/Caddyfile.dev new file mode 100644 index 00000000..8253db14 --- /dev/null +++ b/docker/Caddyfile.dev @@ -0,0 +1,107 @@ +{ + auto_https off + admin off + servers { + protocols h1 + timeouts { + # default for all endpoints, upload endpoints are extended below + read_body {$API_REQUEST_BODY_TIMEOUT:30s} + } + } + log { + output stdout + format json + } + frankenphp { + # upload requests hold a PHP thread while the client sends the request body, so more threads than the default + # (2x cores) may be started under load; both values are calculated by the entrypoint, the fallbacks are only used + # if it did not run. `num_threads` has to be set explicitly, as it drops to 1 as soon as `max_threads` is set. + num_threads {$FRANKENPHP_NUM_THREADS:16} + max_threads {$FRANKENPHP_MAX_THREADS:auto} + # do not queue requests forever if all threads are busy + max_wait_time {$FRANKENPHP_MAX_WAIT_TIME:30s} + } + # no `supervisor` block here, unlike the production Caddyfile: the dev image has no vendor/ installed by default + # (it's provided by the mounted source instead), so supercronic's scheduled `php bin/console cron` runs would fail + # with a missing symfony/runtime error until `composer install` is run manually +} + +# headers of problem json responses which are created by the server instead of the application, they have to be +# identical to the headers of the application's error responses, see public/index.php and ProblemJsonResponse; +# errors of the web server have no route, therefore no `Allow` header +(problem_json_headers) { + header { + Access-Control-Allow-Headers "*" + Access-Control-Allow-Methods "GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK" + Access-Control-Allow-Origin "*" + Access-Control-Expose-Headers "ETag, Location" + Cache-Control "no-cache, private" + Content-Type "application/problem+json; charset=utf-8" + X-Powered-By "Ember Nexus API" + } +} + +:80 { + root * /var/www/html/public + encode zstd br gzip + header { + -Server + -Set-Cookie + } + # identifies the request in the access log below and, via the request header, in the logs of the application; it is + # not part of the response headers, but of the `instance` of every problem json response (`urn:uuid:`); always + # set by the server, so that clients can not spoof it in the logs + request_header X-Request-Id "{http.request.uuid}" + log_append request_id {http.request.uuid} + request_body { + max_size 101MiB + } + # only endpoints which receive upload data from the client may take up to 15 minutes to receive it: single request + # uploads (POST/PUT //file) and resumable upload chunks (PATCH /upload/); slow clients have to send + # smaller chunks + @uploadFile { + method POST PUT + path_regexp ^/[0-9a-fA-F-]{36}/file$ + } + @uploadChunk { + method PATCH + path_regexp ^/upload/[0-9a-fA-F-]{36}$ + } + request_body @uploadFile { + read_timeout {$API_UPLOAD_REQUEST_BODY_TIMEOUT:15m} + } + request_body @uploadChunk { + read_timeout {$API_UPLOAD_REQUEST_BODY_TIMEOUT:15m} + } + # server errors which do not come from the application as problem json still get a problem json body, in the same + # format as the application's errors (compact json, member order type, title, status, instance, detail, `type` links to the error page, but as relative reference + # without scheme and host, which clients resolve against the request URI, see RFC 9457): + # - PHP failures before the application handles them (e.g. PHP can not start the application at all) answer with + # an empty body and the default html content type -> 500 + # - FrankenPHP answers by itself, without content type, if it did not get a free PHP thread in time + # (`max_wait_time`) -> 503; this is the only such case, other server errors are not rewritten + intercept { + @phpFailure { + status 5xx + header Content-Type text/html* + } + handle_response @phpFailure { + import problem_json_headers + respond `{"type":"/error/500/internal-server-error","title":"Internal server error","status":500,"instance":"urn:uuid:{http.request.uuid}","detail":"Internal server error, see log."}` 500 + } + @noFreePhpThread { + status 503 + header !Content-Type + } + handle_response @noFreePhpThread { + import problem_json_headers + respond `{"type":"/error/503/service-unavailable","title":"Service unavailable","status":503,"instance":"urn:uuid:{http.request.uuid}","detail":"The service itself or an internal component is currently unavailable."}` 503 + } + } + log { + no_hostname + output stdout + format json + } + php_server +} diff --git a/docker/Dockerfile b/docker/Dockerfile index d4820337..cd9cb35d 100755 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -101,10 +101,13 @@ COPY public /var/www/html/public COPY src /var/www/html/src COPY lib/EmberNexusBundle /var/www/html/lib/EmberNexusBundle COPY docker/.env.docker /var/www/html/.env +COPY ./docker/php-embed/php.prod.ini "$PHP_INI_DIR/php.ini" COPY composer.json /var/www/html/composer.json COPY composer.lock /var/www/html/composer.lock COPY LICENSE /var/www/html/LICENSE RUN APP_ENV=prod COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader --no-cache \ + && rm -rf /var/www/html/var/cache \ + && mkdir -p /var/www/html/var/cache \ && rm -rf /usr/local/include/php \ && mv /usr/local/lib/php/extensions /usr/local/lib/php_extensions \ && rm -rf /usr/local/lib/php \ @@ -151,6 +154,11 @@ USER worker FROM php AS development +# the dev image has no vendor/ installed by default (it's provided by the mounted source instead), so supercronic's +# scheduled `php bin/console cron` runs would fail until `composer install` is run manually; the dev Caddyfile drops +# the `supervisor` block so supercronic never starts +COPY ./docker/Caddyfile.dev /etc/frankenphp/Caddyfile + COPY ./docker/php-embed/php.dev.ini "$PHP_INI_DIR/php.ini" RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS judy-dev bsd-compat-headers linux-headers \ @@ -174,5 +182,4 @@ USER worker ENV CLASSPATH=".:/usr/local/lib/antlr-4.13.2-complete.jar:/usr/local/lib/rrd-antlr4-0.1.2.jar:$CLASSPATH" -HEALTHCHECK --interval=30s --timeout=3s \ - CMD php bin/console healthcheck +HEALTHCHECK NONE diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 465fafef..18c5f716 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -9,6 +9,12 @@ set -e # exit 1 #fi +# upload requests hold a PHP thread while the client is sending data, so start 2 (FrankenPHP's default) and allow up to +# 8 threads per CPU core, see Caddyfile +CPU_COUNT="$(nproc)" +export FRANKENPHP_NUM_THREADS="${FRANKENPHP_NUM_THREADS:-$((CPU_COUNT * 2))}" +export FRANKENPHP_MAX_THREADS="${FRANKENPHP_MAX_THREADS:-$((CPU_COUNT * 8))}" + if [ -z "$@" ]; then frankenphp run --config /etc/frankenphp/Caddyfile else diff --git a/docker/php-embed/php.dev.ini b/docker/php-embed/php.dev.ini index 0a52b1d4..710a7bca 100644 --- a/docker/php-embed/php.dev.ini +++ b/docker/php-embed/php.dev.ini @@ -2,11 +2,15 @@ expose_php = Off memory_limit = -1 error_log = /dev/stderr -# upload limited to 101 MiB +; upload limited to 101 MiB upload_max_filesize = 105906176 post_max_size = 105906176 max_file_uploads = 1 +; a single request may take at most 15 minutes, see also `read_body` in the Caddyfile; on Linux this only counts time +; in which PHP itself is working, not time spent waiting for the client or S3 +max_execution_time = 900 + xdebug.mode=profile xdebug.start_with_request=trigger xdebug.output_dir="/var/www/html/xdebug-trace" diff --git a/docker/php-embed/php.prod.ini b/docker/php-embed/php.prod.ini index 6b74b6b4..2116f931 100644 --- a/docker/php-embed/php.prod.ini +++ b/docker/php-embed/php.prod.ini @@ -1,8 +1,30 @@ expose_php = Off -memory_limit = -1 +memory_limit = 256M error_log = /dev/stderr +display_errors = Off +; failures which happen before the application handles them (e.g. exhausted memory while booting) are only visible here +log_errors = On +html_errors = Off +zend.assertions = -1 -# upload limited to 101 MiB +; upload limited to 101 MiB upload_max_filesize = 105906176 post_max_size = 105906176 max_file_uploads = 1 + +; a single request may take at most 15 minutes, see also `read_body` in the Caddyfile; on Linux this only counts time +; in which PHP itself is working, not time spent waiting for the client or S3 +max_execution_time = 900 + +; production: the image is immutable, so files are never checked for changes; the compiled container/preload file is +; not shipped in the image and is instead generated fresh on first request from whatever config is mounted at +; container start; config/preload.php itself checks whether that generated file exists before requiring it +opcache.enable = 1 +opcache.validate_timestamps = 0 +opcache.memory_consumption = 256 +opcache.interned_strings_buffer = 16 +opcache.max_accelerated_files = 20000 +opcache.preload = /var/www/html/config/preload.php +opcache.preload_user = worker +realpath_cache_size = 4096K +realpath_cache_ttl = 600 diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 99df8241..8b04796e 100755 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -55,9 +55,11 @@ - [GET` /error/401/unauthorized -` Get Details for Error 401 Unauthorized Endpoint](/api-endpoints/error/get-401-unauthorized) - [GET` /error/403/forbidden -` Get Details for Error 403 Forbidden Endpoint](/api-endpoints/error/get-403-forbidden) - [GET` /error/404/not-found -` Get Details for Error 404 Not Found Endpoint](/api-endpoints/error/get-404-not-found) + - [GET` /error/408/request-timeout -` Get Details for Error 408 Request Timeout Endpoint](/api-endpoints/error/get-408-request-timeout) - [GET` /error/412/precondition-failed -` Get Details for Error 412 Precondition Failed Endpoint](/api-endpoints/error/get-412-precondition-failed) - [GET` /error/500/internal-server-error -` Get Details for Error 500 Internal Server Error Endpoint](/api-endpoints/error/get-500-internal-server-error) - [GET` /error/501/not-implemented -` Get Details for Error 501 Not Implemented Endpoint](/api-endpoints/error/get-501-not-implemented) + - [GET` /error/503/service-unavailable -` Get Details for Error 503 Service Unavailable Endpoint](/api-endpoints/error/get-503-service-unavailable) - Commands - **System Commands** diff --git a/docs/api-endpoints/element/delete-element/204-response-header.txt b/docs/api-endpoints/element/delete-element/204-response-header.txt index ece7090c..3bf4180b 100644 --- a/docs/api-endpoints/element/delete-element/204-response-header.txt +++ b/docs/api-endpoints/element/delete-element/204-response-header.txt @@ -2,7 +2,7 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private -Date: Mon, 11 Dec 2023 12:11:20 GMT +Date: Sat, 26 Sep 2026 21:53:22 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/delete-element/401-response-body.json b/docs/api-endpoints/element/delete-element/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/delete-element/401-response-body.json +++ b/docs/api-endpoints/element/delete-element/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/delete-element/401-response-header.txt b/docs/api-endpoints/element/delete-element/401-response-header.txt index 7c496222..7f926484 100644 --- a/docs/api-endpoints/element/delete-element/401-response-header.txt +++ b/docs/api-endpoints/element/delete-element/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 11 Dec 2023 12:11:20 GMT +Date: Sat, 26 Sep 2026 21:53:22 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/delete-element/404-response-body.json b/docs/api-endpoints/element/delete-element/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/element/delete-element/404-response-body.json +++ b/docs/api-endpoints/element/delete-element/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/element/delete-element/404-response-header.txt b/docs/api-endpoints/element/delete-element/404-response-header.txt index f4bfe07d..f8e78b2a 100644 --- a/docs/api-endpoints/element/delete-element/404-response-header.txt +++ b/docs/api-endpoints/element/delete-element/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 11 Dec 2023 12:12:52 GMT +Date: Sat, 26 Sep 2026 21:53:23 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/delete-element/412-response-body.json b/docs/api-endpoints/element/delete-element/412-response-body.json index 4c3d4ecf..216a1e4c 100644 --- a/docs/api-endpoints/element/delete-element/412-response-body.json +++ b/docs/api-endpoints/element/delete-element/412-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/412/precondition-failed", "title": "Precondition Failed", "status": 412, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Precondition does not match." } \ No newline at end of file diff --git a/docs/api-endpoints/element/delete-element/412-response-header.txt b/docs/api-endpoints/element/delete-element/412-response-header.txt index f08fbe4c..f1fa7e48 100644 --- a/docs/api-endpoints/element/delete-element/412-response-header.txt +++ b/docs/api-endpoints/element/delete-element/412-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 08 Mar 2024 21:34:53 GMT +Date: Sat, 26 Sep 2026 21:53:22 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/delete-element/429-response-body.json b/docs/api-endpoints/element/delete-element/429-response-body.json index 793a1e82..70ca31f8 100644 --- a/docs/api-endpoints/element/delete-element/429-response-body.json +++ b/docs/api-endpoints/element/delete-element/429-response-body.json @@ -2,5 +2,6 @@ "type": "429-too-many-requests", "title": "Too Many Requests", "status": "429", + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "The client sent too many requests in a given timeframe; rate limiting is active." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-children/200-response-header.txt b/docs/api-endpoints/element/get-children/200-response-header.txt index 1c1642ca..ceece017 100644 --- a/docs/api-endpoints/element/get-children/200-response-header.txt +++ b/docs/api-endpoints/element/get-children/200-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:48:30 GMT -Etag: "OniT9I2cuUI" +Date: Sat, 26 Sep 2026 21:53:23 GMT +Etag: "12KhufbfDKl" Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-children/304-response-header.txt b/docs/api-endpoints/element/get-children/304-response-header.txt index 4abaf276..a8ae013b 100644 --- a/docs/api-endpoints/element/get-children/304-response-header.txt +++ b/docs/api-endpoints/element/get-children/304-response-header.txt @@ -2,8 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private -Date: Sun, 10 Mar 2024 13:46:31 GMT -Etag: "TMfc6KdsY3a" +Date: Sat, 26 Sep 2026 21:53:23 GMT +Etag: "12KhufbfDKl" X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-children/401-response-body.json b/docs/api-endpoints/element/get-children/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/get-children/401-response-body.json +++ b/docs/api-endpoints/element/get-children/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-children/401-response-header.txt b/docs/api-endpoints/element/get-children/401-response-header.txt index 7f751692..2db6fda5 100644 --- a/docs/api-endpoints/element/get-children/401-response-header.txt +++ b/docs/api-endpoints/element/get-children/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:47:05 GMT +Date: Sat, 26 Sep 2026 21:53:23 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-children/404-response-body.json b/docs/api-endpoints/element/get-children/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/element/get-children/404-response-body.json +++ b/docs/api-endpoints/element/get-children/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-children/404-response-header.txt b/docs/api-endpoints/element/get-children/404-response-header.txt index 7f751692..291100d8 100644 --- a/docs/api-endpoints/element/get-children/404-response-header.txt +++ b/docs/api-endpoints/element/get-children/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:47:05 GMT +Date: Sat, 26 Sep 2026 21:53:23 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-children/412-response-body.json b/docs/api-endpoints/element/get-children/412-response-body.json index 4c3d4ecf..216a1e4c 100644 --- a/docs/api-endpoints/element/get-children/412-response-body.json +++ b/docs/api-endpoints/element/get-children/412-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/412/precondition-failed", "title": "Precondition Failed", "status": 412, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Precondition does not match." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-children/412-response-header.txt b/docs/api-endpoints/element/get-children/412-response-header.txt index f08fbe4c..2ba5ffad 100644 --- a/docs/api-endpoints/element/get-children/412-response-header.txt +++ b/docs/api-endpoints/element/get-children/412-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 08 Mar 2024 21:34:53 GMT +Date: Sat, 26 Sep 2026 21:53:24 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-children/429-response-body.json b/docs/api-endpoints/element/get-children/429-response-body.json index 793a1e82..70ca31f8 100644 --- a/docs/api-endpoints/element/get-children/429-response-body.json +++ b/docs/api-endpoints/element/get-children/429-response-body.json @@ -2,5 +2,6 @@ "type": "429-too-many-requests", "title": "Too Many Requests", "status": "429", + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "The client sent too many requests in a given timeframe; rate limiting is active." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/200-node-response-header.txt b/docs/api-endpoints/element/get-element/200-node-response-header.txt index 2dde0d23..44594781 100644 --- a/docs/api-endpoints/element/get-element/200-node-response-header.txt +++ b/docs/api-endpoints/element/get-element/200-node-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:24:10 GMT -Etag: "YgbFC4u5EgC" +Date: Sat, 26 Sep 2026 21:53:24 GMT +Etag: "1j9qWbHFjNH" Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/200-relation-response-header.txt b/docs/api-endpoints/element/get-element/200-relation-response-header.txt index c61e8be7..6f82308b 100644 --- a/docs/api-endpoints/element/get-element/200-relation-response-header.txt +++ b/docs/api-endpoints/element/get-element/200-relation-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:24:11 GMT -Etag: "9FX6hrAKraU" +Date: Sat, 26 Sep 2026 21:53:24 GMT +Etag: "L56AMWMRMl1" Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/304-response-header.txt b/docs/api-endpoints/element/get-element/304-response-header.txt index 4abaf276..4dae5845 100644 --- a/docs/api-endpoints/element/get-element/304-response-header.txt +++ b/docs/api-endpoints/element/get-element/304-response-header.txt @@ -2,8 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private -Date: Sun, 10 Mar 2024 13:46:31 GMT -Etag: "TMfc6KdsY3a" +Date: Sat, 26 Sep 2026 21:53:24 GMT +Etag: "1j9qWbHFjNH" X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/401-response-body.json b/docs/api-endpoints/element/get-element/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/get-element/401-response-body.json +++ b/docs/api-endpoints/element/get-element/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/401-response-header.txt b/docs/api-endpoints/element/get-element/401-response-header.txt index baead971..7d16f8a0 100644 --- a/docs/api-endpoints/element/get-element/401-response-header.txt +++ b/docs/api-endpoints/element/get-element/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:22:32 GMT +Date: Sat, 26 Sep 2026 21:53:24 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/404-response-body.json b/docs/api-endpoints/element/get-element/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/element/get-element/404-response-body.json +++ b/docs/api-endpoints/element/get-element/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/404-response-header.txt b/docs/api-endpoints/element/get-element/404-response-header.txt index e324243a..c22a2ede 100644 --- a/docs/api-endpoints/element/get-element/404-response-header.txt +++ b/docs/api-endpoints/element/get-element/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:32:44 GMT +Date: Sat, 26 Sep 2026 21:53:24 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/412-response-body.json b/docs/api-endpoints/element/get-element/412-response-body.json index 4c3d4ecf..216a1e4c 100644 --- a/docs/api-endpoints/element/get-element/412-response-body.json +++ b/docs/api-endpoints/element/get-element/412-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/412/precondition-failed", "title": "Precondition Failed", "status": 412, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Precondition does not match." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/412-response-header.txt b/docs/api-endpoints/element/get-element/412-response-header.txt index f08fbe4c..483d8cb7 100644 --- a/docs/api-endpoints/element/get-element/412-response-header.txt +++ b/docs/api-endpoints/element/get-element/412-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 08 Mar 2024 21:34:53 GMT +Date: Sat, 26 Sep 2026 21:53:25 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-element/429-response-body.json b/docs/api-endpoints/element/get-element/429-response-body.json index 793a1e82..70ca31f8 100644 --- a/docs/api-endpoints/element/get-element/429-response-body.json +++ b/docs/api-endpoints/element/get-element/429-response-body.json @@ -2,5 +2,6 @@ "type": "429-too-many-requests", "title": "Too Many Requests", "status": "429", + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "The client sent too many requests in a given timeframe; rate limiting is active." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-index/200-response-header.txt b/docs/api-endpoints/element/get-index/200-response-header.txt index 8e6d8a2a..f8a994f5 100644 --- a/docs/api-endpoints/element/get-index/200-response-header.txt +++ b/docs/api-endpoints/element/get-index/200-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:02:22 GMT -Etag: "Fj0kUmVpX2T" +Date: Sat, 26 Sep 2026 21:53:25 GMT +Etag: "ZMJmRHQD9pB" Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-index/304-response-header.txt b/docs/api-endpoints/element/get-index/304-response-header.txt index 4abaf276..a9594e1a 100644 --- a/docs/api-endpoints/element/get-index/304-response-header.txt +++ b/docs/api-endpoints/element/get-index/304-response-header.txt @@ -2,8 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private -Date: Sun, 10 Mar 2024 13:46:31 GMT -Etag: "TMfc6KdsY3a" +Date: Sat, 26 Sep 2026 21:53:25 GMT +Etag: "ZMJmRHQD9pB" X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-index/401-response-body.json b/docs/api-endpoints/element/get-index/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/get-index/401-response-body.json +++ b/docs/api-endpoints/element/get-index/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-index/401-response-header.txt b/docs/api-endpoints/element/get-index/401-response-header.txt index 99cc7fba..64693faa 100644 --- a/docs/api-endpoints/element/get-index/401-response-header.txt +++ b/docs/api-endpoints/element/get-index/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:15:40 GMT +Date: Sat, 26 Sep 2026 21:53:25 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-index/412-response-body.json b/docs/api-endpoints/element/get-index/412-response-body.json index 4c3d4ecf..216a1e4c 100644 --- a/docs/api-endpoints/element/get-index/412-response-body.json +++ b/docs/api-endpoints/element/get-index/412-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/412/precondition-failed", "title": "Precondition Failed", "status": 412, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Precondition does not match." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-index/412-response-header.txt b/docs/api-endpoints/element/get-index/412-response-header.txt index f08fbe4c..499872e1 100644 --- a/docs/api-endpoints/element/get-index/412-response-header.txt +++ b/docs/api-endpoints/element/get-index/412-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 08 Mar 2024 21:34:53 GMT +Date: Sat, 26 Sep 2026 21:53:25 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-index/429-response-body.json b/docs/api-endpoints/element/get-index/429-response-body.json index 793a1e82..70ca31f8 100644 --- a/docs/api-endpoints/element/get-index/429-response-body.json +++ b/docs/api-endpoints/element/get-index/429-response-body.json @@ -2,5 +2,6 @@ "type": "429-too-many-requests", "title": "Too Many Requests", "status": "429", + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "The client sent too many requests in a given timeframe; rate limiting is active." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-parents/200-response-header.txt b/docs/api-endpoints/element/get-parents/200-response-header.txt index d60cef26..158add43 100644 --- a/docs/api-endpoints/element/get-parents/200-response-header.txt +++ b/docs/api-endpoints/element/get-parents/200-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:39:19 GMT -Etag: "Fj0kUmVpX2T" +Date: Sat, 26 Sep 2026 21:53:25 GMT +Etag: "OWLkEWfemhQ" Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-parents/304-response-header.txt b/docs/api-endpoints/element/get-parents/304-response-header.txt index 4abaf276..412a7717 100644 --- a/docs/api-endpoints/element/get-parents/304-response-header.txt +++ b/docs/api-endpoints/element/get-parents/304-response-header.txt @@ -2,8 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private -Date: Sun, 10 Mar 2024 13:46:31 GMT -Etag: "TMfc6KdsY3a" +Date: Sat, 26 Sep 2026 21:53:25 GMT +Etag: "OWLkEWfemhQ" X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-parents/401-response-body.json b/docs/api-endpoints/element/get-parents/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/get-parents/401-response-body.json +++ b/docs/api-endpoints/element/get-parents/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-parents/401-response-header.txt b/docs/api-endpoints/element/get-parents/401-response-header.txt index 18fe0a15..5ea71c85 100644 --- a/docs/api-endpoints/element/get-parents/401-response-header.txt +++ b/docs/api-endpoints/element/get-parents/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:39:20 GMT +Date: Sat, 26 Sep 2026 21:53:26 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-parents/404-response-body.json b/docs/api-endpoints/element/get-parents/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/element/get-parents/404-response-body.json +++ b/docs/api-endpoints/element/get-parents/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-parents/404-response-header.txt b/docs/api-endpoints/element/get-parents/404-response-header.txt index 18fe0a15..882c6dc0 100644 --- a/docs/api-endpoints/element/get-parents/404-response-header.txt +++ b/docs/api-endpoints/element/get-parents/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:39:20 GMT +Date: Sat, 26 Sep 2026 21:53:26 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-parents/412-response-body.json b/docs/api-endpoints/element/get-parents/412-response-body.json index 4c3d4ecf..216a1e4c 100644 --- a/docs/api-endpoints/element/get-parents/412-response-body.json +++ b/docs/api-endpoints/element/get-parents/412-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/412/precondition-failed", "title": "Precondition Failed", "status": 412, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Precondition does not match." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-parents/412-response-header.txt b/docs/api-endpoints/element/get-parents/412-response-header.txt index f08fbe4c..882c6dc0 100644 --- a/docs/api-endpoints/element/get-parents/412-response-header.txt +++ b/docs/api-endpoints/element/get-parents/412-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 08 Mar 2024 21:34:53 GMT +Date: Sat, 26 Sep 2026 21:53:26 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-parents/429-response-body.json b/docs/api-endpoints/element/get-parents/429-response-body.json index 793a1e82..70ca31f8 100644 --- a/docs/api-endpoints/element/get-parents/429-response-body.json +++ b/docs/api-endpoints/element/get-parents/429-response-body.json @@ -2,5 +2,6 @@ "type": "429-too-many-requests", "title": "Too Many Requests", "status": "429", + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "The client sent too many requests in a given timeframe; rate limiting is active." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-related/200-response-header.txt b/docs/api-endpoints/element/get-related/200-response-header.txt index 311fc923..a17f816b 100644 --- a/docs/api-endpoints/element/get-related/200-response-header.txt +++ b/docs/api-endpoints/element/get-related/200-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 20 Dec 2025 12:54:52 GMT -Etag: "WGivC9HMX9o" +Date: Sat, 26 Sep 2026 21:53:26 GMT +Etag: "GY3sOdKPRSo" Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-related/304-response-header.txt b/docs/api-endpoints/element/get-related/304-response-header.txt index 67680814..9bdfadc0 100644 --- a/docs/api-endpoints/element/get-related/304-response-header.txt +++ b/docs/api-endpoints/element/get-related/304-response-header.txt @@ -2,8 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private -Date: Sat, 20 Dec 2025 13:05:16 GMT -Etag: "LZWAF9Oo38W" +Date: Sat, 26 Sep 2026 21:53:26 GMT +Etag: "GY3sOdKPRSo" X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-related/401-response-body.json b/docs/api-endpoints/element/get-related/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/get-related/401-response-body.json +++ b/docs/api-endpoints/element/get-related/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-related/401-response-header.txt b/docs/api-endpoints/element/get-related/401-response-header.txt index 837bc6a8..e24e846d 100644 --- a/docs/api-endpoints/element/get-related/401-response-header.txt +++ b/docs/api-endpoints/element/get-related/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:54:33 GMT +Date: Sat, 26 Sep 2026 21:53:27 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-related/404-response-body.json b/docs/api-endpoints/element/get-related/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/element/get-related/404-response-body.json +++ b/docs/api-endpoints/element/get-related/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-related/404-response-header.txt b/docs/api-endpoints/element/get-related/404-response-header.txt index 837bc6a8..e3ec629e 100644 --- a/docs/api-endpoints/element/get-related/404-response-header.txt +++ b/docs/api-endpoints/element/get-related/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 20:54:33 GMT +Date: Sat, 26 Sep 2026 21:53:27 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-related/412-response-body.json b/docs/api-endpoints/element/get-related/412-response-body.json index 4c3d4ecf..216a1e4c 100644 --- a/docs/api-endpoints/element/get-related/412-response-body.json +++ b/docs/api-endpoints/element/get-related/412-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/412/precondition-failed", "title": "Precondition Failed", "status": 412, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Precondition does not match." } \ No newline at end of file diff --git a/docs/api-endpoints/element/get-related/412-response-header.txt b/docs/api-endpoints/element/get-related/412-response-header.txt index f08fbe4c..e3ec629e 100644 --- a/docs/api-endpoints/element/get-related/412-response-header.txt +++ b/docs/api-endpoints/element/get-related/412-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 08 Mar 2024 21:34:53 GMT +Date: Sat, 26 Sep 2026 21:53:27 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/get-related/429-response-body.json b/docs/api-endpoints/element/get-related/429-response-body.json index 793a1e82..70ca31f8 100644 --- a/docs/api-endpoints/element/get-related/429-response-body.json +++ b/docs/api-endpoints/element/get-related/429-response-body.json @@ -2,5 +2,6 @@ "type": "429-too-many-requests", "title": "Too Many Requests", "status": "429", + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "The client sent too many requests in a given timeframe; rate limiting is active." } \ No newline at end of file diff --git a/docs/api-endpoints/element/patch-element/204-response-header.txt b/docs/api-endpoints/element/patch-element/204-response-header.txt index 114d5c86..56b34c30 100644 --- a/docs/api-endpoints/element/patch-element/204-response-header.txt +++ b/docs/api-endpoints/element/patch-element/204-response-header.txt @@ -2,7 +2,7 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private -Date: Sun, 10 Mar 2024 15:09:01 GMT +Date: Sat, 26 Sep 2026 21:53:27 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/patch-element/401-response-body.json b/docs/api-endpoints/element/patch-element/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/patch-element/401-response-body.json +++ b/docs/api-endpoints/element/patch-element/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/patch-element/401-response-header.txt b/docs/api-endpoints/element/patch-element/401-response-header.txt index fe972e89..e24e846d 100644 --- a/docs/api-endpoints/element/patch-element/401-response-header.txt +++ b/docs/api-endpoints/element/patch-element/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 09 Oct 2023 21:22:18 GMT +Date: Sat, 26 Sep 2026 21:53:27 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/patch-element/404-response-body.json b/docs/api-endpoints/element/patch-element/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/element/patch-element/404-response-body.json +++ b/docs/api-endpoints/element/patch-element/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/element/patch-element/404-response-header.txt b/docs/api-endpoints/element/patch-element/404-response-header.txt index fe972e89..814b0b48 100644 --- a/docs/api-endpoints/element/patch-element/404-response-header.txt +++ b/docs/api-endpoints/element/patch-element/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 09 Oct 2023 21:22:18 GMT +Date: Sat, 26 Sep 2026 21:53:27 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/patch-element/412-response-body.json b/docs/api-endpoints/element/patch-element/412-response-body.json index 4c3d4ecf..216a1e4c 100644 --- a/docs/api-endpoints/element/patch-element/412-response-body.json +++ b/docs/api-endpoints/element/patch-element/412-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/412/precondition-failed", "title": "Precondition Failed", "status": 412, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Precondition does not match." } \ No newline at end of file diff --git a/docs/api-endpoints/element/patch-element/412-response-header.txt b/docs/api-endpoints/element/patch-element/412-response-header.txt index f08fbe4c..563c5777 100644 --- a/docs/api-endpoints/element/patch-element/412-response-header.txt +++ b/docs/api-endpoints/element/patch-element/412-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 08 Mar 2024 21:34:53 GMT +Date: Sat, 26 Sep 2026 21:53:28 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/patch-element/429-response-body.json b/docs/api-endpoints/element/patch-element/429-response-body.json index 4555b0be..d1552ef4 100644 --- a/docs/api-endpoints/element/patch-element/429-response-body.json +++ b/docs/api-endpoints/element/patch-element/429-response-body.json @@ -2,5 +2,6 @@ "type": "401-unauthorized", "title": "Request does not contain valid token, or anonymous user is disabled.", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "" } \ No newline at end of file diff --git a/docs/api-endpoints/element/post-element/204-response-header.txt b/docs/api-endpoints/element/post-element/204-response-header.txt index ef8201f0..fee81459 100644 --- a/docs/api-endpoints/element/post-element/204-response-header.txt +++ b/docs/api-endpoints/element/post-element/204-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: text/html; charset=UTF-8 -Date: Mon, 09 Oct 2023 21:22:17 GMT -Location: /67d1a1d5-243a-468f-b993-2bbe2e756194 +Date: Sat, 26 Sep 2026 21:53:28 GMT +Location: /2834532e-38b3-445c-8a13-bc2fe3af51d0 Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/post-element/400-response-body.json b/docs/api-endpoints/element/post-element/400-response-body.json index e55923cb..e606f2ec 100644 --- a/docs/api-endpoints/element/post-element/400-response-body.json +++ b/docs/api-endpoints/element/post-element/400-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/400/missing-property", "title": "Missing property", "status": 400, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Endpoint requires that the request contains property 'type' to be set to a valid type." } \ No newline at end of file diff --git a/docs/api-endpoints/element/post-element/400-response-header.txt b/docs/api-endpoints/element/post-element/400-response-header.txt index 8cbb1246..563c5777 100644 --- a/docs/api-endpoints/element/post-element/400-response-header.txt +++ b/docs/api-endpoints/element/post-element/400-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 09 Oct 2023 21:22:17 GMT +Date: Sat, 26 Sep 2026 21:53:28 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/post-element/401-response-body.json b/docs/api-endpoints/element/post-element/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/post-element/401-response-body.json +++ b/docs/api-endpoints/element/post-element/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/post-element/401-response-header.txt b/docs/api-endpoints/element/post-element/401-response-header.txt index fe972e89..20b628c7 100644 --- a/docs/api-endpoints/element/post-element/401-response-header.txt +++ b/docs/api-endpoints/element/post-element/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 09 Oct 2023 21:22:18 GMT +Date: Sat, 26 Sep 2026 21:53:28 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/post-element/429-response-body.json b/docs/api-endpoints/element/post-element/429-response-body.json index 4555b0be..d1552ef4 100644 --- a/docs/api-endpoints/element/post-element/429-response-body.json +++ b/docs/api-endpoints/element/post-element/429-response-body.json @@ -2,5 +2,6 @@ "type": "401-unauthorized", "title": "Request does not contain valid token, or anonymous user is disabled.", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "" } \ No newline at end of file diff --git a/docs/api-endpoints/element/post-index/204-response-header.txt b/docs/api-endpoints/element/post-index/204-response-header.txt index d2c62763..309bf4bf 100644 --- a/docs/api-endpoints/element/post-index/204-response-header.txt +++ b/docs/api-endpoints/element/post-index/204-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: text/html; charset=UTF-8 -Date: Mon, 09 Oct 2023 20:32:13 GMT -Location: /455a89b9-4ec7-4b02-b023-bcfbcbba9a9f +Date: Sat, 26 Sep 2026 21:53:28 GMT +Location: /a09019c1-127c-4ecc-89aa-13a6fa10f734 Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/post-index/400-response-body.json b/docs/api-endpoints/element/post-index/400-response-body.json index e55923cb..e606f2ec 100644 --- a/docs/api-endpoints/element/post-index/400-response-body.json +++ b/docs/api-endpoints/element/post-index/400-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/400/missing-property", "title": "Missing property", "status": 400, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Endpoint requires that the request contains property 'type' to be set to a valid type." } \ No newline at end of file diff --git a/docs/api-endpoints/element/post-index/400-response-header.txt b/docs/api-endpoints/element/post-index/400-response-header.txt index 7e0ad98b..547d6f59 100644 --- a/docs/api-endpoints/element/post-index/400-response-header.txt +++ b/docs/api-endpoints/element/post-index/400-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 09 Oct 2023 20:53:18 GMT +Date: Sat, 26 Sep 2026 21:53:28 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/post-index/401-response-body.json b/docs/api-endpoints/element/post-index/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/post-index/401-response-body.json +++ b/docs/api-endpoints/element/post-index/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/post-index/401-response-header.txt b/docs/api-endpoints/element/post-index/401-response-header.txt index 7e0ad98b..20b628c7 100644 --- a/docs/api-endpoints/element/post-index/401-response-header.txt +++ b/docs/api-endpoints/element/post-index/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 09 Oct 2023 20:53:18 GMT +Date: Sat, 26 Sep 2026 21:53:28 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/post-index/404-response-body.json b/docs/api-endpoints/element/post-index/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/element/post-index/404-response-body.json +++ b/docs/api-endpoints/element/post-index/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/element/post-index/404-response-header.txt b/docs/api-endpoints/element/post-index/404-response-header.txt index ba72e465..547d6f59 100644 --- a/docs/api-endpoints/element/post-index/404-response-header.txt +++ b/docs/api-endpoints/element/post-index/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 09 Oct 2023 21:02:26 GMT +Date: Sat, 26 Sep 2026 21:53:28 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/post-index/429-response-body.json b/docs/api-endpoints/element/post-index/429-response-body.json index 4555b0be..d1552ef4 100644 --- a/docs/api-endpoints/element/post-index/429-response-body.json +++ b/docs/api-endpoints/element/post-index/429-response-body.json @@ -2,5 +2,6 @@ "type": "401-unauthorized", "title": "Request does not contain valid token, or anonymous user is disabled.", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "" } \ No newline at end of file diff --git a/docs/api-endpoints/element/put-element/204-response-header.txt b/docs/api-endpoints/element/put-element/204-response-header.txt index 7f8490ed..e26f67f1 100644 --- a/docs/api-endpoints/element/put-element/204-response-header.txt +++ b/docs/api-endpoints/element/put-element/204-response-header.txt @@ -2,7 +2,7 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private -Date: Sun, 10 Mar 2024 15:24:31 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/put-element/401-response-body.json b/docs/api-endpoints/element/put-element/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/element/put-element/401-response-body.json +++ b/docs/api-endpoints/element/put-element/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/element/put-element/401-response-header.txt b/docs/api-endpoints/element/put-element/401-response-header.txt index fe972e89..5e268213 100644 --- a/docs/api-endpoints/element/put-element/401-response-header.txt +++ b/docs/api-endpoints/element/put-element/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 09 Oct 2023 21:22:18 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/put-element/404-response-body.json b/docs/api-endpoints/element/put-element/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/element/put-element/404-response-body.json +++ b/docs/api-endpoints/element/put-element/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/element/put-element/404-response-header.txt b/docs/api-endpoints/element/put-element/404-response-header.txt index fe972e89..a6bf1ea3 100644 --- a/docs/api-endpoints/element/put-element/404-response-header.txt +++ b/docs/api-endpoints/element/put-element/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 09 Oct 2023 21:22:18 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/put-element/412-response-body.json b/docs/api-endpoints/element/put-element/412-response-body.json index 4c3d4ecf..216a1e4c 100644 --- a/docs/api-endpoints/element/put-element/412-response-body.json +++ b/docs/api-endpoints/element/put-element/412-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/412/precondition-failed", "title": "Precondition Failed", "status": 412, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Precondition does not match." } \ No newline at end of file diff --git a/docs/api-endpoints/element/put-element/412-response-header.txt b/docs/api-endpoints/element/put-element/412-response-header.txt index f08fbe4c..a6bf1ea3 100644 --- a/docs/api-endpoints/element/put-element/412-response-header.txt +++ b/docs/api-endpoints/element/put-element/412-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 08 Mar 2024 21:34:53 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/element/put-element/429-response-body.json b/docs/api-endpoints/element/put-element/429-response-body.json index 4555b0be..d1552ef4 100644 --- a/docs/api-endpoints/element/put-element/429-response-body.json +++ b/docs/api-endpoints/element/put-element/429-response-body.json @@ -2,5 +2,6 @@ "type": "401-unauthorized", "title": "Request does not contain valid token, or anonymous user is disabled.", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "" } \ No newline at end of file diff --git a/docs/api-endpoints/error/get-400-bad-content/200-response-header.txt b/docs/api-endpoints/error/get-400-bad-content/200-response-header.txt index 015cf16b..8be37682 100644 --- a/docs/api-endpoints/error/get-400-bad-content/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-bad-content/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:06:53 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-400-forbidden-property/200-response-header.txt b/docs/api-endpoints/error/get-400-forbidden-property/200-response-header.txt index 56d0cebd..8be37682 100644 --- a/docs/api-endpoints/error/get-400-forbidden-property/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-forbidden-property/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:12:04 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-400-incomplete-mutual-dependency/200-response-header.txt b/docs/api-endpoints/error/get-400-incomplete-mutual-dependency/200-response-header.txt index d31463f0..8be37682 100644 --- a/docs/api-endpoints/error/get-400-incomplete-mutual-dependency/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-incomplete-mutual-dependency/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:12:05 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-400-missing-property/200-response-header.txt b/docs/api-endpoints/error/get-400-missing-property/200-response-header.txt index d31463f0..8be37682 100644 --- a/docs/api-endpoints/error/get-400-missing-property/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-missing-property/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:12:05 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-400-reserved-identifier/200-response-header.txt b/docs/api-endpoints/error/get-400-reserved-identifier/200-response-header.txt index 015cf16b..8be37682 100644 --- a/docs/api-endpoints/error/get-400-reserved-identifier/200-response-header.txt +++ b/docs/api-endpoints/error/get-400-reserved-identifier/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:06:53 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-401-unauthorized/200-response-header.txt b/docs/api-endpoints/error/get-401-unauthorized/200-response-header.txt index 015cf16b..8be37682 100644 --- a/docs/api-endpoints/error/get-401-unauthorized/200-response-header.txt +++ b/docs/api-endpoints/error/get-401-unauthorized/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:06:53 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-403-forbidden/200-response-header.txt b/docs/api-endpoints/error/get-403-forbidden/200-response-header.txt index f1cbdeb2..8be37682 100644 --- a/docs/api-endpoints/error/get-403-forbidden/200-response-header.txt +++ b/docs/api-endpoints/error/get-403-forbidden/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:12:06 GMT +Date: Sat, 26 Sep 2026 21:53:29 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-404-not-found/200-response-header.txt b/docs/api-endpoints/error/get-404-not-found/200-response-header.txt index 015cf16b..aaed9785 100644 --- a/docs/api-endpoints/error/get-404-not-found/200-response-header.txt +++ b/docs/api-endpoints/error/get-404-not-found/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:06:53 GMT +Date: Sat, 26 Sep 2026 21:53:30 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-408-request-timeout.md b/docs/api-endpoints/error/get-408-request-timeout.md new file mode 100644 index 00000000..18a8594d --- /dev/null +++ b/docs/api-endpoints/error/get-408-request-timeout.md @@ -0,0 +1,21 @@ +# GET` /error/408/request-timeout`Get Details for Error 408 Request Timeout Endpoint + +## Request Example + +```bash +curl https://api.localhost/error/408/request-timeout +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./get-408-request-timeout/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./get-408-request-timeout/200-response-body.txt ':include :type=code') + + diff --git a/docs/api-endpoints/error/get-408-request-timeout/200-response-body.txt b/docs/api-endpoints/error/get-408-request-timeout/200-response-body.txt new file mode 100644 index 00000000..4e5d38fd --- /dev/null +++ b/docs/api-endpoints/error/get-408-request-timeout/200-response-body.txt @@ -0,0 +1,16 @@ + _____ _ _ _ _ ____ ___ + | ____|_ __ ___ | |__ ___ _ __ | \ | | _____ ___ _ ___ / \ | _ \_ _| + | _| | '_ ` _ \| '_ \ / _ \ '__| | \| |/ _ \ \/ / | | / __| / _ \ | |_) | | + | |___| | | | | | |_) | __/ | | |\ | __/⟩ ⟨| |_| \__ \ / ___ \| __/| | + |_____|_| |_| |_|_.__/ \___|_| |_| \_|\___/_/\_\\__,_|___/ /_/ \_\_| |___| + + + Error 408 - Request Timeout + + The HTTP 408 Request Timeout client error response status code indicates that the server did not receive the complete + request body, e.g. because the connection was interrupted or because the request body was not sent within the time + limit of the server. Retry the request, if necessary with a smaller request body, e.g. by uploading smaller chunks. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 408 error code: https://http.dev/408 diff --git a/docs/api-endpoints/user/post-change-password-old/204-response-header.txt b/docs/api-endpoints/error/get-408-request-timeout/200-response-header.txt similarity index 67% rename from docs/api-endpoints/user/post-change-password-old/204-response-header.txt rename to docs/api-endpoints/error/get-408-request-timeout/200-response-header.txt index e37e0075..aaed9785 100644 --- a/docs/api-endpoints/user/post-change-password-old/204-response-header.txt +++ b/docs/api-endpoints/error/get-408-request-timeout/200-response-header.txt @@ -2,7 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private -Date: Fri, 29 Sep 2023 10:05:24 GMT +Content-Type: text/plain; charset=utf-8 +Date: Sat, 26 Sep 2026 21:53:30 GMT +Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-412-precondition-failed/200-response-header.txt b/docs/api-endpoints/error/get-412-precondition-failed/200-response-header.txt index d31463f0..aaed9785 100644 --- a/docs/api-endpoints/error/get-412-precondition-failed/200-response-header.txt +++ b/docs/api-endpoints/error/get-412-precondition-failed/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:12:05 GMT +Date: Sat, 26 Sep 2026 21:53:30 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-500-internal-server-error/200-response-header.txt b/docs/api-endpoints/error/get-500-internal-server-error/200-response-header.txt index 5531dbdd..aaed9785 100644 --- a/docs/api-endpoints/error/get-500-internal-server-error/200-response-header.txt +++ b/docs/api-endpoints/error/get-500-internal-server-error/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Fri, 08 Mar 2024 19:07:41 GMT +Date: Sat, 26 Sep 2026 21:53:30 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-501-not-implemented/200-response-header.txt b/docs/api-endpoints/error/get-501-not-implemented/200-response-header.txt index 015cf16b..aaed9785 100644 --- a/docs/api-endpoints/error/get-501-not-implemented/200-response-header.txt +++ b/docs/api-endpoints/error/get-501-not-implemented/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Sat, 16 Sep 2023 14:06:53 GMT +Date: Sat, 26 Sep 2026 21:53:30 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/error/get-503-service-unavailable.md b/docs/api-endpoints/error/get-503-service-unavailable.md new file mode 100644 index 00000000..370c82e2 --- /dev/null +++ b/docs/api-endpoints/error/get-503-service-unavailable.md @@ -0,0 +1,21 @@ +# GET` /error/503/service-unavailable`Get Details for Error 503 Service Unavailable Endpoint + +## Request Example + +```bash +curl https://api.localhost/error/503/service-unavailable +``` + + + +### **🟢 Success 200** + +
Response Headers
+ +[Response Body](./get-503-service-unavailable/200-response-header.txt ':include :type=code') + +
Response Body
+ +[Response Body](./get-503-service-unavailable/200-response-body.txt ':include :type=code') + + diff --git a/docs/api-endpoints/error/get-503-service-unavailable/200-response-body.txt b/docs/api-endpoints/error/get-503-service-unavailable/200-response-body.txt new file mode 100644 index 00000000..0b718162 --- /dev/null +++ b/docs/api-endpoints/error/get-503-service-unavailable/200-response-body.txt @@ -0,0 +1,16 @@ + _____ _ _ _ _ ____ ___ + | ____|_ __ ___ | |__ ___ _ __ | \ | | _____ ___ _ ___ / \ | _ \_ _| + | _| | '_ ` _ \| '_ \ / _ \ '__| | \| |/ _ \ \/ / | | / __| / _ \ | |_) | | + | |___| | | | | | |_) | __/ | | |\ | __/⟩ ⟨| |_| \__ \ / ___ \| __/| | + |_____|_| |_| |_|_.__/ \___|_| |_| \_|\___/_/\_\\__,_|___/ /_/ \_\_| |___| + + + Error 503 - Service Unavailable + + The HTTP 503 Service Unavailable server error response status code indicates that the server is not ready to handle + the request, e.g. because it is overloaded or because a component of the service is not reachable. Retry the request + after a short pause. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 503 error code: https://http.dev/503 diff --git a/docs/api-endpoints/user/post-token-old/201-response-header.txt b/docs/api-endpoints/error/get-503-service-unavailable/200-response-header.txt similarity index 63% rename from docs/api-endpoints/user/post-token-old/201-response-header.txt rename to docs/api-endpoints/error/get-503-service-unavailable/200-response-header.txt index 5c78b3c0..aaed9785 100644 --- a/docs/api-endpoints/user/post-token-old/201-response-header.txt +++ b/docs/api-endpoints/error/get-503-service-unavailable/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private -Content-Type: application/json; charset=utf-8 -Date: Fri, 06 Oct 2023 16:26:13 GMT +Content-Type: text/plain; charset=utf-8 +Date: Sat, 26 Sep 2026 21:53:30 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/delete-element-file/204-response-header.txt b/docs/api-endpoints/file/delete-element-file/204-response-header.txt index f36281ab..4fa3aef2 100644 --- a/docs/api-endpoints/file/delete-element-file/204-response-header.txt +++ b/docs/api-endpoints/file/delete-element-file/204-response-header.txt @@ -2,7 +2,7 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private -Date: Mon, 24 Aug 2026 21:47:09 GMT +Date: Sat, 26 Sep 2026 21:53:30 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/delete-element-file/401-response-body.json b/docs/api-endpoints/file/delete-element-file/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/file/delete-element-file/401-response-body.json +++ b/docs/api-endpoints/file/delete-element-file/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/file/delete-element-file/401-response-header.txt b/docs/api-endpoints/file/delete-element-file/401-response-header.txt index d570f880..8ca32a44 100644 --- a/docs/api-endpoints/file/delete-element-file/401-response-header.txt +++ b/docs/api-endpoints/file/delete-element-file/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:47:09 GMT +Date: Sat, 26 Sep 2026 21:53:30 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/delete-element-file/404-response-body.json b/docs/api-endpoints/file/delete-element-file/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/file/delete-element-file/404-response-body.json +++ b/docs/api-endpoints/file/delete-element-file/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/file/delete-element-file/404-response-header.txt b/docs/api-endpoints/file/delete-element-file/404-response-header.txt index d570f880..6cc9a309 100644 --- a/docs/api-endpoints/file/delete-element-file/404-response-header.txt +++ b/docs/api-endpoints/file/delete-element-file/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:47:09 GMT +Date: Sat, 26 Sep 2026 21:53:31 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-body.txt b/docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-body.txt new file mode 100644 index 00000000..fac6cd6e --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-body.txt @@ -0,0 +1 @@ +Hello, Ember Nexus! \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-header.txt b/docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-header.txt new file mode 100644 index 00000000..90fa400d --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-header.txt @@ -0,0 +1,15 @@ +Accept-Ranges: bytes +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT +Cache-Control: no-cache, private +Content-Disposition: attachment; filename=get-element-file-if-range-mismatch.txt +Content-Length: 19 +Content-Type: text/plain; charset=UTF-8 +Date: Sat, 26 Sep 2026 21:53:32 GMT +Etag: "4HUcq4Sqvnf" +Repr-Digest: sha-256=:n6JhFCAJB2SKrVzEZIBGHLJy7OJDYDEeTHh0rdG1hD4=: +X-Content-Type-Options: nosniff +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/200-response-header.txt b/docs/api-endpoints/file/get-element-file/200-response-header.txt index 63b573f0..9d34909b 100644 --- a/docs/api-endpoints/file/get-element-file/200-response-header.txt +++ b/docs/api-endpoints/file/get-element-file/200-response-header.txt @@ -3,11 +3,13 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Disposition: attachment; filename=Rose.jpg Content-Length: 138937 Content-Type: image/jpeg -Date: Mon, 24 Aug 2026 21:39:11 GMT -Etag: "XHImZOcVmUc" +Date: Sat, 26 Sep 2026 21:53:31 GMT +Etag: "KlfY8H2vLTJ" +Repr-Digest: sha-256=:cSS7oKhtzBDhtceO+Wi5Lh/mgcFtDy7J81sOhEjBpMA=: +X-Content-Type-Options: nosniff X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/206-if-range-match-response-body.txt b/docs/api-endpoints/file/get-element-file/206-if-range-match-response-body.txt new file mode 100644 index 00000000..d0ac5a3c --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/206-if-range-match-response-body.txt @@ -0,0 +1 @@ +Ember \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/206-if-range-match-response-header.txt b/docs/api-endpoints/file/get-element-file/206-if-range-match-response-header.txt new file mode 100644 index 00000000..6bf2d772 --- /dev/null +++ b/docs/api-endpoints/file/get-element-file/206-if-range-match-response-header.txt @@ -0,0 +1,16 @@ +Accept-Ranges: bytes +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT +Cache-Control: no-cache, private +Content-Disposition: attachment; filename=get-element-file-if-range-match.txt +Content-Length: 5 +Content-Range: bytes 7-11/19 +Content-Type: text/plain; charset=UTF-8 +Date: Sat, 26 Sep 2026 21:53:32 GMT +Etag: "cGOE2M88hV" +Repr-Digest: sha-256=:n6JhFCAJB2SKrVzEZIBGHLJy7OJDYDEeTHh0rdG1hD4=: +X-Content-Type-Options: nosniff +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/206-response-header.txt b/docs/api-endpoints/file/get-element-file/206-response-header.txt index 3eb27d6b..a5497a47 100644 --- a/docs/api-endpoints/file/get-element-file/206-response-header.txt +++ b/docs/api-endpoints/file/get-element-file/206-response-header.txt @@ -3,12 +3,14 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Disposition: attachment; filename=Rose.jpg Content-Length: 100 Content-Range: bytes 0-99/138937 Content-Type: image/jpeg -Date: Mon, 24 Aug 2026 21:39:11 GMT -Etag: "XHImZOcVmUc" +Date: Sat, 26 Sep 2026 21:53:31 GMT +Etag: "KlfY8H2vLTJ" +Repr-Digest: sha-256=:cSS7oKhtzBDhtceO+Wi5Lh/mgcFtDy7J81sOhEjBpMA=: +X-Content-Type-Options: nosniff X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/400-response-body.json b/docs/api-endpoints/file/get-element-file/400-response-body.json index f6b50e78..beec7404 100644 --- a/docs/api-endpoints/file/get-element-file/400-response-body.json +++ b/docs/api-endpoints/file/get-element-file/400-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/400/bad-content", "title": "Bad content", "status": 400, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Could not parse 'Range' header: 'bytes=not-a-valid-range'. Only a single range of the form 'bytes=-', 'bytes=-' or 'bytes=-' is supported." } \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/400-response-header.txt b/docs/api-endpoints/file/get-element-file/400-response-header.txt index 288d4c95..6cc9a309 100644 --- a/docs/api-endpoints/file/get-element-file/400-response-header.txt +++ b/docs/api-endpoints/file/get-element-file/400-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:39:11 GMT +Date: Sat, 26 Sep 2026 21:53:31 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/401-response-body.json b/docs/api-endpoints/file/get-element-file/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/file/get-element-file/401-response-body.json +++ b/docs/api-endpoints/file/get-element-file/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/401-response-header.txt b/docs/api-endpoints/file/get-element-file/401-response-header.txt index 288d4c95..369899ef 100644 --- a/docs/api-endpoints/file/get-element-file/401-response-header.txt +++ b/docs/api-endpoints/file/get-element-file/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:39:11 GMT +Date: Sat, 26 Sep 2026 21:53:31 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/404-response-body.json b/docs/api-endpoints/file/get-element-file/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/file/get-element-file/404-response-body.json +++ b/docs/api-endpoints/file/get-element-file/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/404-response-header.txt b/docs/api-endpoints/file/get-element-file/404-response-header.txt index 288d4c95..6cc9a309 100644 --- a/docs/api-endpoints/file/get-element-file/404-response-header.txt +++ b/docs/api-endpoints/file/get-element-file/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:39:11 GMT +Date: Sat, 26 Sep 2026 21:53:31 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/416-response-body.json b/docs/api-endpoints/file/get-element-file/416-response-body.json index d985ac39..a603f170 100644 --- a/docs/api-endpoints/file/get-element-file/416-response-body.json +++ b/docs/api-endpoints/file/get-element-file/416-response-body.json @@ -2,7 +2,8 @@ "type": "http://api/error/416/range-not-satisfiable", "title": "Range Not Satisfiable", "status": 416, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested range start (999999999) is beyond the end of the resource (138937 bytes long).", - "requested-start": 999999999, - "total-length": 138937 + "requestedStart": 999999999, + "totalLength": 138937 } \ No newline at end of file diff --git a/docs/api-endpoints/file/get-element-file/416-response-header.txt b/docs/api-endpoints/file/get-element-file/416-response-header.txt index 288d4c95..3743ef6d 100644 --- a/docs/api-endpoints/file/get-element-file/416-response-header.txt +++ b/docs/api-endpoints/file/get-element-file/416-response-header.txt @@ -2,9 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private +Content-Range: bytes */138937 Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:39:11 GMT +Date: Sat, 26 Sep 2026 21:53:31 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/201-response-header.txt b/docs/api-endpoints/file/post-element-file/201-response-header.txt index 31dea429..b7fbf93c 100644 --- a/docs/api-endpoints/file/post-element-file/201-response-header.txt +++ b/docs/api-endpoints/file/post-element-file/201-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: text/html; charset=UTF-8 -Date: Mon, 24 Aug 2026 21:41:44 GMT +Date: Sat, 26 Sep 2026 21:53:33 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/204-with-initial-data-response-header.txt b/docs/api-endpoints/file/post-element-file/204-with-initial-data-response-header.txt index ce4b8319..db90de9a 100644 --- a/docs/api-endpoints/file/post-element-file/204-with-initial-data-response-header.txt +++ b/docs/api-endpoints/file/post-element-file/204-with-initial-data-response-header.txt @@ -2,11 +2,11 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-store, private -Date: Mon, 24 Aug 2026 21:43:17 GMT -Expires: Tue, 25 Aug 2026 00:43:17 GMT -Location: /upload/31395bc1-b202-47fb-aebc-0f8ddfcc3d46 +Date: Sat, 26 Sep 2026 21:53:33 GMT +Expires: Sun, 27 Sep 2026 00:53:33 GMT +Location: /upload/077ab7bf-5ba9-4a89-8721-3b9c9e4af2ef Upload-Complete: ?0 Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 Upload-Offset: 5242880 diff --git a/docs/api-endpoints/file/post-element-file/204-without-initial-data-response-header.txt b/docs/api-endpoints/file/post-element-file/204-without-initial-data-response-header.txt index 8b45df0d..f0791f08 100644 --- a/docs/api-endpoints/file/post-element-file/204-without-initial-data-response-header.txt +++ b/docs/api-endpoints/file/post-element-file/204-without-initial-data-response-header.txt @@ -2,11 +2,11 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-store, private -Date: Mon, 24 Aug 2026 21:43:17 GMT -Expires: Tue, 25 Aug 2026 00:43:17 GMT -Location: /upload/a0505189-d7a8-49ac-8589-6f14c140acbe +Date: Sat, 26 Sep 2026 21:53:33 GMT +Expires: Sun, 27 Sep 2026 00:53:33 GMT +Location: /upload/102615c0-dc59-4d14-982a-ec2963ae5103 Upload-Complete: ?0 Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 Upload-Offset: 0 diff --git a/docs/api-endpoints/file/post-element-file/400-response-body.json b/docs/api-endpoints/file/post-element-file/400-response-body.json index f891ae85..c310c02e 100644 --- a/docs/api-endpoints/file/post-element-file/400-response-body.json +++ b/docs/api-endpoints/file/post-element-file/400-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/400/bad-content", "title": "Bad content", "status": 400, - "detail": "Could not verify upload: the declared digest does not match the uploaded file's actual content." + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", + "detail": "Could not verify upload: the digest declared in 'Repr-Digest' does not match the uploaded file's actual content." } \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/400-response-header.txt b/docs/api-endpoints/file/post-element-file/400-response-header.txt index bb6ef52b..017425c7 100644 --- a/docs/api-endpoints/file/post-element-file/400-response-header.txt +++ b/docs/api-endpoints/file/post-element-file/400-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:41:45 GMT +Date: Sat, 26 Sep 2026 21:53:34 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/401-response-body.json b/docs/api-endpoints/file/post-element-file/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/file/post-element-file/401-response-body.json +++ b/docs/api-endpoints/file/post-element-file/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/401-response-header.txt b/docs/api-endpoints/file/post-element-file/401-response-header.txt index bb6ef52b..a79582a7 100644 --- a/docs/api-endpoints/file/post-element-file/401-response-header.txt +++ b/docs/api-endpoints/file/post-element-file/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:41:45 GMT +Date: Sat, 26 Sep 2026 21:53:34 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/404-response-body.json b/docs/api-endpoints/file/post-element-file/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/file/post-element-file/404-response-body.json +++ b/docs/api-endpoints/file/post-element-file/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/404-response-header.txt b/docs/api-endpoints/file/post-element-file/404-response-header.txt index bb6ef52b..017425c7 100644 --- a/docs/api-endpoints/file/post-element-file/404-response-header.txt +++ b/docs/api-endpoints/file/post-element-file/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:41:45 GMT +Date: Sat, 26 Sep 2026 21:53:34 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/409-response-body.json b/docs/api-endpoints/file/post-element-file/409-response-body.json index 44f71494..025eab1f 100644 --- a/docs/api-endpoints/file/post-element-file/409-response-body.json +++ b/docs/api-endpoints/file/post-element-file/409-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/409/conflict", "title": "Conflict", "status": 409, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Element with id '6b1c2d3e-4f5a-4b6c-8d9e-0f1a2b3c4d5e' already has an associated file; can not create new file. Delete existing file first or replace it with PUT." } \ No newline at end of file diff --git a/docs/api-endpoints/file/post-element-file/409-response-header.txt b/docs/api-endpoints/file/post-element-file/409-response-header.txt index bb6ef52b..017425c7 100644 --- a/docs/api-endpoints/file/post-element-file/409-response-header.txt +++ b/docs/api-endpoints/file/post-element-file/409-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:41:45 GMT +Date: Sat, 26 Sep 2026 21:53:34 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/201-response-header.txt b/docs/api-endpoints/file/put-element-file/201-response-header.txt index 3319b340..57682beb 100644 --- a/docs/api-endpoints/file/put-element-file/201-response-header.txt +++ b/docs/api-endpoints/file/put-element-file/201-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: text/html; charset=UTF-8 -Date: Mon, 24 Aug 2026 21:47:12 GMT +Date: Sat, 26 Sep 2026 21:53:35 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/204-with-initial-data-response-header.txt b/docs/api-endpoints/file/put-element-file/204-with-initial-data-response-header.txt index c5e5d4fc..54d0bd90 100644 --- a/docs/api-endpoints/file/put-element-file/204-with-initial-data-response-header.txt +++ b/docs/api-endpoints/file/put-element-file/204-with-initial-data-response-header.txt @@ -2,11 +2,11 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-store, private -Date: Mon, 24 Aug 2026 21:47:12 GMT -Expires: Tue, 25 Aug 2026 00:47:12 GMT -Location: /upload/17cf07da-f3a2-4b39-b29d-b6f63e0ba7f5 +Date: Sat, 26 Sep 2026 21:53:35 GMT +Expires: Sun, 27 Sep 2026 00:53:35 GMT +Location: /upload/ee2dbea6-ff3b-4ee3-83fd-e9c2c2e60a54 Upload-Complete: ?0 Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 Upload-Offset: 5242880 diff --git a/docs/api-endpoints/file/put-element-file/204-without-initial-data-response-header.txt b/docs/api-endpoints/file/put-element-file/204-without-initial-data-response-header.txt index 2239cad2..508f46d2 100644 --- a/docs/api-endpoints/file/put-element-file/204-without-initial-data-response-header.txt +++ b/docs/api-endpoints/file/put-element-file/204-without-initial-data-response-header.txt @@ -2,11 +2,11 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-store, private -Date: Mon, 24 Aug 2026 21:47:12 GMT -Expires: Tue, 25 Aug 2026 00:47:12 GMT -Location: /upload/ca02c95e-e93b-4759-96a1-f5e703ea802f +Date: Sat, 26 Sep 2026 21:53:35 GMT +Expires: Sun, 27 Sep 2026 00:53:35 GMT +Location: /upload/e5b73f0b-072d-4701-abda-421e313821b4 Upload-Complete: ?0 Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 Upload-Offset: 0 diff --git a/docs/api-endpoints/file/put-element-file/400-response-body.json b/docs/api-endpoints/file/put-element-file/400-response-body.json index f891ae85..c310c02e 100644 --- a/docs/api-endpoints/file/put-element-file/400-response-body.json +++ b/docs/api-endpoints/file/put-element-file/400-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/400/bad-content", "title": "Bad content", "status": 400, - "detail": "Could not verify upload: the declared digest does not match the uploaded file's actual content." + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", + "detail": "Could not verify upload: the digest declared in 'Repr-Digest' does not match the uploaded file's actual content." } \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/400-response-header.txt b/docs/api-endpoints/file/put-element-file/400-response-header.txt index bec870ac..c10f3409 100644 --- a/docs/api-endpoints/file/put-element-file/400-response-header.txt +++ b/docs/api-endpoints/file/put-element-file/400-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:47:13 GMT +Date: Sat, 26 Sep 2026 21:53:35 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/401-response-body.json b/docs/api-endpoints/file/put-element-file/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/file/put-element-file/401-response-body.json +++ b/docs/api-endpoints/file/put-element-file/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/401-response-header.txt b/docs/api-endpoints/file/put-element-file/401-response-header.txt index bec870ac..61f9f054 100644 --- a/docs/api-endpoints/file/put-element-file/401-response-header.txt +++ b/docs/api-endpoints/file/put-element-file/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:47:13 GMT +Date: Sat, 26 Sep 2026 21:53:36 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/404-response-body.json b/docs/api-endpoints/file/put-element-file/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/file/put-element-file/404-response-body.json +++ b/docs/api-endpoints/file/put-element-file/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/file/put-element-file/404-response-header.txt b/docs/api-endpoints/file/put-element-file/404-response-header.txt index bec870ac..aa0d4702 100644 --- a/docs/api-endpoints/file/put-element-file/404-response-header.txt +++ b/docs/api-endpoints/file/put-element-file/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST, PUT Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:47:13 GMT +Date: Sat, 26 Sep 2026 21:53:36 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/system/get-graph-structure/200-response-body.json b/docs/api-endpoints/system/get-graph-structure/200-response-body.json index 533bb720..af00ec53 100644 --- a/docs/api-endpoints/system/get-graph-structure/200-response-body.json +++ b/docs/api-endpoints/system/get-graph-structure/200-response-body.json @@ -10,6 +10,7 @@ "Tag", "Taxon", "Token", + "Upload", "User" ], "relationTypes": [ diff --git a/docs/api-endpoints/system/get-graph-structure/200-response-header.txt b/docs/api-endpoints/system/get-graph-structure/200-response-header.txt index cb55f9b8..480955fe 100644 --- a/docs/api-endpoints/system/get-graph-structure/200-response-header.txt +++ b/docs/api-endpoints/system/get-graph-structure/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sun, 08 Oct 2023 13:23:09 GMT +Date: Sat, 26 Sep 2026 21:53:40 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/system/get-graph-structure/403-response-body.json b/docs/api-endpoints/system/get-graph-structure/403-response-body.json index ab91d2ac..0564eed9 100644 --- a/docs/api-endpoints/system/get-graph-structure/403-response-body.json +++ b/docs/api-endpoints/system/get-graph-structure/403-response-body.json @@ -1,6 +1,7 @@ { - "type": "http://api/error/403/forbidden", + "type": "http://api-different-configuration/error/403/forbidden", "title": "Forbidden", "status": 403, + "instance": "urn:uuid:9057aaff-b54c-413e-9b3a-8e886c4da4a9", "detail": "Requested endpoint, element or action is forbidden." } \ No newline at end of file diff --git a/docs/api-endpoints/system/get-graph-structure/403-response-header.txt b/docs/api-endpoints/system/get-graph-structure/403-response-header.txt index 6d5f4750..8f35658b 100644 --- a/docs/api-endpoints/system/get-graph-structure/403-response-header.txt +++ b/docs/api-endpoints/system/get-graph-structure/403-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private +Content-Length: 220 Content-Type: application/problem+json; charset=utf-8 -Date: Sun, 08 Oct 2023 13:29:08 GMT -Transfer-Encoding: chunked +Date: Sun, 27 Sep 2026 08:41:32 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/system/get-instance-configuration/200-response-header.txt b/docs/api-endpoints/system/get-instance-configuration/200-response-header.txt index cb55f9b8..480955fe 100644 --- a/docs/api-endpoints/system/get-instance-configuration/200-response-header.txt +++ b/docs/api-endpoints/system/get-instance-configuration/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sun, 08 Oct 2023 13:23:09 GMT +Date: Sat, 26 Sep 2026 21:53:40 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/system/get-instance-configuration/403-response-body.json b/docs/api-endpoints/system/get-instance-configuration/403-response-body.json index ab91d2ac..08b35b58 100644 --- a/docs/api-endpoints/system/get-instance-configuration/403-response-body.json +++ b/docs/api-endpoints/system/get-instance-configuration/403-response-body.json @@ -1,6 +1,7 @@ { - "type": "http://api/error/403/forbidden", + "type": "http://api-different-configuration/error/403/forbidden", "title": "Forbidden", "status": 403, + "instance": "urn:uuid:2bac65f0-3b45-4584-9015-f19a4d1ca259", "detail": "Requested endpoint, element or action is forbidden." } \ No newline at end of file diff --git a/docs/api-endpoints/system/get-instance-configuration/403-response-header.txt b/docs/api-endpoints/system/get-instance-configuration/403-response-header.txt index 6d5f4750..8f35658b 100644 --- a/docs/api-endpoints/system/get-instance-configuration/403-response-header.txt +++ b/docs/api-endpoints/system/get-instance-configuration/403-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private +Content-Length: 220 Content-Type: application/problem+json; charset=utf-8 -Date: Sun, 08 Oct 2023 13:29:08 GMT -Transfer-Encoding: chunked +Date: Sun, 27 Sep 2026 08:41:32 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/system/get-well-known-security-txt/200-response-header.txt b/docs/api-endpoints/system/get-well-known-security-txt/200-response-header.txt index 26ce312b..653e51d0 100644 --- a/docs/api-endpoints/system/get-well-known-security-txt/200-response-header.txt +++ b/docs/api-endpoints/system/get-well-known-security-txt/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: text/plain; charset=utf-8 -Date: Fri, 22 Dec 2023 23:05:08 GMT +Date: Sat, 26 Sep 2026 21:53:40 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload.md b/docs/api-endpoints/upload/delete-upload.md index 8bc97b93..43f6c2be 100644 --- a/docs/api-endpoints/upload/delete-upload.md +++ b/docs/api-endpoints/upload/delete-upload.md @@ -1,6 +1,16 @@ # DELETE` /upload/`Delete Upload Endpoint -Deletes a resumable upload. +Deletes a resumable upload, discarding any chunks already uploaded for it. + +## Access Control + +Only the user who created the upload may delete it; for anyone else the upload responds as if it did not exist. + +Unlike `PATCH /upload/` and `HEAD /upload/`, this endpoint deliberately does **not** re-check access to +the upload's target element. Those two endpoints re-check it on every call, so an upload whose target became +inaccessible while it was in progress can no longer be inspected or continued. Deleting it is what remains +possible: an upload which can never be completed can always be cancelled by its owner, rather than being stuck +until `cron:delete-expired-uploads` eventually collects it. ## Request Example diff --git a/docs/api-endpoints/upload/delete-upload/204-response-header.txt b/docs/api-endpoints/upload/delete-upload/204-response-header.txt index 6aa079b4..158ede01 100644 --- a/docs/api-endpoints/upload/delete-upload/204-response-header.txt +++ b/docs/api-endpoints/upload/delete-upload/204-response-header.txt @@ -2,7 +2,7 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, HEAD, OPTIONS, PATCH Cache-Control: no-cache, private -Date: Mon, 24 Aug 2026 21:50:10 GMT +Date: Sat, 26 Sep 2026 21:53:41 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload/401-response-body.json b/docs/api-endpoints/upload/delete-upload/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/upload/delete-upload/401-response-body.json +++ b/docs/api-endpoints/upload/delete-upload/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload/401-response-header.txt b/docs/api-endpoints/upload/delete-upload/401-response-header.txt index f8ea676c..2d50e5e4 100644 --- a/docs/api-endpoints/upload/delete-upload/401-response-header.txt +++ b/docs/api-endpoints/upload/delete-upload/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:50:11 GMT +Date: Sat, 26 Sep 2026 21:53:41 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload/404-response-body.json b/docs/api-endpoints/upload/delete-upload/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/upload/delete-upload/404-response-body.json +++ b/docs/api-endpoints/upload/delete-upload/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/upload/delete-upload/404-response-header.txt b/docs/api-endpoints/upload/delete-upload/404-response-header.txt index f8ea676c..f19f9037 100644 --- a/docs/api-endpoints/upload/delete-upload/404-response-header.txt +++ b/docs/api-endpoints/upload/delete-upload/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, HEAD, OPTIONS, PATCH Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:50:11 GMT +Date: Sat, 26 Sep 2026 21:53:41 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/head-upload.md b/docs/api-endpoints/upload/head-upload.md index 29493e90..58496926 100644 --- a/docs/api-endpoints/upload/head-upload.md +++ b/docs/api-endpoints/upload/head-upload.md @@ -2,6 +2,14 @@ Queries the current state of the resumable upload. +## Access Control + +Only the user who created the upload may query it. + +As with `PATCH /upload/`, access to the upload's target element is re-checked on every call, so an upload +whose target became inaccessible is reported as if it did not exist rather than appearing healthy. It can still be +cancelled with `DELETE /upload/`. + ## Request Example ```bash diff --git a/docs/api-endpoints/upload/head-upload/204-response-header.txt b/docs/api-endpoints/upload/head-upload/204-response-header.txt index ba939a79..ffd991b9 100644 --- a/docs/api-endpoints/upload/head-upload/204-response-header.txt +++ b/docs/api-endpoints/upload/head-upload/204-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, HEAD, OPTIONS, PATCH Cache-Control: no-store, private -Date: Mon, 24 Aug 2026 21:50:11 GMT -Expires: Tue, 25 Aug 2026 00:50:11 GMT +Date: Sat, 26 Sep 2026 21:53:42 GMT +Expires: Sun, 27 Sep 2026 00:53:42 GMT Upload-Complete: ?0 Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 Upload-Offset: 5242880 diff --git a/docs/api-endpoints/upload/head-upload/401-response-header.txt b/docs/api-endpoints/upload/head-upload/401-response-header.txt index ec1433d1..2f7006bc 100644 --- a/docs/api-endpoints/upload/head-upload/401-response-header.txt +++ b/docs/api-endpoints/upload/head-upload/401-response-header.txt @@ -2,8 +2,7 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:52:20 GMT +Date: Sat, 26 Sep 2026 21:53:42 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/head-upload/404-response-header.txt b/docs/api-endpoints/upload/head-upload/404-response-header.txt index ec1433d1..befdcbb3 100644 --- a/docs/api-endpoints/upload/head-upload/404-response-header.txt +++ b/docs/api-endpoints/upload/head-upload/404-response-header.txt @@ -2,8 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, HEAD, OPTIONS, PATCH Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:52:20 GMT +Date: Sat, 26 Sep 2026 21:53:42 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload.md b/docs/api-endpoints/upload/patch-upload.md index 5a6b0dd1..8926563d 100644 --- a/docs/api-endpoints/upload/patch-upload.md +++ b/docs/api-endpoints/upload/patch-upload.md @@ -5,6 +5,14 @@ This endpoint is used to upload a new chunk to the resumable upload. The new chunk can be of zero length - acting similar to the `HEAD /upload/` endpoint, but also enabling the user to complete an upload without transmitting additional chunks. +## Access Control + +Only the user who created the upload may add chunks to it. + +Access to the upload's target element is re-checked on **every** chunk, not just when the upload is created. If +that access is revoked mid-upload, further chunks are rejected and the upload can no longer be completed; it can +still be cancelled with `DELETE /upload/`. + ## Request Example ```bash diff --git a/docs/api-endpoints/upload/patch-upload/204-final-chunk-response-header.txt b/docs/api-endpoints/upload/patch-upload/204-final-chunk-response-header.txt index d15a91df..08f0371d 100644 --- a/docs/api-endpoints/upload/patch-upload/204-final-chunk-response-header.txt +++ b/docs/api-endpoints/upload/patch-upload/204-final-chunk-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, HEAD, OPTIONS, PATCH Cache-Control: no-store, private -Date: Mon, 24 Aug 2026 21:52:21 GMT -Expires: Tue, 25 Aug 2026 00:52:21 GMT +Date: Sat, 26 Sep 2026 21:53:44 GMT +Expires: Sun, 27 Sep 2026 00:53:44 GMT Upload-Complete: ?1 Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 Upload-Offset: 10485760 diff --git a/docs/api-endpoints/upload/patch-upload/204-intermediate-chunk-response-header.txt b/docs/api-endpoints/upload/patch-upload/204-intermediate-chunk-response-header.txt index f4eb9461..a6bb75d8 100644 --- a/docs/api-endpoints/upload/patch-upload/204-intermediate-chunk-response-header.txt +++ b/docs/api-endpoints/upload/patch-upload/204-intermediate-chunk-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, HEAD, OPTIONS, PATCH Cache-Control: no-store, private -Date: Mon, 24 Aug 2026 21:52:21 GMT -Expires: Tue, 25 Aug 2026 00:52:20 GMT +Date: Sat, 26 Sep 2026 21:53:43 GMT +Expires: Sun, 27 Sep 2026 00:53:43 GMT Upload-Complete: ?0 Upload-Limit: max-age=10800, max-size=10737418240, min-append-size=5242880, max-append-size=105906176 Upload-Offset: 10485760 diff --git a/docs/api-endpoints/upload/patch-upload/400-response-body.json b/docs/api-endpoints/upload/patch-upload/400-response-body.json index f891ae85..c17c9129 100644 --- a/docs/api-endpoints/upload/patch-upload/400-response-body.json +++ b/docs/api-endpoints/upload/patch-upload/400-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/400/bad-content", "title": "Bad content", "status": 400, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Could not verify upload: the declared digest does not match the uploaded file's actual content." } \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/400-response-header.txt b/docs/api-endpoints/upload/patch-upload/400-response-header.txt index 543cac58..e197a2e7 100644 --- a/docs/api-endpoints/upload/patch-upload/400-response-header.txt +++ b/docs/api-endpoints/upload/patch-upload/400-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, HEAD, OPTIONS, PATCH Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:50:13 GMT +Date: Sat, 26 Sep 2026 21:53:44 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/401-response-body.json b/docs/api-endpoints/upload/patch-upload/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/upload/patch-upload/401-response-body.json +++ b/docs/api-endpoints/upload/patch-upload/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/401-response-header.txt b/docs/api-endpoints/upload/patch-upload/401-response-header.txt index 484dcc39..f08f9249 100644 --- a/docs/api-endpoints/upload/patch-upload/401-response-header.txt +++ b/docs/api-endpoints/upload/patch-upload/401-response-header.txt @@ -2,10 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Connection: close Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:50:15 GMT +Date: Sat, 26 Sep 2026 21:53:46 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/404-response-body.json b/docs/api-endpoints/upload/patch-upload/404-response-body.json index d2abaadb..0e06dd51 100644 --- a/docs/api-endpoints/upload/patch-upload/404-response-body.json +++ b/docs/api-endpoints/upload/patch-upload/404-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/404/not-found", "title": "Not found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/404-response-header.txt b/docs/api-endpoints/upload/patch-upload/404-response-header.txt index d2eac4b4..5ac528dd 100644 --- a/docs/api-endpoints/upload/patch-upload/404-response-header.txt +++ b/docs/api-endpoints/upload/patch-upload/404-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, HEAD, OPTIONS, PATCH Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:50:16 GMT +Date: Sat, 26 Sep 2026 21:53:47 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/409-response-body.json b/docs/api-endpoints/upload/patch-upload/409-response-body.json index 0aa02e64..e1b5d048 100644 --- a/docs/api-endpoints/upload/patch-upload/409-response-body.json +++ b/docs/api-endpoints/upload/patch-upload/409-response-body.json @@ -2,7 +2,8 @@ "type": "http://api/error/409/conflict", "title": "Conflict", "status": 409, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Offset from request does not match offset of resource.", - "expected-offset": 5242880, - "provided-offset": 5242881 + "expectedOffset": 5242880, + "providedOffset": 5242881 } \ No newline at end of file diff --git a/docs/api-endpoints/upload/patch-upload/409-response-header.txt b/docs/api-endpoints/upload/patch-upload/409-response-header.txt index 44b31c9e..c6b74024 100644 --- a/docs/api-endpoints/upload/patch-upload/409-response-header.txt +++ b/docs/api-endpoints/upload/patch-upload/409-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, HEAD, OPTIONS, PATCH Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 24 Aug 2026 21:50:14 GMT +Date: Sat, 26 Sep 2026 21:53:45 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/delete-token/204-response-header.txt b/docs/api-endpoints/user/delete-token/204-response-header.txt index 56c80b8b..f6b76f6a 100644 --- a/docs/api-endpoints/user/delete-token/204-response-header.txt +++ b/docs/api-endpoints/user/delete-token/204-response-header.txt @@ -2,7 +2,7 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private -Date: Mon, 11 Dec 2023 12:05:26 GMT +Date: Sat, 26 Sep 2026 21:53:48 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/delete-token/401-response-body.json b/docs/api-endpoints/user/delete-token/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/user/delete-token/401-response-body.json +++ b/docs/api-endpoints/user/delete-token/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/user/delete-token/401-response-header.txt b/docs/api-endpoints/user/delete-token/401-response-header.txt index 07c7a7ef..03ffbadf 100644 --- a/docs/api-endpoints/user/delete-token/401-response-header.txt +++ b/docs/api-endpoints/user/delete-token/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 11 Dec 2023 12:03:46 GMT +Date: Sat, 26 Sep 2026 21:53:48 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/delete-token/429-response-body.json b/docs/api-endpoints/user/delete-token/429-response-body.json index 793a1e82..70ca31f8 100644 --- a/docs/api-endpoints/user/delete-token/429-response-body.json +++ b/docs/api-endpoints/user/delete-token/429-response-body.json @@ -2,5 +2,6 @@ "type": "429-too-many-requests", "title": "Too Many Requests", "status": "429", + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "The client sent too many requests in a given timeframe; rate limiting is active." } \ No newline at end of file diff --git a/docs/api-endpoints/user/get-me/200-response-header.txt b/docs/api-endpoints/user/get-me/200-response-header.txt index 74595220..694c7eb8 100644 --- a/docs/api-endpoints/user/get-me/200-response-header.txt +++ b/docs/api-endpoints/user/get-me/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Fri, 06 Oct 2023 10:06:46 GMT +Date: Sat, 26 Sep 2026 21:53:48 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/get-me/401-response-body.json b/docs/api-endpoints/user/get-me/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/user/get-me/401-response-body.json +++ b/docs/api-endpoints/user/get-me/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/user/get-me/401-response-header.txt b/docs/api-endpoints/user/get-me/401-response-header.txt index e3593080..03ffbadf 100644 --- a/docs/api-endpoints/user/get-me/401-response-header.txt +++ b/docs/api-endpoints/user/get-me/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Mon, 27 Nov 2023 17:32:45 GMT +Date: Sat, 26 Sep 2026 21:53:48 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/get-me/429-response-body.json b/docs/api-endpoints/user/get-me/429-response-body.json index 12bcda51..d617c0ba 100644 --- a/docs/api-endpoints/user/get-me/429-response-body.json +++ b/docs/api-endpoints/user/get-me/429-response-body.json @@ -2,5 +2,6 @@ "type": "400-bad-request", "title": "Bad Request", "status": 400, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Property 'user' must be set." } \ No newline at end of file diff --git a/docs/api-endpoints/user/get-token/200-response-header.txt b/docs/api-endpoints/user/get-token/200-response-header.txt index 74595220..df3cdc7b 100644 --- a/docs/api-endpoints/user/get-token/200-response-header.txt +++ b/docs/api-endpoints/user/get-token/200-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Fri, 06 Oct 2023 10:06:46 GMT +Date: Sat, 26 Sep 2026 21:53:48 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/get-token/401-response-body.json b/docs/api-endpoints/user/get-token/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/user/get-token/401-response-body.json +++ b/docs/api-endpoints/user/get-token/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/user/get-token/401-response-header.txt b/docs/api-endpoints/user/get-token/401-response-header.txt index 648f10ca..03ffbadf 100644 --- a/docs/api-endpoints/user/get-token/401-response-header.txt +++ b/docs/api-endpoints/user/get-token/401-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 17:26:23 GMT +Date: Sat, 26 Sep 2026 21:53:48 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/get-token/403-response-body.json b/docs/api-endpoints/user/get-token/403-response-body.json index ab91d2ac..e609dd12 100644 --- a/docs/api-endpoints/user/get-token/403-response-body.json +++ b/docs/api-endpoints/user/get-token/403-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/403/forbidden", "title": "Forbidden", "status": 403, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested endpoint, element or action is forbidden." } \ No newline at end of file diff --git a/docs/api-endpoints/user/get-token/403-response-header.txt b/docs/api-endpoints/user/get-token/403-response-header.txt index 648f10ca..24c7a5ca 100644 --- a/docs/api-endpoints/user/get-token/403-response-header.txt +++ b/docs/api-endpoints/user/get-token/403-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 17:26:23 GMT +Date: Sat, 26 Sep 2026 21:53:48 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password-old.md b/docs/api-endpoints/user/post-change-password-old.md deleted file mode 100644 index a1d1fcc3..00000000 --- a/docs/api-endpoints/user/post-change-password-old.md +++ /dev/null @@ -1,204 +0,0 @@ -# POST` /change-password`Change Password Endpoint (Old) - - - - -> [!WARNING] -> You are looking at deprecated documentation. -> The behavior described on this page will be removed in version 0.2.0. -> For the up-to-date version of the `POST /change-password` endpoint, see [POST /change-password](/api-endpoints/user/post-change-password.md). - -Endpoint allows changing the user's current password. - -This endpoint requires knowing the currently used password. - -Using auth tokens for this endpoint is optional; they are just used for rate limiting here. - -## Request Body - -The posted request must be a valid JSON document. - -The request must contain the following attributes: - -- `type`: Contains the value `ActionChangePassword`. No other types are currently allowed. -- `currentPassword`: The user's currently used password in plain text. -- `newPassword`: The user's new password in plain text. -- `data.`: By default, `data.email` must contain a new unique string. While not required, keeping the - content within 256 bytes is encouraged. Optional limits might be added at a later time. - The required identifier name is returned by the - [instance configuration endpoint](/api-endpoints/get-instance-configuration) and in error messages. - -> [!SECURITY] -> **Notice regarding passwords**: They can contain any string and will be hashed internally. Whitespace at the start or -> end of the password will **not** be removed, though it is discouraged. No password complexity checks are performed. - -```json -{ - "type": "ActionChangePassword", - "currentPassword": "1234", - "newPassword": "9876", - "data": { - "": "test@example.com" - } -} -``` - -## Request Example - -```bash -curl \ - -X POST \ - -H "Content-Type: application/json" \ - -d '{"type": "ActionChangePassword", "currentPassword": "1234", "newPassword": "9876", "data": {"email": "test@example.com"}}' \ - https://api.localhost/change-password -``` - - - -### **🟢 Success 204** - -
Response Headers
- -[Response Body](./post-change-password-old/204-response-header.txt ':include :type=code') - -Success response does not have a return body. The password is successfully changed, and creating new tokens require the -new password. - -### **🔴 Error 400** - -
Response Headers
- -[Response Body](./post-change-password-old/400-response-header.txt ':include :type=code') - -
Response Body
- -[Response Body](./post-change-password-old/400-response-body.json ':include :type=code problem+json') - -### **🔴 Error 401** - -
Response Headers
- -[Response Body](./post-change-password-old/401-response-header.txt ':include :type=code') - -
Response Body
- -[Response Body](./post-change-password-old/401-response-body.json ':include :type=code problem+json') - -### **🔴 Error 403** - -
Response Headers
- -[Response Body](./post-change-password-old/403-response-header.txt ':include :type=code') - -
Response Body
- -[Response Body](./post-change-password-old/403-response-body.json ':include :type=code problem+json') - -### **🔴 Error 429** - -
Response Headers
- -[Response Body](./post-change-password-old/429-response-header.txt ':include :type=code') - -
Response Body
- -[Response Body](./post-change-password-old/429-response-body.json ':include :type=code problem+json') - - - - - -## Internal Workflow - -Once the server receives such a request, it checks several things internally: - -
- - - - diff --git a/docs/api-endpoints/user/post-change-password-old/400-response-body.json b/docs/api-endpoints/user/post-change-password-old/400-response-body.json deleted file mode 100644 index 381871a3..00000000 --- a/docs/api-endpoints/user/post-change-password-old/400-response-body.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "http://api/error/400/bad-content", - "title": "Bad content", - "status": 400, - "detail": "Endpoint expects property 'type' to be ActionChangePassword, got 'NotActionChangePassword'." -} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password-old/401-response-body.json b/docs/api-endpoints/user/post-change-password-old/401-response-body.json deleted file mode 100644 index f91c5190..00000000 --- a/docs/api-endpoints/user/post-change-password-old/401-response-body.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "http://api/error/401/unauthorized", - "title": "Unauthorized", - "status": 401, - "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." -} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password-old/403-response-body.json b/docs/api-endpoints/user/post-change-password-old/403-response-body.json deleted file mode 100644 index ab91d2ac..00000000 --- a/docs/api-endpoints/user/post-change-password-old/403-response-body.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "http://api/error/403/forbidden", - "title": "Forbidden", - "status": 403, - "detail": "Requested endpoint, element or action is forbidden." -} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password-old/429-response-body.json b/docs/api-endpoints/user/post-change-password-old/429-response-body.json deleted file mode 100644 index 4555b0be..00000000 --- a/docs/api-endpoints/user/post-change-password-old/429-response-body.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "401-unauthorized", - "title": "Request does not contain valid token, or anonymous user is disabled.", - "status": 401, - "detail": "" -} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password-old/429-response-header.txt b/docs/api-endpoints/user/post-change-password-old/429-response-header.txt deleted file mode 100644 index 0f6ebffa..00000000 --- a/docs/api-endpoints/user/post-change-password-old/429-response-header.txt +++ /dev/null @@ -1,9 +0,0 @@ -Access-Control-Allow-Origin: * -Access-Control-Expose-Headers: ETag, Location -Access-Control-Allow-Headers: * -Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, PATCH, DELETE -Allow: GET, POST, OPTIONS, PUT, PATCH, DELETE -Content-Type: application/json; charset=utf-8 -Cache-Control: no-cache, private -Date: Fri, 15 Sep 2023 08:03:41 GMT -Transfer-Encoding: chunked \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password.md b/docs/api-endpoints/user/post-change-password.md index c6c0a3de..484bba0b 100644 --- a/docs/api-endpoints/user/post-change-password.md +++ b/docs/api-endpoints/user/post-change-password.md @@ -3,11 +3,6 @@ -> [!NOTE] -> This endpoint's request body received a breaking change with version [0.1.6](https://github.com/ember-nexus/api/releases/tag/0.1.6). -> The previous variant is deprecated and will be removed in version 0.2.0. -> Link to the old documentation: [POST /change-password (old)](/api-endpoints/user/post-change-password-old.md). - Endpoint allows changing the user's current password. Knowledge of the currently used (old) password is required. ## Request Body diff --git a/docs/api-endpoints/user/post-change-password/204-response-header.txt b/docs/api-endpoints/user/post-change-password/204-response-header.txt index e37e0075..b34f2e37 100644 --- a/docs/api-endpoints/user/post-change-password/204-response-header.txt +++ b/docs/api-endpoints/user/post-change-password/204-response-header.txt @@ -2,7 +2,7 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private -Date: Fri, 29 Sep 2023 10:05:24 GMT +Date: Sat, 26 Sep 2026 21:53:49 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password/400-response-body.json b/docs/api-endpoints/user/post-change-password/400-response-body.json index d65e40af..70ef0eaf 100644 --- a/docs/api-endpoints/user/post-change-password/400-response-body.json +++ b/docs/api-endpoints/user/post-change-password/400-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/400/bad-content", "title": "Bad content", "status": 400, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Endpoint expects property 'type' to be ActionChangePassword, got string 'NotActionChangePassword'." } \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password/400-response-header.txt b/docs/api-endpoints/user/post-change-password/400-response-header.txt index de6a7dce..8962b997 100644 --- a/docs/api-endpoints/user/post-change-password/400-response-header.txt +++ b/docs/api-endpoints/user/post-change-password/400-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 29 Sep 2023 10:11:29 GMT +Date: Sat, 26 Sep 2026 21:53:49 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password/401-response-body.json b/docs/api-endpoints/user/post-change-password/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/user/post-change-password/401-response-body.json +++ b/docs/api-endpoints/user/post-change-password/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password/401-response-header.txt b/docs/api-endpoints/user/post-change-password/401-response-header.txt index 83c3ceec..8962b997 100644 --- a/docs/api-endpoints/user/post-change-password/401-response-header.txt +++ b/docs/api-endpoints/user/post-change-password/401-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 29 Sep 2023 10:14:53 GMT +Date: Sat, 26 Sep 2026 21:53:49 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password/403-response-body.json b/docs/api-endpoints/user/post-change-password/403-response-body.json index ab91d2ac..e609dd12 100644 --- a/docs/api-endpoints/user/post-change-password/403-response-body.json +++ b/docs/api-endpoints/user/post-change-password/403-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/403/forbidden", "title": "Forbidden", "status": 403, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested endpoint, element or action is forbidden." } \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password/403-response-header.txt b/docs/api-endpoints/user/post-change-password/403-response-header.txt index ee6d8e82..8962b997 100644 --- a/docs/api-endpoints/user/post-change-password/403-response-header.txt +++ b/docs/api-endpoints/user/post-change-password/403-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 29 Sep 2023 17:33:00 GMT +Date: Sat, 26 Sep 2026 21:53:49 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password/429-response-body.json b/docs/api-endpoints/user/post-change-password/429-response-body.json index 4555b0be..d1552ef4 100644 --- a/docs/api-endpoints/user/post-change-password/429-response-body.json +++ b/docs/api-endpoints/user/post-change-password/429-response-body.json @@ -2,5 +2,6 @@ "type": "401-unauthorized", "title": "Request does not contain valid token, or anonymous user is disabled.", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "" } \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register-old.md b/docs/api-endpoints/user/post-register-old.md deleted file mode 100644 index 439f1101..00000000 --- a/docs/api-endpoints/user/post-register-old.md +++ /dev/null @@ -1,169 +0,0 @@ -# POST` /register`Register New Account Endpoint (Old) - - - - -> [!WARNING] -> You are looking at deprecated documentation. -> The behavior described on this page will be removed in version 0.2.0. -> For the up-to-date version of the `POST /register` endpoint, see [POST /register](/api-endpoints/user/post-register.md). - -Endpoint for registering / creating new accounts. - -The endpoint can be disabled; see -[application configuration](/getting-started/configuration?id=application-configuration) for details. - -## Request Body - -The posted request must be a valid JSON document. - -The request must contain the following attributes: - -- `type`: Containing the content "User". No other values are currently possible. -- `password`: The plain text password of the new user. It can contain any string and will be hashed internally. - Whitespace at the start or end of the string will **not** be removed, though it is discouraged. - No password complexity check is performed. -- `data.`: By default, `data.email` must contain a new unique string. While not required, keeping the - content within 256 bytes is encouraged. Optional limits might be added at a later time. - The required identifier name is returned by the - [instance configuration endpoint](/api-endpoints/get-instance-configuration) and in error messages. - -```json -{ - "type": "User", - "password": "1234", - "data": { - "": "test@example.com" - } -} -``` - -## Request Example - -```bash -curl \ - -X POST \ - -H "Content-Type: application/json" \ - -d '{"type": "User", "password": "1234", "data": {"email": "test@example.com"}}' \ - https://api.localhost/register -``` - - - -### **🟢 Success 201** - -
Response Headers
- -[Response Body](./post-register-old/201-response-header.txt ':include :type=code') - -Success response does not have a return body. The location of the new user, containing the user's UUID, is written in -the `Location` header. - -### **🔴 Error 400** - -
Response Headers
- -[Response Body](./post-register-old/400-response-header.txt ':include :type=code') - -
Response Body
- -[Response Body](./post-register-old/400-response-body.json ':include :type=code problem+json') - -### **🔴 Error 403** - -
Response Headers
- -[Response Body](./post-register-old/403-response-header.txt ':include :type=code') - -
Response Body
- -[Response Body](./post-register-old/403-response-body.json ':include :type=code problem+json') - - - - - -## Internal Workflow - -Once the server receives such a request, it checks several things internally: - -
- - - - diff --git a/docs/api-endpoints/user/post-register-old/201-response-header.txt b/docs/api-endpoints/user/post-register-old/201-response-header.txt deleted file mode 100644 index a3660157..00000000 --- a/docs/api-endpoints/user/post-register-old/201-response-header.txt +++ /dev/null @@ -1,11 +0,0 @@ -Access-Control-Allow-Headers: * -Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Access-Control-Allow-Origin: * -Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Cache-Control: no-cache, private -Content-Type: text/html; charset=UTF-8 -Date: Sat, 16 Sep 2023 12:40:24 GMT -Location: /a5f95955-1d24-43db-8832-f365e6a96dfa -Transfer-Encoding: chunked -X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register-old/400-response-body.json b/docs/api-endpoints/user/post-register-old/400-response-body.json deleted file mode 100644 index ae00c917..00000000 --- a/docs/api-endpoints/user/post-register-old/400-response-body.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "http://api/error/400/bad-content", - "title": "Bad content", - "status": 400, - "detail": "Endpoint expects property 'type' to be User, got 'NotAUser'." -} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register-old/403-response-body.json b/docs/api-endpoints/user/post-register-old/403-response-body.json deleted file mode 100644 index ab91d2ac..00000000 --- a/docs/api-endpoints/user/post-register-old/403-response-body.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "http://api/error/403/forbidden", - "title": "Forbidden", - "status": 403, - "detail": "Requested endpoint, element or action is forbidden." -} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register-old/403-response-header.txt b/docs/api-endpoints/user/post-register-old/403-response-header.txt deleted file mode 100644 index 77bf2279..00000000 --- a/docs/api-endpoints/user/post-register-old/403-response-header.txt +++ /dev/null @@ -1,10 +0,0 @@ -Access-Control-Allow-Headers: * -Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Access-Control-Allow-Origin: * -Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Cache-Control: no-cache, private -Content-Type: application/problem+json; charset=utf-8 -Date: Sat, 16 Sep 2023 12:55:27 GMT -Transfer-Encoding: chunked -X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register.md b/docs/api-endpoints/user/post-register.md index 431deb6f..155d8ce9 100644 --- a/docs/api-endpoints/user/post-register.md +++ b/docs/api-endpoints/user/post-register.md @@ -3,11 +3,6 @@ -> [!NOTE] -> This endpoint's request body received a breaking change with version [0.1.6](https://github.com/ember-nexus/api/releases/tag/0.1.6). -> The previous variant is deprecated and will be removed in version 0.2.0. -> Link to the old documentation: [POST /register (old)](/api-endpoints/user/post-register-old.md). - Endpoint for registering / creating new accounts. The endpoint can be disabled; see diff --git a/docs/api-endpoints/user/post-register/201-response-header.txt b/docs/api-endpoints/user/post-register/201-response-header.txt index a3660157..9cd5b315 100644 --- a/docs/api-endpoints/user/post-register/201-response-header.txt +++ b/docs/api-endpoints/user/post-register/201-response-header.txt @@ -2,10 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: text/html; charset=UTF-8 -Date: Sat, 16 Sep 2023 12:40:24 GMT -Location: /a5f95955-1d24-43db-8832-f365e6a96dfa +Date: Sat, 26 Sep 2026 21:53:49 GMT +Location: /1361c76a-33e6-49fc-a221-12d7e50e6e71 Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register/400-response-body.json b/docs/api-endpoints/user/post-register/400-response-body.json index f4c0a215..b1465e32 100644 --- a/docs/api-endpoints/user/post-register/400-response-body.json +++ b/docs/api-endpoints/user/post-register/400-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/400/bad-content", "title": "Bad content", "status": 400, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Endpoint expects property 'type' to be User, got string 'NotAUser'." } \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register/400-response-header.txt b/docs/api-endpoints/user/post-register/400-response-header.txt index b09a0f20..8962b997 100644 --- a/docs/api-endpoints/user/post-register/400-response-header.txt +++ b/docs/api-endpoints/user/post-register/400-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Sat, 16 Sep 2023 12:40:25 GMT +Date: Sat, 26 Sep 2026 21:53:49 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register/403-response-body.json b/docs/api-endpoints/user/post-register/403-response-body.json index ab91d2ac..c4b86c6e 100644 --- a/docs/api-endpoints/user/post-register/403-response-body.json +++ b/docs/api-endpoints/user/post-register/403-response-body.json @@ -1,6 +1,7 @@ { - "type": "http://api/error/403/forbidden", + "type": "http://api-different-configuration/error/403/forbidden", "title": "Forbidden", "status": 403, + "instance": "urn:uuid:7ade7fdf-f7f7-47a0-bd83-2ab01d68f36c", "detail": "Requested endpoint, element or action is forbidden." } \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register/403-response-header.txt b/docs/api-endpoints/user/post-register/403-response-header.txt index 77bf2279..ab53191f 100644 --- a/docs/api-endpoints/user/post-register/403-response-header.txt +++ b/docs/api-endpoints/user/post-register/403-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private +Content-Length: 220 Content-Type: application/problem+json; charset=utf-8 -Date: Sat, 16 Sep 2023 12:55:27 GMT -Transfer-Encoding: chunked +Date: Sun, 27 Sep 2026 08:41:32 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token-old.md b/docs/api-endpoints/user/post-token-old.md deleted file mode 100644 index 6ddc755d..00000000 --- a/docs/api-endpoints/user/post-token-old.md +++ /dev/null @@ -1,164 +0,0 @@ -# POST` /token`Create Token Endpoint (Old) - - - - -> [!WARNING] -> You are looking at deprecated documentation. -> The behavior described on this page will be removed in version 0.2.0. -> For the up-to-date version of the `POST /token` endpoint, see [POST /token](/api-endpoints/user/post-token.md). - -Endpoint for creating new tokens. - -The endpoint can be configured; see -[application configuration](/getting-started/configuration?id=application-configuration) for details. - -## Request Body - -The posted request must be a valid JSON document. - -The request must contain the following attributes: - -- `type`: Containing the content "Token". No other values are currently possible. -- `user`: The value for the user's identifying property, by default the user's email address. -- `password`: The plain text password of the user. -- `data`: Object of properties, optional. - -```json -{ - "type": "Token", - "user": "test@localhost.dev", - "password": "1234", - "data": { - "key": "value" - } -} -``` - -## Request Example - -```bash -curl \ - -X POST \ - -H "Content-Type: application/json" \ - -d '{"type": "Token", "user": "test@localhost.dev", "password": "1234"}' \ - https://api.localhost/token -``` - - - -### **🟢 Success 201** - -
Response Headers
- -[Response Body](./post-token-old/201-response-header.txt ':include :type=code') - -
Response Body
- -[Response Body](./post-token-old/201-response-body.json ':include :type=code') - -### **🔴 Error 400** - -
Response Headers
- -[Response Body](./post-token-old/400-response-header.txt ':include :type=code') - -
Response Body
- -[Response Body](./post-token-old/400-response-body.json ':include :type=code problem+json') - -### **🔴 Error 401** - -
Response Headers
- -[Response Body](./post-token-old/401-response-header.txt ':include :type=code') - -
Response Body
- -[Response Body](./post-token-old/401-response-body.json ':include :type=code problem+json') - - - - - -## Internal Workflow - -Once the server receives such a request, it checks several things internally: - -
- - - - diff --git a/docs/api-endpoints/user/post-token-old/201-response-body.json b/docs/api-endpoints/user/post-token-old/201-response-body.json deleted file mode 100644 index 5745054e..00000000 --- a/docs/api-endpoints/user/post-token-old/201-response-body.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "_TokenResponse", - "token": "secret-token:ERgAAnWl0CY8bQs0m11nZ3" -} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token-old/400-response-body.json b/docs/api-endpoints/user/post-token-old/400-response-body.json deleted file mode 100644 index a59d70e2..00000000 --- a/docs/api-endpoints/user/post-token-old/400-response-body.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "http://api/error/400/missing-property", - "title": "Missing property", - "status": 400, - "detail": "Endpoint requires that the request contains property 'password' to be set to string." -} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token-old/400-response-header.txt b/docs/api-endpoints/user/post-token-old/400-response-header.txt deleted file mode 100644 index 7862b9ce..00000000 --- a/docs/api-endpoints/user/post-token-old/400-response-header.txt +++ /dev/null @@ -1,10 +0,0 @@ -Access-Control-Allow-Headers: * -Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Access-Control-Allow-Origin: * -Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Cache-Control: no-cache, private -Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 16:29:29 GMT -Transfer-Encoding: chunked -X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token-old/401-response-body.json b/docs/api-endpoints/user/post-token-old/401-response-body.json deleted file mode 100644 index f91c5190..00000000 --- a/docs/api-endpoints/user/post-token-old/401-response-body.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "http://api/error/401/unauthorized", - "title": "Unauthorized", - "status": 401, - "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." -} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token-old/401-response-header.txt b/docs/api-endpoints/user/post-token-old/401-response-header.txt deleted file mode 100644 index 35897218..00000000 --- a/docs/api-endpoints/user/post-token-old/401-response-header.txt +++ /dev/null @@ -1,10 +0,0 @@ -Access-Control-Allow-Headers: * -Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Access-Control-Allow-Origin: * -Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK -Cache-Control: no-cache, private -Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 16:32:25 GMT -Transfer-Encoding: chunked -X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token.md b/docs/api-endpoints/user/post-token.md index b1333702..bb143811 100644 --- a/docs/api-endpoints/user/post-token.md +++ b/docs/api-endpoints/user/post-token.md @@ -3,11 +3,6 @@ -> [!NOTE] -> This endpoint's request body received a breaking change with version [0.1.6](https://github.com/ember-nexus/api/releases/tag/0.1.6). -> The previous variant is deprecated and will be removed in version 0.2.0. -> Link to the old documentation: [POST /token (old)](/api-endpoints/user/post-token-old.md). - Endpoint for creating new tokens. The endpoint can be configured; see diff --git a/docs/api-endpoints/user/post-token/201-response-header.txt b/docs/api-endpoints/user/post-token/201-response-header.txt index 5c78b3c0..c901a9ff 100644 --- a/docs/api-endpoints/user/post-token/201-response-header.txt +++ b/docs/api-endpoints/user/post-token/201-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Fri, 06 Oct 2023 16:26:13 GMT +Date: Sat, 26 Sep 2026 21:53:50 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token/400-response-body.json b/docs/api-endpoints/user/post-token/400-response-body.json index a59d70e2..f03bfe63 100644 --- a/docs/api-endpoints/user/post-token/400-response-body.json +++ b/docs/api-endpoints/user/post-token/400-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/400/missing-property", "title": "Missing property", "status": 400, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Endpoint requires that the request contains property 'password' to be set to string." } \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token/400-response-header.txt b/docs/api-endpoints/user/post-token/400-response-header.txt index 7862b9ce..436df1d5 100644 --- a/docs/api-endpoints/user/post-token/400-response-header.txt +++ b/docs/api-endpoints/user/post-token/400-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 16:29:29 GMT +Date: Sat, 26 Sep 2026 21:53:50 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token/401-response-body.json b/docs/api-endpoints/user/post-token/401-response-body.json index f91c5190..18c2b31a 100644 --- a/docs/api-endpoints/user/post-token/401-response-body.json +++ b/docs/api-endpoints/user/post-token/401-response-body.json @@ -2,5 +2,6 @@ "type": "http://api/error/401/unauthorized", "title": "Unauthorized", "status": 401, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Authorization for the request failed due to possible problems with the token (incorrect or expired), password (incorrect or changed), the user's unique identifier, or the user's status (e.g., missing, blocked, or deleted)." } \ No newline at end of file diff --git a/docs/api-endpoints/user/post-token/401-response-header.txt b/docs/api-endpoints/user/post-token/401-response-header.txt index 35897218..436df1d5 100644 --- a/docs/api-endpoints/user/post-token/401-response-header.txt +++ b/docs/api-endpoints/user/post-token/401-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: DELETE, GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 06 Oct 2023 16:32:25 GMT +Date: Sat, 26 Sep 2026 21:53:50 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/commands/assets/backup-create-help.html b/docs/commands/assets/backup-create-help.html index d3875a8e..1ffcdb97 100644 --- a/docs/commands/assets/backup-create-help.html +++ b/docs/commands/assets/backup-create-help.html @@ -38,6 +38,28 @@
+Description:
+  Creates a new backup.
+
+Usage:
+  backup:create [options] [--] [<name>]
+
+Arguments:
+  name                          Name of the backup, defaults to the current timestamp. [default: "20260923234945"]
+
+Options:
+  -p, --pretty|--no-pretty      Activates pretty print of JSON.
+      --no-files|--no-no-files  Disable file export.
+  -h, --help                    Display help for the given command. When no command is given display help for the list command
+      --silent                  Do not output any message
+  -q, --quiet                   Only errors are displayed. All other output is suppressed
+  -V, --version                 Display this application version
+      --ansi|--no-ansi          Force (or disable --no-ansi) ANSI output
+  -n, --no-interaction          Do not ask any interactive question
+  -e, --env=ENV                 The Environment name. [default: "prod"]
+      --no-debug                Switch off debug mode.
+      --profile                 Enables profiling (requires debug).
+  -v|vv|vvv, --verbose          Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
 
\ No newline at end of file diff --git a/docs/commands/assets/backup-create.html b/docs/commands/assets/backup-create.html index 8a63c3e6..ae238268 100644 --- a/docs/commands/assets/backup-create.html +++ b/docs/commands/assets/backup-create.html @@ -38,30 +38,29 @@
-TypeError {#397
-  #message: "App\Command\BackupCreateCommand::__construct(): Argument #5 ($elementService) must be of type App\Service\ElementService, Symfony\Component\DependencyInjection\ParameterBag\ContainerBag given, called in /var/www/html/var/cache/prod/ContainerRnqxWK2/getBackupCreateCommandService.php on line 21"
-  #code: 0
-  #file: "./src/Command/BackupCreateCommand.php"
-  #line: 53
-  trace: {
-    ./src/Command/BackupCreateCommand.php:53 {
-      App\Command\BackupCreateCommand->__construct(ElementManager $elementManager, EntityManager $cypherEntityManager, FilesystemOperator $backupStorage, ElementToRawService $elementToRawService, ElementService $elementService, ParameterBagInterface $bag, FileService $fileService, S3Service $s3Service, FileOperationFactory $fileOperationFactory, Server500LogicExceptionFactory $server500LogicExceptionFactory)
-      › private ElementToRawService $elementToRawService,
-      › private ElementService $elementService,
-      › private ParameterBagInterface $bag,
-    }
-    ./var/cache/prod/ContainerRnqxWK2/getBackupCreateCommandService.php:21 { …}
-    ./var/cache/prod/ContainerRnqxWK2/App_KernelProdContainer.php:130 { …}
-    ./var/cache/prod/ContainerRnqxWK2/getBackupCreateCommand_LazyService.php:21 { …}
-    ./vendor/symfony/console/Command/LazyCommand.php:189 { …}
-    ./vendor/symfony/console/Application.php:352 { …}
-    ./vendor/symfony/framework-bundle/Console/Application.php:77 { …}
-    ./vendor/symfony/console/Application.php:195 { …}
-    ./vendor/symfony/runtime/Runner/Symfony/ConsoleApplicationRunner.php:49 { …}
-    ./vendor/autoload_runtime.php:32 { …}
-    ./bin/console:11 { …}
-  }
-}
+
+    ▄
+  ▄▀ ▀▄  Ember Nexus API
+   ▀█▀   v0.1.31, production mode
+
+  Backup Create
+
+Creating backup test in folder ./var/backup
+
+┌ Step 1 of 3: Backing up Nodes ────────────────────────────────────────────────
+│ Found 0 nodes.
+└ Successfully backed up 0 nodes.
+
+┌ Step 2 of 3: Backing up Relations ────────────────────────────────────────────
+│ Found 0 relations.
+└ Successfully backed up 0 relations.
+
+┌ Step 3 of 3: Backing up Files ────────────────────────────────────────────────
+│ Found 0 files.
+└ File backup skipped.
+
+▶ Backup finished successfully.
+
 
\ No newline at end of file diff --git a/docs/commands/assets/backup-fetch.html b/docs/commands/assets/backup-fetch.html index f27fcd71..b2b09c5e 100644 --- a/docs/commands/assets/backup-fetch.html +++ b/docs/commands/assets/backup-fetch.html @@ -46,9 +46,9 @@ Backup Fetch ┌ Downloading and inspecting archive ─────────────────────────────────────────── -│ Loaded archive is 313.67 KB big. -│ SHA1 sum of file is f232952d9fc8a742d7f8441e97d3ceeafb14622a. -│ Found backup inside ZIP in folder reference-dataset-0.0.19. +│ Loaded archive is 7.39 MB big. +│ SHA1 sum of file is a3829eb9c3286c0b77106293136505a84242ba8e. +│ Found backup inside ZIP in folder reference-dataset-0.0.33. │ Required folders exist. └ Download complete. diff --git a/docs/commands/assets/backup-list.html b/docs/commands/assets/backup-list.html index 9c6aa938..cf892917 100644 --- a/docs/commands/assets/backup-list.html +++ b/docs/commands/assets/backup-list.html @@ -45,8 +45,9 @@ Backup List - Name Hostname Version Date Nodes Edges Files - reference-dataset ember-nexus dev 2023-05-06 282 338 0 + Name Hostname Version Date Nodes Edges Files + test 8304be5e95d9 0.1.31 2026-09-26 0 0 0 + reference-dataset ember-nexus dev 2023-05-06 790 1614 69 ▶ Command finished successfully. diff --git a/docs/commands/assets/backup-load-help.html b/docs/commands/assets/backup-load-help.html index c0383933..8b3ef388 100644 --- a/docs/commands/assets/backup-load-help.html +++ b/docs/commands/assets/backup-load-help.html @@ -42,12 +42,13 @@ Loads a local backup into the empty database. Usage: - backup:load <name> + backup:load [options] [--] <name> Arguments: name Name of the backup Options: + --skip-verify Skip verifying file contents against their stored 'file.hash' values -h, --help Display help for the given command. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed diff --git a/docs/commands/assets/backup-load.html b/docs/commands/assets/backup-load.html index ad2ed1ab..bf1894a8 100644 --- a/docs/commands/assets/backup-load.html +++ b/docs/commands/assets/backup-load.html @@ -46,13 +46,13 @@ Backup Load ┌ Step 1 of 4: Loading Nodes ─────────────────────────────────────────────────── -└ Loaded 306 nodes. +└ Loaded 790 nodes. ┌ Step 2 of 4: Loading Relations ─────────────────────────────────────────────── -└ Loaded 356 relations. +└ Loaded 1614 relations. ┌ Step 3 of 4: Loading Files ─────────────────────────────────────────────────── -└ Loaded 0 files. +└ Loaded 69 files. ┌ Step 4 of 4: After Backup Tasks ────────────────────────────────────────────── └ Finished all after backup tasks. diff --git a/docs/commands/assets/token-create.html b/docs/commands/assets/token-create.html index b8eae7a2..3e7f2b2d 100644 --- a/docs/commands/assets/token-create.html +++ b/docs/commands/assets/token-create.html @@ -45,9 +45,9 @@ Token Create - Found user with identifier 'token-create-test@localhost.dev', user's UUID is ef73711d-30cc-4e6a-be32-b69389e5522b. + Found user with identifier 'token-create-test@localhost.dev', user's UUID is 5d26f447-a875-4b98-9dc6-a069f222caca. -▶ Successfully created new token: secret-token:BmTOWN1qv6gaFagt320Ntq . +▶ Successfully created new token: secret-token:Q869bcS10ofGIT8E9gHKDm . diff --git a/docs/commands/assets/token-revoke.html b/docs/commands/assets/token-revoke.html index a8c58c65..b34c4670 100644 --- a/docs/commands/assets/token-revoke.html +++ b/docs/commands/assets/token-revoke.html @@ -45,19 +45,19 @@ Token Revocation - 51 tokens are affected by revocation. First 10 are shown: + 91 tokens are affected by revocation. First 10 are shown: - Token UUID User UUID User identifier Created Expires on - 571b30e2-40eb-43f5-a888-a6b7ec6c15fc fdf0509d-812c-4435-a863-a4fd6f17e9c4 user@relation.postIndex.endpoint.localhost.dev 2026-04-14 21:01:58 - - 6009d74a-1db9-4d1a-bc16-a948706dca58 2274873a-b9a2-4e77-b330-900857b76c1d user@node.postIndex.endpoint.localhost.dev 2026-04-14 21:01:58 - - 84e32a4f-840b-435a-a064-2e72f19d8b20 53ae199c-6b71-44c1-b10c-fca306b32cbf user@relation.putElement.endpoint.localhost.dev 2026-04-14 21:01:58 - - ca9fa4fe-1fbd-4e8a-a6bf-701d97a7ec12 7935a9d2-e453-4dec-bc4a-243e3ced678a user@node.putElement.endpoint.localhost.dev 2026-04-14 21:01:58 - - 840fee16-996e-4b1a-af2a-77d77c4aeda7 a70e49dc-ec45-414a-beb2-43e6fa56eb41 user@relation.deleteElement.endpoint.localhost.dev 2026-04-14 21:01:58 - - 374fa7ea-4df7-475c-a6a6-b35ff7b9bae1 1a0fd886-9f62-497f-8d20-bd61a40b49b7 user@node.deleteElement.endpoint.localhost.dev 2026-04-14 21:01:58 - - 9543e70d-6552-4409-ab6a-dfaf003382a2 8d70608f-ebca-4077-98d2-12733b7fd08e user@relation.patchElement.endpoint.localhost.dev 2026-04-14 21:01:58 - - 5f450171-c535-4365-ac70-a283955d4ff5 4250a83d-707b-4f7a-b7dc-225af430f093 user@node.patchElement.endpoint.localhost.dev 2026-04-14 21:01:58 - - befd839e-5711-414e-8fe3-a8a3225dd603 1eb25cca-9c9e-4ec9-a9be-9044741b2b8c user@deleteToken.user.endpoint.localhost.dev 2026-04-14 21:01:58 - - ed1891bf-d7a8-4e64-ba0d-ebec82ad173e 1eb25cca-9c9e-4ec9-a9be-9044741b2b8c user@deleteToken.user.endpoint.localhost.dev 2026-04-14 21:01:58 - + Token UUID User UUID User identifier Created Expires on + 0110cadf-07a6-4c8b-8f8c-ad4ffb47dedb 332b9edb-c015-4fdf-bfb3-b02302618f7b user@delete-owns-relation.etag.general.localhost.dev 2020-01-01 00:00:00 - + 281957bc-6764-43bb-a56f-6f9af99fd7c8 3977e2e8-2756-4cd9-a34d-6189b5d920e8 user@update-node.etag.general.localhost.dev 2020-01-01 00:00:00 - + 409eacbc-0f5e-43ff-9604-cafed272b63f 42c7c48b-8d0b-4b1f-aabf-8d12ec12851f user@issuedBefore.revoke.token.command.localhost.dev 2020-01-01 00:00:00 - + 53e802e6-7e7e-4707-ad61-641d8d6a59bc 03c72577-080e-480e-b02d-d86c81fec11e user@maximum.etag.general.localhost.dev 2020-01-01 00:00:00 - + 59eacea8-7a11-4563-a3b0-bb190cc2b6a7 752ab248-05b5-4307-984b-d2591d102c31 user@update-owns-relation.etag.general.localhost.dev 2020-01-01 00:00:00 - + 5d1263c9-e7d8-420a-9356-cea900d141de ec30da95-7eef-43f9-86f4-cc76765f4523 user@create-node.etag.general.localhost.dev 2020-01-01 00:00:00 - + 6451df27-9a2d-489b-9371-0aba1f0afbf2 e31c671c-e736-4122-b9a9-d087286a48a2 user@if-match.general.localhost.dev 2020-01-01 00:00:00 - + 9af6704e-3b1e-4144-a944-48e0ee4d40c1 77454c09-eb46-4df8-aa41-cbd3e190d043 user@if-none-match.general.localhost.dev 2020-01-01 00:00:00 - + 9b008ec6-fbd7-49e3-8578-336755f94dbe f55aa4a1-5352-4881-ab56-203e5fce0497 user@create-owns-relation.etag.general.localhost.dev 2020-01-01 00:00:00 - + 9e140160-608d-4533-9c2a-93b87ea873a8 f6eb8924-ea5e-4c29-a59e-02e8e21f41e9 user@create-normal-relation.etag.general.localhost.dev 2020-01-01 00:00:00 - Revoking tokens... diff --git a/docs/commands/assets/user-create.html b/docs/commands/assets/user-create.html index a4317a4c..5560c388 100644 --- a/docs/commands/assets/user-create.html +++ b/docs/commands/assets/user-create.html @@ -45,7 +45,7 @@ User Create -▶ Created user with email 'command-test@localhost.dev' successfully, UUID is 2348c6c0-ac64-40ac-9a66-7a47f89df52f. +▶ Created user with email 'command-test@localhost.dev' successfully, UUID is fb214f0f-f1b1-433b-b9f1-a569e0f9fe9d. diff --git a/docs/concepts/caching.md b/docs/concepts/caching.md index 80618b8f..ea08c720 100644 --- a/docs/concepts/caching.md +++ b/docs/concepts/caching.md @@ -19,6 +19,10 @@ Ember Nexus API supports the headers to determine whether cached content is stil - `If-Match`, see [MDN's documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/If-Match). - `If-None-Match`, see [MDN's documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/If-None-Match). +Note that `If-Match` is checked before the write, but is not atomic with it: a concurrent write between the check and +the actual write is possible and is not detected. `If-Match` therefore reduces, but does not eliminate, the chance of +overwriting a concurrent change. + The value of the `Etag` header is calculated as the XXH3 hash from the identifiers and `updatedAt` timestamps of all involved elements. As the `updatedAt` timestamp uses microsecond precision, the resulting `Etag` will be different as long as updates diff --git a/docs/example/default-parameters.yaml b/docs/example/default-parameters.yaml index 07ee11a3..76ae6f06 100644 --- a/docs/example/default-parameters.yaml +++ b/docs/example/default-parameters.yaml @@ -70,12 +70,6 @@ ember_nexus: # Maximum number of items in a collection for which to generate Etags. Bigger collection do not contain Etags due to performance problems. etagUpperLimitInCollectionEndpoints: 100 - # Configures feature flags. - featureFlag: - - # If true, the original behavior of some user endpoints in versions before 0.1.6 will no longer be supported. Old behavior will be dropped with version 0.2.0. See GitHub issue #280. - 280_OldUniqueUserIdentifierDisabled: false - # Configures behavior related to files. file: diff --git a/docs/open-api/examples/NotFoundProblemExample.json b/docs/open-api/examples/NotFoundProblemExample.json index a583172d..001ea62a 100644 --- a/docs/open-api/examples/NotFoundProblemExample.json +++ b/docs/open-api/examples/NotFoundProblemExample.json @@ -4,6 +4,7 @@ "type": "http://api/error/404/not-found", "title": "Not Found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } } diff --git a/docs/open-api/schemas/Problem.json b/docs/open-api/schemas/Problem.json index ea41edc9..03e61c19 100644 --- a/docs/open-api/schemas/Problem.json +++ b/docs/open-api/schemas/Problem.json @@ -1,7 +1,8 @@ { "$schema": "https://spec.openapis.org/oas/3.0/schema/2021-09-28#/definitions/Schema", "type": "object", - "description": "RFC 7807 Problem Details object.", + "description": "RFC 9457 Problem Details object. Some problems carry additional camelCase members, e.g. `expectedOffset` and `providedOffset` (409 on a resumable upload) or `totalLength`, `requestedStart` and `requestedEnd` (416 on a file range request).", + "additionalProperties": true, "properties": { "type": { "type": "string", @@ -15,6 +16,32 @@ }, "detail": { "type": "string" + }, + "instance": { + "type": "string", + "format": "uri", + "description": "Identifies this occurrence of the problem. The id is the request id of the application log (`requestId`) and of the web server access log (`request_id`).", + "example": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f" + }, + "expectedOffset": { + "type": "integer", + "description": "409 on a resumable upload: the offset the upload is currently at." + }, + "providedOffset": { + "type": "integer", + "description": "409 on a resumable upload: the offset which was sent with the request." + }, + "totalLength": { + "type": "integer", + "description": "416 on a file range request: the length of the file in bytes." + }, + "requestedStart": { + "type": "integer", + "description": "416 on a file range request: the requested first byte." + }, + "requestedEnd": { + "type": "integer", + "description": "416 on a file range request: the requested last byte." } } } diff --git a/docs/open-api/swagger.json b/docs/open-api/swagger.json index 2619682f..716f372f 100644 --- a/docs/open-api/swagger.json +++ b/docs/open-api/swagger.json @@ -59,7 +59,8 @@ "Problem": { "$schema": "https://spec.openapis.org/oas/3.0/schema/2021-09-28#/definitions/Schema", "type": "object", - "description": "RFC 7807 Problem Details object.", + "description": "RFC 9457 Problem Details object. Some problems carry additional camelCase members, e.g. `expectedOffset` and `providedOffset` (409 on a resumable upload) or `totalLength`, `requestedStart` and `requestedEnd` (416 on a file range request).", + "additionalProperties": true, "properties": { "type": { "type": "string", @@ -73,6 +74,32 @@ }, "detail": { "type": "string" + }, + "instance": { + "type": "string", + "format": "uri", + "description": "Identifies this occurrence of the problem. The id is the request id of the application log (`requestId`) and of the web server access log (`request_id`).", + "example": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f" + }, + "expectedOffset": { + "type": "integer", + "description": "409 on a resumable upload: the offset the upload is currently at." + }, + "providedOffset": { + "type": "integer", + "description": "409 on a resumable upload: the offset which was sent with the request." + }, + "totalLength": { + "type": "integer", + "description": "416 on a file range request: the length of the file in bytes." + }, + "requestedStart": { + "type": "integer", + "description": "416 on a file range request: the requested first byte." + }, + "requestedEnd": { + "type": "integer", + "description": "416 on a file range request: the requested last byte." } } } @@ -84,6 +111,7 @@ "type": "http://api/error/404/not-found", "title": "Not Found", "status": 404, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", "detail": "Requested element was not found." } } diff --git a/docs/open-api/swagger_old.yml b/docs/open-api/swagger_old.yml index e0c8ff24..cb992cf0 100644 --- a/docs/open-api/swagger_old.yml +++ b/docs/open-api/swagger_old.yml @@ -189,4 +189,4 @@ components: type: http://api/error/404/not-found title: Not Found status: 404 - detail: Requested element was not found. \ No newline at end of file + detail: Requested element was not found. diff --git a/docs/search/example/cypher-path-subset/find-nested-elements/response-body.json b/docs/search/example/cypher-path-subset/find-nested-elements/response-body.json index 5b427658..4e6cdec7 100644 --- a/docs/search/example/cypher-path-subset/find-nested-elements/response-body.json +++ b/docs/search/example/cypher-path-subset/find-nested-elements/response-body.json @@ -7,8 +7,8 @@ "start": "16884ac8-ba7a-4c91-8449-4fdd803644b6", "end": "b7cda919-deea-49f9-9151-49132f83cbff", "data": { - "created": "2025-09-25T18:43:24+00:00", - "updated": "2025-09-25T18:43:24+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } }, @@ -18,8 +18,8 @@ "start": "9abc0ab3-fa1a-4138-80c1-49db5c4d082f", "end": "30cb4ff2-591f-4fba-aa47-ca6ada630b9a", "data": { - "created": "2025-09-25T18:43:24+00:00", - "updated": "2025-09-25T18:43:24+00:00", + "created": "2026-09-24T19:04:17+00:00", + "updated": "2026-09-24T19:04:17+00:00", "scenario": "general.botanicExample" } }, @@ -27,9 +27,9 @@ "type": "Taxon", "id": "16884ac8-ba7a-4c91-8449-4fdd803644b6", "data": { - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "Gloriosa", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Colchicaceae/Gloriosa", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_(plant)", @@ -40,10 +40,10 @@ "type": "Taxon", "id": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { + "created": "2026-09-24T19:04:01+00:00", "description": "Liliales is an order of monocotyledonous flowering plants in the Angiosperm Phylogeny Group and Angiosperm Phylogeny Web system, within the lilioid monocots. This order of necessity includes the family Liliaceae. The APG III system (2009) places this order in the monocot clade. In APG III, the family Luzuriagaceae is combined with the family Alstroemeriaceae and the family Petermanniaceae is recognized. Both the order Lililiales and the family Liliaceae have had a widely disputed history, with the circumscription varying greatly from one taxonomist to another. Previous members of this order, which at one stage included most monocots with conspicuous tepals and lacking starch in the endosperm are now distributed over three orders, Liliales, Dioscoreales and Asparagales, using predominantly molecular phylogenetics. The newly delimited Liliales is monophyletic, with ten families. Well known plants from the order include Lilium (lily), tulip, the North American wildflower Trillium, and greenbrier.\n\nThus circumscribed, this order consists mostly of herbaceous plants, but lianas and shrubs also occur. They are mostly perennial plants, with food storage organs such as corms or rhizomes. The family Corsiaceae is notable for being heterotrophic.\n\nThe order has worldwide distribution. The larger families (with more than 100 species) are roughly confined to the Northern Hemisphere, or are distributed worldwide, centering on the north. On the other hand, the smaller families (with up to 10 species) are confined to the Southern Hemisphere, or sometimes just to Australia or South America. The total number of species in the order is now about 1768.\n\nAs with any herbaceous group, the fossil record of the Liliales is rather scarce. There are several species from the Eocene, such as Petermanniopsis anglesaensis or Smilax, but their identification is not definite. Another known fossil is Ripogonum scandens from the Miocene. Due to the scarcity of data, it seems impossible to determine precisely the age and the initial distribution of the order. It is assumed that the Liliales originate from the Lower Cretaceous, over 100 million years ago. Fossil aquatic plants from the Cretaceous of northeastern Brazil and a new terrestrial species placed in the new genus Cratosmilax suggest that the first species have appeared around 120 million years ago when the continents formed Pangea, before dispersing as Asia, Africa and America. The initial diversification to the current families took place between 82 and 48 million years ago. The order consists of 10 families, 67 genera and about 1,768 species.", - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", "name": "Liliales", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliales" @@ -53,9 +53,9 @@ "type": "Taxon", "id": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "data": { - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "Lilieae", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilieae", @@ -66,6 +66,7 @@ "type": "Plant", "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Lilium candidum, Israel", @@ -80,19 +81,28 @@ "link": "https://en.wikipedia.org/wiki/File:Lilium_candidum_1.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "L. candidum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium_candidum", - "description": "Lilium candidum, the Madonna lily[2][3] or white lily,[4] is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions.[1][5] It has been cultivated since antiquity, for at least 3,000 years,[6] and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + "description": "Lilium candidum, the Madonna lily or white lily, is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions. It has been cultivated since antiquity, for at least 3,000 years, and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + }, + "file": { + "contentLength": 115168, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ee066b93ae72c719f863a428f03c86ecd28e52818528eacf4d4e32acb4b4b37c" + } } }, { "type": "Plant", "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "", @@ -107,22 +117,30 @@ "link": "https://en.wikipedia.org/wiki/File:Gloriosa_superba_1.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "Flame Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "G. superba", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_superba", - "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily,[3] gloriosa lily, tiger claw,[4] and fire lily.[5]" + "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily, gloriosa lily, tiger claw, and fire lily." + }, + "file": { + "contentLength": 90681, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "72e3e3770db795b2f04f0b5bb0be43e74df70de4faa0bf83a706bbde29e4ea0b" + } } }, { "type": "Taxon", "id": "30cb4ff2-591f-4fba-aa47-ca6ada630b9a", "data": { - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "Melanthiaceae", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Melanthiaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Melanthiaceae", @@ -133,10 +151,10 @@ "type": "Taxon", "id": "425235d6-75ab-4ccb-8208-783ced0decc6", "data": { + "created": "2026-09-24T19:04:01+00:00", "description": "The lily family, Liliaceae, consists of about 15 genera and 610 species of flowering plants within the order Liliales. They are monocotyledonous, perennial, herbaceous, often bulbous geophytes. Plants in this family have evolved with a fair amount of morphological diversity despite genetic similarity. Common characteristics include large flowers with parts arranged in threes: with six colored or patterned petaloid tepals (undifferentiated petals and sepals) arranged in two whorls, six stamens and a superior ovary. The leaves are linear in shape, with their veins usually arranged parallel to the edges, single and arranged alternating on the stem, or in a rosette at the base. Most species are grown from bulbs, although some have rhizomes. First described in 1789, the lily family became a paraphyletic \"catch-all\" (wastebasket) group of lilioid monocots that did not fit into other families and included a great number of genera now included in other families and in some cases in other orders. Consequently, many sources and descriptions labelled \"Liliaceae\" deal with the broader sense of the family.\n\nThe family evolved approximately 68 million years ago during the Late Cretaceous to Early Paleogene epochs. Liliaceae are widely distributed, mainly in temperate regions of the Northern Hemisphere and the flowers are insect pollinated. Many Liliaceae are important ornamental plants, widely grown for their attractive flowers and involved in a major floriculture of cut flowers and dry bulbs. Some species are poisonous if eaten and can have adverse health effects in humans and household pets.\n\nA number of Liliaceae genera are popular cultivated plants in private and public spaces. Lilies and tulips in particular have had considerable symbolic and decorative value, and appear frequently in paintings and the decorative arts. They are also an economically important product. Most of their genera, Lilium in particular, face considerable herbivory pressure from deer in some areas, both wild and domestic.", - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", "name": "Liliaceae", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliaceae" @@ -148,8 +166,8 @@ "start": "b7cda919-deea-49f9-9151-49132f83cbff", "end": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { - "created": "2025-09-25T18:43:22+00:00", - "updated": "2025-09-25T18:43:22+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } }, @@ -157,6 +175,7 @@ "type": "Plant", "id": "70965ab5-bc3e-4bbe-aacf-1b7e12b76110", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "White trillium (Trillium grandiflorum) blooming in Backus Woods.", @@ -177,13 +196,21 @@ "link": "https://en.wikipedia.org/wiki/File:White_Trillium_Trillium_grandiflorum_Flower_2613px.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "Trillium", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "T. grandiflorum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Trillium_grandiflorum", - "description": "Trillium grandiflorum, the white trillium,[3] large-flowered trillium, great white trillium,[4] white wake-robin or French: trille blanc, is a species of flowering plant in the family Melanthiaceae. A monocotyledonous, herbaceous perennial, the plant is native to eastern North America, from northern Quebec to the southern parts of the United States through the Appalachian Mountains into northernmost Georgia and west to Minnesota. There are also several isolated populations in Nova Scotia, Maine, southern Illinois, and Iowa.[5]" + "description": "Trillium grandiflorum, the white trillium, large-flowered trillium, great white trillium, white wake-robin or French: trille blanc, is a species of flowering plant in the family Melanthiaceae. A monocotyledonous, herbaceous perennial, the plant is native to eastern North America, from northern Quebec to the southern parts of the United States through the Appalachian Mountains into northernmost Georgia and west to Minnesota. There are also several isolated populations in Nova Scotia, Maine, southern Illinois, and Iowa." + }, + "file": { + "contentLength": 52082, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a235debeb572b5ab0cd3a84dfcf869dd558042dead656097fa5d414f2fee2b31" + } } }, { @@ -192,8 +219,8 @@ "start": "7dd3a650-d60e-40c9-89d0-bccaf395369f", "end": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "data": { - "created": "2025-09-25T18:43:22+00:00", - "updated": "2025-09-25T18:43:22+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } }, @@ -201,9 +228,9 @@ "type": "Taxon", "id": "7dd3a650-d60e-40c9-89d0-bccaf395369f", "data": { - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "Lilium", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae/Lilium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium", @@ -216,8 +243,8 @@ "start": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "end": "8288a39a-4b41-4ed5-8d0f-0b795048163c", "data": { - "created": "2025-09-25T18:43:22+00:00", - "updated": "2025-09-25T18:43:22+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } }, @@ -225,9 +252,9 @@ "type": "Taxon", "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", "data": { - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "Lilioideae", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilioideae", @@ -238,6 +265,7 @@ "type": "Plant", "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "in Seoul Forest, South Korea", @@ -258,22 +286,30 @@ "link": "https://en.wikipedia.org/wiki/File:Tulipa_orphanidea_060506.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "Tulip", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "Tulipa", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Tulip", - "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae,[2] along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae, along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + }, + "file": { + "contentLength": 105488, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c4c242897c43a74e9e4a8b5bf89e6b2cb1cbd99f8d621f33eea175cbf6dfab97" + } } }, { "type": "Taxon", "id": "9abc0ab3-fa1a-4138-80c1-49db5c4d082f", "data": { - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "Trillium", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Melanthiaceae/Trillium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Trillium", @@ -286,8 +322,8 @@ "start": "425235d6-75ab-4ccb-8208-783ced0decc6", "end": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { - "created": "2025-09-25T18:43:24+00:00", - "updated": "2025-09-25T18:43:24+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } }, @@ -297,8 +333,8 @@ "start": "8288a39a-4b41-4ed5-8d0f-0b795048163c", "end": "425235d6-75ab-4ccb-8208-783ced0decc6", "data": { - "created": "2025-09-25T18:43:22+00:00", - "updated": "2025-09-25T18:43:22+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } }, @@ -306,9 +342,9 @@ "type": "Taxon", "id": "b7cda919-deea-49f9-9151-49132f83cbff", "data": { - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "Colchicaceae", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Colchicaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Colchicaceae", @@ -321,8 +357,8 @@ "start": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "end": "16884ac8-ba7a-4c91-8449-4fdd803644b6", "data": { - "created": "2025-09-25T18:43:22+00:00", - "updated": "2025-09-25T18:43:22+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } }, @@ -332,8 +368,8 @@ "start": "23508e6c-632a-496d-9bd6-8c62a03216af", "end": "7dd3a650-d60e-40c9-89d0-bccaf395369f", "data": { - "created": "2025-09-25T18:43:22+00:00", - "updated": "2025-09-25T18:43:22+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } }, @@ -343,8 +379,8 @@ "start": "70965ab5-bc3e-4bbe-aacf-1b7e12b76110", "end": "9abc0ab3-fa1a-4138-80c1-49db5c4d082f", "data": { - "created": "2025-09-25T18:43:22+00:00", - "updated": "2025-09-25T18:43:22+00:00", + "created": "2026-09-24T19:04:17+00:00", + "updated": "2026-09-24T19:04:17+00:00", "scenario": "general.botanicExample" } }, @@ -354,8 +390,8 @@ "start": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "end": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "data": { - "created": "2025-09-25T18:43:22+00:00", - "updated": "2025-09-25T18:43:22+00:00", + "created": "2026-09-24T19:04:17+00:00", + "updated": "2026-09-24T19:04:17+00:00", "scenario": "general.botanicExample" } }, @@ -365,8 +401,8 @@ "start": "30cb4ff2-591f-4fba-aa47-ca6ada630b9a", "end": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { - "created": "2025-09-25T18:43:22+00:00", - "updated": "2025-09-25T18:43:22+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } } @@ -381,9 +417,9 @@ } }, "step-handler": "cypher-path-subset (App\\EventSystem\\SearchStep\\EventListener\\CypherPathSearchStepEventListener)", - "start": 1758825856.933289, - "end": 1758825857.210775, - "duration": 0.27748584747314453 + "start": 1790276704.482475, + "end": 1790276704.941544, + "duration": 0.45906901359558105 }, { "input": { @@ -395,31 +431,27 @@ "paths": [ { "nodeIds": [ - "8940d70b-5b6f-43b7-bee4-41d073396ff8", - "1feedd9f-0c12-490c-8461-b12ff7447ac8", - "8288a39a-4b41-4ed5-8d0f-0b795048163c", - "425235d6-75ab-4ccb-8208-783ced0decc6", + "258c0dfe-b1d8-4839-beed-d00d1b544a96", + "16884ac8-ba7a-4c91-8449-4fdd803644b6", + "b7cda919-deea-49f9-9151-49132f83cbff", "1852bdff-a4dd-4f91-9243-0e802775f757" ], "relationIds": [ - "ee29e267-63c8-45fb-a9a5-47835f46a839", - "81b19288-a74b-4496-84a9-0d07386b8e26", - "b60561b3-46d1-4632-9320-b0e02afa4198", - "9bea8667-ab81-48ff-871a-00334afc1a07" + "b9fee9e3-f0c5-451c-b9a6-f5a3578e023c", + "024e049b-219a-477c-9b8c-be1244c16dab", + "49d9a28c-b712-4be6-9cf1-11d2062ffa89" ] }, { "nodeIds": [ - "23508e6c-632a-496d-9bd6-8c62a03216af", - "7dd3a650-d60e-40c9-89d0-bccaf395369f", + "8940d70b-5b6f-43b7-bee4-41d073396ff8", "1feedd9f-0c12-490c-8461-b12ff7447ac8", "8288a39a-4b41-4ed5-8d0f-0b795048163c", "425235d6-75ab-4ccb-8208-783ced0decc6", "1852bdff-a4dd-4f91-9243-0e802775f757" ], "relationIds": [ - "e73faa5c-2c41-4d04-8a09-f160c4accea8", - "78228dfa-196a-48ef-9a46-e8fa5dc2628f", + "ee29e267-63c8-45fb-a9a5-47835f46a839", "81b19288-a74b-4496-84a9-0d07386b8e26", "b60561b3-46d1-4632-9320-b0e02afa4198", "9bea8667-ab81-48ff-871a-00334afc1a07" @@ -440,15 +472,19 @@ }, { "nodeIds": [ - "258c0dfe-b1d8-4839-beed-d00d1b544a96", - "16884ac8-ba7a-4c91-8449-4fdd803644b6", - "b7cda919-deea-49f9-9151-49132f83cbff", + "23508e6c-632a-496d-9bd6-8c62a03216af", + "7dd3a650-d60e-40c9-89d0-bccaf395369f", + "1feedd9f-0c12-490c-8461-b12ff7447ac8", + "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "425235d6-75ab-4ccb-8208-783ced0decc6", "1852bdff-a4dd-4f91-9243-0e802775f757" ], "relationIds": [ - "b9fee9e3-f0c5-451c-b9a6-f5a3578e023c", - "024e049b-219a-477c-9b8c-be1244c16dab", - "49d9a28c-b712-4be6-9cf1-11d2062ffa89" + "e73faa5c-2c41-4d04-8a09-f160c4accea8", + "78228dfa-196a-48ef-9a46-e8fa5dc2628f", + "81b19288-a74b-4496-84a9-0d07386b8e26", + "b60561b3-46d1-4632-9320-b0e02afa4198", + "9bea8667-ab81-48ff-871a-00334afc1a07" ] } ] @@ -457,7 +493,7 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825857.210962, + "start": 1790276704.941809, "element-hydration:redundantElementIds": { "message": "Removed 9 redundant elementIds.", "removedElementIds": [ @@ -497,8 +533,8 @@ "ee29e267-63c8-45fb-a9a5-47835f46a839", "ee933330-c413-4680-be79-03f51b18aa7b" ], - "end": 1758825857.318839, - "duration": 0.107877 + "end": 1790276705.080997, + "duration": 0.139188 } ] } \ No newline at end of file diff --git a/docs/search/example/cypher-path-subset/find-nested-elements/response-header.txt b/docs/search/example/cypher-path-subset/find-nested-elements/response-header.txt index c3ba5b8f..bc6d0c3e 100644 --- a/docs/search/example/cypher-path-subset/find-nested-elements/response-header.txt +++ b/docs/search/example/cypher-path-subset/find-nested-elements/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:36 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/cypher-path-subset/find-tagged-elements/response-body.json b/docs/search/example/cypher-path-subset/find-tagged-elements/response-body.json index d708ad9f..4f17cabe 100644 --- a/docs/search/example/cypher-path-subset/find-tagged-elements/response-body.json +++ b/docs/search/example/cypher-path-subset/find-tagged-elements/response-body.json @@ -5,6 +5,7 @@ "type": "Plant", "id": "78dd0a4d-2b94-4997-b4a4-353de854047c", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "Strelitzia reginae or Bird of Paradise", @@ -19,23 +20,31 @@ "link": "https://en.wikipedia.org/wiki/File:2010-03-05_13_16_21_Portugal-Jardim_Bot%C3%A2nico_da_Madeira.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "Bird of Paradise", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "S. reginae", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Strelitzia_reginae", - "description": "Strelitzia reginae, commonly known as the crane flower, bird of paradise, or isigude in Nguni,[3] is a species of flowering plant native to the Cape Provinces and KwaZulu-Natal in South Africa. An evergreen perennial, it is widely cultivated for its dramatic flowers. In temperate areas it is a popular houseplant." + "description": "Strelitzia reginae, commonly known as the crane flower, bird of paradise, or isigude in Nguni, is a species of flowering plant native to the Cape Provinces and KwaZulu-Natal in South Africa. An evergreen perennial, it is widely cultivated for its dramatic flowers. In temperate areas it is a popular houseplant." + }, + "file": { + "contentLength": 63663, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "b2a4e02335603478ee1c5e9d551708e3d2f41cfc1e27059bf891409ca4e771df" + } } }, { "type": "Tag", "id": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "description": "Blue is one of the three primary colours in the RGB (additive) colour model, as well as in the RYB colour model (traditional colour theory).[2] It lies between violet and cyan on the spectrum of visible light. The term blue generally describes colours perceived by humans observing light with a dominant wavelength that's between approximately 450 and 495 nanometres. The clear daytime sky and the deep sea appear blue because of an optical effect known as Rayleigh scattering. An optical effect called the Tyndall effect explains blue eyes. Distant objects appear more blue because of another optical effect called aerial perspective.\n\nBlue has been an important colour in art and decoration since ancient times. The semi-precious stone lapis lazuli was used in ancient Egypt for jewellery and ornament and later, in the Renaissance, to make the pigment ultramarine, the most expensive of all pigments.[3] In the eighth century Chinese artists used cobalt blue to colour fine blue and white porcelain. In the Middle Ages, European artists used it in the windows of cathedrals. Europeans wore clothing coloured with the vegetable dye woad until it was replaced by the finer indigo from America. In the 19th century, synthetic blue dyes and pigments gradually replaced organic dyes and mineral pigments. Dark blue became a common colour for military uniforms and later, in the late 20th century, for business suits. Because blue has commonly been associated with harmony, it was chosen as the colour of the flags of the United Nations and the European Union.[4]\n\nIn the United States and Europe, blue is the colour that both men and women are most likely to choose as their favourite, with at least one recent survey showing the same across several other countries, including China, Malaysia, and Indonesia.[5][6] Past surveys in the US and Europe have found that blue is the colour most commonly associated with harmony, confidence, masculinity, knowledge, intelligence, calmness, distance, infinity, the imagination, cold, and sadness.[7]", - "created": "2025-09-25T18:43:12+00:00", - "updated": "2025-09-25T18:43:12+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "Blue is one of the three primary colours in the RGB (additive) colour model, as well as in the RYB colour model (traditional colour theory). It lies between violet and cyan on the spectrum of visible light. The term blue generally describes colours perceived by humans observing light with a dominant wavelength that's between approximately 450 and 495 nanometres. The clear daytime sky and the deep sea appear blue because of an optical effect known as Rayleigh scattering. An optical effect called the Tyndall effect explains blue eyes. Distant objects appear more blue because of another optical effect called aerial perspective.\n\nBlue has been an important colour in art and decoration since ancient times. The semi-precious stone lapis lazuli was used in ancient Egypt for jewellery and ornament and later, in the Renaissance, to make the pigment ultramarine, the most expensive of all pigments. In the eighth century Chinese artists used cobalt blue to colour fine blue and white porcelain. In the Middle Ages, European artists used it in the windows of cathedrals. Europeans wore clothing coloured with the vegetable dye woad until it was replaced by the finer indigo from America. In the 19th century, synthetic blue dyes and pigments gradually replaced organic dyes and mineral pigments. Dark blue became a common colour for military uniforms and later, in the late 20th century, for business suits. Because blue has commonly been associated with harmony, it was chosen as the colour of the flags of the United Nations and the European Union.\n\nIn the United States and Europe, blue is the colour that both men and women are most likely to choose as their favourite, with at least one recent survey showing the same across several other countries, including China, Malaysia, and Indonesia. Past surveys in the US and Europe have found that blue is the colour most commonly associated with harmony, confidence, masculinity, knowledge, intelligence, calmness, distance, infinity, the imagination, cold, and sadness.", "name": "blue", + "updated": "2026-09-24T19:04:01+00:00", "color": "#4363d8", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Blue" @@ -45,6 +54,7 @@ "type": "Plant", "id": "83b35321-4a91-4e9f-a49b-66d718c32ba7", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Mertensia virginica Virginia Bluebell seen in Brooklyn Botanic Garden", @@ -59,19 +69,28 @@ "link": "https://en.wikipedia.org/wiki/File:Virginia_Bluebells_at_Rocky_River.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "Bluebell", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "M. virginica", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Mertensia_virginica", - "description": "Mertensia virginica (common names Virginia bluebells,[1] Virginia cowslip,[2] lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + "description": "Mertensia virginica (common names Virginia bluebells, Virginia cowslip, lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + }, + "file": { + "contentLength": 82651, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "527fa0ca5006ae3c888c06c3e208db64aa0214b31ce9f26a1ccd5fa7cdf91c3b" + } } }, { "type": "Plant", "id": "884dd581-bcdd-43f1-9dab-11524578d9e2", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "Centaurea cyanus (introduced species) near Peshastin, Chelan County Washington", @@ -86,13 +105,21 @@ "link": "https://en.wikipedia.org/wiki/File:Bachelor%27s_button,_Basket_flower,_Boutonniere_flower,_Cornflower_-_3.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "Cornflower", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "C. cyanus", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Centaurea_cyanus", - "description": "Centaurea cyanus, commonly known as cornflower or bachelor's button (among other names[note 1]), is an annual flowering plant in the family Asteraceae native to Europe. In the past, it often grew as a weed in cornfields (in the broad sense of \"corn\", referring to grains, such as wheat, barley, rye, or oats), hence its name. It is now endangered in its native habitat by agricultural intensification, particularly by over-use of herbicides. However, Centaurea cyanus is now also naturalised in many other parts of the world, including North America and parts of Australia through introduction as an ornamental plant in gardens and as a seed contaminant in crop seeds.[3]" + "description": "Centaurea cyanus, commonly known as cornflower or bachelor's button (among other names[note 1]), is an annual flowering plant in the family Asteraceae native to Europe. In the past, it often grew as a weed in cornfields (in the broad sense of \"corn\", referring to grains, such as wheat, barley, rye, or oats), hence its name. It is now endangered in its native habitat by agricultural intensification, particularly by over-use of herbicides. However, Centaurea cyanus is now also naturalised in many other parts of the world, including North America and parts of Australia through introduction as an ornamental plant in gardens and as a seed contaminant in crop seeds." + }, + "file": { + "contentLength": 88765, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c8ff4f7ef5afa6903c09b84e1824a1e296cfeae09f21a154536e562e9b7950c9" + } } }, { @@ -101,8 +128,8 @@ "start": "78dd0a4d-2b94-4997-b4a4-353de854047c", "end": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "created": "2025-09-25T18:43:24+00:00", - "updated": "2025-09-25T18:43:24+00:00", + "created": "2026-09-24T19:04:12+00:00", + "updated": "2026-09-24T19:04:12+00:00", "scenario": "general.botanicExample" } }, @@ -110,6 +137,7 @@ "type": "Plant", "id": "ad80a619-2b53-4f27-a4bd-56a316c15251", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Jacarandas in New Farm Park October 2006", @@ -130,13 +158,21 @@ "link": "https://en.wikipedia.org/wiki/File:Jacaranda_mimosifolia_5334.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "Jacaranda", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "J. mimosifolia", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Jacaranda_mimosifolia", - "description": "Jacaranda mimosifolia is a sub-tropical tree native to south-central South America that has been widely planted elsewhere because of its attractive and long-lasting violet-colored flowers. It is also known as the jacaranda, blue jacaranda, black poui, Nupur or fern tree. Older sources call it J. acutifolia, but modern authorities usually classify it as J. mimosifolia. In scientific usage, the name \"jacaranda\" refers to the genus Jacaranda, which has many other members, but in horticultural and everyday usage, it nearly always means the blue jacaranda.\n\nIn its native range in the wild, J. mimosifolia is listed as Vulnerable by the IUCN.[1]" + "description": "Jacaranda mimosifolia is a sub-tropical tree native to south-central South America that has been widely planted elsewhere because of its attractive and long-lasting violet-colored flowers. It is also known as the jacaranda, blue jacaranda, black poui, Nupur or fern tree. Older sources call it J. acutifolia, but modern authorities usually classify it as J. mimosifolia. In scientific usage, the name \"jacaranda\" refers to the genus Jacaranda, which has many other members, but in horticultural and everyday usage, it nearly always means the blue jacaranda.\n\nIn its native range in the wild, J. mimosifolia is listed as Vulnerable by the IUCN." + }, + "file": { + "contentLength": 174162, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "dc87ed191952b8a4bfec69ff9af4103c93d52f81e43c343bc8be1a2ddb61aa20" + } } }, { @@ -145,8 +181,8 @@ "start": "d747bfe6-0d52-4119-87c6-1fc37ed9395b", "end": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "created": "2025-09-25T18:43:24+00:00", - "updated": "2025-09-25T18:43:24+00:00", + "created": "2026-09-24T19:04:12+00:00", + "updated": "2026-09-24T19:04:12+00:00", "scenario": "general.botanicExample" } }, @@ -154,6 +190,7 @@ "type": "Plant", "id": "d747bfe6-0d52-4119-87c6-1fc37ed9395b", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "Anchusa capensis in the San Francisco Botanical Garden", @@ -162,19 +199,28 @@ "link": "https://en.wikipedia.org/wiki/File:Anchusa_capensis_kz1.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "Cape Forget-me-not", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "A. capensis", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Anchusa_capensis", - "description": "Anchusa capensis, is a species of flowering plant in the family Boraginaceae, native to Namibia, South Africa and Lesotho.[1][2] The genus Anchusa is from the Greek word anchousa, which makes reference to its use as a dye base for cosmetic paint obtained from the roots of another plant in the genus Anchusa tinctoria. The species capensis translates to \\u2018from the Cape\\u2019 referring to South Africa[3]\n\nA. capensis typically reaches about 60 cm (24 in) tall, and grows best in full sun.[4] In summer, red buds open to reveal small, bright cobalt blue flowers with five petals and five white scales protecting the stigma and anthers.[5]\n\nIn the US, it grows well in zones 8\\u201311.[6] Cultivars include 'Dawn', with white, pink, mauve and blue flowers, 'Blue Angel', with dark blue flowers, and 'Blue Bird', taller and with indigo blue flowers.[7]" + "description": "Anchusa capensis, is a species of flowering plant in the family Boraginaceae, native to Namibia, South Africa and Lesotho. The genus Anchusa is from the Greek word anchousa, which makes reference to its use as a dye base for cosmetic paint obtained from the roots of another plant in the genus Anchusa tinctoria. The species capensis translates to \\u2018from the Cape\\u2019 referring to South Africa\n\nA. capensis typically reaches about 60 cm (24 in) tall, and grows best in full sun. In summer, red buds open to reveal small, bright cobalt blue flowers with five petals and five white scales protecting the stigma and anthers.\n\nIn the US, it grows well in zones 8\\u201311. Cultivars include 'Dawn', with white, pink, mauve and blue flowers, 'Blue Angel', with dark blue flowers, and 'Blue Bird', taller and with indigo blue flowers." + }, + "file": { + "contentLength": 75801, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "853580a14affe615f6fecbd2c16e8f6805ba53867669b3a220209a7b8d002cf1" + } } }, { "type": "Plant", "id": "ef54daa7-f62c-47ea-8762-fae02bd44485", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "My wonderful Saintpaulia", @@ -189,13 +235,21 @@ "link": "https://en.wikipedia.org/wiki/File:2007-04-20Saintpaulia_ionantha03.jpg" } ], - "created": "2025-09-25T18:43:14+00:00", - "updated": "2025-09-25T18:43:14+00:00", + "hasFile": true, "name": "African Violet", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "S. sect. Saintpaulia", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Streptocarpus_sect._Saintpaulia", - "description": "Streptocarpus sect. Saintpaulia is a section within Streptocarpus subgenus Streptocarpella[1] consisting of about ten species of herbaceous perennial flowering plants in the family Gesneriaceae, native to Tanzania and adjacent southeastern Kenya in eastern tropical Africa. The section was previously treated as a separate genus, Saintpaulia, but molecular phylogenetic studies showed that it was nested within the genus Streptocarpus.\n\nSpecies and cultivars are commonly called African violets (although they are not closely related to true violets) or saintpaulias. They are commonly sold as house plants." + "description": "Streptocarpus sect. Saintpaulia is a section within Streptocarpus subgenus Streptocarpella consisting of about ten species of herbaceous perennial flowering plants in the family Gesneriaceae, native to Tanzania and adjacent southeastern Kenya in eastern tropical Africa. The section was previously treated as a separate genus, Saintpaulia, but molecular phylogenetic studies showed that it was nested within the genus Streptocarpus.\n\nSpecies and cultivars are commonly called African violets (although they are not closely related to true violets) or saintpaulias. They are commonly sold as house plants." + }, + "file": { + "contentLength": 128977, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "d3e05a199cc6bba407eb6f91604c5a1cc1945a82fdf07f42a3f24d3834902406" + } } }, { @@ -204,8 +258,8 @@ "start": "ad80a619-2b53-4f27-a4bd-56a316c15251", "end": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "created": "2025-09-25T18:43:24+00:00", - "updated": "2025-09-25T18:43:24+00:00", + "created": "2026-09-24T19:04:12+00:00", + "updated": "2026-09-24T19:04:12+00:00", "scenario": "general.botanicExample" } }, @@ -215,8 +269,8 @@ "start": "ef54daa7-f62c-47ea-8762-fae02bd44485", "end": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "created": "2025-09-25T18:43:24+00:00", - "updated": "2025-09-25T18:43:24+00:00", + "created": "2026-09-24T19:04:12+00:00", + "updated": "2026-09-24T19:04:12+00:00", "scenario": "general.botanicExample" } }, @@ -226,8 +280,8 @@ "start": "884dd581-bcdd-43f1-9dab-11524578d9e2", "end": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "created": "2025-09-25T18:43:24+00:00", - "updated": "2025-09-25T18:43:24+00:00", + "created": "2026-09-24T19:04:12+00:00", + "updated": "2026-09-24T19:04:12+00:00", "scenario": "general.botanicExample" } }, @@ -237,8 +291,8 @@ "start": "83b35321-4a91-4e9f-a49b-66d718c32ba7", "end": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "created": "2025-09-25T18:43:24+00:00", - "updated": "2025-09-25T18:43:24+00:00", + "created": "2026-09-24T19:04:14+00:00", + "updated": "2026-09-24T19:04:14+00:00", "scenario": "general.botanicExample" } } @@ -253,9 +307,9 @@ } }, "step-handler": "cypher-path-subset (App\\EventSystem\\SearchStep\\EventListener\\CypherPathSearchStepEventListener)", - "start": 1758825857.489016, - "end": 1758825857.750409, - "duration": 0.26139283180236816 + "start": 1790276705.139105, + "end": 1790276705.562573, + "duration": 0.42346787452697754 }, { "input": { @@ -267,11 +321,11 @@ "paths": [ { "nodeIds": [ - "ad80a619-2b53-4f27-a4bd-56a316c15251", + "83b35321-4a91-4e9f-a49b-66d718c32ba7", "79ca27e5-93bb-43e6-a76b-096ea45e2e8e" ], "relationIds": [ - "f096b595-d67b-4600-9b88-a0255d55db32" + "f8d6e90d-455c-46d8-b02d-430ad02217ee" ] }, { @@ -285,20 +339,20 @@ }, { "nodeIds": [ - "884dd581-bcdd-43f1-9dab-11524578d9e2", + "d747bfe6-0d52-4119-87c6-1fc37ed9395b", "79ca27e5-93bb-43e6-a76b-096ea45e2e8e" ], "relationIds": [ - "f63ef383-ad99-48c6-9699-9d71c7c9cd16" + "cae5b040-b013-4cb3-9ecd-62fd6e30e46e" ] }, { "nodeIds": [ - "d747bfe6-0d52-4119-87c6-1fc37ed9395b", + "ad80a619-2b53-4f27-a4bd-56a316c15251", "79ca27e5-93bb-43e6-a76b-096ea45e2e8e" ], "relationIds": [ - "cae5b040-b013-4cb3-9ecd-62fd6e30e46e" + "f096b595-d67b-4600-9b88-a0255d55db32" ] }, { @@ -312,11 +366,11 @@ }, { "nodeIds": [ - "83b35321-4a91-4e9f-a49b-66d718c32ba7", + "884dd581-bcdd-43f1-9dab-11524578d9e2", "79ca27e5-93bb-43e6-a76b-096ea45e2e8e" ], "relationIds": [ - "f8d6e90d-455c-46d8-b02d-430ad02217ee" + "f63ef383-ad99-48c6-9699-9d71c7c9cd16" ] } ] @@ -325,7 +379,7 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825857.75059, + "start": 1790276705.562889, "element-hydration:redundantElementIds": { "message": "Removed 5 redundant elementIds.", "removedElementIds": [ @@ -347,8 +401,8 @@ "f63ef383-ad99-48c6-9699-9d71c7c9cd16", "f8d6e90d-455c-46d8-b02d-430ad02217ee" ], - "end": 1758825857.812404, - "duration": 0.061814 + "end": 1790276705.636983, + "duration": 0.074094 } ] } \ No newline at end of file diff --git a/docs/search/example/cypher-path-subset/find-tagged-elements/response-header.txt b/docs/search/example/cypher-path-subset/find-tagged-elements/response-header.txt index c3ba5b8f..899a614d 100644 --- a/docs/search/example/cypher-path-subset/find-tagged-elements/response-header.txt +++ b/docs/search/example/cypher-path-subset/find-tagged-elements/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:37 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-body.json b/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-body.json index 6ccbf08a..86dbb66b 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-body.json +++ b/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-body.json @@ -5,10 +5,10 @@ "type": "Tag", "id": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "description": "Blue is one of the three primary colours in the RGB (additive) colour model, as well as in the RYB colour model (traditional colour theory).[2] It lies between violet and cyan on the spectrum of visible light. The term blue generally describes colours perceived by humans observing light with a dominant wavelength that's between approximately 450 and 495 nanometres. The clear daytime sky and the deep sea appear blue because of an optical effect known as Rayleigh scattering. An optical effect called the Tyndall effect explains blue eyes. Distant objects appear more blue because of another optical effect called aerial perspective.\n\nBlue has been an important colour in art and decoration since ancient times. The semi-precious stone lapis lazuli was used in ancient Egypt for jewellery and ornament and later, in the Renaissance, to make the pigment ultramarine, the most expensive of all pigments.[3] In the eighth century Chinese artists used cobalt blue to colour fine blue and white porcelain. In the Middle Ages, European artists used it in the windows of cathedrals. Europeans wore clothing coloured with the vegetable dye woad until it was replaced by the finer indigo from America. In the 19th century, synthetic blue dyes and pigments gradually replaced organic dyes and mineral pigments. Dark blue became a common colour for military uniforms and later, in the late 20th century, for business suits. Because blue has commonly been associated with harmony, it was chosen as the colour of the flags of the United Nations and the European Union.[4]\n\nIn the United States and Europe, blue is the colour that both men and women are most likely to choose as their favourite, with at least one recent survey showing the same across several other countries, including China, Malaysia, and Indonesia.[5][6] Past surveys in the US and Europe have found that blue is the colour most commonly associated with harmony, confidence, masculinity, knowledge, intelligence, calmness, distance, infinity, the imagination, cold, and sadness.[7]", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "Blue is one of the three primary colours in the RGB (additive) colour model, as well as in the RYB colour model (traditional colour theory). It lies between violet and cyan on the spectrum of visible light. The term blue generally describes colours perceived by humans observing light with a dominant wavelength that's between approximately 450 and 495 nanometres. The clear daytime sky and the deep sea appear blue because of an optical effect known as Rayleigh scattering. An optical effect called the Tyndall effect explains blue eyes. Distant objects appear more blue because of another optical effect called aerial perspective.\n\nBlue has been an important colour in art and decoration since ancient times. The semi-precious stone lapis lazuli was used in ancient Egypt for jewellery and ornament and later, in the Renaissance, to make the pigment ultramarine, the most expensive of all pigments. In the eighth century Chinese artists used cobalt blue to colour fine blue and white porcelain. In the Middle Ages, European artists used it in the windows of cathedrals. Europeans wore clothing coloured with the vegetable dye woad until it was replaced by the finer indigo from America. In the 19th century, synthetic blue dyes and pigments gradually replaced organic dyes and mineral pigments. Dark blue became a common colour for military uniforms and later, in the late 20th century, for business suits. Because blue has commonly been associated with harmony, it was chosen as the colour of the flags of the United Nations and the European Union.\n\nIn the United States and Europe, blue is the colour that both men and women are most likely to choose as their favourite, with at least one recent survey showing the same across several other countries, including China, Malaysia, and Indonesia. Past surveys in the US and Europe have found that blue is the colour most commonly associated with harmony, confidence, masculinity, knowledge, intelligence, calmness, distance, infinity, the imagination, cold, and sadness.", "name": "blue", + "updated": "2026-09-24T19:04:01+00:00", "color": "#4363d8", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Blue" @@ -18,6 +18,7 @@ "type": "Plant", "id": "d747bfe6-0d52-4119-87c6-1fc37ed9395b", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "Anchusa capensis in the San Francisco Botanical Garden", @@ -26,23 +27,31 @@ "link": "https://en.wikipedia.org/wiki/File:Anchusa_capensis_kz1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Cape Forget-me-not", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "A. capensis", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Anchusa_capensis", - "description": "Anchusa capensis, is a species of flowering plant in the family Boraginaceae, native to Namibia, South Africa and Lesotho.[1][2] The genus Anchusa is from the Greek word anchousa, which makes reference to its use as a dye base for cosmetic paint obtained from the roots of another plant in the genus Anchusa tinctoria. The species capensis translates to \\u2018from the Cape\\u2019 referring to South Africa[3]\n\nA. capensis typically reaches about 60 cm (24 in) tall, and grows best in full sun.[4] In summer, red buds open to reveal small, bright cobalt blue flowers with five petals and five white scales protecting the stigma and anthers.[5]\n\nIn the US, it grows well in zones 8\\u201311.[6] Cultivars include 'Dawn', with white, pink, mauve and blue flowers, 'Blue Angel', with dark blue flowers, and 'Blue Bird', taller and with indigo blue flowers.[7]" + "description": "Anchusa capensis, is a species of flowering plant in the family Boraginaceae, native to Namibia, South Africa and Lesotho. The genus Anchusa is from the Greek word anchousa, which makes reference to its use as a dye base for cosmetic paint obtained from the roots of another plant in the genus Anchusa tinctoria. The species capensis translates to \\u2018from the Cape\\u2019 referring to South Africa\n\nA. capensis typically reaches about 60 cm (24 in) tall, and grows best in full sun. In summer, red buds open to reveal small, bright cobalt blue flowers with five petals and five white scales protecting the stigma and anthers.\n\nIn the US, it grows well in zones 8\\u201311. Cultivars include 'Dawn', with white, pink, mauve and blue flowers, 'Blue Angel', with dark blue flowers, and 'Blue Bird', taller and with indigo blue flowers." + }, + "file": { + "contentLength": 75801, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "853580a14affe615f6fecbd2c16e8f6805ba53867669b3a220209a7b8d002cf1" + } } }, { "type": "Taxon", "id": "97157162-56eb-4374-8200-41b9b6004ca2", "data": { + "created": "2026-09-24T19:04:01+00:00", "description": "Boraginaceae, the borage or forget-me-not family, includes about 2,000 species of shrubs, trees, and herbs in 146 to 154 genera with a worldwide distribution.\n\nThe APG IV system from 2016 classifies the Boraginaceae as single family of the order Boraginales within the asterids. Under the older Cronquist system, it was included in the Lamiales, but clearly is no more similar to the other families in this order than it is to families in several other asterid orders. A revision of the Boraginales, also from 2016, split the Boraginaceae into 11 distinct families: Boraginaceae sensu stricto, Codonaceae, Coldeniaceae, Cordiaceae, Ehretiaceae, Heliotropiaceae, Hoplestigmataceae, Hydrophyllaceae, Lennoaceae, Namaceae, and Wellstediaceae.\n\nThese plants have alternately arranged leaves, or a combination of alternate and opposite leaves. The leaf blades usually have a narrow shape; many are linear or lance-shaped. They are smooth-edged or toothed, and some have petioles. Most species have bisexual flowers, but some taxa are dioecious. Most pollination is by hymenopterans, such as bees. Most species have inflorescences that have a coiling shape, at least when new, called scorpioid cymes. The flower has a usually five-lobed calyx. The corolla varies in shape from rotate to bell-shaped to tubular, but it generally has five lobes. It can be green, white, yellow, orange, pink, purple, or blue. There are five stamens and one style with one or two stigmas. The fruit is a drupe, sometimes fleshy.\n\nMost members of this family have hairy leaves. The coarse character of the hairs is due to cystoliths of silicon dioxide and calcium carbonate. These hairs can induce an adverse skin reaction, including itching and rash in some individuals, particularly among people who handle the plants regularly, such as gardeners. In some species, anthocyanins cause the flowers to change color from red to blue with age. This may be a signal to pollinators that a flower is old and depleted of pollen and nectar.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Boraginaceae", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Boraginales/Boraginaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Boraginaceae" @@ -52,9 +61,9 @@ "type": "Taxon", "id": "36e02d35-f6b4-4ccc-ae2f-b035ef02e0d6", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "Dahlia", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Asterales/Asteraceae/Dahlia", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Dahlia", @@ -65,6 +74,7 @@ "type": "Plant", "id": "83b35321-4a91-4e9f-a49b-66d718c32ba7", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Mertensia virginica Virginia Bluebell seen in Brooklyn Botanic Garden", @@ -79,19 +89,28 @@ "link": "https://en.wikipedia.org/wiki/File:Virginia_Bluebells_at_Rocky_River.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Bluebell", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "M. virginica", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Mertensia_virginica", - "description": "Mertensia virginica (common names Virginia bluebells,[1] Virginia cowslip,[2] lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + "description": "Mertensia virginica (common names Virginia bluebells, Virginia cowslip, lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + }, + "file": { + "contentLength": 82651, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "527fa0ca5006ae3c888c06c3e208db64aa0214b31ce9f26a1ccd5fa7cdf91c3b" + } } }, { "type": "Plant", "id": "ad80a619-2b53-4f27-a4bd-56a316c15251", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Jacarandas in New Farm Park October 2006", @@ -112,19 +131,28 @@ "link": "https://en.wikipedia.org/wiki/File:Jacaranda_mimosifolia_5334.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Jacaranda", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "J. mimosifolia", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Jacaranda_mimosifolia", - "description": "Jacaranda mimosifolia is a sub-tropical tree native to south-central South America that has been widely planted elsewhere because of its attractive and long-lasting violet-colored flowers. It is also known as the jacaranda, blue jacaranda, black poui, Nupur or fern tree. Older sources call it J. acutifolia, but modern authorities usually classify it as J. mimosifolia. In scientific usage, the name \"jacaranda\" refers to the genus Jacaranda, which has many other members, but in horticultural and everyday usage, it nearly always means the blue jacaranda.\n\nIn its native range in the wild, J. mimosifolia is listed as Vulnerable by the IUCN.[1]" + "description": "Jacaranda mimosifolia is a sub-tropical tree native to south-central South America that has been widely planted elsewhere because of its attractive and long-lasting violet-colored flowers. It is also known as the jacaranda, blue jacaranda, black poui, Nupur or fern tree. Older sources call it J. acutifolia, but modern authorities usually classify it as J. mimosifolia. In scientific usage, the name \"jacaranda\" refers to the genus Jacaranda, which has many other members, but in horticultural and everyday usage, it nearly always means the blue jacaranda.\n\nIn its native range in the wild, J. mimosifolia is listed as Vulnerable by the IUCN." + }, + "file": { + "contentLength": 174162, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "dc87ed191952b8a4bfec69ff9af4103c93d52f81e43c343bc8be1a2ddb61aa20" + } } }, { "type": "Plant", "id": "26dfaa98-5f15-4170-9fdc-94661c1a26c5", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "", @@ -133,23 +161,31 @@ "link": "https://en.wikipedia.org/wiki/File:Eustoma_grandiflorum_purple_02.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Prairie Gentian", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "E. russellianum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Eustoma_russellianum", - "description": "Eustoma russellianum is a species of flowering plant in the gentian family. One of its previous binomial names was Eustoma grandiflorum.[1] Common names include showy prairie gentian, prairie gentian, Texas bluebells, Texas bluebell, bluebell,[2] and Lisianthus.\n\nThere is a cultivar, 'Bolero Deep Blue'.[3]" + "description": "Eustoma russellianum is a species of flowering plant in the gentian family. One of its previous binomial names was Eustoma grandiflorum. Common names include showy prairie gentian, prairie gentian, Texas bluebells, Texas bluebell, bluebell, and Lisianthus.\n\nThere is a cultivar, 'Bolero Deep Blue'." + }, + "file": { + "contentLength": 104134, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "9bbf4efc14d13cb8c268901d0d6395a8745664312a7961d673695072bdb709fb" + } } }, { "type": "Taxon", "id": "30b0e9bc-8c9f-42e2-91c5-62f04b7da3be", "data": { + "created": "2026-09-24T19:04:01+00:00", "description": "The genus Anchusa belongs to the borage family (Boraginaceae). It includes about 35 species found growing in Europe, North Africa, South Africa and Western Asia. They are introduced in the United States.\n\nThey consist of annual plants, biennial plants and perennial plants with the general characteristics of the borage family. They are commonly herbaceous. The leaves are simple or undulate, covered with stiff hairs.\n\nThe small radially symmetrical flowers are sapphire blue and retain their colour a long time. The plants show numerous flowers with five sepals, united at their bases, and five petals forming a narrow tube facing upwards. The flowers grow in several axillary cymes, simple or branched, or are clustered at the end. The flowers are much frequented by bees.\n\nThe genus Anchusa is commonly used in trough or rock gardens.\n\nThe roots of Anchusa (just like those of Alkanna and Lithospermum) contain anchusin (or alkanet-red), a red-brown resinoid colouring matter. It is insoluble in water, but soluble in alcohol, chloroform and ether.\n\nAnchusa species are used as food plants by the larvae of some Lepidoptera species including Coleophora pennella.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Anchusa", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Boraginales/Boraginaceae/Anchusa", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Anchusa" @@ -159,10 +195,10 @@ "type": "Taxon", "id": "7359834e-c79c-4773-89ac-46befceb5f72", "data": { + "created": "2026-09-24T19:04:01+00:00", "description": "Actinotus is a genus of flowering plants in the family Apiaceae, subfamily Mackinlayoideae, with about 18 species. It is native to Australasia. Its best known member is the flannel flower, a common sight in Sydney bushland in the spring. The generic name, meaning \"furnished with rays\" is derived from the Greek stem aktin-/ακτιν- \"ray\" or \"sunbeam\".\n\nMost species are endemic to Australia with one from New Zealand. Other notable species are A. schwarzii from the Macdonnell Ranges in Central Australia, which closely resembles A. helianthi in appearance, and the rare pink-flowering A. forsythii from the Blue Mountains.\n\nThe genus was established by the French botanist Jacques Labillardière with his description of A. helianthi on page 67 of the first volume of his Novae Hollandiae Plantarum Specimen. However the habitat statement is anomalous and according to historian Edward Duyker Labillardière could not have collected the type specimen personally and might have received it from Jean-Baptiste Leschenault de La Tour botanist on the expedition of Nicolas Baudin or another early French visitor to New South Wales.\n\nIts closest relative is Apiopetalum from New Caledonia.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Actinotus", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Apiales/Apiaceae/Actinotus", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Actinotus" @@ -172,10 +208,10 @@ "type": "Taxon", "id": "3a123b1c-8bdc-482c-9d12-c3e9e22ec4fd", "data": { + "created": "2026-09-24T19:04:01+00:00", "description": "Mertensia is a genus of flowering plants in the borage family Boraginaceae. They are perennial herbaceous plants with blue or sometimes white flowers that open from pink-tinged buds. Such a change in flower color is common in Boraginaceae and is caused by changes in soil pH. Mertensia is one of several plants that are commonly called \"bluebell\". In spite of their common name, the flowers are usually salverform (trumpet-shaped) rather than campanulate (bell-shaped).\n\nMertensia is native to most of North America and to a large part of Asia from western China to northeastern Russia. Its center of diversity is in the Rocky Mountains. Mertensia is mostly restricted to alpine, subalpine, and montane habitats. Notable exceptions are Mertensia maritima, a maritime plant of Arctic and subarctic coastlines, and Mertensia virginica, which is found from the Appalachian Mountains west to Minnesota, Iowa, and Missouri. Most of the species are endemic to very small areas of the Rocky Mountains.\n\nMertensia virginica has the largest flowers in the genus and is commonly cultivated. It is sparingly naturalized in Europe. About 12 other species are known in cultivation. The Inuit ate the rhizomes of Mertensia maritima.\n\nMany of the species of Mertensia are hard to distinguish and some are possibly cryptic. Around 150 species names have been published in Mertensia. Most authors have recognized about 45 species, but in 2014, the authors of a molecular phylogenetic study recommended the acceptance of at least 62.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Mertensia", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Boraginales/Boraginaceae/Mertensia", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Mertensia" @@ -185,6 +221,7 @@ "type": "Plant", "id": "152d527e-4e33-4ad4-b13b-1de643740cce", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Mimosa, Silver Wattle", @@ -193,22 +230,30 @@ "link": "https://en.wikipedia.org/wiki/File:Acacia_dealbata-1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Acacia", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "A. dealbata", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Acacia_dealbata", - "description": "Acacia dealbata, commonly known as silver wattle, blue wattle or mimosa,[2] is a species of flowering plant in the legume family, Fabaceae and is endemic to south-eastern mainland Australia. It is a bushy shrub or spreading tree with smooth bark, bluish grey or silvery and glaucous bipinnate leaves, spherical heads of yellow to bright yellow flowers, and straight to slightly curved pods. The Wiradjuri people of New South Wales use the name giigandul for the species.[3]" + "description": "Acacia dealbata, commonly known as silver wattle, blue wattle or mimosa, is a species of flowering plant in the legume family, Fabaceae and is endemic to south-eastern mainland Australia. It is a bushy shrub or spreading tree with smooth bark, bluish grey or silvery and glaucous bipinnate leaves, spherical heads of yellow to bright yellow flowers, and straight to slightly curved pods. The Wiradjuri people of New South Wales use the name giigandul for the species." + }, + "file": { + "contentLength": 133952, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c33c0c115ef2182e02533fd8fc4868f58ffaaf5ba212e4dcf9ec7c71020f2aba" + } } }, { "type": "Tag", "id": "3e5b01a7-5856-461e-bde5-d8388b6d909c", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "navy", + "updated": "2026-09-24T19:04:01+00:00", "color": "#000075", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Navy_blue", @@ -219,23 +264,23 @@ "type": "Tag", "id": "25d0c9a8-8864-42bd-9ea1-6157fb688998", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "cyan", + "updated": "2026-09-24T19:04:01+00:00", "color": "#42d4f4", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Cyan", - "description": "Cyan (/\\u02c8sa\\u026a.\\u0259n, -\\u00e6n/)[2][3][4] is the color between blue and green on the visible spectrum of light.[5][6] It is evoked by light with a predominant wavelength between 500 and 520 nm, between the wavelengths of green and blue.[7]\n\nIn the subtractive color system, or CMYK color model, which can be overlaid to produce all colors in paint and color printing, cyan is one of the primary colors, along with magenta and yellow. In the additive color system, or RGB color model, used to create all the colors on a computer or television display, cyan is made by mixing equal amounts of green and blue light. Cyan is the complement of red; it can be made by the removal of red from white. Mixing red light and cyan light at the right intensity will make white light. It is commonly seen on a bright, sunny day in the sky." + "description": "Cyan (/\\u02c8sa\\u026a.\\u0259n, -\\u00e6n/) is the color between blue and green on the visible spectrum of light. It is evoked by light with a predominant wavelength between 500 and 520 nm, between the wavelengths of green and blue.\n\nIn the subtractive color system, or CMYK color model, which can be overlaid to produce all colors in paint and color printing, cyan is one of the primary colors, along with magenta and yellow. In the additive color system, or RGB color model, used to create all the colors on a computer or television display, cyan is made by mixing equal amounts of green and blue light. Cyan is the complement of red; it can be made by the removal of red from white. Mixing red light and cyan light at the right intensity will make white light. It is commonly seen on a bright, sunny day in the sky." } }, { "type": "Tag", "id": "bbc7529f-72da-4df2-aff5-e139a5618ccb", "data": { - "description": "Brown is a color. It can be thought as a darker, and usually desaturated, shade of orange. In the CMYK color model used in printing and painting, brown is usually made by combining yellow, magenta, and black inks (these two last in a lesser amount).[4][5][6]\n\nIn the RGB color model used to project colors onto television screens and computer monitors, brown is the result of combining red light at low intensity, and green light at an even lower intensity, with blue light also being present in most shades of the color. The color brown is seen widely in nature, wood, soil, human hair color, eye color and skin pigmentation. Brown is the color of dark wood or rich soil.[7]\n\nIn the RYB color model, brown is made by mixing the three primary colors, red, yellow, and blue.\n\nAccording to public opinion surveys in Europe and the United States, brown is the least favorite color of the public; it is often associated with fecal matter, plainness, the rustic, although it does also have positive associations, including baking, warmth, wildlife, the autumn and music.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "Brown is a color. It can be thought as a darker, and usually desaturated, shade of orange. In the CMYK color model used in printing and painting, brown is usually made by combining yellow, magenta, and black inks (these two last in a lesser amount).\n\nIn the RGB color model used to project colors onto television screens and computer monitors, brown is the result of combining red light at low intensity, and green light at an even lower intensity, with blue light also being present in most shades of the color. The color brown is seen widely in nature, wood, soil, human hair color, eye color and skin pigmentation. Brown is the color of dark wood or rich soil.\n\nIn the RYB color model, brown is made by mixing the three primary colors, red, yellow, and blue.\n\nAccording to public opinion surveys in Europe and the United States, brown is the least favorite color of the public; it is often associated with fecal matter, plainness, the rustic, although it does also have positive associations, including baking, warmth, wildlife, the autumn and music.", "name": "brown", + "updated": "2026-09-24T19:04:01+00:00", "color": "#9a6324", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Brown" @@ -245,10 +290,10 @@ "type": "Tag", "id": "b8674d32-af5f-4ef1-a5d8-fc5cbb56b795", "data": { - "description": "Purple is a color similar in appearance to violet light. In the RYB color model historically used in the arts, purple is a secondary color created by combining red and blue pigments. In the CMYK color model used in modern printing, purple is made by combining magenta pigment with either cyan pigment, black pigment, or both. In the RGB color model used in computer and television screens, purple is created by mixing red and blue light in order to create colors that appear similar to violet light. According to color theory, purple is considered a cool color.[1]\n\nPurple has long been associated with royalty, originally because Tyrian purple dye\\u2014made from the secretions of sea snails\\u2014was extremely expensive in antiquity.[2] Purple was the color worn by Roman magistrates; it became the imperial color worn by the rulers of the Byzantine Empire and the Holy Roman Empire, and later by Roman Catholic bishops. Similarly in Japan, the color is traditionally associated with the emperor and aristocracy.[3]\n\nAccording to contemporary surveys in Europe and the United States, purple is the color most often associated with rarity, royalty, luxury, ambition, magic, mystery, piety and spirituality.[4][5] When combined with pink, it is associated with eroticism, femininity, and seduction.[6]\nEtymology and definitions\n\nThe modern English word purple comes from the Old English purpul, which derives from Latin purpura, which, in turn, derives from the Greek \\u03c0\\u03bf\\u03c1\\u03c6\\u03cd\\u03c1\\u03b1 (porphura),[7] the name of the Tyrian purple dye manufactured in classical antiquity from a mucus secreted by the spiny dye-murex snail.[8][9] The first recorded use of the word purple dates to the late 900s AD.[8]", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "Purple is a color similar in appearance to violet light. In the RYB color model historically used in the arts, purple is a secondary color created by combining red and blue pigments. In the CMYK color model used in modern printing, purple is made by combining magenta pigment with either cyan pigment, black pigment, or both. In the RGB color model used in computer and television screens, purple is created by mixing red and blue light in order to create colors that appear similar to violet light. According to color theory, purple is considered a cool color.\n\nPurple has long been associated with royalty, originally because Tyrian purple dye\\u2014made from the secretions of sea snails\\u2014was extremely expensive in antiquity. Purple was the color worn by Roman magistrates; it became the imperial color worn by the rulers of the Byzantine Empire and the Holy Roman Empire, and later by Roman Catholic bishops. Similarly in Japan, the color is traditionally associated with the emperor and aristocracy.\n\nAccording to contemporary surveys in Europe and the United States, purple is the color most often associated with rarity, royalty, luxury, ambition, magic, mystery, piety and spirituality. When combined with pink, it is associated with eroticism, femininity, and seduction.\nEtymology and definitions\n\nThe modern English word purple comes from the Old English purpul, which derives from Latin purpura, which, in turn, derives from the Greek \\u03c0\\u03bf\\u03c1\\u03c6\\u03cd\\u03c1\\u03b1 (porphura), the name of the Tyrian purple dye manufactured in classical antiquity from a mucus secreted by the spiny dye-murex snail. The first recorded use of the word purple dates to the late 900s AD.", "name": "purple", + "updated": "2026-09-24T19:04:01+00:00", "color": "#911eb4", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Purple" @@ -258,23 +303,23 @@ "type": "Tag", "id": "b7121a49-8f17-4858-b988-4bd837a5f1fe", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "lavender", + "updated": "2026-09-24T19:04:01+00:00", "color": "#dcbeff", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lavender_(color)", - "description": "Lavender is a light shade of purple or violet. It applies particularly to the color of the flower of the same name. The web color called lavender is displayed adjacent\\u2014it matches the color of the palest part of the flower; however, the more saturated color shown as floral lavender more closely matches the average color of the lavender flower as shown in the picture and is the tone of lavender historically and traditionally considered lavender by average people as opposed to website designers.[3] The color lavender might be described as a medium purple, a pale bluish purple,[4] or a light pinkish-purple. The term lavender may be used in general to apply to a wide range of pale, light, or grayish-purples, but only on the blue side; lilac is pale purple on the pink side. In paints, the color lavender is made by mixing purple and white paint." + "description": "Lavender is a light shade of purple or violet. It applies particularly to the color of the flower of the same name. The web color called lavender is displayed adjacent\\u2014it matches the color of the palest part of the flower; however, the more saturated color shown as floral lavender more closely matches the average color of the lavender flower as shown in the picture and is the tone of lavender historically and traditionally considered lavender by average people as opposed to website designers. The color lavender might be described as a medium purple, a pale bluish purple, or a light pinkish-purple. The term lavender may be used in general to apply to a wide range of pale, light, or grayish-purples, but only on the blue side; lilac is pale purple on the pink side. In paints, the color lavender is made by mixing purple and white paint." } }, { "type": "Tag", "id": "12de56ab-8e89-4281-ab44-403c7c7ecb00", "data": { - "description": "Magenta (/m\\u0259\\u02c8d\\u0292\\u025bnt\\u0259/) is a purple-red color.[2][3] On color wheels of the RGB (additive) and CMY (subtractive) color models, it is located precisely midway between blue and red. It is one of the four colors of ink used in color printing by most color printers, along with yellow, cyan, and black to make all the other colors. The tone of magenta used in printing, printer's magenta, is redder than the magenta of the RGB (additive) model, the former being closer to rose.\n\nMagenta took its name from an aniline dye made and patented in 1859 by the French chemist Fran\\u00e7ois-Emmanuel Verguin, who originally called it fuchsine. It was renamed to celebrate the French-Sardinian victory under French Emperor Napoleon III at the Battle of Magenta against the larger army of the Austrian Empire on 4 June 1859 near the Italian town of Magenta, at the time in Austria. This battle was decisive in liberating Italy from Austrian domination.[4][5]\n\nA virtually identical color, called roseine, was created in 1860 by two British chemists, Edward Chambers Nicholson, and George Maule.\n\nThe web color magenta is also called fuchsia.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "Magenta (/m\\u0259\\u02c8d\\u0292\\u025bnt\\u0259/) is a purple-red color. On color wheels of the RGB (additive) and CMY (subtractive) color models, it is located precisely midway between blue and red. It is one of the four colors of ink used in color printing by most color printers, along with yellow, cyan, and black to make all the other colors. The tone of magenta used in printing, printer's magenta, is redder than the magenta of the RGB (additive) model, the former being closer to rose.\n\nMagenta took its name from an aniline dye made and patented in 1859 by the French chemist Fran\\u00e7ois-Emmanuel Verguin, who originally called it fuchsine. It was renamed to celebrate the French-Sardinian victory under French Emperor Napoleon III at the Battle of Magenta against the larger army of the Austrian Empire on 4 June 1859 near the Italian town of Magenta, at the time in Austria. This battle was decisive in liberating Italy from Austrian domination.\n\nA virtually identical color, called roseine, was created in 1860 by two British chemists, Edward Chambers Nicholson, and George Maule.\n\nThe web color magenta is also called fuchsia.", "name": "magenta", + "updated": "2026-09-24T19:04:01+00:00", "color": "#f032e6", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Magenta" @@ -284,10 +329,10 @@ "type": "Tag", "id": "90819566-3121-42af-bdb5-19cbef67cd31", "data": { - "description": "White is the lightest color[2] and is achromatic (having no chroma). It is the color of objects such as snow, chalk, and milk, and is the opposite of black. White objects fully (or almost fully) reflect and scatter all the visible wavelengths of light. White on television and computer screens is created by a mixture of red, blue, and green light. The color white can be given with white pigments, especially titanium dioxide.[3]\n\nIn ancient Egypt and ancient Rome, priestesses wore white as a symbol of purity, and Romans wore white togas as symbols of citizenship. In the Middle Ages and Renaissance a white unicorn symbolized chastity, and a white lamb sacrifice and purity. It was the royal color of the kings of France as well as the flag of monarchist France from 1815 to 1830, and of the monarchist movement that opposed the Bolsheviks during the Russian Civil War (1917\\u20131922). Greek temples and Roman temples were faced with white marble, and beginning in the 18th century, with the advent of neoclassical architecture, white became the most common color of new churches, capitols, and other government buildings, especially in the United States. It was also widely used in 20th century modern architecture as a symbol of modernity and simplicity.\n\nAccording to surveys in Europe and the United States, white is the color most often associated with perfection, the good, honesty, cleanliness, the beginning, the new, neutrality, and exactitude.[4] White is an important color for almost all world religions. The pope, the head of the Roman Catholic Church, has worn white since 1566, as a symbol of purity and sacrifice. In Islam, and in the Shinto religion of Japan, it is worn by pilgrims. In Western cultures and in Japan, white is the most common color for wedding dresses, symbolizing purity and virginity. In many Asian cultures, white is also the color of mourning.[5]", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "White is the lightest color and is achromatic (having no chroma). It is the color of objects such as snow, chalk, and milk, and is the opposite of black. White objects fully (or almost fully) reflect and scatter all the visible wavelengths of light. White on television and computer screens is created by a mixture of red, blue, and green light. The color white can be given with white pigments, especially titanium dioxide.\n\nIn ancient Egypt and ancient Rome, priestesses wore white as a symbol of purity, and Romans wore white togas as symbols of citizenship. In the Middle Ages and Renaissance a white unicorn symbolized chastity, and a white lamb sacrifice and purity. It was the royal color of the kings of France as well as the flag of monarchist France from 1815 to 1830, and of the monarchist movement that opposed the Bolsheviks during the Russian Civil War (1917\\u20131922). Greek temples and Roman temples were faced with white marble, and beginning in the 18th century, with the advent of neoclassical architecture, white became the most common color of new churches, capitols, and other government buildings, especially in the United States. It was also widely used in 20th century modern architecture as a symbol of modernity and simplicity.\n\nAccording to surveys in Europe and the United States, white is the color most often associated with perfection, the good, honesty, cleanliness, the beginning, the new, neutrality, and exactitude. White is an important color for almost all world religions. The pope, the head of the Roman Catholic Church, has worn white since 1566, as a symbol of purity and sacrifice. In Islam, and in the Shinto religion of Japan, it is worn by pilgrims. In Western cultures and in Japan, white is the most common color for wedding dresses, symbolizing purity and virginity. In many Asian cultures, white is also the color of mourning.", "name": "white", + "updated": "2026-09-24T19:04:01+00:00", "color": "#ffffff", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/White" @@ -297,10 +342,10 @@ "type": "Tag", "id": "be14916b-0068-4b49-b8d0-f9e528f3d10e", "data": { - "description": "Yellow is the color between green and orange on the spectrum of light. It is evoked by light with a dominant wavelength of roughly 575\\u2013585 nm. It is a primary color in subtractive color systems, used in painting or color printing. In the RGB color model, used to create colors on television and computer screens, yellow is a secondary color made by combining red and green at equal intensity. Carotenoids give the characteristic yellow color to autumn leaves, corn, canaries, daffodils, and lemons, as well as egg yolks, buttercups, and bananas. They absorb light energy and protect plants from photo damage in some cases.[3] Sunlight has a slight yellowish hue when the Sun is near the horizon, due to atmospheric scattering of shorter wavelengths (green, blue, and violet).\n\nBecause it was widely available, yellow ochre pigment was one of the first colors used in art; the Lascaux cave in France has a painting of a yellow horse 17,000 years old. Ochre and orpiment pigments were used to represent gold and skin color in Egyptian tombs, then in the murals in Roman villas.[4] In the early Christian church, yellow was the color associated with the Pope and the golden keys of the Kingdom, but it was also associated with Judas Iscariot and used to mark heretics. In the 20th century, Jews in Nazi-occupied Europe were forced to wear a yellow star. In China, bright yellow was the color of the Middle Kingdom, and could be worn only by the emperor and his household; special guests were welcomed on a yellow carpet.[5]\n\nAccording to surveys in Europe, Canada, the United States and elsewhere, yellow is the color people most often associate with amusement, gentleness, humor, happiness, and spontaneity; however it can also be associated with duplicity, envy, jealousy, greed, justice, and, in the U.S., cowardice.[6] In Iran it has connotations of pallor/sickness,[7] but also wisdom and connection.[8] In China and many Asian countries, it is seen as the color of royalty, nobility, respect, happiness, glory, harmony and wisdom.[9]", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "Yellow is the color between green and orange on the spectrum of light. It is evoked by light with a dominant wavelength of roughly 575\\u2013585 nm. It is a primary color in subtractive color systems, used in painting or color printing. In the RGB color model, used to create colors on television and computer screens, yellow is a secondary color made by combining red and green at equal intensity. Carotenoids give the characteristic yellow color to autumn leaves, corn, canaries, daffodils, and lemons, as well as egg yolks, buttercups, and bananas. They absorb light energy and protect plants from photo damage in some cases. Sunlight has a slight yellowish hue when the Sun is near the horizon, due to atmospheric scattering of shorter wavelengths (green, blue, and violet).\n\nBecause it was widely available, yellow ochre pigment was one of the first colors used in art; the Lascaux cave in France has a painting of a yellow horse 17,000 years old. Ochre and orpiment pigments were used to represent gold and skin color in Egyptian tombs, then in the murals in Roman villas. In the early Christian church, yellow was the color associated with the Pope and the golden keys of the Kingdom, but it was also associated with Judas Iscariot and used to mark heretics. In the 20th century, Jews in Nazi-occupied Europe were forced to wear a yellow star. In China, bright yellow was the color of the Middle Kingdom, and could be worn only by the emperor and his household; special guests were welcomed on a yellow carpet.\n\nAccording to surveys in Europe, Canada, the United States and elsewhere, yellow is the color people most often associate with amusement, gentleness, humor, happiness, and spontaneity; however it can also be associated with duplicity, envy, jealousy, greed, justice, and, in the U.S., cowardice. In Iran it has connotations of pallor/sickness, but also wisdom and connection. In China and many Asian countries, it is seen as the color of royalty, nobility, respect, happiness, glory, harmony and wisdom.", "name": "yellow", + "updated": "2026-09-24T19:04:01+00:00", "color": "#ffe119", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Yellow" @@ -310,10 +355,10 @@ "type": "Tag", "id": "f99ba115-8278-4dba-a549-ab138fc20be0", "data": { - "description": "Green is the color between cyan and yellow on the visible spectrum. It is evoked by light which has a dominant wavelength of roughly 495\\u2013570 nm. In subtractive color systems, used in painting and color printing, it is created by a combination of yellow and cyan; in the RGB color model, used on television and computer screens, it is one of the additive primary colors, along with red and blue, which are mixed in different combinations to create all other colors. By far the largest contributor to green in nature is chlorophyll, the chemical by which plants photosynthesize and convert sunlight into chemical energy. Many creatures have adapted to their green environments by taking on a green hue themselves as camouflage. Several minerals have a green color, including the emerald, which is colored green by its chromium content.\n\nDuring post-classical and early modern Europe, green was the color commonly associated with wealth, merchants, bankers, and the gentry, while red was reserved for the nobility. For this reason, the costume of the Mona Lisa by Leonardo da Vinci and the benches in the British House of Commons are green while those in the House of Lords are red.[1] It also has a long historical tradition as the color of Ireland and of Gaelic culture. It is the historic color of Islam, representing the lush vegetation of Paradise. It was the color of the banner of Muhammad, and is found in the flags of nearly all Islamic countries.[2]\n\nIn surveys made in American, European, and Islamic countries, green is the color most commonly associated with nature, life, health, youth, spring, hope, and envy.[3] In the European Union and the United States, green is also sometimes associated with toxicity and poor health,[4] but in China and most of Asia, its associations are very positive, as the symbol of fertility and happiness.[3] Because of its association with nature, it is the color of the environmental movement. Political groups advocating environmental protection and social justice describe themselves as part of the Green movement, some naming themselves Green parties. This has led to similar campaigns in advertising, as companies have sold green, or environmentally friendly, products. Green is also the traditional color of safety and permission; a green light means go ahead, a green card permits permanent residence in the United States.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "Green is the color between cyan and yellow on the visible spectrum. It is evoked by light which has a dominant wavelength of roughly 495\\u2013570 nm. In subtractive color systems, used in painting and color printing, it is created by a combination of yellow and cyan; in the RGB color model, used on television and computer screens, it is one of the additive primary colors, along with red and blue, which are mixed in different combinations to create all other colors. By far the largest contributor to green in nature is chlorophyll, the chemical by which plants photosynthesize and convert sunlight into chemical energy. Many creatures have adapted to their green environments by taking on a green hue themselves as camouflage. Several minerals have a green color, including the emerald, which is colored green by its chromium content.\n\nDuring post-classical and early modern Europe, green was the color commonly associated with wealth, merchants, bankers, and the gentry, while red was reserved for the nobility. For this reason, the costume of the Mona Lisa by Leonardo da Vinci and the benches in the British House of Commons are green while those in the House of Lords are red. It also has a long historical tradition as the color of Ireland and of Gaelic culture. It is the historic color of Islam, representing the lush vegetation of Paradise. It was the color of the banner of Muhammad, and is found in the flags of nearly all Islamic countries.\n\nIn surveys made in American, European, and Islamic countries, green is the color most commonly associated with nature, life, health, youth, spring, hope, and envy. In the European Union and the United States, green is also sometimes associated with toxicity and poor health, but in China and most of Asia, its associations are very positive, as the symbol of fertility and happiness. Because of its association with nature, it is the color of the environmental movement. Political groups advocating environmental protection and social justice describe themselves as part of the Green movement, some naming themselves Green parties. This has led to similar campaigns in advertising, as companies have sold green, or environmentally friendly, products. Green is also the traditional color of safety and permission; a green light means go ahead, a green card permits permanent residence in the United States.", "name": "green", + "updated": "2026-09-24T19:04:01+00:00", "color": "#3cb44b", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Green" @@ -352,7 +397,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825390.227822, + "start": 1790276705.689941, "indices": "*", "combinedQuery": { "_source": [ @@ -409,8 +454,8 @@ } } }, - "end": 1758825390.287255, - "duration": 0.059433 + "end": 1790276705.737569, + "duration": 0.047628 }, { "input": { @@ -424,14 +469,14 @@ "id": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "type": "Tag", "metadata": { - "score": 26.933556 + "score": 26.929846 } }, { "id": "d747bfe6-0d52-4119-87c6-1fc37ed9395b", "type": "Plant", "metadata": { - "score": 4.160383 + "score": 4.1834593 } }, { @@ -452,21 +497,21 @@ "id": "83b35321-4a91-4e9f-a49b-66d718c32ba7", "type": "Plant", "metadata": { - "score": 3.2498505 + "score": 3.2715626 } }, { "id": "ad80a619-2b53-4f27-a4bd-56a316c15251", "type": "Plant", "metadata": { - "score": 3.1922612 + "score": 3.1628277 } }, { "id": "26dfaa98-5f15-4170-9fdc-94661c1a26c5", "type": "Plant", "metadata": { - "score": 3.038499 + "score": 3.0894513 } }, { @@ -494,81 +539,81 @@ "id": "152d527e-4e33-4ad4-b13b-1de643740cce", "type": "Plant", "metadata": { - "score": 2.5810764 + "score": 2.5530436 } }, { "id": "3e5b01a7-5856-461e-bde5-d8388b6d909c", "type": "Tag", "metadata": { - "score": 1.5008786 + "score": 1.4979569 } }, { "id": "25d0c9a8-8864-42bd-9ea1-6157fb688998", "type": "Tag", "metadata": { - "score": 1.3131481 + "score": 1.3199856 } }, { "id": "bbc7529f-72da-4df2-aff5-e139a5618ccb", "type": "Tag", "metadata": { - "score": 1.113173 + "score": 1.1328773 } }, { "id": "b8674d32-af5f-4ef1-a5d8-fc5cbb56b795", "type": "Tag", "metadata": { - "score": 0.9764628 + "score": 0.98849475 } }, { "id": "b7121a49-8f17-4858-b988-4bd837a5f1fe", "type": "Tag", "metadata": { - "score": 0.87871504 + "score": 0.88779384 } }, { "id": "12de56ab-8e89-4281-ab44-403c7c7ecb00", "type": "Tag", "metadata": { - "score": 0.818965 + "score": 0.8112192 } }, { "id": "90819566-3121-42af-bdb5-19cbef67cd31", "type": "Tag", "metadata": { - "score": 0.6451281 + "score": 0.6371239 } }, { "id": "be14916b-0068-4b49-b8d0-f9e528f3d10e", "type": "Tag", "metadata": { - "score": 0.6129052 + "score": 0.6371239 } }, { "id": "f99ba115-8278-4dba-a549-ab138fc20be0", "type": "Tag", "metadata": { - "score": 0.5838508 + "score": 0.57601744 } } ], "totalHits": 20, - "maxScore": 26.933556 + "maxScore": 26.929846 } ] } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825390.287426, + "start": 1790276705.737894, "accessibleElementIds": [ "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "d747bfe6-0d52-4119-87c6-1fc37ed9395b", @@ -591,8 +636,8 @@ "be14916b-0068-4b49-b8d0-f9e528f3d10e", "f99ba115-8278-4dba-a549-ab138fc20be0" ], - "end": 1758825390.359465, - "duration": 0.072039 + "end": 1790276705.823951, + "duration": 0.086057 } ] } \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-header.txt index 6a9800b9..899a614d 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/advanced-full-text-search/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Thu, 11 Sep 2025 20:23:41 GMT +Date: Sat, 26 Sep 2026 21:53:37 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-body.json b/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-body.json index 0caf892e..fded38c5 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-body.json +++ b/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-body.json @@ -5,6 +5,7 @@ "type": "Plant", "id": "83b35321-4a91-4e9f-a49b-66d718c32ba7", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Mertensia virginica Virginia Bluebell seen in Brooklyn Botanic Garden", @@ -19,13 +20,21 @@ "link": "https://en.wikipedia.org/wiki/File:Virginia_Bluebells_at_Rocky_River.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Bluebell", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "M. virginica", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Mertensia_virginica", - "description": "Mertensia virginica (common names Virginia bluebells,[1] Virginia cowslip,[2] lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + "description": "Mertensia virginica (common names Virginia bluebells, Virginia cowslip, lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + }, + "file": { + "contentLength": 82651, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "527fa0ca5006ae3c888c06c3e208db64aa0214b31ce9f26a1ccd5fa7cdf91c3b" + } } } ], @@ -45,7 +54,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825390.524884, + "start": 1790276705.894463, "indices": "*", "combinedQuery": { "_source": [ @@ -86,8 +95,8 @@ } } }, - "end": 1758825390.554652, - "duration": 0.029768 + "end": 1790276705.906694, + "duration": 0.012231 }, { "input": { @@ -112,12 +121,12 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825390.554819, + "start": 1790276705.906997, "accessibleElementIds": [ "83b35321-4a91-4e9f-a49b-66d718c32ba7" ], - "end": 1758825390.569048, - "duration": 0.014229 + "end": 1790276705.9172, + "duration": 0.010203 } ] } \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-header.txt index c3ba5b8f..899a614d 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/keyword-search/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:37 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-body.json b/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-body.json index 086aee19..fa835585 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-body.json +++ b/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-body.json @@ -5,6 +5,7 @@ "type": "Plant", "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Lilium candidum, Israel", @@ -19,19 +20,28 @@ "link": "https://en.wikipedia.org/wiki/File:Lilium_candidum_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "L. candidum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium_candidum", - "description": "Lilium candidum, the Madonna lily[2][3] or white lily,[4] is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions.[1][5] It has been cultivated since antiquity, for at least 3,000 years,[6] and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + "description": "Lilium candidum, the Madonna lily or white lily, is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions. It has been cultivated since antiquity, for at least 3,000 years, and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + }, + "file": { + "contentLength": 115168, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ee066b93ae72c719f863a428f03c86ecd28e52818528eacf4d4e32acb4b4b37c" + } } }, { "type": "Plant", "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "", @@ -46,19 +56,28 @@ "link": "https://en.wikipedia.org/wiki/File:Gloriosa_superba_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Flame Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "G. superba", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_superba", - "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily,[3] gloriosa lily, tiger claw,[4] and fire lily.[5]" + "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily, gloriosa lily, tiger claw, and fire lily." + }, + "file": { + "contentLength": 90681, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "72e3e3770db795b2f04f0b5bb0be43e74df70de4faa0bf83a706bbde29e4ea0b" + } } }, { "type": "Plant", "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "in Seoul Forest, South Korea", @@ -79,13 +98,21 @@ "link": "https://en.wikipedia.org/wiki/File:Tulipa_orphanidea_060506.jpg" } ], - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "hasFile": true, "name": "Tulip", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "Tulipa", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Tulip", - "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae,[2] along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae, along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + }, + "file": { + "contentLength": 105488, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c4c242897c43a74e9e4a8b5bf89e6b2cb1cbd99f8d621f33eea175cbf6dfab97" + } } } ], @@ -108,7 +135,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825390.715234, + "start": 1790276705.958542, "indices": "*", "combinedQuery": { "_source": [ @@ -151,8 +178,8 @@ } } }, - "end": 1758825390.823238, - "duration": 0.108004 + "end": 1790276706.049568, + "duration": 0.091026 }, { "input": { @@ -191,14 +218,14 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825390.823414, + "start": 1790276706.049865, "accessibleElementIds": [ "23508e6c-632a-496d-9bd6-8c62a03216af", "258c0dfe-b1d8-4839-beed-d00d1b544a96", "8940d70b-5b6f-43b7-bee4-41d073396ff8" ], - "end": 1758825390.844799, - "duration": 0.021385 + "end": 1790276706.069127, + "duration": 0.019262 } ] } \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-header.txt index c3ba5b8f..899a614d 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/parameterized-elasticsearch-query-dsl-mixin-search/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:37 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-body.json b/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-body.json index 20209a68..75396c88 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-body.json +++ b/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-body.json @@ -5,9 +5,9 @@ "type": "Taxon", "id": "36e02d35-f6b4-4ccc-ae2f-b035ef02e0d6", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "Dahlia", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Asterales/Asteraceae/Dahlia", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Dahlia", @@ -18,6 +18,7 @@ "type": "Plant", "id": "83b35321-4a91-4e9f-a49b-66d718c32ba7", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Mertensia virginica Virginia Bluebell seen in Brooklyn Botanic Garden", @@ -32,19 +33,28 @@ "link": "https://en.wikipedia.org/wiki/File:Virginia_Bluebells_at_Rocky_River.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Bluebell", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "M. virginica", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Mertensia_virginica", - "description": "Mertensia virginica (common names Virginia bluebells,[1] Virginia cowslip,[2] lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + "description": "Mertensia virginica (common names Virginia bluebells, Virginia cowslip, lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + }, + "file": { + "contentLength": 82651, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "527fa0ca5006ae3c888c06c3e208db64aa0214b31ce9f26a1ccd5fa7cdf91c3b" + } } }, { "type": "Plant", "id": "ad80a619-2b53-4f27-a4bd-56a316c15251", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Jacarandas in New Farm Park October 2006", @@ -65,19 +75,28 @@ "link": "https://en.wikipedia.org/wiki/File:Jacaranda_mimosifolia_5334.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Jacaranda", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "J. mimosifolia", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Jacaranda_mimosifolia", - "description": "Jacaranda mimosifolia is a sub-tropical tree native to south-central South America that has been widely planted elsewhere because of its attractive and long-lasting violet-colored flowers. It is also known as the jacaranda, blue jacaranda, black poui, Nupur or fern tree. Older sources call it J. acutifolia, but modern authorities usually classify it as J. mimosifolia. In scientific usage, the name \"jacaranda\" refers to the genus Jacaranda, which has many other members, but in horticultural and everyday usage, it nearly always means the blue jacaranda.\n\nIn its native range in the wild, J. mimosifolia is listed as Vulnerable by the IUCN.[1]" + "description": "Jacaranda mimosifolia is a sub-tropical tree native to south-central South America that has been widely planted elsewhere because of its attractive and long-lasting violet-colored flowers. It is also known as the jacaranda, blue jacaranda, black poui, Nupur or fern tree. Older sources call it J. acutifolia, but modern authorities usually classify it as J. mimosifolia. In scientific usage, the name \"jacaranda\" refers to the genus Jacaranda, which has many other members, but in horticultural and everyday usage, it nearly always means the blue jacaranda.\n\nIn its native range in the wild, J. mimosifolia is listed as Vulnerable by the IUCN." + }, + "file": { + "contentLength": 174162, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "dc87ed191952b8a4bfec69ff9af4103c93d52f81e43c343bc8be1a2ddb61aa20" + } } }, { "type": "Plant", "id": "26dfaa98-5f15-4170-9fdc-94661c1a26c5", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "", @@ -86,23 +105,31 @@ "link": "https://en.wikipedia.org/wiki/File:Eustoma_grandiflorum_purple_02.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Prairie Gentian", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "E. russellianum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Eustoma_russellianum", - "description": "Eustoma russellianum is a species of flowering plant in the gentian family. One of its previous binomial names was Eustoma grandiflorum.[1] Common names include showy prairie gentian, prairie gentian, Texas bluebells, Texas bluebell, bluebell,[2] and Lisianthus.\n\nThere is a cultivar, 'Bolero Deep Blue'.[3]" + "description": "Eustoma russellianum is a species of flowering plant in the gentian family. One of its previous binomial names was Eustoma grandiflorum. Common names include showy prairie gentian, prairie gentian, Texas bluebells, Texas bluebell, bluebell, and Lisianthus.\n\nThere is a cultivar, 'Bolero Deep Blue'." + }, + "file": { + "contentLength": 104134, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "9bbf4efc14d13cb8c268901d0d6395a8745664312a7961d673695072bdb709fb" + } } }, { "type": "Taxon", "id": "7359834e-c79c-4773-89ac-46befceb5f72", "data": { + "created": "2026-09-24T19:04:01+00:00", "description": "Actinotus is a genus of flowering plants in the family Apiaceae, subfamily Mackinlayoideae, with about 18 species. It is native to Australasia. Its best known member is the flannel flower, a common sight in Sydney bushland in the spring. The generic name, meaning \"furnished with rays\" is derived from the Greek stem aktin-/ακτιν- \"ray\" or \"sunbeam\".\n\nMost species are endemic to Australia with one from New Zealand. Other notable species are A. schwarzii from the Macdonnell Ranges in Central Australia, which closely resembles A. helianthi in appearance, and the rare pink-flowering A. forsythii from the Blue Mountains.\n\nThe genus was established by the French botanist Jacques Labillardière with his description of A. helianthi on page 67 of the first volume of his Novae Hollandiae Plantarum Specimen. However the habitat statement is anomalous and according to historian Edward Duyker Labillardière could not have collected the type specimen personally and might have received it from Jean-Baptiste Leschenault de La Tour botanist on the expedition of Nicolas Baudin or another early French visitor to New South Wales.\n\nIts closest relative is Apiopetalum from New Caledonia.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Actinotus", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Apiales/Apiaceae/Actinotus", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Actinotus" @@ -112,10 +139,10 @@ "type": "Taxon", "id": "3a123b1c-8bdc-482c-9d12-c3e9e22ec4fd", "data": { + "created": "2026-09-24T19:04:01+00:00", "description": "Mertensia is a genus of flowering plants in the borage family Boraginaceae. They are perennial herbaceous plants with blue or sometimes white flowers that open from pink-tinged buds. Such a change in flower color is common in Boraginaceae and is caused by changes in soil pH. Mertensia is one of several plants that are commonly called \"bluebell\". In spite of their common name, the flowers are usually salverform (trumpet-shaped) rather than campanulate (bell-shaped).\n\nMertensia is native to most of North America and to a large part of Asia from western China to northeastern Russia. Its center of diversity is in the Rocky Mountains. Mertensia is mostly restricted to alpine, subalpine, and montane habitats. Notable exceptions are Mertensia maritima, a maritime plant of Arctic and subarctic coastlines, and Mertensia virginica, which is found from the Appalachian Mountains west to Minnesota, Iowa, and Missouri. Most of the species are endemic to very small areas of the Rocky Mountains.\n\nMertensia virginica has the largest flowers in the genus and is commonly cultivated. It is sparingly naturalized in Europe. About 12 other species are known in cultivation. The Inuit ate the rhizomes of Mertensia maritima.\n\nMany of the species of Mertensia are hard to distinguish and some are possibly cryptic. Around 150 species names have been published in Mertensia. Most authors have recognized about 45 species, but in 2014, the authors of a molecular phylogenetic study recommended the acceptance of at least 62.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Mertensia", + "updated": "2026-09-24T19:04:01+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Boraginales/Boraginaceae/Mertensia", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Mertensia" @@ -125,6 +152,7 @@ "type": "Plant", "id": "152d527e-4e33-4ad4-b13b-1de643740cce", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Mimosa, Silver Wattle", @@ -133,23 +161,31 @@ "link": "https://en.wikipedia.org/wiki/File:Acacia_dealbata-1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Acacia", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "A. dealbata", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Acacia_dealbata", - "description": "Acacia dealbata, commonly known as silver wattle, blue wattle or mimosa,[2] is a species of flowering plant in the legume family, Fabaceae and is endemic to south-eastern mainland Australia. It is a bushy shrub or spreading tree with smooth bark, bluish grey or silvery and glaucous bipinnate leaves, spherical heads of yellow to bright yellow flowers, and straight to slightly curved pods. The Wiradjuri people of New South Wales use the name giigandul for the species.[3]" + "description": "Acacia dealbata, commonly known as silver wattle, blue wattle or mimosa, is a species of flowering plant in the legume family, Fabaceae and is endemic to south-eastern mainland Australia. It is a bushy shrub or spreading tree with smooth bark, bluish grey or silvery and glaucous bipinnate leaves, spherical heads of yellow to bright yellow flowers, and straight to slightly curved pods. The Wiradjuri people of New South Wales use the name giigandul for the species." + }, + "file": { + "contentLength": 133952, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c33c0c115ef2182e02533fd8fc4868f58ffaaf5ba212e4dcf9ec7c71020f2aba" + } } }, { "type": "Tag", "id": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "description": "Blue is one of the three primary colours in the RGB (additive) colour model, as well as in the RYB colour model (traditional colour theory).[2] It lies between violet and cyan on the spectrum of visible light. The term blue generally describes colours perceived by humans observing light with a dominant wavelength that's between approximately 450 and 495 nanometres. The clear daytime sky and the deep sea appear blue because of an optical effect known as Rayleigh scattering. An optical effect called the Tyndall effect explains blue eyes. Distant objects appear more blue because of another optical effect called aerial perspective.\n\nBlue has been an important colour in art and decoration since ancient times. The semi-precious stone lapis lazuli was used in ancient Egypt for jewellery and ornament and later, in the Renaissance, to make the pigment ultramarine, the most expensive of all pigments.[3] In the eighth century Chinese artists used cobalt blue to colour fine blue and white porcelain. In the Middle Ages, European artists used it in the windows of cathedrals. Europeans wore clothing coloured with the vegetable dye woad until it was replaced by the finer indigo from America. In the 19th century, synthetic blue dyes and pigments gradually replaced organic dyes and mineral pigments. Dark blue became a common colour for military uniforms and later, in the late 20th century, for business suits. Because blue has commonly been associated with harmony, it was chosen as the colour of the flags of the United Nations and the European Union.[4]\n\nIn the United States and Europe, blue is the colour that both men and women are most likely to choose as their favourite, with at least one recent survey showing the same across several other countries, including China, Malaysia, and Indonesia.[5][6] Past surveys in the US and Europe have found that blue is the colour most commonly associated with harmony, confidence, masculinity, knowledge, intelligence, calmness, distance, infinity, the imagination, cold, and sadness.[7]", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "Blue is one of the three primary colours in the RGB (additive) colour model, as well as in the RYB colour model (traditional colour theory). It lies between violet and cyan on the spectrum of visible light. The term blue generally describes colours perceived by humans observing light with a dominant wavelength that's between approximately 450 and 495 nanometres. The clear daytime sky and the deep sea appear blue because of an optical effect known as Rayleigh scattering. An optical effect called the Tyndall effect explains blue eyes. Distant objects appear more blue because of another optical effect called aerial perspective.\n\nBlue has been an important colour in art and decoration since ancient times. The semi-precious stone lapis lazuli was used in ancient Egypt for jewellery and ornament and later, in the Renaissance, to make the pigment ultramarine, the most expensive of all pigments. In the eighth century Chinese artists used cobalt blue to colour fine blue and white porcelain. In the Middle Ages, European artists used it in the windows of cathedrals. Europeans wore clothing coloured with the vegetable dye woad until it was replaced by the finer indigo from America. In the 19th century, synthetic blue dyes and pigments gradually replaced organic dyes and mineral pigments. Dark blue became a common colour for military uniforms and later, in the late 20th century, for business suits. Because blue has commonly been associated with harmony, it was chosen as the colour of the flags of the United Nations and the European Union.\n\nIn the United States and Europe, blue is the colour that both men and women are most likely to choose as their favourite, with at least one recent survey showing the same across several other countries, including China, Malaysia, and Indonesia. Past surveys in the US and Europe have found that blue is the colour most commonly associated with harmony, confidence, masculinity, knowledge, intelligence, calmness, distance, infinity, the imagination, cold, and sadness.", "name": "blue", + "updated": "2026-09-24T19:04:01+00:00", "color": "#4363d8", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Blue" @@ -159,9 +195,9 @@ "type": "Tag", "id": "3e5b01a7-5856-461e-bde5-d8388b6d909c", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "navy", + "updated": "2026-09-24T19:04:01+00:00", "color": "#000075", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Navy_blue", @@ -172,13 +208,13 @@ "type": "Tag", "id": "b7121a49-8f17-4858-b988-4bd837a5f1fe", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", "name": "lavender", + "updated": "2026-09-24T19:04:01+00:00", "color": "#dcbeff", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lavender_(color)", - "description": "Lavender is a light shade of purple or violet. It applies particularly to the color of the flower of the same name. The web color called lavender is displayed adjacent\\u2014it matches the color of the palest part of the flower; however, the more saturated color shown as floral lavender more closely matches the average color of the lavender flower as shown in the picture and is the tone of lavender historically and traditionally considered lavender by average people as opposed to website designers.[3] The color lavender might be described as a medium purple, a pale bluish purple,[4] or a light pinkish-purple. The term lavender may be used in general to apply to a wide range of pale, light, or grayish-purples, but only on the blue side; lilac is pale purple on the pink side. In paints, the color lavender is made by mixing purple and white paint." + "description": "Lavender is a light shade of purple or violet. It applies particularly to the color of the flower of the same name. The web color called lavender is displayed adjacent\\u2014it matches the color of the palest part of the flower; however, the more saturated color shown as floral lavender more closely matches the average color of the lavender flower as shown in the picture and is the tone of lavender historically and traditionally considered lavender by average people as opposed to website designers. The color lavender might be described as a medium purple, a pale bluish purple, or a light pinkish-purple. The term lavender may be used in general to apply to a wide range of pale, light, or grayish-purples, but only on the blue side; lilac is pale purple on the pink side. In paints, the color lavender is made by mixing purple and white paint." } } ], @@ -197,7 +233,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825390.992112, + "start": 1790276706.113879, "indices": "*", "combinedQuery": { "_source": [ @@ -237,8 +273,8 @@ } } }, - "end": 1758825391.023698, - "duration": 0.031586 + "end": 1790276706.126923, + "duration": 0.013044 }, { "input": { @@ -259,21 +295,21 @@ "id": "83b35321-4a91-4e9f-a49b-66d718c32ba7", "type": "Plant", "metadata": { - "score": 3.2498505 + "score": 3.2715626 } }, { "id": "ad80a619-2b53-4f27-a4bd-56a316c15251", "type": "Plant", "metadata": { - "score": 3.1922612 + "score": 3.1628277 } }, { "id": "26dfaa98-5f15-4170-9fdc-94661c1a26c5", "type": "Plant", "metadata": { - "score": 3.038499 + "score": 3.0894513 } }, { @@ -294,28 +330,28 @@ "id": "152d527e-4e33-4ad4-b13b-1de643740cce", "type": "Plant", "metadata": { - "score": 2.5810764 + "score": 2.5530436 } }, { "id": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "type": "Tag", "metadata": { - "score": 1.5438207 + "score": 1.5401099 } }, { "id": "3e5b01a7-5856-461e-bde5-d8388b6d909c", "type": "Tag", "metadata": { - "score": 1.5008786 + "score": 1.4979569 } }, { "id": "b7121a49-8f17-4858-b988-4bd837a5f1fe", "type": "Tag", "metadata": { - "score": 0.87871504 + "score": 0.88779384 } } ], @@ -326,7 +362,7 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825391.023871, + "start": 1790276706.127298, "accessibleElementIds": [ "36e02d35-f6b4-4ccc-ae2f-b035ef02e0d6", "83b35321-4a91-4e9f-a49b-66d718c32ba7", @@ -339,8 +375,8 @@ "3e5b01a7-5856-461e-bde5-d8388b6d909c", "b7121a49-8f17-4858-b988-4bd837a5f1fe" ], - "end": 1758825391.067116, - "duration": 0.043245 + "end": 1790276706.17326, + "duration": 0.045962 } ] } \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-header.txt index c3ba5b8f..899a614d 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/query-string-search/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:37 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-body.json b/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-body.json index 5ffa3398..c740c086 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-body.json +++ b/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-body.json @@ -5,9 +5,9 @@ "type": "Taxon", "id": "16884ac8-ba7a-4c91-8449-4fdd803644b6", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Gloriosa", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Colchicaceae/Gloriosa", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_(plant)", @@ -18,6 +18,7 @@ "type": "Plant", "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "", @@ -32,22 +33,30 @@ "link": "https://en.wikipedia.org/wiki/File:Gloriosa_superba_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Flame Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "G. superba", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_superba", - "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily,[3] gloriosa lily, tiger claw,[4] and fire lily.[5]" + "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily, gloriosa lily, tiger claw, and fire lily." + }, + "file": { + "contentLength": 90681, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "72e3e3770db795b2f04f0b5bb0be43e74df70de4faa0bf83a706bbde29e4ea0b" + } } }, { "type": "Taxon", "id": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Lilieae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilieae", @@ -58,19 +67,33 @@ "type": "Taxon", "id": "63bcbffb-29a3-45b5-8cc1-8c41281bcae3", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Kniphofia", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asphodelaceae/Asphodeloideae/Kniphofia", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Kniphofia", "description": "Kniphofia (/nɪpˈhoʊfiə/, /nɪˈfoʊfiə/) is a genus of perennial flowering plants in the family Asphodelaceae, first described as a genus in 1794. All species of Kniphofia are native to Africa. Common names include tritoma, red hot poker, torch lily and poker plant." } }, + { + "type": "Taxon", + "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "data": { + "created": "2026-09-25T18:30:16+00:00", + "name": "Lilioideae", + "updated": "2026-09-25T18:30:16+00:00", + "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae", + "scenario": "general.botanicExample", + "link": "https://en.wikipedia.org/wiki/Lilioideae", + "description": "The Lilioideae are a subfamily of monocotyledonous perennial, herbaceous mainly bulbous flowering plants in the lily family, Liliaceae. They are found predominantly in the temperate and colder regions of the Northern Hemisphere, particularly East Asia and North America. The subfamily includes two tribes. They are of economic importance, particularly the lilies and tulips." + } + }, { "type": "Plant", "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Red-hot poker plants (Kniphofia uvaria) on the shore of Lake Jindabyne, New South Wales, Australia.", @@ -79,35 +102,30 @@ "link": "https://en.wikipedia.org/wiki/File:Kniphofia_uvaria_close.JPG" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Red Hot Poker", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "K. uvaria", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Kniphofia_uvaria", "description": "Kniphofia uvaria is a species of flowering plant in the family Asphodelaceae, commonly known as tritomea, torch lily, or red hot poker, due to the shape and color of its inflorescence. The leaves are reminiscent of a lily, and the flowerhead can reach up to 1.5 m (5 ft) in height. There are many varieties of torch lily, and they bloom at different times during the growing season. The flowers are red, orange, and yellow." - } - }, - { - "type": "Taxon", - "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", - "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", - "name": "Lilioideae", - "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae", - "scenario": "general.botanicExample", - "link": "https://en.wikipedia.org/wiki/Lilioideae", - "description": "The Lilioideae are a subfamily of monocotyledonous perennial, herbaceous mainly bulbous flowering plants in the lily family, Liliaceae. They are found predominantly in the temperate and colder regions of the Northern Hemisphere, particularly East Asia and North America. The subfamily includes two tribes. They are of economic importance, particularly the lilies and tulips." + }, + "file": { + "contentLength": 123686, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a0f461e0383bc5b4518a43e1ac76060612c7e87bf420f9a6bbbe5231012108fe" + } } }, { "type": "Taxon", "id": "9abc0ab3-fa1a-4138-80c1-49db5c4d082f", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Trillium", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Melanthiaceae/Trillium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Trillium", @@ -118,6 +136,7 @@ "type": "Plant", "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Lilium candidum, Israel", @@ -132,22 +151,30 @@ "link": "https://en.wikipedia.org/wiki/File:Lilium_candidum_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "L. candidum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium_candidum", - "description": "Lilium candidum, the Madonna lily[2][3] or white lily,[4] is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions.[1][5] It has been cultivated since antiquity, for at least 3,000 years,[6] and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + "description": "Lilium candidum, the Madonna lily or white lily, is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions. It has been cultivated since antiquity, for at least 3,000 years, and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + }, + "file": { + "contentLength": 115168, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ee066b93ae72c719f863a428f03c86ecd28e52818528eacf4d4e32acb4b4b37c" + } } }, { "type": "Taxon", "id": "7dd3a650-d60e-40c9-89d0-bccaf395369f", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Lilium", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae/Lilium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium", @@ -158,9 +185,9 @@ "type": "Taxon", "id": "2c0bb4b9-c1ec-41f3-b9d9-f59a8b8bef38", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Asparagaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asparagaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asparagaceae", @@ -171,10 +198,10 @@ "type": "Taxon", "id": "425235d6-75ab-4ccb-8208-783ced0decc6", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "The lily family, Liliaceae, consists of about 15 genera and 610 species of flowering plants within the order Liliales. They are monocotyledonous, perennial, herbaceous, often bulbous geophytes. Plants in this family have evolved with a fair amount of morphological diversity despite genetic similarity. Common characteristics include large flowers with parts arranged in threes: with six colored or patterned petaloid tepals (undifferentiated petals and sepals) arranged in two whorls, six stamens and a superior ovary. The leaves are linear in shape, with their veins usually arranged parallel to the edges, single and arranged alternating on the stem, or in a rosette at the base. Most species are grown from bulbs, although some have rhizomes. First described in 1789, the lily family became a paraphyletic \"catch-all\" (wastebasket) group of lilioid monocots that did not fit into other families and included a great number of genera now included in other families and in some cases in other orders. Consequently, many sources and descriptions labelled \"Liliaceae\" deal with the broader sense of the family.\n\nThe family evolved approximately 68 million years ago during the Late Cretaceous to Early Paleogene epochs. Liliaceae are widely distributed, mainly in temperate regions of the Northern Hemisphere and the flowers are insect pollinated. Many Liliaceae are important ornamental plants, widely grown for their attractive flowers and involved in a major floriculture of cut flowers and dry bulbs. Some species are poisonous if eaten and can have adverse health effects in humans and household pets.\n\nA number of Liliaceae genera are popular cultivated plants in private and public spaces. Lilies and tulips in particular have had considerable symbolic and decorative value, and appear frequently in paintings and the decorative arts. They are also an economically important product. Most of their genera, Lilium in particular, face considerable herbivory pressure from deer in some areas, both wild and domestic.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Liliaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliaceae" @@ -184,10 +211,10 @@ "type": "Taxon", "id": "1a9579bd-1331-4d5c-93ec-a9ff8d9c576b", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Aloe (/ˈæloʊ(i), əˈloʊi/; also written Aloë) is a genus containing over 650 species of flowering succulent plants. The most widely known species is Aloe vera, or \"true aloe\". It is called this because it is cultivated as the standard source for assorted pharmaceutical purposes. Other species, such as Aloe ferox, are also cultivated or harvested from the wild for similar applications.\n\nThe APG IV system (2016) places the genus in the family Asphodelaceae, subfamily Asphodeloideae. Within the subfamily it may be placed in the tribe Aloeae. In the past, it has been assigned to the family Aloaceae (now included in the Asphodeloidae) or to a broadly circumscribed family Liliaceae (the lily family). The plant Agave americana, which is sometimes called \"American aloe\", belongs to the Asparagaceae, a different family.\n\nThe genus is native to tropical and southern Africa, Madagascar, Jordan, the Arabian Peninsula, and various islands in the Indian Ocean (Mauritius, Réunion, Comoros, etc.). A few species have also become naturalized in other regions (Mediterranean, India, Australia, North and South America, Hawaiian Islands, etc.).", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Aloe", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asphodelaceae/Asphodeloideae/Aloe", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Aloe" @@ -197,10 +224,10 @@ "type": "Taxon", "id": "47e135a7-c77a-4489-9262-1e425a4b7177", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Asparagales (asparagoid lilies) are a diverse order of flowering plants in the monocots. Under the APG IV system of flowering plant classification, Asparagales are the largest order of monocots with 14 families, 1,122 genera, and about 36,000 species, with members as varied as asparaguses, orchids, yuccas, irises, onions, garlics, leeks, and other alliums, daffodils, snowdrops, amaryllis, agaves, butcher's broom, agapanthuses, Solomon's seal, hyacinths, bluebells, spider plants, grasstrees, aloes, freesias, gladioli, crocuses, and saffrons.\n\nMost species of Asparagales are herbaceous perennials, although some are climbers and some are trees or shrubs. The order also contains many geophytes (bulbs, corms, and various kinds of tuber). The leaves of almost all species form a tight rosette, either at the base of the plant or at the end of the stem, but occasionally along the stem. The flowers are not particularly distinctive, being 'lily type', with six tepals and up to six stamina. One of the defining characteristics (synapomorphies) of the order is the presence of phytomelanin, a black pigment present in the seed coat, creating a dark crust. Phytomelanin is found in most families of the Asparagales (although not in Orchidaceae, thought to be the sister-group of the rest of the order).\n\nThe order Asparagales takes its name from the type family Asparagaceae and has only recently been recognized in classification systems. The order is clearly circumscribed on the basis of molecular phylogenetics, but it is difficult to define morphologically since its members are structurally diverse. The order was first put forward by Huber in 1977 and later taken up in the Dahlgren system of 1985 and then the Angiosperm Phylogeny Group systems. Before this, many of its families were assigned to the old order Liliales, which was redistributed over three orders, Liliales, Asparagales, and Dioscoreales, based on molecular phylogenetics. The boundaries of the Asparagales and of its families have undergone a series of changes in recent years; future research may lead to further changes and ultimately greater stability.\n\nThe order is thought to have first diverged from other related monocots some 120–130 million years ago (early in the Cretaceous period), although given the difficulty in classifying the families involved, estimates are likely to be uncertain.\n\nFrom an economic point of view, the order Asparagales is second in importance within the monocots only to the order Poales (which includes grasses and cereals). Species are used as food and flavourings (e.g. onion, garlic, leek, asparagus, vanilla, saffron), in medicinal or cosmetic applications (Aloe), as cut flowers (e.g. freesia, gladiolus, iris, orchids), and as garden ornamentals (e.g. day lilies, lily of the valley, Agapanthus).", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Asparagales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asparagales" @@ -210,6 +237,7 @@ "type": "Plant", "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Flower of Nelumbo nucifera, bean of India.", @@ -224,19 +252,28 @@ "link": "https://en.wikipedia.org/wiki/File:Nelumbo_nucifera1romainguy.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lotus", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "N. nucifera", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Nelumbo_nucifera", - "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus,[1] or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae.[2] The lotus belongs in the order Proteales." + "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus, or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae. The lotus belongs in the order Proteales." + }, + "file": { + "contentLength": 63295, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a46657ef9ece1a7e0939ee5edcc4358f23a3b8b7871c6eba4ad2ce0f30cf4e9f" + } } }, { "type": "Plant", "id": "74023bac-88ea-4031-b4a5-68d861ea2e91", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Saucer Magnolia -- Magnolia × soulangeana 'Rustica Rubra' found in Houston, Texas", @@ -251,19 +288,28 @@ "link": "https://en.wikipedia.org/wiki/File:Yulan_1.JPG" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Magnolia", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "M. denudata", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Magnolia_denudata", - "description": "Magnolia denudata, the lilytree[2] or Yulan magnolia (simplified Chinese: \\u7389\\u5170\\u82b1; traditional Chinese: \\u7389\\u862d\\u82b1; pinyin: y\\u00f9l\\u00e1nhu\\u0101; lit. 'jade orchid/lily'), is native to central and eastern China.[3] It has been cultivated in Chinese Buddhist temple gardens since 600 AD. Its flowers were regarded as a symbol of purity in the Tang dynasty and it was planted in the grounds of the emperor's palace. It is the official city flower of Shanghai." + "description": "Magnolia denudata, the lilytree or Yulan magnolia (simplified Chinese: \\u7389\\u5170\\u82b1; traditional Chinese: \\u7389\\u862d\\u82b1; pinyin: y\\u00f9l\\u00e1nhu\\u0101; lit. 'jade orchid/lily'), is native to central and eastern China. It has been cultivated in Chinese Buddhist temple gardens since 600 AD. Its flowers were regarded as a symbol of purity in the Tang dynasty and it was planted in the grounds of the emperor's palace. It is the official city flower of Shanghai." + }, + "file": { + "contentLength": 50389, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c615404e051e1e2e6c1d107cb7c043fd0db9a71eb9671be35533e9aac5e72e82" + } } }, { "type": "Plant", "id": "de004523-5165-4f10-872b-92eb9637a5d1", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "Adenium obesum (desert rose), macro of a flower, side view showing calyx, corolla and styles. Focus stack of 7 images. Uthandi, Chennai", @@ -278,19 +324,28 @@ "link": "https://en.wikipedia.org/wiki/File:RCN_Mother.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Desert Rose", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "A. obesum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Adenium_obesum", - "description": "Adenium obesum, more commonly known as a desert rose, is a poisonous species of flowering plant belonging to the tribe Nerieae of the subfamily Apocynoideae of the dogbane family, Apocynaceae.[3] It is native to the Sahel regions south of the Sahara (from Mauritania and Senegal to Sudan), tropical and subtropical eastern and southern Africa, as well as the Arabian Peninsula. Other names for the flower include Sabi star, kudu, mock azalea, and impala lily. Adenium obesum is a popular houseplant and bonsai in temperate regions." + "description": "Adenium obesum, more commonly known as a desert rose, is a poisonous species of flowering plant belonging to the tribe Nerieae of the subfamily Apocynoideae of the dogbane family, Apocynaceae. It is native to the Sahel regions south of the Sahara (from Mauritania and Senegal to Sudan), tropical and subtropical eastern and southern Africa, as well as the Arabian Peninsula. Other names for the flower include Sabi star, kudu, mock azalea, and impala lily. Adenium obesum is a popular houseplant and bonsai in temperate regions." + }, + "file": { + "contentLength": 58116, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "858fe2bee255dd252d51d161d4f183a93c2da0d2d6662b20870cbbf3eec81acb" + } } }, { "type": "Plant", "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "in Seoul Forest, South Korea", @@ -311,23 +366,31 @@ "link": "https://en.wikipedia.org/wiki/File:Tulipa_orphanidea_060506.jpg" } ], - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "hasFile": true, "name": "Tulip", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "Tulipa", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Tulip", - "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae,[2] along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae, along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + }, + "file": { + "contentLength": 105488, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c4c242897c43a74e9e4a8b5bf89e6b2cb1cbd99f8d621f33eea175cbf6dfab97" + } } }, { "type": "Taxon", "id": "cec3972f-322f-432c-b86e-611c85a96b6c", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Yucca (/ˈjʌkə/ YUCK-uh) is both the scientific name and common name for a genus native to North America from Panama to southern Canada. It contains 50 accepted species. In addition to yucca, they are also known as Adam's needle or Spanish-bayonet. The genus is generally classified in the asparagus family in a subfamily with the Agave, though historically it was part of the lily family. The species range from small shrubby plants to tree-like giants, such as the Joshua tree. All yuccas have rosettes of leaves that taper to points and inflorescences with many flowers that are mainly cream white with thick petals. Though adapted to a wide range of climates the plants are xerophytes, ones that specialize in dry living conditions.\n\nThe tight relationship between the yucca plants and their pollinators, the yucca moths from the genera Tegeticula and Parategeticula, is a well known example of evolutionary mutualism. They are an important part of the ecology of North American deserts, providing shelter to small animals and creating habitats. The human uses of yuccas include garden plants, as food, and for extracts. The flower petals of various species are eaten as a part of local cuisine, particularly in Central America and Mexico. Historically, the yucca was extensively used for its fibers to make cords, baskets, mats, and sandals. It continues to be used by native peoples for traditional soaps.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Yucca", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asparagaceae/Agavoideae/Yucca", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Yucca" @@ -337,6 +400,7 @@ "type": "Plant", "id": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "Narcissus pseudonarcissus", @@ -345,23 +409,31 @@ "link": "https://en.wikipedia.org/wiki/File:Narcissus_pseudonarcissus_flower_300303.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Daffodil", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "N. pseudonarcissus", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Narcissus_pseudonarcissus", - "description": "Narcissus pseudonarcissus, commonly named the wild daffodil or Lent lily (Welsh: Cennin Pedr), is a perennial flowering plant.[1][2][3][4][5][6]\n\nThis species has pale yellow tepals, with a darker central trumpet. The long, narrow leaves are slightly greyish green in colour and rise from the base of the stem. The plant grows from a bulb. The flowers produce seeds which, when germinated, take five to seven years to produce a flowering plant. (Sexual seed reproduction mixes the traits of both parent flowers, so if garden hybrid cultivars are planted close to wild populations of Narcissus pseudonarcissus, there is a danger that the new seedlings, having hybrid vigour, could out-compete the wild plants.)[7]" + "description": "Narcissus pseudonarcissus, commonly named the wild daffodil or Lent lily (Welsh: Cennin Pedr), is a perennial flowering plant.\n\nThis species has pale yellow tepals, with a darker central trumpet. The long, narrow leaves are slightly greyish green in colour and rise from the base of the stem. The plant grows from a bulb. The flowers produce seeds which, when germinated, take five to seven years to produce a flowering plant. (Sexual seed reproduction mixes the traits of both parent flowers, so if garden hybrid cultivars are planted close to wild populations of Narcissus pseudonarcissus, there is a danger that the new seedlings, having hybrid vigour, could out-compete the wild plants.)" + }, + "file": { + "contentLength": 62307, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ecda5f79c0714acbe609146166abbf3872ba51f459debd5e20c8b61bb932ed37" + } } }, { "type": "Taxon", "id": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Liliales is an order of monocotyledonous flowering plants in the Angiosperm Phylogeny Group and Angiosperm Phylogeny Web system, within the lilioid monocots. This order of necessity includes the family Liliaceae. The APG III system (2009) places this order in the monocot clade. In APG III, the family Luzuriagaceae is combined with the family Alstroemeriaceae and the family Petermanniaceae is recognized. Both the order Lililiales and the family Liliaceae have had a widely disputed history, with the circumscription varying greatly from one taxonomist to another. Previous members of this order, which at one stage included most monocots with conspicuous tepals and lacking starch in the endosperm are now distributed over three orders, Liliales, Dioscoreales and Asparagales, using predominantly molecular phylogenetics. The newly delimited Liliales is monophyletic, with ten families. Well known plants from the order include Lilium (lily), tulip, the North American wildflower Trillium, and greenbrier.\n\nThus circumscribed, this order consists mostly of herbaceous plants, but lianas and shrubs also occur. They are mostly perennial plants, with food storage organs such as corms or rhizomes. The family Corsiaceae is notable for being heterotrophic.\n\nThe order has worldwide distribution. The larger families (with more than 100 species) are roughly confined to the Northern Hemisphere, or are distributed worldwide, centering on the north. On the other hand, the smaller families (with up to 10 species) are confined to the Southern Hemisphere, or sometimes just to Australia or South America. The total number of species in the order is now about 1768.\n\nAs with any herbaceous group, the fossil record of the Liliales is rather scarce. There are several species from the Eocene, such as Petermanniopsis anglesaensis or Smilax, but their identification is not definite. Another known fossil is Ripogonum scandens from the Miocene. Due to the scarcity of data, it seems impossible to determine precisely the age and the initial distribution of the order. It is assumed that the Liliales originate from the Lower Cretaceous, over 100 million years ago. Fossil aquatic plants from the Cretaceous of northeastern Brazil and a new terrestrial species placed in the new genus Cratosmilax suggest that the first species have appeared around 120 million years ago when the continents formed Pangea, before dispersing as Asia, Africa and America. The initial diversification to the current families took place between 82 and 48 million years ago. The order consists of 10 families, 67 genera and about 1,768 species.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Liliales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliales" @@ -384,7 +456,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825391.222043, + "start": 1790361091.670929, "indices": "*", "combinedQuery": { "_source": [ @@ -425,8 +497,8 @@ } } }, - "end": 1758825391.252388, - "duration": 0.030345 + "end": 1790361091.689555, + "duration": 0.018626 }, { "input": { @@ -447,7 +519,7 @@ "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "type": "Plant", "metadata": { - "score": 3.9976888 + "score": 4.0113134 } }, { @@ -465,17 +537,17 @@ } }, { - "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", - "type": "Plant", + "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "type": "Taxon", "metadata": { - "score": 3.2730358 + "score": 3.269838 } }, { - "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", - "type": "Taxon", + "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", + "type": "Plant", "metadata": { - "score": 3.269838 + "score": 3.2554708 } }, { @@ -489,7 +561,7 @@ "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "type": "Plant", "metadata": { - "score": 3.021591 + "score": 3.060035 } }, { @@ -531,28 +603,28 @@ "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "type": "Plant", "metadata": { - "score": 2.152007 + "score": 2.1294188 } }, { "id": "74023bac-88ea-4031-b4a5-68d861ea2e91", "type": "Plant", "metadata": { - "score": 2.1088712 + "score": 2.085987 } }, { "id": "de004523-5165-4f10-872b-92eb9637a5d1", "type": "Plant", "metadata": { - "score": 2.02761 + "score": 2.0042536 } }, { "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "type": "Plant", "metadata": { - "score": 1.9892946 + "score": 1.9657542 } }, { @@ -566,7 +638,7 @@ "id": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "type": "Plant", "metadata": { - "score": 1.7868379 + "score": 1.8255562 } }, { @@ -584,14 +656,14 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825391.25256, + "start": 1790361091.689842, "accessibleElementIds": [ "16884ac8-ba7a-4c91-8449-4fdd803644b6", "258c0dfe-b1d8-4839-beed-d00d1b544a96", "1feedd9f-0c12-490c-8461-b12ff7447ac8", "63bcbffb-29a3-45b5-8cc1-8c41281bcae3", - "f8179dfc-5578-44de-adc0-d6b613eb7a32", "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "f8179dfc-5578-44de-adc0-d6b613eb7a32", "9abc0ab3-fa1a-4138-80c1-49db5c4d082f", "23508e6c-632a-496d-9bd6-8c62a03216af", "7dd3a650-d60e-40c9-89d0-bccaf395369f", @@ -607,8 +679,8 @@ "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "1852bdff-a4dd-4f91-9243-0e802775f757" ], - "end": 1758825391.322722, - "duration": 0.070162 + "end": 1790361091.778086, + "duration": 0.088244 } ] } \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-header.txt index c3ba5b8f..e2995351 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/simple-full-text-search/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:38 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-body.json b/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-body.json index 68ae203a..a25a81b5 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-body.json +++ b/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-body.json @@ -5,10 +5,10 @@ "type": "Tag", "id": "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "data": { - "description": "Blue is one of the three primary colours in the RGB (additive) colour model, as well as in the RYB colour model (traditional colour theory).[2] It lies between violet and cyan on the spectrum of visible light. The term blue generally describes colours perceived by humans observing light with a dominant wavelength that's between approximately 450 and 495 nanometres. The clear daytime sky and the deep sea appear blue because of an optical effect known as Rayleigh scattering. An optical effect called the Tyndall effect explains blue eyes. Distant objects appear more blue because of another optical effect called aerial perspective.\n\nBlue has been an important colour in art and decoration since ancient times. The semi-precious stone lapis lazuli was used in ancient Egypt for jewellery and ornament and later, in the Renaissance, to make the pigment ultramarine, the most expensive of all pigments.[3] In the eighth century Chinese artists used cobalt blue to colour fine blue and white porcelain. In the Middle Ages, European artists used it in the windows of cathedrals. Europeans wore clothing coloured with the vegetable dye woad until it was replaced by the finer indigo from America. In the 19th century, synthetic blue dyes and pigments gradually replaced organic dyes and mineral pigments. Dark blue became a common colour for military uniforms and later, in the late 20th century, for business suits. Because blue has commonly been associated with harmony, it was chosen as the colour of the flags of the United Nations and the European Union.[4]\n\nIn the United States and Europe, blue is the colour that both men and women are most likely to choose as their favourite, with at least one recent survey showing the same across several other countries, including China, Malaysia, and Indonesia.[5][6] Past surveys in the US and Europe have found that blue is the colour most commonly associated with harmony, confidence, masculinity, knowledge, intelligence, calmness, distance, infinity, the imagination, cold, and sadness.[7]", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-24T19:04:01+00:00", + "description": "Blue is one of the three primary colours in the RGB (additive) colour model, as well as in the RYB colour model (traditional colour theory). It lies between violet and cyan on the spectrum of visible light. The term blue generally describes colours perceived by humans observing light with a dominant wavelength that's between approximately 450 and 495 nanometres. The clear daytime sky and the deep sea appear blue because of an optical effect known as Rayleigh scattering. An optical effect called the Tyndall effect explains blue eyes. Distant objects appear more blue because of another optical effect called aerial perspective.\n\nBlue has been an important colour in art and decoration since ancient times. The semi-precious stone lapis lazuli was used in ancient Egypt for jewellery and ornament and later, in the Renaissance, to make the pigment ultramarine, the most expensive of all pigments. In the eighth century Chinese artists used cobalt blue to colour fine blue and white porcelain. In the Middle Ages, European artists used it in the windows of cathedrals. Europeans wore clothing coloured with the vegetable dye woad until it was replaced by the finer indigo from America. In the 19th century, synthetic blue dyes and pigments gradually replaced organic dyes and mineral pigments. Dark blue became a common colour for military uniforms and later, in the late 20th century, for business suits. Because blue has commonly been associated with harmony, it was chosen as the colour of the flags of the United Nations and the European Union.\n\nIn the United States and Europe, blue is the colour that both men and women are most likely to choose as their favourite, with at least one recent survey showing the same across several other countries, including China, Malaysia, and Indonesia. Past surveys in the US and Europe have found that blue is the colour most commonly associated with harmony, confidence, masculinity, knowledge, intelligence, calmness, distance, infinity, the imagination, cold, and sadness.", "name": "blue", + "updated": "2026-09-24T19:04:01+00:00", "color": "#4363d8", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Blue" @@ -18,6 +18,7 @@ "type": "Plant", "id": "83b35321-4a91-4e9f-a49b-66d718c32ba7", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Mertensia virginica Virginia Bluebell seen in Brooklyn Botanic Garden", @@ -32,13 +33,21 @@ "link": "https://en.wikipedia.org/wiki/File:Virginia_Bluebells_at_Rocky_River.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Bluebell", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "M. virginica", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Mertensia_virginica", - "description": "Mertensia virginica (common names Virginia bluebells,[1] Virginia cowslip,[2] lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + "description": "Mertensia virginica (common names Virginia bluebells, Virginia cowslip, lungwort oysterleaf, Roanoke bells) is a spring ephemeral plant in the Boraginaceae (borage) family with bell-shaped sky-blue flowers, native to eastern North America." + }, + "file": { + "contentLength": 82651, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "527fa0ca5006ae3c888c06c3e208db64aa0214b31ce9f26a1ccd5fa7cdf91c3b" + } } } ], @@ -58,7 +67,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825391.494736, + "start": 1790276706.395696, "indices": "*", "combinedQuery": { "_source": [ @@ -99,8 +108,8 @@ } } }, - "end": 1758825391.524346, - "duration": 0.02961 + "end": 1790276706.408176, + "duration": 0.01248 }, { "input": { @@ -132,13 +141,13 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825391.524511, + "start": 1790276706.408475, "accessibleElementIds": [ "79ca27e5-93bb-43e6-a76b-096ea45e2e8e", "83b35321-4a91-4e9f-a49b-66d718c32ba7" ], - "end": 1758825391.541848, - "duration": 0.017337 + "end": 1790276706.422993, + "duration": 0.014518 } ] } \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-header.txt index c3ba5b8f..e2995351 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/text-prefix-search/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:38 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/elasticsearch-query-dsl-mixin/wildcard-search/response-header.txt b/docs/search/example/elasticsearch-query-dsl-mixin/wildcard-search/response-header.txt index c3ba5b8f..e2995351 100644 --- a/docs/search/example/elasticsearch-query-dsl-mixin/wildcard-search/response-header.txt +++ b/docs/search/example/elasticsearch-query-dsl-mixin/wildcard-search/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:38 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/element-hydration/explicit-elementids-hydration/response-body.json b/docs/search/example/element-hydration/explicit-elementids-hydration/response-body.json index ca8ad9c4..6570d2e5 100644 --- a/docs/search/example/element-hydration/explicit-elementids-hydration/response-body.json +++ b/docs/search/example/element-hydration/explicit-elementids-hydration/response-body.json @@ -5,6 +5,7 @@ "type": "Plant", "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "", @@ -19,19 +20,28 @@ "link": "https://en.wikipedia.org/wiki/File:Gloriosa_superba_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Flame Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "G. superba", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_superba", - "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily,[3] gloriosa lily, tiger claw,[4] and fire lily.[5]" + "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily, gloriosa lily, tiger claw, and fire lily." + }, + "file": { + "contentLength": 90681, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "72e3e3770db795b2f04f0b5bb0be43e74df70de4faa0bf83a706bbde29e4ea0b" + } } }, { "type": "Plant", "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Flower of Nelumbo nucifera, bean of India.", @@ -46,19 +56,28 @@ "link": "https://en.wikipedia.org/wiki/File:Nelumbo_nucifera1romainguy.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lotus", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "N. nucifera", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Nelumbo_nucifera", - "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus,[1] or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae.[2] The lotus belongs in the order Proteales." + "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus, or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae. The lotus belongs in the order Proteales." + }, + "file": { + "contentLength": 63295, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a46657ef9ece1a7e0939ee5edcc4358f23a3b8b7871c6eba4ad2ce0f30cf4e9f" + } } }, { "type": "Plant", "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "in Seoul Forest, South Korea", @@ -79,13 +98,21 @@ "link": "https://en.wikipedia.org/wiki/File:Tulipa_orphanidea_060506.jpg" } ], - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "hasFile": true, "name": "Tulip", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "Tulipa", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Tulip", - "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae,[2] along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae, along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + }, + "file": { + "contentLength": 105488, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c4c242897c43a74e9e4a8b5bf89e6b2cb1cbd99f8d621f33eea175cbf6dfab97" + } } } ], @@ -105,14 +132,14 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825391.88911, + "start": 1790276706.547772, "accessibleElementIds": [ "258c0dfe-b1d8-4839-beed-d00d1b544a96", "3283fd3d-8083-4170-882a-41b1cd152c55", "8940d70b-5b6f-43b7-bee4-41d073396ff8" ], - "end": 1758825391.926094, - "duration": 0.036984 + "end": 1790276706.56921, + "duration": 0.021438 } ] } \ No newline at end of file diff --git a/docs/search/example/element-hydration/explicit-elementids-hydration/response-header.txt b/docs/search/example/element-hydration/explicit-elementids-hydration/response-header.txt index c3ba5b8f..e2995351 100644 --- a/docs/search/example/element-hydration/explicit-elementids-hydration/response-header.txt +++ b/docs/search/example/element-hydration/explicit-elementids-hydration/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:38 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/element-hydration/implicit-elements-hydration/response-body.json b/docs/search/example/element-hydration/implicit-elements-hydration/response-body.json index ae3e9d82..662bab90 100644 --- a/docs/search/example/element-hydration/implicit-elements-hydration/response-body.json +++ b/docs/search/example/element-hydration/implicit-elements-hydration/response-body.json @@ -5,9 +5,9 @@ "type": "Taxon", "id": "16884ac8-ba7a-4c91-8449-4fdd803644b6", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Gloriosa", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Colchicaceae/Gloriosa", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_(plant)", @@ -18,6 +18,7 @@ "type": "Plant", "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "", @@ -32,22 +33,30 @@ "link": "https://en.wikipedia.org/wiki/File:Gloriosa_superba_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Flame Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "G. superba", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_superba", - "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily,[3] gloriosa lily, tiger claw,[4] and fire lily.[5]" + "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily, gloriosa lily, tiger claw, and fire lily." + }, + "file": { + "contentLength": 90681, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "72e3e3770db795b2f04f0b5bb0be43e74df70de4faa0bf83a706bbde29e4ea0b" + } } }, { "type": "Taxon", "id": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Lilieae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilieae", @@ -58,19 +67,33 @@ "type": "Taxon", "id": "63bcbffb-29a3-45b5-8cc1-8c41281bcae3", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Kniphofia", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asphodelaceae/Asphodeloideae/Kniphofia", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Kniphofia", "description": "Kniphofia (/nɪpˈhoʊfiə/, /nɪˈfoʊfiə/) is a genus of perennial flowering plants in the family Asphodelaceae, first described as a genus in 1794. All species of Kniphofia are native to Africa. Common names include tritoma, red hot poker, torch lily and poker plant." } }, + { + "type": "Taxon", + "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "data": { + "created": "2026-09-25T18:30:16+00:00", + "name": "Lilioideae", + "updated": "2026-09-25T18:30:16+00:00", + "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae", + "scenario": "general.botanicExample", + "link": "https://en.wikipedia.org/wiki/Lilioideae", + "description": "The Lilioideae are a subfamily of monocotyledonous perennial, herbaceous mainly bulbous flowering plants in the lily family, Liliaceae. They are found predominantly in the temperate and colder regions of the Northern Hemisphere, particularly East Asia and North America. The subfamily includes two tribes. They are of economic importance, particularly the lilies and tulips." + } + }, { "type": "Plant", "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Red-hot poker plants (Kniphofia uvaria) on the shore of Lake Jindabyne, New South Wales, Australia.", @@ -79,35 +102,30 @@ "link": "https://en.wikipedia.org/wiki/File:Kniphofia_uvaria_close.JPG" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Red Hot Poker", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "K. uvaria", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Kniphofia_uvaria", "description": "Kniphofia uvaria is a species of flowering plant in the family Asphodelaceae, commonly known as tritomea, torch lily, or red hot poker, due to the shape and color of its inflorescence. The leaves are reminiscent of a lily, and the flowerhead can reach up to 1.5 m (5 ft) in height. There are many varieties of torch lily, and they bloom at different times during the growing season. The flowers are red, orange, and yellow." - } - }, - { - "type": "Taxon", - "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", - "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", - "name": "Lilioideae", - "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae", - "scenario": "general.botanicExample", - "link": "https://en.wikipedia.org/wiki/Lilioideae", - "description": "The Lilioideae are a subfamily of monocotyledonous perennial, herbaceous mainly bulbous flowering plants in the lily family, Liliaceae. They are found predominantly in the temperate and colder regions of the Northern Hemisphere, particularly East Asia and North America. The subfamily includes two tribes. They are of economic importance, particularly the lilies and tulips." + }, + "file": { + "contentLength": 123686, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a0f461e0383bc5b4518a43e1ac76060612c7e87bf420f9a6bbbe5231012108fe" + } } }, { "type": "Taxon", "id": "9abc0ab3-fa1a-4138-80c1-49db5c4d082f", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Trillium", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Melanthiaceae/Trillium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Trillium", @@ -118,6 +136,7 @@ "type": "Plant", "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Lilium candidum, Israel", @@ -132,22 +151,30 @@ "link": "https://en.wikipedia.org/wiki/File:Lilium_candidum_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "L. candidum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium_candidum", - "description": "Lilium candidum, the Madonna lily[2][3] or white lily,[4] is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions.[1][5] It has been cultivated since antiquity, for at least 3,000 years,[6] and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + "description": "Lilium candidum, the Madonna lily or white lily, is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions. It has been cultivated since antiquity, for at least 3,000 years, and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + }, + "file": { + "contentLength": 115168, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ee066b93ae72c719f863a428f03c86ecd28e52818528eacf4d4e32acb4b4b37c" + } } }, { "type": "Taxon", "id": "7dd3a650-d60e-40c9-89d0-bccaf395369f", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Lilium", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae/Lilium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium", @@ -158,9 +185,9 @@ "type": "Taxon", "id": "2c0bb4b9-c1ec-41f3-b9d9-f59a8b8bef38", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Asparagaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asparagaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asparagaceae", @@ -171,10 +198,10 @@ "type": "Taxon", "id": "425235d6-75ab-4ccb-8208-783ced0decc6", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "The lily family, Liliaceae, consists of about 15 genera and 610 species of flowering plants within the order Liliales. They are monocotyledonous, perennial, herbaceous, often bulbous geophytes. Plants in this family have evolved with a fair amount of morphological diversity despite genetic similarity. Common characteristics include large flowers with parts arranged in threes: with six colored or patterned petaloid tepals (undifferentiated petals and sepals) arranged in two whorls, six stamens and a superior ovary. The leaves are linear in shape, with their veins usually arranged parallel to the edges, single and arranged alternating on the stem, or in a rosette at the base. Most species are grown from bulbs, although some have rhizomes. First described in 1789, the lily family became a paraphyletic \"catch-all\" (wastebasket) group of lilioid monocots that did not fit into other families and included a great number of genera now included in other families and in some cases in other orders. Consequently, many sources and descriptions labelled \"Liliaceae\" deal with the broader sense of the family.\n\nThe family evolved approximately 68 million years ago during the Late Cretaceous to Early Paleogene epochs. Liliaceae are widely distributed, mainly in temperate regions of the Northern Hemisphere and the flowers are insect pollinated. Many Liliaceae are important ornamental plants, widely grown for their attractive flowers and involved in a major floriculture of cut flowers and dry bulbs. Some species are poisonous if eaten and can have adverse health effects in humans and household pets.\n\nA number of Liliaceae genera are popular cultivated plants in private and public spaces. Lilies and tulips in particular have had considerable symbolic and decorative value, and appear frequently in paintings and the decorative arts. They are also an economically important product. Most of their genera, Lilium in particular, face considerable herbivory pressure from deer in some areas, both wild and domestic.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Liliaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliaceae" @@ -184,10 +211,10 @@ "type": "Taxon", "id": "1a9579bd-1331-4d5c-93ec-a9ff8d9c576b", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Aloe (/ˈæloʊ(i), əˈloʊi/; also written Aloë) is a genus containing over 650 species of flowering succulent plants. The most widely known species is Aloe vera, or \"true aloe\". It is called this because it is cultivated as the standard source for assorted pharmaceutical purposes. Other species, such as Aloe ferox, are also cultivated or harvested from the wild for similar applications.\n\nThe APG IV system (2016) places the genus in the family Asphodelaceae, subfamily Asphodeloideae. Within the subfamily it may be placed in the tribe Aloeae. In the past, it has been assigned to the family Aloaceae (now included in the Asphodeloidae) or to a broadly circumscribed family Liliaceae (the lily family). The plant Agave americana, which is sometimes called \"American aloe\", belongs to the Asparagaceae, a different family.\n\nThe genus is native to tropical and southern Africa, Madagascar, Jordan, the Arabian Peninsula, and various islands in the Indian Ocean (Mauritius, Réunion, Comoros, etc.). A few species have also become naturalized in other regions (Mediterranean, India, Australia, North and South America, Hawaiian Islands, etc.).", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Aloe", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asphodelaceae/Asphodeloideae/Aloe", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Aloe" @@ -197,10 +224,10 @@ "type": "Taxon", "id": "47e135a7-c77a-4489-9262-1e425a4b7177", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Asparagales (asparagoid lilies) are a diverse order of flowering plants in the monocots. Under the APG IV system of flowering plant classification, Asparagales are the largest order of monocots with 14 families, 1,122 genera, and about 36,000 species, with members as varied as asparaguses, orchids, yuccas, irises, onions, garlics, leeks, and other alliums, daffodils, snowdrops, amaryllis, agaves, butcher's broom, agapanthuses, Solomon's seal, hyacinths, bluebells, spider plants, grasstrees, aloes, freesias, gladioli, crocuses, and saffrons.\n\nMost species of Asparagales are herbaceous perennials, although some are climbers and some are trees or shrubs. The order also contains many geophytes (bulbs, corms, and various kinds of tuber). The leaves of almost all species form a tight rosette, either at the base of the plant or at the end of the stem, but occasionally along the stem. The flowers are not particularly distinctive, being 'lily type', with six tepals and up to six stamina. One of the defining characteristics (synapomorphies) of the order is the presence of phytomelanin, a black pigment present in the seed coat, creating a dark crust. Phytomelanin is found in most families of the Asparagales (although not in Orchidaceae, thought to be the sister-group of the rest of the order).\n\nThe order Asparagales takes its name from the type family Asparagaceae and has only recently been recognized in classification systems. The order is clearly circumscribed on the basis of molecular phylogenetics, but it is difficult to define morphologically since its members are structurally diverse. The order was first put forward by Huber in 1977 and later taken up in the Dahlgren system of 1985 and then the Angiosperm Phylogeny Group systems. Before this, many of its families were assigned to the old order Liliales, which was redistributed over three orders, Liliales, Asparagales, and Dioscoreales, based on molecular phylogenetics. The boundaries of the Asparagales and of its families have undergone a series of changes in recent years; future research may lead to further changes and ultimately greater stability.\n\nThe order is thought to have first diverged from other related monocots some 120–130 million years ago (early in the Cretaceous period), although given the difficulty in classifying the families involved, estimates are likely to be uncertain.\n\nFrom an economic point of view, the order Asparagales is second in importance within the monocots only to the order Poales (which includes grasses and cereals). Species are used as food and flavourings (e.g. onion, garlic, leek, asparagus, vanilla, saffron), in medicinal or cosmetic applications (Aloe), as cut flowers (e.g. freesia, gladiolus, iris, orchids), and as garden ornamentals (e.g. day lilies, lily of the valley, Agapanthus).", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Asparagales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asparagales" @@ -210,6 +237,7 @@ "type": "Plant", "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Flower of Nelumbo nucifera, bean of India.", @@ -224,19 +252,28 @@ "link": "https://en.wikipedia.org/wiki/File:Nelumbo_nucifera1romainguy.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lotus", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "N. nucifera", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Nelumbo_nucifera", - "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus,[1] or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae.[2] The lotus belongs in the order Proteales." + "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus, or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae. The lotus belongs in the order Proteales." + }, + "file": { + "contentLength": 63295, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a46657ef9ece1a7e0939ee5edcc4358f23a3b8b7871c6eba4ad2ce0f30cf4e9f" + } } }, { "type": "Plant", "id": "74023bac-88ea-4031-b4a5-68d861ea2e91", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Saucer Magnolia -- Magnolia × soulangeana 'Rustica Rubra' found in Houston, Texas", @@ -251,19 +288,28 @@ "link": "https://en.wikipedia.org/wiki/File:Yulan_1.JPG" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Magnolia", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "M. denudata", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Magnolia_denudata", - "description": "Magnolia denudata, the lilytree[2] or Yulan magnolia (simplified Chinese: \\u7389\\u5170\\u82b1; traditional Chinese: \\u7389\\u862d\\u82b1; pinyin: y\\u00f9l\\u00e1nhu\\u0101; lit. 'jade orchid/lily'), is native to central and eastern China.[3] It has been cultivated in Chinese Buddhist temple gardens since 600 AD. Its flowers were regarded as a symbol of purity in the Tang dynasty and it was planted in the grounds of the emperor's palace. It is the official city flower of Shanghai." + "description": "Magnolia denudata, the lilytree or Yulan magnolia (simplified Chinese: \\u7389\\u5170\\u82b1; traditional Chinese: \\u7389\\u862d\\u82b1; pinyin: y\\u00f9l\\u00e1nhu\\u0101; lit. 'jade orchid/lily'), is native to central and eastern China. It has been cultivated in Chinese Buddhist temple gardens since 600 AD. Its flowers were regarded as a symbol of purity in the Tang dynasty and it was planted in the grounds of the emperor's palace. It is the official city flower of Shanghai." + }, + "file": { + "contentLength": 50389, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c615404e051e1e2e6c1d107cb7c043fd0db9a71eb9671be35533e9aac5e72e82" + } } }, { "type": "Plant", "id": "de004523-5165-4f10-872b-92eb9637a5d1", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "Adenium obesum (desert rose), macro of a flower, side view showing calyx, corolla and styles. Focus stack of 7 images. Uthandi, Chennai", @@ -278,19 +324,28 @@ "link": "https://en.wikipedia.org/wiki/File:RCN_Mother.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Desert Rose", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "A. obesum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Adenium_obesum", - "description": "Adenium obesum, more commonly known as a desert rose, is a poisonous species of flowering plant belonging to the tribe Nerieae of the subfamily Apocynoideae of the dogbane family, Apocynaceae.[3] It is native to the Sahel regions south of the Sahara (from Mauritania and Senegal to Sudan), tropical and subtropical eastern and southern Africa, as well as the Arabian Peninsula. Other names for the flower include Sabi star, kudu, mock azalea, and impala lily. Adenium obesum is a popular houseplant and bonsai in temperate regions." + "description": "Adenium obesum, more commonly known as a desert rose, is a poisonous species of flowering plant belonging to the tribe Nerieae of the subfamily Apocynoideae of the dogbane family, Apocynaceae. It is native to the Sahel regions south of the Sahara (from Mauritania and Senegal to Sudan), tropical and subtropical eastern and southern Africa, as well as the Arabian Peninsula. Other names for the flower include Sabi star, kudu, mock azalea, and impala lily. Adenium obesum is a popular houseplant and bonsai in temperate regions." + }, + "file": { + "contentLength": 58116, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "858fe2bee255dd252d51d161d4f183a93c2da0d2d6662b20870cbbf3eec81acb" + } } }, { "type": "Plant", "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "in Seoul Forest, South Korea", @@ -311,23 +366,31 @@ "link": "https://en.wikipedia.org/wiki/File:Tulipa_orphanidea_060506.jpg" } ], - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "hasFile": true, "name": "Tulip", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "Tulipa", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Tulip", - "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae,[2] along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae, along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + }, + "file": { + "contentLength": 105488, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c4c242897c43a74e9e4a8b5bf89e6b2cb1cbd99f8d621f33eea175cbf6dfab97" + } } }, { "type": "Taxon", "id": "cec3972f-322f-432c-b86e-611c85a96b6c", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Yucca (/ˈjʌkə/ YUCK-uh) is both the scientific name and common name for a genus native to North America from Panama to southern Canada. It contains 50 accepted species. In addition to yucca, they are also known as Adam's needle or Spanish-bayonet. The genus is generally classified in the asparagus family in a subfamily with the Agave, though historically it was part of the lily family. The species range from small shrubby plants to tree-like giants, such as the Joshua tree. All yuccas have rosettes of leaves that taper to points and inflorescences with many flowers that are mainly cream white with thick petals. Though adapted to a wide range of climates the plants are xerophytes, ones that specialize in dry living conditions.\n\nThe tight relationship between the yucca plants and their pollinators, the yucca moths from the genera Tegeticula and Parategeticula, is a well known example of evolutionary mutualism. They are an important part of the ecology of North American deserts, providing shelter to small animals and creating habitats. The human uses of yuccas include garden plants, as food, and for extracts. The flower petals of various species are eaten as a part of local cuisine, particularly in Central America and Mexico. Historically, the yucca was extensively used for its fibers to make cords, baskets, mats, and sandals. It continues to be used by native peoples for traditional soaps.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Yucca", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asparagaceae/Agavoideae/Yucca", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Yucca" @@ -337,6 +400,7 @@ "type": "Plant", "id": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "Narcissus pseudonarcissus", @@ -345,23 +409,31 @@ "link": "https://en.wikipedia.org/wiki/File:Narcissus_pseudonarcissus_flower_300303.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Daffodil", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "N. pseudonarcissus", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Narcissus_pseudonarcissus", - "description": "Narcissus pseudonarcissus, commonly named the wild daffodil or Lent lily (Welsh: Cennin Pedr), is a perennial flowering plant.[1][2][3][4][5][6]\n\nThis species has pale yellow tepals, with a darker central trumpet. The long, narrow leaves are slightly greyish green in colour and rise from the base of the stem. The plant grows from a bulb. The flowers produce seeds which, when germinated, take five to seven years to produce a flowering plant. (Sexual seed reproduction mixes the traits of both parent flowers, so if garden hybrid cultivars are planted close to wild populations of Narcissus pseudonarcissus, there is a danger that the new seedlings, having hybrid vigour, could out-compete the wild plants.)[7]" + "description": "Narcissus pseudonarcissus, commonly named the wild daffodil or Lent lily (Welsh: Cennin Pedr), is a perennial flowering plant.\n\nThis species has pale yellow tepals, with a darker central trumpet. The long, narrow leaves are slightly greyish green in colour and rise from the base of the stem. The plant grows from a bulb. The flowers produce seeds which, when germinated, take five to seven years to produce a flowering plant. (Sexual seed reproduction mixes the traits of both parent flowers, so if garden hybrid cultivars are planted close to wild populations of Narcissus pseudonarcissus, there is a danger that the new seedlings, having hybrid vigour, could out-compete the wild plants.)" + }, + "file": { + "contentLength": 62307, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ecda5f79c0714acbe609146166abbf3872ba51f459debd5e20c8b61bb932ed37" + } } }, { "type": "Taxon", "id": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Liliales is an order of monocotyledonous flowering plants in the Angiosperm Phylogeny Group and Angiosperm Phylogeny Web system, within the lilioid monocots. This order of necessity includes the family Liliaceae. The APG III system (2009) places this order in the monocot clade. In APG III, the family Luzuriagaceae is combined with the family Alstroemeriaceae and the family Petermanniaceae is recognized. Both the order Lililiales and the family Liliaceae have had a widely disputed history, with the circumscription varying greatly from one taxonomist to another. Previous members of this order, which at one stage included most monocots with conspicuous tepals and lacking starch in the endosperm are now distributed over three orders, Liliales, Dioscoreales and Asparagales, using predominantly molecular phylogenetics. The newly delimited Liliales is monophyletic, with ten families. Well known plants from the order include Lilium (lily), tulip, the North American wildflower Trillium, and greenbrier.\n\nThus circumscribed, this order consists mostly of herbaceous plants, but lianas and shrubs also occur. They are mostly perennial plants, with food storage organs such as corms or rhizomes. The family Corsiaceae is notable for being heterotrophic.\n\nThe order has worldwide distribution. The larger families (with more than 100 species) are roughly confined to the Northern Hemisphere, or are distributed worldwide, centering on the north. On the other hand, the smaller families (with up to 10 species) are confined to the Southern Hemisphere, or sometimes just to Australia or South America. The total number of species in the order is now about 1768.\n\nAs with any herbaceous group, the fossil record of the Liliales is rather scarce. There are several species from the Eocene, such as Petermanniopsis anglesaensis or Smilax, but their identification is not definite. Another known fossil is Ripogonum scandens from the Miocene. Due to the scarcity of data, it seems impossible to determine precisely the age and the initial distribution of the order. It is assumed that the Liliales originate from the Lower Cretaceous, over 100 million years ago. Fossil aquatic plants from the Cretaceous of northeastern Brazil and a new terrestrial species placed in the new genus Cratosmilax suggest that the first species have appeared around 120 million years ago when the continents formed Pangea, before dispersing as Asia, Africa and America. The initial diversification to the current families took place between 82 and 48 million years ago. The order consists of 10 families, 67 genera and about 1,768 species.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Liliales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliales" @@ -384,7 +456,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825392.074784, + "start": 1790361092.028617, "indices": "*", "combinedQuery": { "_source": [ @@ -425,8 +497,8 @@ } } }, - "end": 1758825392.106174, - "duration": 0.03139 + "end": 1790361092.045917, + "duration": 0.0173 }, { "input": { @@ -447,7 +519,7 @@ "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "type": "Plant", "metadata": { - "score": 3.9976888 + "score": 4.0113134 } }, { @@ -465,17 +537,17 @@ } }, { - "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", - "type": "Plant", + "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "type": "Taxon", "metadata": { - "score": 3.2730358 + "score": 3.269838 } }, { - "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", - "type": "Taxon", + "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", + "type": "Plant", "metadata": { - "score": 3.269838 + "score": 3.2554708 } }, { @@ -489,7 +561,7 @@ "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "type": "Plant", "metadata": { - "score": 3.021591 + "score": 3.060035 } }, { @@ -531,28 +603,28 @@ "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "type": "Plant", "metadata": { - "score": 2.152007 + "score": 2.1294188 } }, { "id": "74023bac-88ea-4031-b4a5-68d861ea2e91", "type": "Plant", "metadata": { - "score": 2.1088712 + "score": 2.085987 } }, { "id": "de004523-5165-4f10-872b-92eb9637a5d1", "type": "Plant", "metadata": { - "score": 2.02761 + "score": 2.0042536 } }, { "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "type": "Plant", "metadata": { - "score": 1.9892946 + "score": 1.9657542 } }, { @@ -566,7 +638,7 @@ "id": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "type": "Plant", "metadata": { - "score": 1.7868379 + "score": 1.8255562 } }, { @@ -584,14 +656,14 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825392.106344, + "start": 1790361092.046199, "accessibleElementIds": [ "16884ac8-ba7a-4c91-8449-4fdd803644b6", "258c0dfe-b1d8-4839-beed-d00d1b544a96", "1feedd9f-0c12-490c-8461-b12ff7447ac8", "63bcbffb-29a3-45b5-8cc1-8c41281bcae3", - "f8179dfc-5578-44de-adc0-d6b613eb7a32", "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "f8179dfc-5578-44de-adc0-d6b613eb7a32", "9abc0ab3-fa1a-4138-80c1-49db5c4d082f", "23508e6c-632a-496d-9bd6-8c62a03216af", "7dd3a650-d60e-40c9-89d0-bccaf395369f", @@ -607,8 +679,8 @@ "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "1852bdff-a4dd-4f91-9243-0e802775f757" ], - "end": 1758825392.178265, - "duration": 0.071921 + "end": 1790361092.126483, + "duration": 0.080284 } ] } \ No newline at end of file diff --git a/docs/search/example/element-hydration/implicit-elements-hydration/response-header.txt b/docs/search/example/element-hydration/implicit-elements-hydration/response-header.txt index c3ba5b8f..e2995351 100644 --- a/docs/search/example/element-hydration/implicit-elements-hydration/response-header.txt +++ b/docs/search/example/element-hydration/implicit-elements-hydration/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:38 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/element-hydration/implicit-paths-hydration/response-body.json b/docs/search/example/element-hydration/implicit-paths-hydration/response-body.json index 58b34445..0f5e6cbd 100644 --- a/docs/search/example/element-hydration/implicit-paths-hydration/response-body.json +++ b/docs/search/example/element-hydration/implicit-paths-hydration/response-body.json @@ -5,9 +5,9 @@ "type": "Taxon", "id": "16884ac8-ba7a-4c91-8449-4fdd803644b6", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Gloriosa", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Colchicaceae/Gloriosa", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_(plant)", @@ -18,6 +18,7 @@ "type": "Plant", "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "", @@ -32,22 +33,30 @@ "link": "https://en.wikipedia.org/wiki/File:Gloriosa_superba_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Flame Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "G. superba", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_superba", - "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily,[3] gloriosa lily, tiger claw,[4] and fire lily.[5]" + "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily, gloriosa lily, tiger claw, and fire lily." + }, + "file": { + "contentLength": 90681, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "72e3e3770db795b2f04f0b5bb0be43e74df70de4faa0bf83a706bbde29e4ea0b" + } } }, { "type": "Taxon", "id": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Lilieae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilieae", @@ -58,19 +67,33 @@ "type": "Taxon", "id": "63bcbffb-29a3-45b5-8cc1-8c41281bcae3", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Kniphofia", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asphodelaceae/Asphodeloideae/Kniphofia", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Kniphofia", "description": "Kniphofia (/nɪpˈhoʊfiə/, /nɪˈfoʊfiə/) is a genus of perennial flowering plants in the family Asphodelaceae, first described as a genus in 1794. All species of Kniphofia are native to Africa. Common names include tritoma, red hot poker, torch lily and poker plant." } }, + { + "type": "Taxon", + "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "data": { + "created": "2026-09-25T18:30:16+00:00", + "name": "Lilioideae", + "updated": "2026-09-25T18:30:16+00:00", + "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae", + "scenario": "general.botanicExample", + "link": "https://en.wikipedia.org/wiki/Lilioideae", + "description": "The Lilioideae are a subfamily of monocotyledonous perennial, herbaceous mainly bulbous flowering plants in the lily family, Liliaceae. They are found predominantly in the temperate and colder regions of the Northern Hemisphere, particularly East Asia and North America. The subfamily includes two tribes. They are of economic importance, particularly the lilies and tulips." + } + }, { "type": "Plant", "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Red-hot poker plants (Kniphofia uvaria) on the shore of Lake Jindabyne, New South Wales, Australia.", @@ -79,35 +102,30 @@ "link": "https://en.wikipedia.org/wiki/File:Kniphofia_uvaria_close.JPG" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Red Hot Poker", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "K. uvaria", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Kniphofia_uvaria", "description": "Kniphofia uvaria is a species of flowering plant in the family Asphodelaceae, commonly known as tritomea, torch lily, or red hot poker, due to the shape and color of its inflorescence. The leaves are reminiscent of a lily, and the flowerhead can reach up to 1.5 m (5 ft) in height. There are many varieties of torch lily, and they bloom at different times during the growing season. The flowers are red, orange, and yellow." - } - }, - { - "type": "Taxon", - "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", - "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", - "name": "Lilioideae", - "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae", - "scenario": "general.botanicExample", - "link": "https://en.wikipedia.org/wiki/Lilioideae", - "description": "The Lilioideae are a subfamily of monocotyledonous perennial, herbaceous mainly bulbous flowering plants in the lily family, Liliaceae. They are found predominantly in the temperate and colder regions of the Northern Hemisphere, particularly East Asia and North America. The subfamily includes two tribes. They are of economic importance, particularly the lilies and tulips." + }, + "file": { + "contentLength": 123686, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a0f461e0383bc5b4518a43e1ac76060612c7e87bf420f9a6bbbe5231012108fe" + } } }, { "type": "Taxon", "id": "9abc0ab3-fa1a-4138-80c1-49db5c4d082f", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Trillium", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Melanthiaceae/Trillium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Trillium", @@ -118,6 +136,7 @@ "type": "Plant", "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Lilium candidum, Israel", @@ -132,22 +151,30 @@ "link": "https://en.wikipedia.org/wiki/File:Lilium_candidum_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "L. candidum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium_candidum", - "description": "Lilium candidum, the Madonna lily[2][3] or white lily,[4] is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions.[1][5] It has been cultivated since antiquity, for at least 3,000 years,[6] and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + "description": "Lilium candidum, the Madonna lily or white lily, is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions. It has been cultivated since antiquity, for at least 3,000 years, and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + }, + "file": { + "contentLength": 115168, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ee066b93ae72c719f863a428f03c86ecd28e52818528eacf4d4e32acb4b4b37c" + } } }, { "type": "Taxon", "id": "7dd3a650-d60e-40c9-89d0-bccaf395369f", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Lilium", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae/Lilium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium", @@ -158,9 +185,9 @@ "type": "Taxon", "id": "2c0bb4b9-c1ec-41f3-b9d9-f59a8b8bef38", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Asparagaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asparagaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asparagaceae", @@ -171,10 +198,10 @@ "type": "Taxon", "id": "425235d6-75ab-4ccb-8208-783ced0decc6", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "The lily family, Liliaceae, consists of about 15 genera and 610 species of flowering plants within the order Liliales. They are monocotyledonous, perennial, herbaceous, often bulbous geophytes. Plants in this family have evolved with a fair amount of morphological diversity despite genetic similarity. Common characteristics include large flowers with parts arranged in threes: with six colored or patterned petaloid tepals (undifferentiated petals and sepals) arranged in two whorls, six stamens and a superior ovary. The leaves are linear in shape, with their veins usually arranged parallel to the edges, single and arranged alternating on the stem, or in a rosette at the base. Most species are grown from bulbs, although some have rhizomes. First described in 1789, the lily family became a paraphyletic \"catch-all\" (wastebasket) group of lilioid monocots that did not fit into other families and included a great number of genera now included in other families and in some cases in other orders. Consequently, many sources and descriptions labelled \"Liliaceae\" deal with the broader sense of the family.\n\nThe family evolved approximately 68 million years ago during the Late Cretaceous to Early Paleogene epochs. Liliaceae are widely distributed, mainly in temperate regions of the Northern Hemisphere and the flowers are insect pollinated. Many Liliaceae are important ornamental plants, widely grown for their attractive flowers and involved in a major floriculture of cut flowers and dry bulbs. Some species are poisonous if eaten and can have adverse health effects in humans and household pets.\n\nA number of Liliaceae genera are popular cultivated plants in private and public spaces. Lilies and tulips in particular have had considerable symbolic and decorative value, and appear frequently in paintings and the decorative arts. They are also an economically important product. Most of their genera, Lilium in particular, face considerable herbivory pressure from deer in some areas, both wild and domestic.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Liliaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliaceae" @@ -184,10 +211,10 @@ "type": "Taxon", "id": "1a9579bd-1331-4d5c-93ec-a9ff8d9c576b", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Aloe (/ˈæloʊ(i), əˈloʊi/; also written Aloë) is a genus containing over 650 species of flowering succulent plants. The most widely known species is Aloe vera, or \"true aloe\". It is called this because it is cultivated as the standard source for assorted pharmaceutical purposes. Other species, such as Aloe ferox, are also cultivated or harvested from the wild for similar applications.\n\nThe APG IV system (2016) places the genus in the family Asphodelaceae, subfamily Asphodeloideae. Within the subfamily it may be placed in the tribe Aloeae. In the past, it has been assigned to the family Aloaceae (now included in the Asphodeloidae) or to a broadly circumscribed family Liliaceae (the lily family). The plant Agave americana, which is sometimes called \"American aloe\", belongs to the Asparagaceae, a different family.\n\nThe genus is native to tropical and southern Africa, Madagascar, Jordan, the Arabian Peninsula, and various islands in the Indian Ocean (Mauritius, Réunion, Comoros, etc.). A few species have also become naturalized in other regions (Mediterranean, India, Australia, North and South America, Hawaiian Islands, etc.).", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Aloe", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asphodelaceae/Asphodeloideae/Aloe", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Aloe" @@ -197,10 +224,10 @@ "type": "Taxon", "id": "47e135a7-c77a-4489-9262-1e425a4b7177", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Asparagales (asparagoid lilies) are a diverse order of flowering plants in the monocots. Under the APG IV system of flowering plant classification, Asparagales are the largest order of monocots with 14 families, 1,122 genera, and about 36,000 species, with members as varied as asparaguses, orchids, yuccas, irises, onions, garlics, leeks, and other alliums, daffodils, snowdrops, amaryllis, agaves, butcher's broom, agapanthuses, Solomon's seal, hyacinths, bluebells, spider plants, grasstrees, aloes, freesias, gladioli, crocuses, and saffrons.\n\nMost species of Asparagales are herbaceous perennials, although some are climbers and some are trees or shrubs. The order also contains many geophytes (bulbs, corms, and various kinds of tuber). The leaves of almost all species form a tight rosette, either at the base of the plant or at the end of the stem, but occasionally along the stem. The flowers are not particularly distinctive, being 'lily type', with six tepals and up to six stamina. One of the defining characteristics (synapomorphies) of the order is the presence of phytomelanin, a black pigment present in the seed coat, creating a dark crust. Phytomelanin is found in most families of the Asparagales (although not in Orchidaceae, thought to be the sister-group of the rest of the order).\n\nThe order Asparagales takes its name from the type family Asparagaceae and has only recently been recognized in classification systems. The order is clearly circumscribed on the basis of molecular phylogenetics, but it is difficult to define morphologically since its members are structurally diverse. The order was first put forward by Huber in 1977 and later taken up in the Dahlgren system of 1985 and then the Angiosperm Phylogeny Group systems. Before this, many of its families were assigned to the old order Liliales, which was redistributed over three orders, Liliales, Asparagales, and Dioscoreales, based on molecular phylogenetics. The boundaries of the Asparagales and of its families have undergone a series of changes in recent years; future research may lead to further changes and ultimately greater stability.\n\nThe order is thought to have first diverged from other related monocots some 120–130 million years ago (early in the Cretaceous period), although given the difficulty in classifying the families involved, estimates are likely to be uncertain.\n\nFrom an economic point of view, the order Asparagales is second in importance within the monocots only to the order Poales (which includes grasses and cereals). Species are used as food and flavourings (e.g. onion, garlic, leek, asparagus, vanilla, saffron), in medicinal or cosmetic applications (Aloe), as cut flowers (e.g. freesia, gladiolus, iris, orchids), and as garden ornamentals (e.g. day lilies, lily of the valley, Agapanthus).", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Asparagales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asparagales" @@ -210,6 +237,7 @@ "type": "Plant", "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Flower of Nelumbo nucifera, bean of India.", @@ -224,19 +252,28 @@ "link": "https://en.wikipedia.org/wiki/File:Nelumbo_nucifera1romainguy.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lotus", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "N. nucifera", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Nelumbo_nucifera", - "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus,[1] or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae.[2] The lotus belongs in the order Proteales." + "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus, or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae. The lotus belongs in the order Proteales." + }, + "file": { + "contentLength": 63295, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a46657ef9ece1a7e0939ee5edcc4358f23a3b8b7871c6eba4ad2ce0f30cf4e9f" + } } }, { "type": "Plant", "id": "74023bac-88ea-4031-b4a5-68d861ea2e91", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Saucer Magnolia -- Magnolia × soulangeana 'Rustica Rubra' found in Houston, Texas", @@ -251,19 +288,28 @@ "link": "https://en.wikipedia.org/wiki/File:Yulan_1.JPG" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Magnolia", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "M. denudata", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Magnolia_denudata", - "description": "Magnolia denudata, the lilytree[2] or Yulan magnolia (simplified Chinese: \\u7389\\u5170\\u82b1; traditional Chinese: \\u7389\\u862d\\u82b1; pinyin: y\\u00f9l\\u00e1nhu\\u0101; lit. 'jade orchid/lily'), is native to central and eastern China.[3] It has been cultivated in Chinese Buddhist temple gardens since 600 AD. Its flowers were regarded as a symbol of purity in the Tang dynasty and it was planted in the grounds of the emperor's palace. It is the official city flower of Shanghai." + "description": "Magnolia denudata, the lilytree or Yulan magnolia (simplified Chinese: \\u7389\\u5170\\u82b1; traditional Chinese: \\u7389\\u862d\\u82b1; pinyin: y\\u00f9l\\u00e1nhu\\u0101; lit. 'jade orchid/lily'), is native to central and eastern China. It has been cultivated in Chinese Buddhist temple gardens since 600 AD. Its flowers were regarded as a symbol of purity in the Tang dynasty and it was planted in the grounds of the emperor's palace. It is the official city flower of Shanghai." + }, + "file": { + "contentLength": 50389, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c615404e051e1e2e6c1d107cb7c043fd0db9a71eb9671be35533e9aac5e72e82" + } } }, { "type": "Plant", "id": "de004523-5165-4f10-872b-92eb9637a5d1", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "Adenium obesum (desert rose), macro of a flower, side view showing calyx, corolla and styles. Focus stack of 7 images. Uthandi, Chennai", @@ -278,19 +324,28 @@ "link": "https://en.wikipedia.org/wiki/File:RCN_Mother.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Desert Rose", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "A. obesum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Adenium_obesum", - "description": "Adenium obesum, more commonly known as a desert rose, is a poisonous species of flowering plant belonging to the tribe Nerieae of the subfamily Apocynoideae of the dogbane family, Apocynaceae.[3] It is native to the Sahel regions south of the Sahara (from Mauritania and Senegal to Sudan), tropical and subtropical eastern and southern Africa, as well as the Arabian Peninsula. Other names for the flower include Sabi star, kudu, mock azalea, and impala lily. Adenium obesum is a popular houseplant and bonsai in temperate regions." + "description": "Adenium obesum, more commonly known as a desert rose, is a poisonous species of flowering plant belonging to the tribe Nerieae of the subfamily Apocynoideae of the dogbane family, Apocynaceae. It is native to the Sahel regions south of the Sahara (from Mauritania and Senegal to Sudan), tropical and subtropical eastern and southern Africa, as well as the Arabian Peninsula. Other names for the flower include Sabi star, kudu, mock azalea, and impala lily. Adenium obesum is a popular houseplant and bonsai in temperate regions." + }, + "file": { + "contentLength": 58116, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "858fe2bee255dd252d51d161d4f183a93c2da0d2d6662b20870cbbf3eec81acb" + } } }, { "type": "Plant", "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "in Seoul Forest, South Korea", @@ -311,23 +366,31 @@ "link": "https://en.wikipedia.org/wiki/File:Tulipa_orphanidea_060506.jpg" } ], - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "hasFile": true, "name": "Tulip", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "Tulipa", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Tulip", - "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae,[2] along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae, along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + }, + "file": { + "contentLength": 105488, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c4c242897c43a74e9e4a8b5bf89e6b2cb1cbd99f8d621f33eea175cbf6dfab97" + } } }, { "type": "Taxon", "id": "cec3972f-322f-432c-b86e-611c85a96b6c", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Yucca (/ˈjʌkə/ YUCK-uh) is both the scientific name and common name for a genus native to North America from Panama to southern Canada. It contains 50 accepted species. In addition to yucca, they are also known as Adam's needle or Spanish-bayonet. The genus is generally classified in the asparagus family in a subfamily with the Agave, though historically it was part of the lily family. The species range from small shrubby plants to tree-like giants, such as the Joshua tree. All yuccas have rosettes of leaves that taper to points and inflorescences with many flowers that are mainly cream white with thick petals. Though adapted to a wide range of climates the plants are xerophytes, ones that specialize in dry living conditions.\n\nThe tight relationship between the yucca plants and their pollinators, the yucca moths from the genera Tegeticula and Parategeticula, is a well known example of evolutionary mutualism. They are an important part of the ecology of North American deserts, providing shelter to small animals and creating habitats. The human uses of yuccas include garden plants, as food, and for extracts. The flower petals of various species are eaten as a part of local cuisine, particularly in Central America and Mexico. Historically, the yucca was extensively used for its fibers to make cords, baskets, mats, and sandals. It continues to be used by native peoples for traditional soaps.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Yucca", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asparagaceae/Agavoideae/Yucca", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Yucca" @@ -337,6 +400,7 @@ "type": "Plant", "id": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "Narcissus pseudonarcissus", @@ -345,23 +409,31 @@ "link": "https://en.wikipedia.org/wiki/File:Narcissus_pseudonarcissus_flower_300303.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Daffodil", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "N. pseudonarcissus", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Narcissus_pseudonarcissus", - "description": "Narcissus pseudonarcissus, commonly named the wild daffodil or Lent lily (Welsh: Cennin Pedr), is a perennial flowering plant.[1][2][3][4][5][6]\n\nThis species has pale yellow tepals, with a darker central trumpet. The long, narrow leaves are slightly greyish green in colour and rise from the base of the stem. The plant grows from a bulb. The flowers produce seeds which, when germinated, take five to seven years to produce a flowering plant. (Sexual seed reproduction mixes the traits of both parent flowers, so if garden hybrid cultivars are planted close to wild populations of Narcissus pseudonarcissus, there is a danger that the new seedlings, having hybrid vigour, could out-compete the wild plants.)[7]" + "description": "Narcissus pseudonarcissus, commonly named the wild daffodil or Lent lily (Welsh: Cennin Pedr), is a perennial flowering plant.\n\nThis species has pale yellow tepals, with a darker central trumpet. The long, narrow leaves are slightly greyish green in colour and rise from the base of the stem. The plant grows from a bulb. The flowers produce seeds which, when germinated, take five to seven years to produce a flowering plant. (Sexual seed reproduction mixes the traits of both parent flowers, so if garden hybrid cultivars are planted close to wild populations of Narcissus pseudonarcissus, there is a danger that the new seedlings, having hybrid vigour, could out-compete the wild plants.)" + }, + "file": { + "contentLength": 62307, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ecda5f79c0714acbe609146166abbf3872ba51f459debd5e20c8b61bb932ed37" + } } }, { "type": "Taxon", "id": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Liliales is an order of monocotyledonous flowering plants in the Angiosperm Phylogeny Group and Angiosperm Phylogeny Web system, within the lilioid monocots. This order of necessity includes the family Liliaceae. The APG III system (2009) places this order in the monocot clade. In APG III, the family Luzuriagaceae is combined with the family Alstroemeriaceae and the family Petermanniaceae is recognized. Both the order Lililiales and the family Liliaceae have had a widely disputed history, with the circumscription varying greatly from one taxonomist to another. Previous members of this order, which at one stage included most monocots with conspicuous tepals and lacking starch in the endosperm are now distributed over three orders, Liliales, Dioscoreales and Asparagales, using predominantly molecular phylogenetics. The newly delimited Liliales is monophyletic, with ten families. Well known plants from the order include Lilium (lily), tulip, the North American wildflower Trillium, and greenbrier.\n\nThus circumscribed, this order consists mostly of herbaceous plants, but lianas and shrubs also occur. They are mostly perennial plants, with food storage organs such as corms or rhizomes. The family Corsiaceae is notable for being heterotrophic.\n\nThe order has worldwide distribution. The larger families (with more than 100 species) are roughly confined to the Northern Hemisphere, or are distributed worldwide, centering on the north. On the other hand, the smaller families (with up to 10 species) are confined to the Southern Hemisphere, or sometimes just to Australia or South America. The total number of species in the order is now about 1768.\n\nAs with any herbaceous group, the fossil record of the Liliales is rather scarce. There are several species from the Eocene, such as Petermanniopsis anglesaensis or Smilax, but their identification is not definite. Another known fossil is Ripogonum scandens from the Miocene. Due to the scarcity of data, it seems impossible to determine precisely the age and the initial distribution of the order. It is assumed that the Liliales originate from the Lower Cretaceous, over 100 million years ago. Fossil aquatic plants from the Cretaceous of northeastern Brazil and a new terrestrial species placed in the new genus Cratosmilax suggest that the first species have appeared around 120 million years ago when the continents formed Pangea, before dispersing as Asia, Africa and America. The initial diversification to the current families took place between 82 and 48 million years ago. The order consists of 10 families, 67 genera and about 1,768 species.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Liliales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliales" @@ -384,7 +456,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825392.350405, + "start": 1790361092.173303, "indices": "*", "combinedQuery": { "_source": [ @@ -425,8 +497,8 @@ } } }, - "end": 1758825392.381313, - "duration": 0.030908 + "end": 1790361092.190453, + "duration": 0.01715 }, { "input": { @@ -447,7 +519,7 @@ "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "type": "Plant", "metadata": { - "score": 3.9976888 + "score": 4.0113134 } }, { @@ -465,17 +537,17 @@ } }, { - "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", - "type": "Plant", + "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "type": "Taxon", "metadata": { - "score": 3.2730358 + "score": 3.269838 } }, { - "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", - "type": "Taxon", + "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", + "type": "Plant", "metadata": { - "score": 3.269838 + "score": 3.2554708 } }, { @@ -489,7 +561,7 @@ "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "type": "Plant", "metadata": { - "score": 3.021591 + "score": 3.060035 } }, { @@ -531,28 +603,28 @@ "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "type": "Plant", "metadata": { - "score": 2.152007 + "score": 2.1294188 } }, { "id": "74023bac-88ea-4031-b4a5-68d861ea2e91", "type": "Plant", "metadata": { - "score": 2.1088712 + "score": 2.085987 } }, { "id": "de004523-5165-4f10-872b-92eb9637a5d1", "type": "Plant", "metadata": { - "score": 2.02761 + "score": 2.0042536 } }, { "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "type": "Plant", "metadata": { - "score": 1.9892946 + "score": 1.9657542 } }, { @@ -566,7 +638,7 @@ "id": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "type": "Plant", "metadata": { - "score": 1.7868379 + "score": 1.8255562 } }, { @@ -584,14 +656,14 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825392.381483, + "start": 1790361092.190744, "accessibleElementIds": [ "16884ac8-ba7a-4c91-8449-4fdd803644b6", "258c0dfe-b1d8-4839-beed-d00d1b544a96", "1feedd9f-0c12-490c-8461-b12ff7447ac8", "63bcbffb-29a3-45b5-8cc1-8c41281bcae3", - "f8179dfc-5578-44de-adc0-d6b613eb7a32", "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "f8179dfc-5578-44de-adc0-d6b613eb7a32", "9abc0ab3-fa1a-4138-80c1-49db5c4d082f", "23508e6c-632a-496d-9bd6-8c62a03216af", "7dd3a650-d60e-40c9-89d0-bccaf395369f", @@ -607,8 +679,8 @@ "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "1852bdff-a4dd-4f91-9243-0e802775f757" ], - "end": 1758825392.451822, - "duration": 0.070339 + "end": 1790361092.274449, + "duration": 0.083705 } ] } \ No newline at end of file diff --git a/docs/search/example/element-hydration/implicit-paths-hydration/response-header.txt b/docs/search/example/element-hydration/implicit-paths-hydration/response-header.txt index c3ba5b8f..e2995351 100644 --- a/docs/search/example/element-hydration/implicit-paths-hydration/response-header.txt +++ b/docs/search/example/element-hydration/implicit-paths-hydration/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:38 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/element-hydration/parameterized-elementids-hydration/response-body.json b/docs/search/example/element-hydration/parameterized-elementids-hydration/response-body.json index b8c9c3fe..8069962f 100644 --- a/docs/search/example/element-hydration/parameterized-elementids-hydration/response-body.json +++ b/docs/search/example/element-hydration/parameterized-elementids-hydration/response-body.json @@ -5,6 +5,7 @@ "type": "Plant", "id": "70965ab5-bc3e-4bbe-aacf-1b7e12b76110", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "White trillium (Trillium grandiflorum) blooming in Backus Woods.", @@ -25,19 +26,28 @@ "link": "https://en.wikipedia.org/wiki/File:White_Trillium_Trillium_grandiflorum_Flower_2613px.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Trillium", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "T. grandiflorum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Trillium_grandiflorum", - "description": "Trillium grandiflorum, the white trillium,[3] large-flowered trillium, great white trillium,[4] white wake-robin or French: trille blanc, is a species of flowering plant in the family Melanthiaceae. A monocotyledonous, herbaceous perennial, the plant is native to eastern North America, from northern Quebec to the southern parts of the United States through the Appalachian Mountains into northernmost Georgia and west to Minnesota. There are also several isolated populations in Nova Scotia, Maine, southern Illinois, and Iowa.[5]" + "description": "Trillium grandiflorum, the white trillium, large-flowered trillium, great white trillium, white wake-robin or French: trille blanc, is a species of flowering plant in the family Melanthiaceae. A monocotyledonous, herbaceous perennial, the plant is native to eastern North America, from northern Quebec to the southern parts of the United States through the Appalachian Mountains into northernmost Georgia and west to Minnesota. There are also several isolated populations in Nova Scotia, Maine, southern Illinois, and Iowa." + }, + "file": { + "contentLength": 52082, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a235debeb572b5ab0cd3a84dfcf869dd558042dead656097fa5d414f2fee2b31" + } } }, { "type": "Plant", "id": "4a335e0b-e6bf-4c64-8c28-2a17bd6f0dec", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Hibiscus heterophyllus cultivated plant growing in Thirlmere", @@ -52,19 +62,28 @@ "link": "https://en.wikipedia.org/wiki/File:Hibiscus_heterophyllus_Chatswood_West.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Hibiscus", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "H. heterophyllus", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Hibiscus_heterophyllus", - "description": "Hibiscus heterophyllus, commonly known as native rosella or toilet paper bush,[2] is a flowering plant in the family Malvaceae. It a shrub or small tree with white, pale pink or yellow flowers with a dark red centre and grows in New South Wales and Queensland." + "description": "Hibiscus heterophyllus, commonly known as native rosella or toilet paper bush, is a flowering plant in the family Malvaceae. It a shrub or small tree with white, pale pink or yellow flowers with a dark red centre and grows in New South Wales and Queensland." + }, + "file": { + "contentLength": 91123, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "9adcacd32ddda75b23007df4be9d74d85f1e768ae1a991891703e3d1bee64006" + } } }, { "type": "Plant", "id": "b133d1d5-a24c-4409-955f-99b53b3f2a73", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "Arabian jasmin 'Grand Duke of Tuscany'", @@ -73,13 +92,21 @@ "link": "https://en.wikipedia.org/wiki/File:Jasminum_sambac_%27Grand_Duke_of_Tuscany%27.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Jasmine", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "J. sambac", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Jasminum_sambac", - "description": "Jasminum sambac (Arabian jasmine or Sambac jasmine)[1][3] is a species of jasmine native to Bhutan and India.[4][5] It is cultivated in many places, especially West Asia, South Asia and Southeast Asia. It is naturalised in many scattered locales: Mauritius, Madagascar, the Maldives, Christmas Island, Chiapas, Central America, southern Florida, the Bahamas, Cuba, Hispaniola, Jamaica, Puerto Rico, and the Lesser Antilles.[6][4][7]" + "description": "Jasminum sambac (Arabian jasmine or Sambac jasmine) is a species of jasmine native to Bhutan and India. It is cultivated in many places, especially West Asia, South Asia and Southeast Asia. It is naturalised in many scattered locales: Mauritius, Madagascar, the Maldives, Christmas Island, Chiapas, Central America, southern Florida, the Bahamas, Cuba, Hispaniola, Jamaica, Puerto Rico, and the Lesser Antilles." + }, + "file": { + "contentLength": 27888, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "96e35f27fa6c148d68ba326b80f3172f4de2e08ad35127bd8e3e4fb006fb5176" + } } } ], @@ -113,14 +140,14 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825392.623079, + "start": 1790276706.945658, "accessibleElementIds": [ "70965ab5-bc3e-4bbe-aacf-1b7e12b76110", "4a335e0b-e6bf-4c64-8c28-2a17bd6f0dec", "b133d1d5-a24c-4409-955f-99b53b3f2a73" ], - "end": 1758825392.730921, - "duration": 0.107842 + "end": 1790276707.040742, + "duration": 0.095084 } ] } \ No newline at end of file diff --git a/docs/search/example/element-hydration/parameterized-elementids-hydration/response-header.txt b/docs/search/example/element-hydration/parameterized-elementids-hydration/response-header.txt index c3ba5b8f..e2995351 100644 --- a/docs/search/example/element-hydration/parameterized-elementids-hydration/response-header.txt +++ b/docs/search/example/element-hydration/parameterized-elementids-hydration/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:38 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-body.json b/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-body.json index ae10aa4e..b607f38c 100644 --- a/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-body.json +++ b/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-body.json @@ -5,6 +5,7 @@ "type": "Plant", "id": "a3e1063d-a714-4fb4-a95e-6a11e7076bb0", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Wild columbine (Aquilegia canadensis) in Talcott Mountain State Park", @@ -19,19 +20,28 @@ "link": "https://en.wikipedia.org/wiki/File:Aquilegia_canadensis_Redmarkviolinist.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Columbine", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "A. canadensis", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Aquilegia_canadensis", "description": "Aquilegia canadensis, the Canadian or Canada columbine, eastern red columbine, or wild columbine, is a species of flowering plant in the buttercup family Ranunculaceae. It is an herbaceous perennial native to woodland and rocky slopes in eastern North America, prized for its red and yellow flowers. It readily hybridizes with other species in the genus Aquilegia." + }, + "file": { + "contentLength": 51355, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "25772ade78beadc7fc55053016c88b17ede751fc001518b0a25428af523f8e64" + } } }, { "type": "Plant", "id": "eb5373ef-50ae-462f-a07a-5ccf767bff21", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Flowering Corymbia ficifolia, Austins Ferry, Tasmania, Australia", @@ -46,19 +56,28 @@ "link": "https://en.wikipedia.org/wiki/File:Corymbia_ficifolia_in_Berkeley_California.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Eucalyptus Blossom", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "C. ficifolia", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Corymbia_ficifolia", - "description": "Corymbia ficifolia, commonly known as red flowering gum,[2] is a species of small tree that is endemic to the south-west of Western Australia. It has rough, fibrous bark on the trunk and branches, egg-shaped to broadly lance-shape adult leaves, flower buds in groups of seven, bright red, pink or orange flowers and urn-shaped fruit. It has a restricted distribution in the wild but is one of the most commonly planted ornamental eucalypts." + "description": "Corymbia ficifolia, commonly known as red flowering gum, is a species of small tree that is endemic to the south-west of Western Australia. It has rough, fibrous bark on the trunk and branches, egg-shaped to broadly lance-shape adult leaves, flower buds in groups of seven, bright red, pink or orange flowers and urn-shaped fruit. It has a restricted distribution in the wild but is one of the most commonly planted ornamental eucalypts." + }, + "file": { + "contentLength": 102098, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "dbd69e49d315abad39a57c90fee358301f7217b4ef5597286d9b16ae09d4a3c2" + } } }, { "type": "Plant", "id": "731fd394-6e0d-4a43-9693-4a7a7d97731e", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "", @@ -67,19 +86,28 @@ "link": "https://en.wikipedia.org/wiki/File:Bromeliaceae03.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Bromeliad", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "G. lingulata", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Guzmania_lingulata", - "description": "Guzmania lingulata, the droophead tufted airplant or scarlet star, is a species of flowering plant in the family Bromeliaceae, subfamily Tillandsioideae. This evergreen epiphytic perennial[2] is native to rainforest habitats in Central America, northern and central South America and southern Mexico.[1] The Latin word lingulata means \"tongue-shaped\".[3] The foliage grows in a star-shaped basal rosette culminating in an orange and red bracted inflorescence. It is among the most commonly cultivated bromeliad types, with cultivars producing flowers in shades of maroon, red, orange, yellow or pink." + "description": "Guzmania lingulata, the droophead tufted airplant or scarlet star, is a species of flowering plant in the family Bromeliaceae, subfamily Tillandsioideae. This evergreen epiphytic perennial is native to rainforest habitats in Central America, northern and central South America and southern Mexico. The Latin word lingulata means \"tongue-shaped\". The foliage grows in a star-shaped basal rosette culminating in an orange and red bracted inflorescence. It is among the most commonly cultivated bromeliad types, with cultivars producing flowers in shades of maroon, red, orange, yellow or pink." + }, + "file": { + "contentLength": 82201, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "d13ab134d7d1b9933d4369c9aeeae07827fdfb3cd6f2ae6c4b549375412ef097" + } } }, { "type": "Plant", "id": "6a2b73e7-716b-4a8d-a8e6-69ab80b65e67", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "A poster showing three different stages of a Common Poppy flower (Papaver rhoeas): bud, flower and fruit (capsule).", @@ -88,19 +116,28 @@ "link": "https://en.wikipedia.org/wiki/File:Poster_papaver_3a.jpg" } ], - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "hasFile": true, "name": "Poppy", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "P. rhoeas", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Papaver_rhoeas", - "description": "Papaver rhoeas, with common names including common poppy,[3] corn poppy, corn rose, field poppy,[4] Flanders poppy, red poppy, and Odai, is an annual herbaceous species of flowering plant in the poppy family Papaveraceae. It is native to north Africa and temperate Eurasia and is introduced into temperate areas on all other continents except Antarctica." + "description": "Papaver rhoeas, with common names including common poppy, corn poppy, corn rose, field poppy, Flanders poppy, red poppy, and Odai, is an annual herbaceous species of flowering plant in the poppy family Papaveraceae. It is native to north Africa and temperate Eurasia and is introduced into temperate areas on all other continents except Antarctica." + }, + "file": { + "contentLength": 125585, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "0e468bf4e2b26f8e04914eb4dda88622c7c196b4a7a71e82bb857068a6e0b70a" + } } }, { "type": "Plant", "id": "3f6be764-d780-44d0-a12f-15326abe091d", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Grevillea rosmarinifolia subsp. glabella at Stocks Nursery and Botanic Gardens, Harden, NSW", @@ -109,19 +146,28 @@ "link": "https://en.wikipedia.org/wiki/File:Grevillea_rosmarinifolia_glabella.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Grevillea", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "G. rosmarinifolia", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Grevillea_rosmarinifolia", - "description": "Grevillea rosmarinifolia, commonly known as rosemary grevillea,[2] is a species of flowering plant in the family Proteaceae and is endemic to south-eastern continental Australia and naturalised in other parts of the country. It is usually an erect, compact to open shrub with linear, narrowly elliptic or narrowly oblong leaves with the edges rolled under, and loose clusters of pink to red flowers." + "description": "Grevillea rosmarinifolia, commonly known as rosemary grevillea, is a species of flowering plant in the family Proteaceae and is endemic to south-eastern continental Australia and naturalised in other parts of the country. It is usually an erect, compact to open shrub with linear, narrowly elliptic or narrowly oblong leaves with the edges rolled under, and loose clusters of pink to red flowers." + }, + "file": { + "contentLength": 84441, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "488aa6a8d34a9927c057eacc28d8917f462af819f011bca44d533d4204b56d6b" + } } }, { "type": "Plant", "id": "36eca1a2-8a7b-42c5-860a-4ad444f8792f", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Sturt's Desert Pea Swainsona formosa, Melbourne Zoo", @@ -136,19 +182,28 @@ "link": "https://en.wikipedia.org/wiki/File:A.I.B.T_Sturt_Peas.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Sturt\\u2019s Desert Pea", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "S. formosa", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Swainsona_formosa", - "description": "Swainsona formosa, commonly known as Sturt's desert pea or Sturt pea,[3] is a species of flowering plant in the family Fabaceae and is native to all continental states and the Northern Territory of Australia, with the exception of Victoria. It is a prostrate annual or short lived perennial herb with imparipinnate leaves with about 15 elliptic to egg-shaped leaflets with the narrower end towards the base, and racemes of usually red flowers in racemes of 2 to 6." + "description": "Swainsona formosa, commonly known as Sturt's desert pea or Sturt pea, is a species of flowering plant in the family Fabaceae and is native to all continental states and the Northern Territory of Australia, with the exception of Victoria. It is a prostrate annual or short lived perennial herb with imparipinnate leaves with about 15 elliptic to egg-shaped leaflets with the narrower end towards the base, and racemes of usually red flowers in racemes of 2 to 6." + }, + "file": { + "contentLength": 98146, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "23c083fd8583e0c241fa72f8dca1af7523b531625317fd209cdd0b3102d724a4" + } } }, { "type": "Plant", "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "data": { + "created": "2026-09-24T19:04:01+00:00", "images": [ { "description": "in Seoul Forest, South Korea", @@ -169,19 +224,28 @@ "link": "https://en.wikipedia.org/wiki/File:Tulipa_orphanidea_060506.jpg" } ], - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "hasFile": true, "name": "Tulip", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:01+00:00", "scientificName": "Tulipa", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Tulip", - "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae,[2] along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae, along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + }, + "file": { + "contentLength": 105488, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c4c242897c43a74e9e4a8b5bf89e6b2cb1cbd99f8d621f33eea175cbf6dfab97" + } } }, { "type": "Plant", "id": "0f3aced5-8484-49d4-9579-e3ec5facde95", "data": { + "created": "2026-09-24T19:04:03+00:00", "description": "Telopea speciosissima, commonly known as the New South Wales waratah or simply waratah, is a large shrub in the plant family Proteaceae. It is endemic to New South Wales in Australia. No subspecies are recognised; the closely related Telopea aspera was classified as a separate species in 1995. T. speciosissima is a shrub to 3 or 4 m (9.8 or 13.1 ft) high and 2 m (6.6 ft) wide, with dark green leaves. Its several stems arise from a pronounced woody base known as a lignotuber. The species is well renowned for its striking large red springtime inflorescences (flowerheads), each including hundreds of individual flowers. These are visited by the eastern pygmy possum (Cercartetus nanus), birds such as honeyeaters (Meliphagidae), and various insects.\n\nThe floral emblem for its home state of New South Wales, Telopea speciosissima has featured prominently in art, architecture, and advertising, particularly since Australian federation. Commercially grown in several countries as a cut flower, it is also cultivated in home gardens, requiring good drainage yet adequate moisture, but is vulnerable to various fungal diseases and pests. A number of cultivars with various shades of red, pink and even white flowers are available. Horticulturists have also developed hybrids with T. oreades and T. mongaensis which are more tolerant of cold, shade, and heavier soils.", "images": [ { @@ -197,18 +261,27 @@ "link": "https://en.wikipedia.org/wiki/File:Telopea_speciosissima_RNP.JPG" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Waratah", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "T. speciosissima", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Telopea_speciosissima" + }, + "file": { + "contentLength": 72759, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "0926def0aa909d3e889aa5ba0a01df37772ea79f87a67d0565f4d4c6ed1a60fe" + } } }, { "type": "Plant", "id": "c05a9e25-11bc-48db-8fea-6d1b885119b0", "data": { + "created": "2026-09-24T19:04:03+00:00", "images": [ { "description": "Bush Ochre (Anigozanthos), Australian Garden, Royal Botanic Gardens, Cranbourne, Victoria, Australia. 29 June, 2006.", @@ -217,13 +290,21 @@ "link": "https://en.wikipedia.org/wiki/File:Bush-Pearl-in-RBG-Cranbourne.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Kangaroo Paw", - "scenario": "general.botanicExample", + "updated": "2026-09-24T19:04:03+00:00", "scientificName": "Anigozanthos", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Anigozanthos", - "description": "Anigozanthos is a genus of plant found naturally in the Southwestern Australia biogeographic region, belonging to the bloodwort family Haemodoraceae.[1][2] The 11 species and their subspecies are commonly known as kangaroo paw or catspaw, depending on their size, and the shape and colour of their flowers. A further species, previously identified as Anigozanthos fuliginosus (black kangaroo paw), was separated to a monotypic genus as Macropidia fuliginosa. All 11 species of Anigozanthos are endemic to the south west of Western Australia,[3] Noongar Boodjar.\n\nThe species are recognised by their unusual flowers, numerous hybrids and cultivars have been developed for cultivation and floristry in recent years; kangaroo paws are much in demand as house plants and as cut flowers. The red-and-green kangaroo paw is the floral emblem of Western Australia." + "description": "Anigozanthos is a genus of plant found naturally in the Southwestern Australia biogeographic region, belonging to the bloodwort family Haemodoraceae. The 11 species and their subspecies are commonly known as kangaroo paw or catspaw, depending on their size, and the shape and colour of their flowers. A further species, previously identified as Anigozanthos fuliginosus (black kangaroo paw), was separated to a monotypic genus as Macropidia fuliginosa. All 11 species of Anigozanthos are endemic to the south west of Western Australia, Noongar Boodjar.\n\nThe species are recognised by their unusual flowers, numerous hybrids and cultivars have been developed for cultivation and floristry in recent years; kangaroo paws are much in demand as house plants and as cut flowers. The red-and-green kangaroo paw is the floral emblem of Western Australia." + }, + "file": { + "contentLength": 133185, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a5bc64f577b8715d3f8c33f17bf7a7d87dbd00001fb341de53dc52279147f04f" + } } } ], @@ -238,9 +319,9 @@ } }, "step-handler": "cypher-path-subset (App\\EventSystem\\SearchStep\\EventListener\\CypherPathSearchStepEventListener)", - "start": 1758825392.880632, - "end": 1758825393.209822, - "duration": 0.3291900157928467 + "start": 1790276707.085009, + "end": 1790276707.595679, + "duration": 0.5106699466705322 }, { "input": { @@ -508,7 +589,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825393.21006, + "start": 1790276707.596112, "indices": "*", "combinedQuery": { "_source": [ @@ -587,8 +668,8 @@ } } }, - "end": 1758825393.372277, - "duration": 0.162217 + "end": 1790276707.754085, + "duration": 0.157973 }, { "input": { @@ -841,74 +922,74 @@ "id": "a3e1063d-a714-4fb4-a95e-6a11e7076bb0", "type": "Plant", "metadata": { - "score": 3.463559 + "score": 3.4491417 } }, { "id": "eb5373ef-50ae-462f-a07a-5ccf767bff21", "type": "Plant", "metadata": { - "score": 3.296052 + "score": 3.2790928 } }, { "id": "731fd394-6e0d-4a43-9693-4a7a7d97731e", "type": "Plant", "metadata": { - "score": 3.206095 + "score": 3.2175324 } }, { "id": "6a2b73e7-716b-4a8d-a8e6-69ab80b65e67", "type": "Plant", "metadata": { - "score": 2.8846285 + "score": 2.8893025 } }, { "id": "3f6be764-d780-44d0-a12f-15326abe091d", "type": "Plant", "metadata": { - "score": 2.8042715 + "score": 2.826378 } }, { "id": "36eca1a2-8a7b-42c5-860a-4ad444f8792f", "type": "Plant", "metadata": { - "score": 2.6625946 + "score": 2.6774929 } }, { "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "type": "Plant", "metadata": { - "score": 2.599828 + "score": 2.5809202 } }, { "id": "0f3aced5-8484-49d4-9579-e3ec5facde95", "type": "Plant", "metadata": { - "score": 2.5568495 + "score": 2.5349326 } }, { "id": "c05a9e25-11bc-48db-8fea-6d1b885119b0", "type": "Plant", "metadata": { - "score": 2.3461056 + "score": 2.3267329 } } ], "totalHits": 9, - "maxScore": 3.463559 + "maxScore": 3.4491417 } ] } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825393.372446, + "start": 1790276707.754366, "accessibleElementIds": [ "a3e1063d-a714-4fb4-a95e-6a11e7076bb0", "eb5373ef-50ae-462f-a07a-5ccf767bff21", @@ -920,8 +1001,8 @@ "0f3aced5-8484-49d4-9579-e3ec5facde95", "c05a9e25-11bc-48db-8fea-6d1b885119b0" ], - "end": 1758825393.410484, - "duration": 0.038038 + "end": 1790276707.799931, + "duration": 0.045565 } ] } \ No newline at end of file diff --git a/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-header.txt b/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-header.txt index c3ba5b8f..463dc8c3 100644 --- a/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-header.txt +++ b/docs/search/example/polyglot/cypher-path-es-query-dsl-mixin-element-hydration/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:39 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-body.json b/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-body.json index c355d1dc..fd2880a1 100644 --- a/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-body.json +++ b/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-body.json @@ -7,8 +7,8 @@ "start": "16884ac8-ba7a-4c91-8449-4fdd803644b6", "end": "b7cda919-deea-49f9-9151-49132f83cbff", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -18,8 +18,8 @@ "start": "75a7a9c8-8330-483c-b7f3-f7ca74c48535", "end": "47e135a7-c77a-4489-9262-1e425a4b7177", "data": { - "created": "2025-09-25T18:35:32+00:00", - "updated": "2025-09-25T18:35:32+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -27,9 +27,9 @@ "type": "Taxon", "id": "0fe4710a-1998-4761-919e-aa3cb9c055f8", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Magnoliaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Magnoliids/Magnoliales/Magnoliaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Magnoliaceae", @@ -40,9 +40,9 @@ "type": "Taxon", "id": "13fc213f-d90d-4c14-b841-dafb5eb1d817", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Magnolia sect. Yulania", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Magnoliids/Magnoliales/Magnoliaceae/Magnolia/Magnolia subg. Yulania/Magnolia sect. Yulania", "scenario": "general.botanicExample" } @@ -51,9 +51,9 @@ "type": "Taxon", "id": "16884ac8-ba7a-4c91-8449-4fdd803644b6", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Gloriosa", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Colchicaceae/Gloriosa", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_(plant)", @@ -64,10 +64,10 @@ "type": "Taxon", "id": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Liliales is an order of monocotyledonous flowering plants in the Angiosperm Phylogeny Group and Angiosperm Phylogeny Web system, within the lilioid monocots. This order of necessity includes the family Liliaceae. The APG III system (2009) places this order in the monocot clade. In APG III, the family Luzuriagaceae is combined with the family Alstroemeriaceae and the family Petermanniaceae is recognized. Both the order Lililiales and the family Liliaceae have had a widely disputed history, with the circumscription varying greatly from one taxonomist to another. Previous members of this order, which at one stage included most monocots with conspicuous tepals and lacking starch in the endosperm are now distributed over three orders, Liliales, Dioscoreales and Asparagales, using predominantly molecular phylogenetics. The newly delimited Liliales is monophyletic, with ten families. Well known plants from the order include Lilium (lily), tulip, the North American wildflower Trillium, and greenbrier.\n\nThus circumscribed, this order consists mostly of herbaceous plants, but lianas and shrubs also occur. They are mostly perennial plants, with food storage organs such as corms or rhizomes. The family Corsiaceae is notable for being heterotrophic.\n\nThe order has worldwide distribution. The larger families (with more than 100 species) are roughly confined to the Northern Hemisphere, or are distributed worldwide, centering on the north. On the other hand, the smaller families (with up to 10 species) are confined to the Southern Hemisphere, or sometimes just to Australia or South America. The total number of species in the order is now about 1768.\n\nAs with any herbaceous group, the fossil record of the Liliales is rather scarce. There are several species from the Eocene, such as Petermanniopsis anglesaensis or Smilax, but their identification is not definite. Another known fossil is Ripogonum scandens from the Miocene. Due to the scarcity of data, it seems impossible to determine precisely the age and the initial distribution of the order. It is assumed that the Liliales originate from the Lower Cretaceous, over 100 million years ago. Fossil aquatic plants from the Cretaceous of northeastern Brazil and a new terrestrial species placed in the new genus Cratosmilax suggest that the first species have appeared around 120 million years ago when the continents formed Pangea, before dispersing as Asia, Africa and America. The initial diversification to the current families took place between 82 and 48 million years ago. The order consists of 10 families, 67 genera and about 1,768 species.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Liliales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliales" @@ -77,9 +77,9 @@ "type": "Taxon", "id": "1d5350ed-86c5-4933-8089-5fa053a3ab02", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Gentianales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Gentianales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gentianales", @@ -90,9 +90,9 @@ "type": "Taxon", "id": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Lilieae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilieae", @@ -105,8 +105,8 @@ "start": "927c7763-0e29-4bd4-817e-d2b19ec424fa", "end": "2f4e71c8-851b-4d3d-a0f5-f357eab1dec1", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -114,10 +114,10 @@ "type": "Taxon", "id": "2341f6f7-1b5d-4ce9-a68e-2973be25ec9a", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "The eudicots or eudicotyledons are flowering plants that have two seed leaves (cotyledons) upon germination. The term derives from dicotyledon (etymologically, eu = true; di = two; cotyledon = seed leaf). Historically, authors have used the terms tricolpates or non-magnoliid dicots. The current botanical terms were introduced in 1991, by evolutionary botanist James A. Doyle and paleobotanist Carol L. Hotton, to emphasize the later evolutionary divergence of tricolpate dicots from earlier, less specialized, dicots.\n\nScores of familiar plants are eudicots, including many commonly cultivated and edible plants, numerous trees, tropicals and ornamentals. Among the most well-known eudicot genera are those of the sunflower (Helianthus), dandelion (Taraxacum), forget-me-not (Myosotis), cabbage (Brassica), apple (Malus), buttercup (Ranunculus), maple (Acer) and macadamia (Macadamia). Most leafy, mid-latitude trees are also classified as eudicots, with notable exceptions being the magnolias and American tulip tree (Liriodendron)—which belong to the magnoliids—and Ginkgo biloba, which is not an angiosperm.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Eudicots", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Eudicots" @@ -127,6 +127,7 @@ "type": "Plant", "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Lilium candidum, Israel", @@ -141,19 +142,28 @@ "link": "https://en.wikipedia.org/wiki/File:Lilium_candidum_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "L. candidum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium_candidum", - "description": "Lilium candidum, the Madonna lily[2][3] or white lily,[4] is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions.[1][5] It has been cultivated since antiquity, for at least 3,000 years,[6] and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + "description": "Lilium candidum, the Madonna lily or white lily, is a plant in the true lily family. It is native to the Balkans and Middle East, and naturalized in other parts of Europe, including France, Italy, and Ukraine, and in North Africa, the Canary Islands, Mexico, and other regions. It has been cultivated since antiquity, for at least 3,000 years, and has great symbolic value since then for many cultures. It is susceptible to several virus diseases common to lilies, and especially to Botrytis fungus. One technique to avoid problems with viruses is to grow plants from seed instead of bulblets." + }, + "file": { + "contentLength": 115168, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ee066b93ae72c719f863a428f03c86ecd28e52818528eacf4d4e32acb4b4b37c" + } } }, { "type": "Plant", "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "", @@ -168,13 +178,21 @@ "link": "https://en.wikipedia.org/wiki/File:Gloriosa_superba_1.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Flame Lily", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "G. superba", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Gloriosa_superba", - "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily,[3] gloriosa lily, tiger claw,[4] and fire lily.[5]" + "description": "Gloriosa superba is a species of flowering plant in the family Colchicaceae. Common names include flame lily, climbing lily, creeping lily, glory lily, gloriosa lily, tiger claw, and fire lily." + }, + "file": { + "contentLength": 90681, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "72e3e3770db795b2f04f0b5bb0be43e74df70de4faa0bf83a706bbde29e4ea0b" + } } }, { @@ -183,8 +201,8 @@ "start": "acbe7b5d-784a-4956-8fa4-786589584135", "end": "13fc213f-d90d-4c14-b841-dafb5eb1d817", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -192,9 +210,9 @@ "type": "Taxon", "id": "2f4e71c8-851b-4d3d-a0f5-f357eab1dec1", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Amaryllidoideae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Amaryllidaceae/Amaryllidoideae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Amaryllidoideae", @@ -205,6 +223,7 @@ "type": "Plant", "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Flower of Nelumbo nucifera, bean of India.", @@ -219,13 +238,21 @@ "link": "https://en.wikipedia.org/wiki/File:Nelumbo_nucifera1romainguy.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Lotus", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "N. nucifera", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Nelumbo_nucifera", - "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus,[1] or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae.[2] The lotus belongs in the order Proteales." + "description": "Nelumbo nucifera, also known as Padma (Sanskrit: \\u092a\\u0926\\u094d\\u092e, romanized: Padm\\u0101, lit.\\u2009'Lotus') or Kamala (Sanskrit: \\u0915\\u092e\\u0932, lit. 'Lotus'), sacred lotus, pink lotus, Indian lotus, or simply lotus, is one of two extant species of aquatic plant in the family Nelumbonaceae. It is sometimes colloquially called a water lily, though this more often refers to members of the family Nymphaeaceae. The lotus belongs in the order Proteales." + }, + "file": { + "contentLength": 63295, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a46657ef9ece1a7e0939ee5edcc4358f23a3b8b7871c6eba4ad2ce0f30cf4e9f" + } } }, { @@ -234,8 +261,8 @@ "start": "6f84870c-62c1-4a1a-8189-a453e00e296f", "end": "a2c4ba63-65f2-460e-b77c-6db283efe3ab", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -245,8 +272,8 @@ "start": "74023bac-88ea-4031-b4a5-68d861ea2e91", "end": "acbe7b5d-784a-4956-8fa4-786589584135", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -254,10 +281,10 @@ "type": "Taxon", "id": "425235d6-75ab-4ccb-8208-783ced0decc6", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "The lily family, Liliaceae, consists of about 15 genera and 610 species of flowering plants within the order Liliales. They are monocotyledonous, perennial, herbaceous, often bulbous geophytes. Plants in this family have evolved with a fair amount of morphological diversity despite genetic similarity. Common characteristics include large flowers with parts arranged in threes: with six colored or patterned petaloid tepals (undifferentiated petals and sepals) arranged in two whorls, six stamens and a superior ovary. The leaves are linear in shape, with their veins usually arranged parallel to the edges, single and arranged alternating on the stem, or in a rosette at the base. Most species are grown from bulbs, although some have rhizomes. First described in 1789, the lily family became a paraphyletic \"catch-all\" (wastebasket) group of lilioid monocots that did not fit into other families and included a great number of genera now included in other families and in some cases in other orders. Consequently, many sources and descriptions labelled \"Liliaceae\" deal with the broader sense of the family.\n\nThe family evolved approximately 68 million years ago during the Late Cretaceous to Early Paleogene epochs. Liliaceae are widely distributed, mainly in temperate regions of the Northern Hemisphere and the flowers are insect pollinated. Many Liliaceae are important ornamental plants, widely grown for their attractive flowers and involved in a major floriculture of cut flowers and dry bulbs. Some species are poisonous if eaten and can have adverse health effects in humans and household pets.\n\nA number of Liliaceae genera are popular cultivated plants in private and public spaces. Lilies and tulips in particular have had considerable symbolic and decorative value, and appear frequently in paintings and the decorative arts. They are also an economically important product. Most of their genera, Lilium in particular, face considerable herbivory pressure from deer in some areas, both wild and domestic.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Liliaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Liliaceae" @@ -269,8 +296,8 @@ "start": "a2daf225-4907-4869-aa55-c82561caac68", "end": "6f84870c-62c1-4a1a-8189-a453e00e296f", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -278,10 +305,10 @@ "type": "Taxon", "id": "47e135a7-c77a-4489-9262-1e425a4b7177", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Asparagales (asparagoid lilies) are a diverse order of flowering plants in the monocots. Under the APG IV system of flowering plant classification, Asparagales are the largest order of monocots with 14 families, 1,122 genera, and about 36,000 species, with members as varied as asparaguses, orchids, yuccas, irises, onions, garlics, leeks, and other alliums, daffodils, snowdrops, amaryllis, agaves, butcher's broom, agapanthuses, Solomon's seal, hyacinths, bluebells, spider plants, grasstrees, aloes, freesias, gladioli, crocuses, and saffrons.\n\nMost species of Asparagales are herbaceous perennials, although some are climbers and some are trees or shrubs. The order also contains many geophytes (bulbs, corms, and various kinds of tuber). The leaves of almost all species form a tight rosette, either at the base of the plant or at the end of the stem, but occasionally along the stem. The flowers are not particularly distinctive, being 'lily type', with six tepals and up to six stamina. One of the defining characteristics (synapomorphies) of the order is the presence of phytomelanin, a black pigment present in the seed coat, creating a dark crust. Phytomelanin is found in most families of the Asparagales (although not in Orchidaceae, thought to be the sister-group of the rest of the order).\n\nThe order Asparagales takes its name from the type family Asparagaceae and has only recently been recognized in classification systems. The order is clearly circumscribed on the basis of molecular phylogenetics, but it is difficult to define morphologically since its members are structurally diverse. The order was first put forward by Huber in 1977 and later taken up in the Dahlgren system of 1985 and then the Angiosperm Phylogeny Group systems. Before this, many of its families were assigned to the old order Liliales, which was redistributed over three orders, Liliales, Asparagales, and Dioscoreales, based on molecular phylogenetics. The boundaries of the Asparagales and of its families have undergone a series of changes in recent years; future research may lead to further changes and ultimately greater stability.\n\nThe order is thought to have first diverged from other related monocots some 120–130 million years ago (early in the Cretaceous period), although given the difficulty in classifying the families involved, estimates are likely to be uncertain.\n\nFrom an economic point of view, the order Asparagales is second in importance within the monocots only to the order Poales (which includes grasses and cereals). Species are used as food and flavourings (e.g. onion, garlic, leek, asparagus, vanilla, saffron), in medicinal or cosmetic applications (Aloe), as cut flowers (e.g. freesia, gladiolus, iris, orchids), and as garden ornamentals (e.g. day lilies, lily of the valley, Agapanthus).", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Asparagales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asparagales" @@ -291,10 +318,10 @@ "type": "Taxon", "id": "487db1d2-a12f-422a-a0d2-1da198ef62a3", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Asterids are a large clade (monophyletic group) of flowering plants, composed of 17 orders and more than 80,000 species, about a third of the total flowering plant species. The asterids are divided into the unranked clades lamiids (8 orders) and campanulids (7 orders), and the single orders Cornales and Ericales. Well-known asterids include dogwoods and hydrangeas (order Cornales), tea, blueberries, cranberries, kiwifruit, Brazil nuts, argan, sapote, and azaleas (order Ericales), sunflowers, lettuce, common daisy, yacon, carrots, celery, parsley, parsnips, ginseng, ivies, holly, honeysuckle, elder, and valerian (clade campanulids), borage, forget-me-nots, comfrey, coffee, frangipani, gentian, pong-pong, oleander, periwinkle, basil, mint, rosemary, sage, oregano, thyme, lavender, wild dagga, olives, ash, teak, foxgloves, lilac, jasmine, snapdragons, African violets, butterfly bushes, sesame, psyllium, potatoes, eggplants, tomatoes, chilli peppers, tobacco, petunias, morning glory, and sweet potato (clade lamiids).\n\nMost of the taxa belonging to this clade had been referred to as Asteridae in the Cronquist system (1981) and as Sympetalae in earlier systems. The name asterids (not necessarily capitalised) resembles the earlier botanical name but is intended to be the name of a clade rather than a formal ranked name, in the sense of the ICBN.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Asterids", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asterids" @@ -306,8 +333,8 @@ "start": "3283fd3d-8083-4170-882a-41b1cd152c55", "end": "abcdcfee-3f4f-464f-8418-6e6640ad72b6", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -317,8 +344,8 @@ "start": "b7cda919-deea-49f9-9151-49132f83cbff", "end": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -328,8 +355,8 @@ "start": "abcdcfee-3f4f-464f-8418-6e6640ad72b6", "end": "ff04eb54-7656-47ae-816b-debc794a0f49", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -339,8 +366,8 @@ "start": "7cc682bf-b648-47b1-b49a-e328f2d1bcd3", "end": "f1622292-ace6-45c0-bd8c-0de67bcfb6bf", "data": { - "created": "2025-09-25T18:35:32+00:00", - "updated": "2025-09-25T18:35:32+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -350,8 +377,8 @@ "start": "2341f6f7-1b5d-4ce9-a68e-2973be25ec9a", "end": "a2c4ba63-65f2-460e-b77c-6db283efe3ab", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -361,8 +388,8 @@ "start": "63bcbffb-29a3-45b5-8cc1-8c41281bcae3", "end": "d633ef5f-f385-44ef-8326-9b17e728cca8", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:37+00:00", + "updated": "2026-09-25T18:30:37+00:00", "scenario": "general.botanicExample" } }, @@ -372,8 +399,8 @@ "start": "99b9f696-7f86-4728-b88f-def50ede6ce0", "end": "a2c4ba63-65f2-460e-b77c-6db283efe3ab", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -383,8 +410,8 @@ "start": "ff04eb54-7656-47ae-816b-debc794a0f49", "end": "b597444b-d50f-4f30-b9f0-a31d2fef5322", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -392,10 +419,10 @@ "type": "Taxon", "id": "632f5679-a4bc-4aae-bdec-d083c74b32ba", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Magnolia is a large genus of about 210 to 340 flowering plant species in the subfamily Magnolioideae of the family Magnoliaceae. The natural range of Magnolia species is disjunct, with a main center in east, south and southeast Asia and a secondary center in eastern North America, Central America, the West Indies, and some species in South America.\n\nMagnolias are evergreen or deciduous trees or shrubs known for their large, fragrant, bowl- or star-shaped flowers with numerous spirally arranged reproductive parts, producing cone-like fruits in autumn that open to reveal seeds. The genus Magnolia was first named in 1703 by Charles Plumier, honoring Pierre Magnol, with early taxonomy refined by Linnaeus in the 18th century based on American and later Asian species. Modern molecular phylogenetic studies have revealed complex relationships leading to taxonomic debates about merging related genera like Michelia with Magnolia. Magnolia species are valued horticulturally for their early and showy flowering, used culinarily in various edible forms, employed in traditional medicine for their bioactive compounds like magnolol and honokiol, and harvested for timber, with hybridization enhancing desirable traits.\n\nMagnolia is an ancient genus that dates back the Cretaceous. Fossilized specimens of M. acuminata have been found dating to 20 million years ago (mya), and fossils of plants identifiably belonging to the Magnoliaceae date to 95 mya. They are theorized to have evolved to encourage pollination by beetles as they existed prior to the evolution of bees. Another aspect of Magnolia considered to represent an ancestral state is that the flower bud is enclosed in a bract rather than in sepals; the perianth parts are undifferentiated and called tepals rather than distinct sepals and petals. Magnolia shares the tepal characteristic with several other flowering plants near the base of the flowering plant lineage, such as Amborella and Nymphaea (as well as with many more recently derived plants, such as Lilium).\n\nMagnolias are culturally significant symbols, serving as official flowers and trees in various regions like Shanghai, Mississippi, Louisiana, North Korea, and Seoul, and are closely associated with the Southern United States. In the arts, magnolias symbolize both beauty and resilience, as seen in the play and film Steel Magnolias, while also evoking the contrasting brutality of lynching in the song \"Strange Fruit\" and Southern stereotypes in political commentary.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Magnolia", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Magnoliids/Magnoliales/Magnoliaceae/Magnolia", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Magnolia" @@ -405,9 +432,9 @@ "type": "Taxon", "id": "63bcbffb-29a3-45b5-8cc1-8c41281bcae3", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Kniphofia", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asphodelaceae/Asphodeloideae/Kniphofia", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Kniphofia", @@ -420,8 +447,8 @@ "start": "47e135a7-c77a-4489-9262-1e425a4b7177", "end": "99b9f696-7f86-4728-b88f-def50ede6ce0", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -429,9 +456,9 @@ "type": "Taxon", "id": "6f84870c-62c1-4a1a-8189-a453e00e296f", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Magnoliids", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Magnoliids", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Magnoliids", @@ -442,6 +469,7 @@ "type": "Plant", "id": "74023bac-88ea-4031-b4a5-68d861ea2e91", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Saucer Magnolia -- Magnolia × soulangeana 'Rustica Rubra' found in Houston, Texas", @@ -456,23 +484,31 @@ "link": "https://en.wikipedia.org/wiki/File:Yulan_1.JPG" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Magnolia", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "M. denudata", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Magnolia_denudata", - "description": "Magnolia denudata, the lilytree[2] or Yulan magnolia (simplified Chinese: \\u7389\\u5170\\u82b1; traditional Chinese: \\u7389\\u862d\\u82b1; pinyin: y\\u00f9l\\u00e1nhu\\u0101; lit. 'jade orchid/lily'), is native to central and eastern China.[3] It has been cultivated in Chinese Buddhist temple gardens since 600 AD. Its flowers were regarded as a symbol of purity in the Tang dynasty and it was planted in the grounds of the emperor's palace. It is the official city flower of Shanghai." + "description": "Magnolia denudata, the lilytree or Yulan magnolia (simplified Chinese: \\u7389\\u5170\\u82b1; traditional Chinese: \\u7389\\u862d\\u82b1; pinyin: y\\u00f9l\\u00e1nhu\\u0101; lit. 'jade orchid/lily'), is native to central and eastern China. It has been cultivated in Chinese Buddhist temple gardens since 600 AD. Its flowers were regarded as a symbol of purity in the Tang dynasty and it was planted in the grounds of the emperor's palace. It is the official city flower of Shanghai." + }, + "file": { + "contentLength": 50389, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c615404e051e1e2e6c1d107cb7c043fd0db9a71eb9671be35533e9aac5e72e82" + } } }, { "type": "Taxon", "id": "75a7a9c8-8330-483c-b7f3-f7ca74c48535", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "The Amaryllidaceae are a family of herbaceous, mainly perennial and bulbous (rarely rhizomatous) flowering plants in the monocot order Asparagales. The family takes its name from the genus Amaryllis and is commonly known as the amaryllis family. The leaves are usually linear, and the flowers are usually bisexual and symmetrical, arranged in umbels on the stem. The petals and sepals are undifferentiated as tepals, which may be fused at the base into a floral tube. Some also display a corona. Allyl sulfide compounds produce the characteristic odour of the onion subfamily (Allioideae).\n\nThe family, which was originally created in 1805, now contains about 1600 species, divided into 71 genera, 17 tribes and three subfamilies, the Agapanthoideae (Agapanthus), Allioideae (onions, garlic and chives) and Amaryllidoideae (amaryllis, daffodils, snowdrops). Over time, it has seen much reorganisation and at various times was combined with the related Liliaceae. Since 2009, a very broad view has prevailed based on phylogenetics, and including a number of other former families.\n\nThe family is found in tropical to subtropical and temperate areas of the world and includes many ornamental garden plants and vegetables.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Amaryllidaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Amaryllidaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Amaryllidaceae" @@ -484,8 +520,8 @@ "start": "7dd3a650-d60e-40c9-89d0-bccaf395369f", "end": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -493,10 +529,10 @@ "type": "Taxon", "id": "7cc682bf-b648-47b1-b49a-e328f2d1bcd3", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Vascular plants (from Latin vasculum 'duct'), also called tracheophytes (UK: /ˈtrækiːəˌfaɪts/, US: /ˈtreɪkiːəˌfaɪts/) or collectively tracheophyta (/ˌtreɪkiːˈɒfɪtə/; from Ancient Greek τραχεῖα ἀρτηρία (trakheîa artēría) 'windpipe' and φυτά (phutá) 'plants'), are plants that have lignified tissues (the xylem) for conducting water and minerals throughout the plant. They also have a specialized non-lignified tissue (the phloem) to conduct products of photosynthesis. The group includes most land plants (c. 300,000 accepted known species) excluding mosses.\n\nVascular plants include the clubmosses, horsetails, ferns, gymnosperms (including conifers), and angiosperms (flowering plants). They are contrasted with nonvascular plants such as mosses and green algae. Scientific names for the vascular plants group include Tracheophyta,: 251  Tracheobionta and Equisetopsida sensu lato. Some early land plants (the rhyniophytes) had less developed vascular tissue; the term eutracheophyte has been used for all other vascular plants, including all living ones.\n\nHistorically, vascular plants were known as \"higher plants\", as it was believed that they were further evolved than other plants due to being more complex organisms. However, this is an antiquated remnant of the obsolete scala naturae, and the term is generally considered to be unscientific.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Tracheophytes", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Vascular_plant" @@ -506,9 +542,9 @@ "type": "Taxon", "id": "7dd3a650-d60e-40c9-89d0-bccaf395369f", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Lilium", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae/Lilieae/Lilium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilium", @@ -521,8 +557,8 @@ "start": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "end": "8288a39a-4b41-4ed5-8d0f-0b795048163c", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -530,9 +566,9 @@ "type": "Taxon", "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Lilioideae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Liliaceae/Lilioideae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Lilioideae", @@ -545,8 +581,8 @@ "start": "487db1d2-a12f-422a-a0d2-1da198ef62a3", "end": "2341f6f7-1b5d-4ce9-a68e-2973be25ec9a", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -554,6 +590,7 @@ "type": "Plant", "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "in Seoul Forest, South Korea", @@ -574,23 +611,31 @@ "link": "https://en.wikipedia.org/wiki/File:Tulipa_orphanidea_060506.jpg" } ], - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "hasFile": true, "name": "Tulip", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "Tulipa", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Tulip", - "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae,[2] along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + "description": "Tulips are spring-blooming perennial herbaceous bulbiferous geophytes in the Tulipa genus. Their flowers are usually large, showy, and brightly coloured, generally red, orange, pink, yellow, or white. They often have a different coloured blotch at the base of the tepals, internally. Because of a degree of variability within the populations and a long history of cultivation, classification has been complex and controversial. The tulip is a member of the lily family, Liliaceae, along with 14 other genera, where it is most closely related to Amana, Erythronium, and Gagea in the tribe Lilieae." + }, + "file": { + "contentLength": 105488, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "c4c242897c43a74e9e4a8b5bf89e6b2cb1cbd99f8d621f33eea175cbf6dfab97" + } } }, { "type": "Taxon", "id": "927c7763-0e29-4bd4-817e-d2b19ec424fa", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Narcissus is a genus of predominantly spring flowering perennial plants of the amaryllis family, Amaryllidaceae. Various common names including daffodil, narcissus (plural narcissi), and jonquil, are used to describe some or all members of the genus.\n\nNarcissus has conspicuous flowers with six petal-like tepals surmounted by a cup- or trumpet-shaped corona. The flowers are generally white and yellow (also orange or pink in garden varieties), with either uniform or contrasting coloured tepals and corona.\n\nNarcissi were well known in ancient civilisation, both medicinally and botanically, but were formally described by Linnaeus in his Species Plantarum (1753). The genus is generally considered to have about ten sections with approximately 70–80 species; the Plants of the World Online database currently accepts 76 species and 93 named hybrids. The number of species has varied, depending on how they are classified, due to similarity between species and hybridisation.\n\nThe genus arose some time in the Late Oligocene to Early Miocene epochs, in the Iberian peninsula and adjacent areas of southwest Europe. The exact origin of the name Narcissus is unknown, but it is often linked to a Greek word (ancient Greek ναρκῶ narkō, \"to make numb\") and the myth of the youth of that name who fell in love with his own reflection. The English word \"daffodil\" appears to be derived from \"asphodel\", with which it was commonly compared.\n\nThe species are native to meadows and woods in southern Europe and North Africa with a centre of diversity in the Western Mediterranean. Both wild and cultivated plants have naturalised widely, and were introduced into the Far East prior to the tenth century. Narcissi tend to be long-lived bulbs, which propagate by division, but are also insect-pollinated. Known pests, diseases and disorders include viruses, fungi, the larvae of flies, mites and nematodes. Some Narcissus species have become extinct, while others are threatened by increasing urbanisation and tourism.\n\nHistorical accounts suggest narcissi have been cultivated from the earliest times, but became increasingly popular in Europe after the 16th century and by the late 19th century were an important commercial crop centred primarily in the Netherlands. Today, narcissi are popular as cut flowers and as ornamental plants. The long history of breeding has resulted in thousands of different cultivars. For horticultural purposes, narcissi are classified into divisions, covering a wide range of shapes and colours. Narcissi produce a number of different alkaloids, which provide some protection for the plant, but may be poisonous if accidentally ingested. This property has been exploited for medicinal use in traditional healing and has resulted in the production of galantamine for the treatment of Alzheimer's dementia. Narcissi are associated with a number of themes in different cultures, ranging from death to good fortune, and as symbols of spring. The daffodil is the national flower of Wales and the symbol of cancer charities in many countries. The appearance of wild flowers in spring is associated with festivals in many places.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Narcissus", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Amaryllidaceae/Amaryllidoideae/Narcissus", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Narcissus_(plant)" @@ -602,8 +647,8 @@ "start": "a2c4ba63-65f2-460e-b77c-6db283efe3ab", "end": "7cc682bf-b648-47b1-b49a-e328f2d1bcd3", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -611,10 +656,10 @@ "type": "Taxon", "id": "99b9f696-7f86-4728-b88f-def50ede6ce0", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Monocotyledons (/ˌmɒnəˌkɒtəˈliːdənz/), commonly referred to as monocots, (Lilianae sensu Chase & Reveal) are flowering plants whose seeds contain only one embryonic leaf, or cotyledon. A monocot taxon has been in use for several decades, but with various ranks and under several different names. The APG IV system recognises its monophyly but does not assign it to a taxonomic rank, and instead uses the term \"monocots\" to refer to the group.\n\nMonocotyledons are contrasted with the dicotyledons, which have two cotyledons. Unlike the monocots however, the dicots are not monophyletic and the two cotyledons are instead the ancestral characteristic of all flowering plants. Botanists now classify dicots into the eudicots (\"true dicots\") and several basal lineages from which the monocots emerged.\n\nThe monocots are extremely important economically, culturally, and ecologically, and make up a majority of plant biomass used in agriculture. Common crops such as dates, onions, garlic, rice, wheat, maize, and sugarcane are all monocots. The grasses alone cover over 40% of Earth's land area[e] and contribute a significant portion of the human diet. Other monocots, like orchids, tulips, daffodils, and lilies are common houseplants and have been the subjects of several celebrations, holidays, and artworks for thousands of years.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Monocots", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Monocotyledon" @@ -626,8 +671,8 @@ "start": "425235d6-75ab-4ccb-8208-783ced0decc6", "end": "1852bdff-a4dd-4f91-9243-0e802775f757", "data": { - "created": "2025-09-25T18:35:32+00:00", - "updated": "2025-09-25T18:35:32+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -637,8 +682,8 @@ "start": "1852bdff-a4dd-4f91-9243-0e802775f757", "end": "99b9f696-7f86-4728-b88f-def50ede6ce0", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -646,10 +691,10 @@ "type": "Taxon", "id": "a1996d15-9882-464c-aab0-1ce6e77af522", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Apocynaceae (/əˌpɑːsəˈneɪsiˌaɪ, -siːˌiː/, from Apocynum, Greek for \"dog-away\") is a family of flowering plants that includes trees, shrubs, herbs, stem succulents, and vines, commonly known as the dogbane family, because some taxa were used as dog poison. Notable members of the family include oleander, dogbanes, milkweeds, and periwinkles. The family is native to the European, Asian, African, Australian, and American tropics or subtropics, with some temperate members as well. The former family Asclepiadaceae (now known as Asclepiadoideae) is considered a subfamily of Apocynaceae and contains 348 genera. A list of Apocynaceae genera may be found here.\n\nMany species are tall trees found in tropical forests, but some grow in tropical dry (xeric) environments. Also perennial herbs from temperate zones occur. Many of these plants have milky latex, and many species are poisonous if ingested, the family being rich in genera containing alkaloids and cardiac glycosides, those containing the latter often finding use as arrow poisons. Some genera of Apocynaceae, such as Adenium, bleed clear sap without latex when damaged, and others, such as Pachypodium, have milky latex apart from their sap.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Apocynaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Gentianales/Apocynaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Apocynaceae" @@ -661,8 +706,8 @@ "start": "d633ef5f-f385-44ef-8326-9b17e728cca8", "end": "c2282076-98d0-42f8-9582-728aed89bfca", "data": { - "created": "2025-09-25T18:35:32+00:00", - "updated": "2025-09-25T18:35:32+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -670,10 +715,10 @@ "type": "Taxon", "id": "a2c4ba63-65f2-460e-b77c-6db283efe3ab", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Flowering plants are plants that bear flowers and fruits, and form the clade Angiospermae (/ˌændʒiəˈspərmiː/). The term angiosperm is derived from the Greek words ἀγγεῖον (angeion; 'container, vessel') and σπέρμα (sperma; 'seed'), meaning that the seeds are enclosed within a fruit. The group was formerly called Magnoliophyta.\n\nAngiosperms are by far the most diverse group of land plants with 64 orders, 416 families, approximately 13,000 known genera and 300,000 known species. They include all forbs (flowering plants without a woody stem), grasses and grass-like plants, a vast majority of broad-leaved trees, shrubs and vines, and most aquatic plants. Angiosperms are distinguished from the other major seed plant clade, the gymnosperms, by having flowers, xylem consisting of vessel elements instead of tracheids, endosperm within their seeds, and fruits that completely envelop the seeds. The ancestors of flowering plants diverged from the common ancestor of all living gymnosperms before the end of the Carboniferous, over 300 million years ago. In the Cretaceous, angiosperms diversified explosively, becoming the dominant group of plants across the planet.\n\nAgriculture is almost entirely dependent on angiosperms, and a small number of flowering plant families supply nearly all plant-based food and livestock feed. Rice, maize and wheat provide half of the world's staple calorie intake, and all three plants are cereals from the Poaceae family (colloquially known as grasses). Other families provide important industrial plant products such as wood, paper and cotton, and supply numerous ingredients for drinks, sugar production, traditional medicine and modern pharmaceuticals. Flowering plants are also commonly grown for decorative purposes, with certain flowers playing significant cultural roles in many societies.\n\nOut of the \"Big Five\" extinction events in Earth's history, only the Cretaceous–Paleogene extinction event occurred while angiosperms dominated plant life on the planet. Today, the Holocene extinction affects all kingdoms of complex life on Earth, and conservation measures are necessary to protect plants in their habitats in the wild (in situ), or failing that, ex situ in seed banks or artificial habitats like botanic gardens. Otherwise, around 40% of plant species may become extinct due to human actions such as habitat destruction, introduction of invasive species, unsustainable logging, land clearing and overharvesting of medicinal or ornamental plants. Further, climate change is starting to impact plants and is likely to cause many species to become extinct by 2100.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Angiosperms", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Flowering_plant" @@ -683,9 +728,9 @@ "type": "Taxon", "id": "a2daf225-4907-4869-aa55-c82561caac68", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Magnoliales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Magnoliids/Magnoliales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Magnoliales", @@ -698,8 +743,8 @@ "start": "e3c61d19-6cb9-4f41-913b-9a445b2fa150", "end": "632f5679-a4bc-4aae-bdec-d083c74b32ba", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:37+00:00", + "updated": "2026-09-25T18:30:37+00:00", "scenario": "general.botanicExample" } }, @@ -709,8 +754,8 @@ "start": "1d5350ed-86c5-4933-8089-5fa053a3ab02", "end": "487db1d2-a12f-422a-a0d2-1da198ef62a3", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -718,9 +763,9 @@ "type": "Taxon", "id": "abcdcfee-3f4f-464f-8418-6e6640ad72b6", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Nelumbo", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Proteales/Nelumbonaceae/Nelumbo", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Nelumbo", @@ -731,9 +776,9 @@ "type": "Taxon", "id": "acbe7b5d-784a-4956-8fa4-786589584135", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Magnolia subsect. Yulania", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Magnoliids/Magnoliales/Magnoliaceae/Magnolia/Magnolia subg. Yulania/Magnolia sect. Yulania/Magnolia subsect. Yulania", "scenario": "general.botanicExample" } @@ -744,8 +789,8 @@ "start": "2f4e71c8-851b-4d3d-a0f5-f357eab1dec1", "end": "75a7a9c8-8330-483c-b7f3-f7ca74c48535", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -755,8 +800,8 @@ "start": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "end": "927c7763-0e29-4bd4-817e-d2b19ec424fa", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -764,9 +809,9 @@ "type": "Taxon", "id": "b597444b-d50f-4f30-b9f0-a31d2fef5322", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Proteales", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Proteales", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Proteales", @@ -779,8 +824,8 @@ "start": "8288a39a-4b41-4ed5-8d0f-0b795048163c", "end": "425235d6-75ab-4ccb-8208-783ced0decc6", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -790,8 +835,8 @@ "start": "cd83808b-80df-41a0-964e-e33071e28830", "end": "a1996d15-9882-464c-aab0-1ce6e77af522", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -799,9 +844,9 @@ "type": "Taxon", "id": "b7cda919-deea-49f9-9151-49132f83cbff", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Colchicaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Liliales/Colchicaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Colchicaceae", @@ -814,8 +859,8 @@ "start": "632f5679-a4bc-4aae-bdec-d083c74b32ba", "end": "0fe4710a-1998-4761-919e-aa3cb9c055f8", "data": { - "created": "2025-09-25T18:35:32+00:00", - "updated": "2025-09-25T18:35:32+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -825,8 +870,8 @@ "start": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "end": "16884ac8-ba7a-4c91-8449-4fdd803644b6", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -836,8 +881,8 @@ "start": "f8179dfc-5578-44de-adc0-d6b613eb7a32", "end": "63bcbffb-29a3-45b5-8cc1-8c41281bcae3", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -845,9 +890,9 @@ "type": "Taxon", "id": "c2282076-98d0-42f8-9582-728aed89bfca", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Asphodelaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asphodelaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asphodelaceae", @@ -858,6 +903,7 @@ "type": "Plant", "id": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "Narcissus pseudonarcissus", @@ -866,22 +912,30 @@ "link": "https://en.wikipedia.org/wiki/File:Narcissus_pseudonarcissus_flower_300303.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Daffodil", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "N. pseudonarcissus", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Narcissus_pseudonarcissus", - "description": "Narcissus pseudonarcissus, commonly named the wild daffodil or Lent lily (Welsh: Cennin Pedr), is a perennial flowering plant.[1][2][3][4][5][6]\n\nThis species has pale yellow tepals, with a darker central trumpet. The long, narrow leaves are slightly greyish green in colour and rise from the base of the stem. The plant grows from a bulb. The flowers produce seeds which, when germinated, take five to seven years to produce a flowering plant. (Sexual seed reproduction mixes the traits of both parent flowers, so if garden hybrid cultivars are planted close to wild populations of Narcissus pseudonarcissus, there is a danger that the new seedlings, having hybrid vigour, could out-compete the wild plants.)[7]" + "description": "Narcissus pseudonarcissus, commonly named the wild daffodil or Lent lily (Welsh: Cennin Pedr), is a perennial flowering plant.\n\nThis species has pale yellow tepals, with a darker central trumpet. The long, narrow leaves are slightly greyish green in colour and rise from the base of the stem. The plant grows from a bulb. The flowers produce seeds which, when germinated, take five to seven years to produce a flowering plant. (Sexual seed reproduction mixes the traits of both parent flowers, so if garden hybrid cultivars are planted close to wild populations of Narcissus pseudonarcissus, there is a danger that the new seedlings, having hybrid vigour, could out-compete the wild plants.)" + }, + "file": { + "contentLength": 62307, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "ecda5f79c0714acbe609146166abbf3872ba51f459debd5e20c8b61bb932ed37" + } } }, { "type": "Taxon", "id": "cd83808b-80df-41a0-964e-e33071e28830", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Adenium", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Asterids/Gentianales/Apocynaceae/Adenium", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Adenium", @@ -892,10 +946,10 @@ "type": "Taxon", "id": "d633ef5f-f385-44ef-8326-9b17e728cca8", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Asphodeloideae is a subfamily of the monocot family Asphodelaceae in the order Asparagales. It has previously been treated as a separate family, Asphodelaceae sensu stricto. The family Asphodelaceae has now been proposed to be a nomen conservandum, and the proposal has been recommended for ratification in 2017. In that case, Asphodelaceae will have priority over Xanthorrhoeaceae. This is reflected in the APG IV family lists.\n\nThe subfamily name is derived from the generic name of the type genus, Asphodelus. Members of this group can be found growing native in coastal Southern Africa, Central and Western Europe, the Mediterranean basin, Central Asia and Australia; one genus, Bulbinella, can additionally be found growing in New Zealand. The greatest diversity occurs in South Africa.\n\nSeveral genera, notably Aloe, Asphodelus, Gasteria, Haworthia and Kniphofia are perhaps the best known of the family due to their use among plant collectors, botanists and horticulturists. Additional genera and hybrids are also known, including Aristaloe, Gasteraloe and Gonialoe (the latter formerly included the now-rehomed Aloe variegata, or partridge-breast aloe).", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Asphodeloideae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Monocots/Asparagales/Asphodelaceae/Asphodeloideae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Asphodeloideae" @@ -907,8 +961,8 @@ "start": "de004523-5165-4f10-872b-92eb9637a5d1", "end": "cd83808b-80df-41a0-964e-e33071e28830", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -916,6 +970,7 @@ "type": "Plant", "id": "de004523-5165-4f10-872b-92eb9637a5d1", "data": { + "created": "2026-09-25T18:30:16+00:00", "images": [ { "description": "Adenium obesum (desert rose), macro of a flower, side view showing calyx, corolla and styles. Focus stack of 7 images. Uthandi, Chennai", @@ -930,13 +985,21 @@ "link": "https://en.wikipedia.org/wiki/File:RCN_Mother.jpg" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Desert Rose", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:16+00:00", "scientificName": "A. obesum", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Adenium_obesum", - "description": "Adenium obesum, more commonly known as a desert rose, is a poisonous species of flowering plant belonging to the tribe Nerieae of the subfamily Apocynoideae of the dogbane family, Apocynaceae.[3] It is native to the Sahel regions south of the Sahara (from Mauritania and Senegal to Sudan), tropical and subtropical eastern and southern Africa, as well as the Arabian Peninsula. Other names for the flower include Sabi star, kudu, mock azalea, and impala lily. Adenium obesum is a popular houseplant and bonsai in temperate regions." + "description": "Adenium obesum, more commonly known as a desert rose, is a poisonous species of flowering plant belonging to the tribe Nerieae of the subfamily Apocynoideae of the dogbane family, Apocynaceae. It is native to the Sahel regions south of the Sahara (from Mauritania and Senegal to Sudan), tropical and subtropical eastern and southern Africa, as well as the Arabian Peninsula. Other names for the flower include Sabi star, kudu, mock azalea, and impala lily. Adenium obesum is a popular houseplant and bonsai in temperate regions." + }, + "file": { + "contentLength": 58116, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "858fe2bee255dd252d51d161d4f183a93c2da0d2d6662b20870cbbf3eec81acb" + } } }, { @@ -945,8 +1008,8 @@ "start": "b597444b-d50f-4f30-b9f0-a31d2fef5322", "end": "2341f6f7-1b5d-4ce9-a68e-2973be25ec9a", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -954,9 +1017,9 @@ "type": "Taxon", "id": "e3c61d19-6cb9-4f41-913b-9a445b2fa150", "data": { - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", + "created": "2026-09-25T18:30:16+00:00", "name": "Magnolia subg. Yulania", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Magnoliids/Magnoliales/Magnoliaceae/Magnolia/Magnolia subg. Yulania", "scenario": "general.botanicExample" } @@ -967,8 +1030,8 @@ "start": "23508e6c-632a-496d-9bd6-8c62a03216af", "end": "7dd3a650-d60e-40c9-89d0-bccaf395369f", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -978,8 +1041,8 @@ "start": "a1996d15-9882-464c-aab0-1ce6e77af522", "end": "1d5350ed-86c5-4933-8089-5fa053a3ab02", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -989,8 +1052,8 @@ "start": "13fc213f-d90d-4c14-b841-dafb5eb1d817", "end": "e3c61d19-6cb9-4f41-913b-9a445b2fa150", "data": { - "created": "2025-09-25T18:35:32+00:00", - "updated": "2025-09-25T18:35:32+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -1000,8 +1063,8 @@ "start": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "end": "1feedd9f-0c12-490c-8461-b12ff7447ac8", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:37+00:00", + "updated": "2026-09-25T18:30:37+00:00", "scenario": "general.botanicExample" } }, @@ -1011,8 +1074,8 @@ "start": "0fe4710a-1998-4761-919e-aa3cb9c055f8", "end": "a2daf225-4907-4869-aa55-c82561caac68", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:34+00:00", + "updated": "2026-09-25T18:30:34+00:00", "scenario": "general.botanicExample" } }, @@ -1020,10 +1083,10 @@ "type": "Taxon", "id": "f1622292-ace6-45c0-bd8c-0de67bcfb6bf", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Plants are the eukaryotes that comprise the kingdom Plantae; they are predominantly photosynthetic. This means that they obtain their energy from sunlight, using chloroplasts derived from endosymbiosis with cyanobacteria to produce sugars from carbon dioxide and water, using the green pigment chlorophyll. Exceptions are parasitic plants that have lost the genes for chlorophyll and photosynthesis, and obtain their energy from other plants or fungi. Most plants are multicellular, except for some green algae.\n\nHistorically, as in Aristotle's biology, the plant kingdom encompassed all living things that were not animals, and included algae and fungi. Definitions have narrowed since then; current definitions exclude fungi and some of the algae. By the definition used in this article, plants form the clade Viridiplantae (green plants), which consists of the green algae and the embryophytes or land plants (hornworts, liverworts, mosses, lycophytes, ferns, conifers and other gymnosperms, and flowering plants). A definition based on genomes includes the Viridiplantae, along with the red algae and the glaucophytes, in the clade Archaeplastida.\n\nThere are about 380,000 known species of plants, of which the majority, some 260,000, produce seeds. They range in size from single cells to the tallest trees. Green plants provide a substantial proportion of the world's molecular oxygen; the sugars they create supply the energy for most of Earth's ecosystems, and other organisms, including animals, either eat plants directly or rely on organisms which do so.\n\nGrain, fruit, and vegetables are basic human foods and have been domesticated for millennia. People use plants for many purposes, such as building materials, ornaments, writing materials, and, in great variety, for medicines. The scientific study of plants is known as botany, a branch of biology.", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Plantae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Plant" @@ -1033,6 +1096,7 @@ "type": "Plant", "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", "data": { + "created": "2026-09-25T18:30:20+00:00", "images": [ { "description": "Red-hot poker plants (Kniphofia uvaria) on the shore of Lake Jindabyne, New South Wales, Australia.", @@ -1041,13 +1105,21 @@ "link": "https://en.wikipedia.org/wiki/File:Kniphofia_uvaria_close.JPG" } ], - "created": "2025-09-25T18:35:28+00:00", - "updated": "2025-09-25T18:35:28+00:00", + "hasFile": true, "name": "Red Hot Poker", - "scenario": "general.botanicExample", + "updated": "2026-09-25T18:30:20+00:00", "scientificName": "K. uvaria", + "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Kniphofia_uvaria", "description": "Kniphofia uvaria is a species of flowering plant in the family Asphodelaceae, commonly known as tritomea, torch lily, or red hot poker, due to the shape and color of its inflorescence. The leaves are reminiscent of a lily, and the flowerhead can reach up to 1.5 m (5 ft) in height. There are many varieties of torch lily, and they bloom at different times during the growing season. The flowers are red, orange, and yellow." + }, + "file": { + "contentLength": 123686, + "extension": "jpg", + "mimeType": "image/jpeg", + "hash": { + "sha256": "a0f461e0383bc5b4518a43e1ac76060612c7e87bf420f9a6bbbe5231012108fe" + } } }, { @@ -1056,8 +1128,8 @@ "start": "c2282076-98d0-42f8-9582-728aed89bfca", "end": "47e135a7-c77a-4489-9262-1e425a4b7177", "data": { - "created": "2025-09-25T18:35:34+00:00", - "updated": "2025-09-25T18:35:34+00:00", + "created": "2026-09-25T18:30:37+00:00", + "updated": "2026-09-25T18:30:37+00:00", "scenario": "general.botanicExample" } }, @@ -1065,10 +1137,10 @@ "type": "Taxon", "id": "ff04eb54-7656-47ae-816b-debc794a0f49", "data": { + "created": "2026-09-25T18:30:16+00:00", "description": "Nelumbonaceae is a family of aquatic flowering plants. Nelumbo is the sole extant genus, containing Nelumbo lutea, native to North America, and Nelumbo nucifera, widespread in Asia. At least five other genera, Nelumbites, Exnelumbites, Paleonelumbo, Nelumbago, and Notocyamus are known from fossils.\n\nNelumbonaceae were once included in the waterlily family, Nymphaeaceae. Genetic analysis determined that the similarities between the families are an example of convergent evolution. Nelumbonaceae are highly modified eudicots belonging to the order Proteales, their closest living relatives being the plane trees (Platanaceae) and Proteaceae. Notocyamus from the Early Cretaceous of Brazil is the oldest known representative of the family, and displays several transitional traits between Nelumbonaceae and both the Proteaceae and Platanaceae.\n\nThe APG IV system of 2016 places the family in the order Proteales, in the clade eudicots. The Cronquist system, of 1981, also recognized this family, but placed it in order Nymphaeales in subclass Magnoliidae in class Magnoliopsida (=dicotyledons), based on gross morphological similarities. The Dahlgren system and Thorne system (1992) also recognized this family and placed it in its own order, Nelumbonales, in superorder Magnolianae in subclass Magnoliidae (=dicotyledons).", - "created": "2025-09-25T18:35:26+00:00", - "updated": "2025-09-25T18:35:26+00:00", "name": "Nelumbonaceae", + "updated": "2026-09-25T18:30:16+00:00", "path": "/Plantae/Tracheophytes/Angiosperms/Eudicots/Proteales/Nelumbonaceae", "scenario": "general.botanicExample", "link": "https://en.wikipedia.org/wiki/Nelumbonaceae" @@ -1091,7 +1163,7 @@ } }, "step-handler": "elasticsearch-query-dsl-mixin (App\\EventSystem\\SearchStep\\EventListener\\ElasticsearchQueryDslMixinSearchStepEventListener)", - "start": 1758825393.576418, + "start": 1790361093.166627, "indices": "*", "combinedQuery": { "_source": [ @@ -1132,8 +1204,8 @@ } } }, - "end": 1758825393.608732, - "duration": 0.032314 + "end": 1790361093.189556, + "duration": 0.022929 }, { "input": { @@ -1154,7 +1226,7 @@ "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "type": "Plant", "metadata": { - "score": 3.9976888 + "score": 4.0113134 } }, { @@ -1172,17 +1244,17 @@ } }, { - "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", - "type": "Plant", + "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "type": "Taxon", "metadata": { - "score": 3.2730358 + "score": 3.269838 } }, { - "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", - "type": "Taxon", + "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", + "type": "Plant", "metadata": { - "score": 3.269838 + "score": 3.2554708 } }, { @@ -1196,7 +1268,7 @@ "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "type": "Plant", "metadata": { - "score": 3.021591 + "score": 3.060035 } }, { @@ -1238,28 +1310,28 @@ "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "type": "Plant", "metadata": { - "score": 2.152007 + "score": 2.1294188 } }, { "id": "74023bac-88ea-4031-b4a5-68d861ea2e91", "type": "Plant", "metadata": { - "score": 2.1088712 + "score": 2.085987 } }, { "id": "de004523-5165-4f10-872b-92eb9637a5d1", "type": "Plant", "metadata": { - "score": 2.02761 + "score": 2.0042536 } }, { "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "type": "Plant", "metadata": { - "score": 1.9892946 + "score": 1.9657542 } }, { @@ -1273,7 +1345,7 @@ "id": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "type": "Plant", "metadata": { - "score": 1.7868379 + "score": 1.8255562 } }, { @@ -1291,9 +1363,9 @@ } }, "step-handler": "cypher-path-subset (App\\EventSystem\\SearchStep\\EventListener\\CypherPathSearchStepEventListener)", - "start": 1758825393.608918, - "end": 1758825394.031818, - "duration": 0.4228999614715576 + "start": 1790361093.189827, + "end": 1790361093.933255, + "duration": 0.7434279918670654 }, { "input": { @@ -1314,7 +1386,7 @@ "id": "258c0dfe-b1d8-4839-beed-d00d1b544a96", "type": "Plant", "metadata": { - "score": 3.9976888 + "score": 4.0113134 } }, { @@ -1332,17 +1404,17 @@ } }, { - "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", - "type": "Plant", + "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", + "type": "Taxon", "metadata": { - "score": 3.2730358 + "score": 3.269838 } }, { - "id": "8288a39a-4b41-4ed5-8d0f-0b795048163c", - "type": "Taxon", + "id": "f8179dfc-5578-44de-adc0-d6b613eb7a32", + "type": "Plant", "metadata": { - "score": 3.269838 + "score": 3.2554708 } }, { @@ -1356,7 +1428,7 @@ "id": "23508e6c-632a-496d-9bd6-8c62a03216af", "type": "Plant", "metadata": { - "score": 3.021591 + "score": 3.060035 } }, { @@ -1398,28 +1470,28 @@ "id": "3283fd3d-8083-4170-882a-41b1cd152c55", "type": "Plant", "metadata": { - "score": 2.152007 + "score": 2.1294188 } }, { "id": "74023bac-88ea-4031-b4a5-68d861ea2e91", "type": "Plant", "metadata": { - "score": 2.1088712 + "score": 2.085987 } }, { "id": "de004523-5165-4f10-872b-92eb9637a5d1", "type": "Plant", "metadata": { - "score": 2.02761 + "score": 2.0042536 } }, { "id": "8940d70b-5b6f-43b7-bee4-41d073396ff8", "type": "Plant", "metadata": { - "score": 1.9892946 + "score": 1.9657542 } }, { @@ -1433,7 +1505,7 @@ "id": "c3b3dcf9-4947-43b3-b877-7a337eea5fda", "type": "Plant", "metadata": { - "score": 1.7868379 + "score": 1.8255562 } }, { @@ -2510,7 +2582,7 @@ } }, "step-handler": "element-hydration (App\\EventSystem\\SearchStep\\EventListener\\ElementHydrationSearchStepEventListener)", - "start": 1758825394.032423, + "start": 1790361093.933932, "element-hydration:redundantElementIds": { "message": "Removed 584 redundant elementIds.", "removedElementIds": [ @@ -2680,8 +2752,8 @@ "fefde536-6ed4-4d09-9141-68f754fadb67", "ff04eb54-7656-47ae-816b-debc794a0f49" ], - "end": 1758825394.361263, - "duration": 0.32884 + "end": 1790361094.322943, + "duration": 0.389011 } ] } \ No newline at end of file diff --git a/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-header.txt b/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-header.txt index c3ba5b8f..39efff34 100644 --- a/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-header.txt +++ b/docs/search/example/polyglot/es-query-dsl-mixin-cypher-path-element-hydration/response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: OPTIONS, POST Cache-Control: no-cache, private Content-Type: application/json; charset=utf-8 -Date: Sat, 06 Sep 2025 14:57:35 GMT +Date: Sat, 26 Sep 2026 21:53:40 GMT Transfer-Encoding: chunked X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/security/access.md b/docs/security/access.md index 1f338959..0373bef4 100755 --- a/docs/security/access.md +++ b/docs/security/access.md @@ -101,7 +101,7 @@ WHERE ( relation.onCreatedByUser IS NULL OR - (element)<-[:CREATED_BY*]-(user) + (element)<-[:CREATED*]-(user) ) ) ) @@ -145,7 +145,7 @@ WHERE ( startRelation.onCreatedByUser IS NULL OR - (start)<-[:CREATED_BY*]-(user) + (start)<-[:CREATED*]-(user) ) ) ) @@ -177,7 +177,7 @@ WHERE ( endRelation.onCreatedByUser IS NULL OR - (end)<-[:CREATED_BY*]-(user) + (end)<-[:CREATED*]-(user) ) ) ) diff --git a/docs/server/no-free-php-thread/503-response-body.json b/docs/server/no-free-php-thread/503-response-body.json new file mode 100644 index 00000000..c0588300 --- /dev/null +++ b/docs/server/no-free-php-thread/503-response-body.json @@ -0,0 +1,7 @@ +{ + "type": "/error/503/service-unavailable", + "title": "Service unavailable", + "status": 503, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", + "detail": "The service itself or an internal component is currently unavailable." +} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password-old/400-response-header.txt b/docs/server/no-free-php-thread/503-response-header.txt similarity index 67% rename from docs/api-endpoints/user/post-change-password-old/400-response-header.txt rename to docs/server/no-free-php-thread/503-response-header.txt index de6a7dce..6ee57951 100644 --- a/docs/api-endpoints/user/post-change-password-old/400-response-header.txt +++ b/docs/server/no-free-php-thread/503-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private +Content-Length: 224 Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 29 Sep 2023 10:11:29 GMT -Transfer-Encoding: chunked +Date: Sat, 26 Sep 2026 13:55:26 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/server/php-boot-failure/500-response-body.json b/docs/server/php-boot-failure/500-response-body.json new file mode 100644 index 00000000..0952289a --- /dev/null +++ b/docs/server/php-boot-failure/500-response-body.json @@ -0,0 +1,7 @@ +{ + "type": "/error/500/internal-server-error", + "title": "Internal server error", + "status": 500, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", + "detail": "Internal server error, see log." +} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password-old/401-response-header.txt b/docs/server/php-boot-failure/500-response-header.txt similarity index 67% rename from docs/api-endpoints/user/post-change-password-old/401-response-header.txt rename to docs/server/php-boot-failure/500-response-header.txt index 83c3ceec..846fc6d6 100644 --- a/docs/api-endpoints/user/post-change-password-old/401-response-header.txt +++ b/docs/server/php-boot-failure/500-response-header.txt @@ -2,9 +2,8 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Cache-Control: no-cache, private +Content-Length: 190 Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 29 Sep 2023 10:14:53 GMT -Transfer-Encoding: chunked +Date: Sat, 26 Sep 2026 13:55:49 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/server/php-out-of-memory/500-response-body.json b/docs/server/php-out-of-memory/500-response-body.json new file mode 100644 index 00000000..98043f2b --- /dev/null +++ b/docs/server/php-out-of-memory/500-response-body.json @@ -0,0 +1,7 @@ +{ + "type": "http://api-prod/error/500/internal-server-error", + "title": "Internal server error", + "status": 500, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", + "detail": "Internal server error, see log." +} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-change-password-old/403-response-header.txt b/docs/server/php-out-of-memory/500-response-header.txt similarity index 67% rename from docs/api-endpoints/user/post-change-password-old/403-response-header.txt rename to docs/server/php-out-of-memory/500-response-header.txt index ee6d8e82..80cd6f57 100644 --- a/docs/api-endpoints/user/post-change-password-old/403-response-header.txt +++ b/docs/server/php-out-of-memory/500-response-header.txt @@ -2,9 +2,9 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private +Content-Length: 205 Content-Type: application/problem+json; charset=utf-8 -Date: Fri, 29 Sep 2023 17:33:00 GMT -Transfer-Encoding: chunked +Date: Sat, 26 Sep 2026 22:33:53 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/server/request-body-timeout/408-response-body.json b/docs/server/request-body-timeout/408-response-body.json new file mode 100644 index 00000000..1f38e8f4 --- /dev/null +++ b/docs/server/request-body-timeout/408-response-body.json @@ -0,0 +1,7 @@ +{ + "type": "http://api-prod/error/408/request-timeout", + "title": "Request timeout", + "status": 408, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", + "detail": "The request body is incomplete, received 10 of 50 bytes announced by the Content-Length header. The connection was interrupted or the request body was not sent within the time limit of the server." +} \ No newline at end of file diff --git a/docs/api-endpoints/user/post-register-old/400-response-header.txt b/docs/server/request-body-timeout/408-response-header.txt similarity index 67% rename from docs/api-endpoints/user/post-register-old/400-response-header.txt rename to docs/server/request-body-timeout/408-response-header.txt index b09a0f20..9a319871 100644 --- a/docs/api-endpoints/user/post-register-old/400-response-header.txt +++ b/docs/server/request-body-timeout/408-response-header.txt @@ -2,9 +2,10 @@ Access-Control-Allow-Headers: * Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK Access-Control-Allow-Origin: * Access-Control-Expose-Headers: ETag, Location -Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Allow: GET, HEAD, OPTIONS, POST Cache-Control: no-cache, private +Connection: close +Content-Length: 358 Content-Type: application/problem+json; charset=utf-8 -Date: Sat, 16 Sep 2023 12:40:25 GMT -Transfer-Encoding: chunked +Date: Sat, 26 Sep 2026 22:32:38 GMT X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/docs/server/upload-request-body-timeout/408-response-body.json b/docs/server/upload-request-body-timeout/408-response-body.json new file mode 100644 index 00000000..d3d4eb8c --- /dev/null +++ b/docs/server/upload-request-body-timeout/408-response-body.json @@ -0,0 +1,7 @@ +{ + "type": "http://api-prod/error/408/request-timeout", + "title": "Request timeout", + "status": 408, + "instance": "urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f", + "detail": "The request body is incomplete, received 5 of 11 bytes announced by the Content-Length header. The connection was interrupted or the request body was not sent within the time limit of the server." +} \ No newline at end of file diff --git a/docs/server/upload-request-body-timeout/408-response-header.txt b/docs/server/upload-request-body-timeout/408-response-header.txt new file mode 100644 index 00000000..c4a2a60a --- /dev/null +++ b/docs/server/upload-request-body-timeout/408-response-header.txt @@ -0,0 +1,11 @@ +Access-Control-Allow-Headers: * +Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: ETag, Location +Allow: DELETE, HEAD, OPTIONS, PATCH +Cache-Control: no-cache, private +Connection: close +Content-Length: 357 +Content-Type: application/problem+json; charset=utf-8 +Date: Sat, 26 Sep 2026 22:33:17 GMT +X-Powered-By: Ember Nexus API \ No newline at end of file diff --git a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php index 3389208c..32cb880a 100644 --- a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php +++ b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php @@ -144,17 +144,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() - ->arrayNode(EmberNexusConfiguration::FEATURE_FLAG) - ->info('Configures feature flags.') - ->addDefaultsIfNotSet() - ->children() - ->booleanNode(EmberNexusConfiguration::FEATURE_FLAG_280_OLD_UNIQUE_USER_IDENTIFIER_DISABLED) - ->info('If true, the original behavior of some user endpoints in versions before 0.1.6 will no longer be supported. Old behavior will be dropped with version 0.2.0. See GitHub issue #280.') - ->defaultFalse() - ->end() - ->end() - ->end() - ->arrayNode(EmberNexusConfiguration::FILE) ->info('Configures behavior related to files.') ->addDefaultsIfNotSet() diff --git a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php index a5a22edf..5d82e3ac 100644 --- a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php +++ b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php @@ -42,9 +42,6 @@ class EmberNexusConfiguration public const string CACHE_ETAG_SEED = 'etagSeed'; public const string CACHE_ETAG_UPPER_LIMIT_IN_COLLECTION_ENDPOINTS = 'etagUpperLimitInCollectionEndpoints'; - public const string FEATURE_FLAG = 'featureFlag'; - public const string FEATURE_FLAG_280_OLD_UNIQUE_USER_IDENTIFIER_DISABLED = '280_OldUniqueUserIdentifierDisabled'; - public const string FILE = 'file'; public const string FILE_MAX_FILE_SIZE_IN_BYTES = 'maxFileSizeInBytes'; public const string FILE_UPLOAD_EXPIRES_IN_SECONDS_AFTER_FIRST_REQUEST = 'uploadExpiresInSecondsAfterFirstRequest'; @@ -76,7 +73,6 @@ class EmberNexusConfiguration private int|false $tokenDeleteExpiredTokensAutomaticallyInSeconds; private string $cacheEtagSeed; private int $cacheEtagUpperLimitInCollectionEndpoints; - private bool $featureFlag280OldUniqueUserIdentifierDisabled; private int $fileMaxFileSizeInBytes; private int $fileUploadExpiresInSecondsAfterFirstRequest; @@ -268,15 +264,6 @@ public static function createFromConfiguration(array $configuration): static ); $emberNexusConfiguration->setCacheEtagUpperLimitInCollectionEndpoints($value); - $value = self::getValueFromConfig( - $configuration, - [ - self::FEATURE_FLAG, - self::FEATURE_FLAG_280_OLD_UNIQUE_USER_IDENTIFIER_DISABLED, - ] - ); - $emberNexusConfiguration->setFeatureFlag280OldUniqueUserIdentifierDisabled($value); - $value = (int) self::getValueFromConfig( $configuration, [ @@ -323,7 +310,7 @@ public static function createFromConfiguration(array $configuration): static $emberNexusConfiguration->setFileUploadMaxChunkSizeInBytes($value); if ($emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes() < $emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()) { - throw new Exception(sprintf('%s.%s can not be smaller than %s.%s.', self::FILE, self::FILE_MAX_FILE_SIZE_IN_BYTES, self::FILE, self::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES)); + throw new Exception(sprintf('%s.%s can not be smaller than %s.%s.', self::FILE, self::FILE_UPLOAD_MAX_CHUNK_SIZE_IN_BYTES, self::FILE, self::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES)); } $value = (int) self::getValueFromConfig( @@ -602,18 +589,6 @@ public function setCacheEtagUpperLimitInCollectionEndpoints(int $cacheEtagUpperL return $this; } - public function isFeatureFlag280OldUniqueUserIdentifierDisabled(): bool - { - return $this->featureFlag280OldUniqueUserIdentifierDisabled; - } - - public function setFeatureFlag280OldUniqueUserIdentifierDisabled(bool $featureFlag280OldUniqueUserIdentifierDisabled): static - { - $this->featureFlag280OldUniqueUserIdentifierDisabled = $featureFlag280OldUniqueUserIdentifierDisabled; - - return $this; - } - public function getFileMaxFileSizeInBytes(): int { return $this->fileMaxFileSizeInBytes; diff --git a/public/index.php b/public/index.php index 9e893942..fceba143 100755 --- a/public/index.php +++ b/public/index.php @@ -6,10 +6,11 @@ require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; +// keep in sync with the snippet `problem_json_headers` in docker/Caddyfile; the `Allow` header depends on the route, +// see AllowHeaderResponseEventListener header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Headers: *'); header('Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK'); -header('Allow: GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK'); header('Access-Control-Expose-Headers: ETag, Location'); header('X-Powered-By: Ember Nexus API'); $method = $_SERVER['REQUEST_METHOD']; diff --git a/src/Command/BackupCreateCommand.php b/src/Command/BackupCreateCommand.php index db4046d2..27380982 100755 --- a/src/Command/BackupCreateCommand.php +++ b/src/Command/BackupCreateCommand.php @@ -5,7 +5,7 @@ namespace App\Command; use App\Factory\Exception\Server500LogicErrorExceptionFactory; -use App\Factory\Type\S3\FileOperationFactory; +use App\Factory\Type\S3\S3OperationFactory; use App\Service\ElementManager; use App\Service\ElementService; use App\Service\ElementToRawService; @@ -24,6 +24,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\DependencyInjection\Attribute\Target; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; @@ -51,13 +52,14 @@ class BackupCreateCommand extends Command public function __construct( private ElementManager $elementManager, private CypherEntityManager $cypherEntityManager, + #[Target('backup.storage')] private FilesystemOperator $backupStorage, private ElementToRawService $elementToRawService, private ElementService $elementService, private ParameterBagInterface $bag, private FileService $fileService, private S3Service $s3Service, - private FileOperationFactory $fileOperationFactory, + private S3OperationFactory $s3OperationFactory, private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, ) { parent::__construct(); @@ -96,6 +98,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->title('Backup Create'); $this->createBackupFolders(); $this->initCount(); + $this->warnAboutUnfinishedUploads(); $this->backupNodes(); $this->backupRelations(); @@ -139,7 +142,7 @@ private function backupNodes(): void while ($nextPage) { $rawNodeIds = $this->cypherEntityManager->getClient()->runStatement( Statement::create( - 'MATCH (n) RETURN n.id SKIP $skip LIMIT $limit', + 'MATCH (n) RETURN n.id ORDER BY n.id SKIP $skip LIMIT $limit', [ 'skip' => $currentPage * $this->pageSize, 'limit' => $this->pageSize, @@ -163,7 +166,7 @@ private function backupNodes(): void if (null === $node) { throw new LogicException('Node can not be null'); } - $data = $this->elementToRawService->elementToRaw($node); + $data = $this->elementToRawService->elementToRaw($node, false); $json = \Safe\json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | ($this->prettyPrint ? JSON_PRETTY_PRINT : 0)); $path = $this->getNodePath($nodeId); $this->backupStorage->write($path, $json); @@ -194,7 +197,7 @@ private function backupRelations(): void while ($nextPage) { $rawRelationIds = $this->cypherEntityManager->getClient()->runStatement( Statement::create( - 'MATCH ()-[r]-() RETURN r.id SKIP $skip LIMIT $limit', + 'MATCH ()-[r]->() RETURN r.id ORDER BY r.id SKIP $skip LIMIT $limit', [ 'skip' => $currentPage * $this->pageSize, 'limit' => $this->pageSize, @@ -218,7 +221,7 @@ private function backupRelations(): void if (null === $relation) { throw new LogicException('Relation can not be null'); } - $data = $this->elementToRawService->elementToRaw($relation); + $data = $this->elementToRawService->elementToRaw($relation, false); $json = \Safe\json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | ($this->prettyPrint ? JSON_PRETTY_PRINT : 0)); $path = $this->getRelationPath($relationId); $this->backupStorage->write($path, $json); @@ -247,11 +250,9 @@ private function backupFiles(): void $rawFileElements = $this->cypherEntityManager->getClient()->runStatement( Statement::create( - 'OPTIONAL MATCH (n) WHERE n.file '. - 'OPTIONAL MATCH ()-[r]->() WHERE r.file '. - 'WITH coalesce(n, r) AS element '. - 'WHERE element.file '. - 'RETURN element.id' + 'MATCH (n) WHERE n.hasFile = true RETURN n.id AS `element.id` '. + 'UNION ALL '. + 'MATCH ()-[r]->() WHERE r.hasFile = true RETURN r.id AS `element.id`' ) ); @@ -288,7 +289,7 @@ private function backupFiles(): void continue; } - $fileOperation = $this->fileOperationFactory->createFileOperationFromElement($element); + $fileOperation = $this->s3OperationFactory->createFileOperationFromElement($element); $resource = $this->s3Service->getFileAsResource($fileOperation); $extension = $this->elementService->getFileNameExtension($element); @@ -333,10 +334,12 @@ private function getFilePath(UuidInterface $elementId, string $extension): strin { $levels = max(0, (int) ceil(log($this->fileCount, 256)) - 1); - return sprintf( - '%s/file/%s.%s', - $this->backupName, - $this->fileService->uuidToNestedFolderStructure($elementId, $levels), + return $this->fileService->appendExtension( + sprintf( + '%s/file/%s', + $this->backupName, + $this->fileService->uuidToNestedFolderStructure($elementId, $levels) + ), $extension ); } @@ -367,6 +370,22 @@ private function createBackupFolders(): void $this->backupStorage->createDirectory($this->backupName.'/file'); } + /** + * Independent of `--no-files`: chunks of unfinished uploads live in the upload bucket, which is never backed up. + */ + private function warnAboutUnfinishedUploads(): void + { + $uploadCount = $this->cypherEntityManager->getClient()->runStatement( + Statement::create('MATCH (n:Upload) RETURN count(n) as count') + )->first()->get('count'); + if (!is_int($uploadCount)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property count as int, not %s.', get_debug_type($uploadCount))); // @codeCoverageIgnore + } + if ($uploadCount > 0) { + $this->io->warning(sprintf('Found %d unfinished upload(s). Their already uploaded chunks are not part of the backup, so these uploads are abandoned and can not be resumed after loading the backup.', $uploadCount)); + } + } + private function initCount(): void { $rawNodeCount = $this->cypherEntityManager->getClient()->runStatement( diff --git a/src/Command/BackupFetchCommand.php b/src/Command/BackupFetchCommand.php index 619ea27b..01fa633c 100644 --- a/src/Command/BackupFetchCommand.php +++ b/src/Command/BackupFetchCommand.php @@ -18,6 +18,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\DependencyInjection\Attribute\Target; use function Safe\file_get_contents; use function Safe\file_put_contents; @@ -34,6 +35,7 @@ class BackupFetchCommand extends Command private EmberNexusStyle $io; public function __construct( + #[Target('backup.storage')] private FilesystemOperator $backupStorage, ) { parent::__construct(); @@ -175,8 +177,7 @@ public function copyFolder(FilesystemOperator $source, string $sourcePath, Files 'dest' => $destination, ]); $manager->createDirectory(sprintf('dest://%s', $destinationPath)); - // Flysystem always normalizes stored paths to be free of leading slashes, regardless of whether the - // requested path had one; $normalizedSourcePath must match that normalized form to be strippable below. + // Flysystem returns paths without leading slashes, so the source path has to match that form $normalizedSourcePath = trim($sourcePath, '/'); $listing = $manager->listContents(sprintf('source://%s', $normalizedSourcePath), true); $progressBar = $this->io->createProgressBarInInteractiveTerminal(); @@ -224,15 +225,8 @@ public function copyFile(FilesystemOperator $source, string $sourcePath, Filesys } /** - * @SuppressWarnings("PHPMD.CountInLoopExpression") - */ - /** - * A backup archive either contains the `node`/`relation` folders directly at its root (e.g. an archive built - * by `git archive` without a `--prefix`), or wrapped inside a single top level folder (e.g. GitHub's release - * archives, which always wrap their content in a `-/` folder). No deeper nesting is supported. - * - * The returned path never has a trailing slash; the root itself is represented as an empty string, so that - * appending `/node` etc. to it never produces a doubled leading slash. + * The backup is either located at the archive's root or inside a single top level folder (as in GitHub's + * release archives). Returns the folder without trailing slash, with the root being an empty string. */ private function findBackupRootFolder(Filesystem $filesystem): ?string { diff --git a/src/Command/BackupListCommand.php b/src/Command/BackupListCommand.php index 02a04701..22fbcb14 100755 --- a/src/Command/BackupListCommand.php +++ b/src/Command/BackupListCommand.php @@ -12,6 +12,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\OutputStyle; +use Symfony\Component\DependencyInjection\Attribute\Target; /** * @psalm-suppress PropertyNotSetInConstructor $io @@ -22,6 +23,7 @@ class BackupListCommand extends Command private OutputStyle $io; public function __construct( + #[Target('backup.storage')] private FilesystemOperator $backupStorage, private LoggerInterface $logger, ) { diff --git a/src/Command/BackupLoadCommand.php b/src/Command/BackupLoadCommand.php index 5247296e..3778c837 100755 --- a/src/Command/BackupLoadCommand.php +++ b/src/Command/BackupLoadCommand.php @@ -4,13 +4,17 @@ namespace App\Command; +use App\Contract\NodeElementInterface; +use App\Contract\RelationElementInterface; use App\DependencyInjection\DeactivatableTraceableEventDispatcher; use App\EventSystem\EntityManager\Event\ElementUpdateAfterBackupLoadEvent; use App\Factory\Exception\Server500LogicErrorExceptionFactory; -use App\Factory\Type\S3\UploadFileOperationFactory; +use App\Factory\Type\S3\S3OperationFactory; use App\Helper\Regex; use App\Service\AppStateService; use App\Service\ElementManager; +use App\Service\FileHashService; +use App\Service\FileSizeLimitService; use App\Service\RawToElementService; use App\Service\S3Service; use App\Style\EmberNexusStyle; @@ -19,6 +23,7 @@ use League\Flysystem\FilesystemOperator; use LogicException; use Predis\Client; +use Psr\Log\LoggerInterface; use Ramsey\Uuid\Rfc4122\UuidV4; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; @@ -26,13 +31,18 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\DependencyInjection\Attribute\Target; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; use Syndesi\ElasticEntityManager\Type\EntityManager as ElasticEntityManager; +use Throwable; /** * @psalm-suppress PropertyNotSetInConstructor $io + * + * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") */ #[AsCommand(name: 'backup:load', description: 'Loads a local backup into the empty database.')] class BackupLoadCommand extends Command @@ -42,6 +52,7 @@ class BackupLoadCommand extends Command private int $fileCount = 0; private int $nodeCount = 0; private int $pageSize = 250; + private bool $skipVerify = false; private EmberNexusStyle $io; @@ -52,14 +63,18 @@ public function __construct( private ElementManager $elementManager, private CypherEntityManager $cypherEntityManager, private Client $redisClient, + #[Target('backup.storage')] private FilesystemOperator $backupStorage, private RawToElementService $rawToElementService, private EventDispatcherInterface $eventDispatcher, private AppStateService $appStateService, private ElasticEntityManager $elasticEntityManager, private S3Service $s3Service, - private UploadFileOperationFactory $uploadFileOperationFactory, + private S3OperationFactory $s3OperationFactory, + private FileSizeLimitService $fileSizeLimitService, + private FileHashService $fileHashService, private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, + private LoggerInterface $logger, ) { parent::__construct(); } @@ -67,6 +82,7 @@ public function __construct( protected function configure(): void { $this->addArgument('name', InputArgument::REQUIRED, 'Name of the backup'); + $this->addOption('skip-verify', null, InputOption::VALUE_NONE, "Skip verifying file contents against their stored 'file.hash' values"); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -83,6 +99,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $this->backupName = $this->checkBackupName($input->getArgument('name')); + $this->skipVerify = (bool) $input->getOption('skip-verify'); $this->loadSummary(); @@ -170,11 +187,11 @@ private function loadRelations(): void )); } - private function parseFilenameAsUuidFromPath(string $path): false|UuidInterface + private function parseFilenameAsUuidFromPath(string $path, bool $extensionIsOptional = false): false|UuidInterface { $filename = basename($path); $parts = explode('.', $filename, 2); - if (2 !== count($parts)) { + if (2 !== count($parts) && !$extensionIsOptional) { return false; } $name = $parts[0]; @@ -185,36 +202,129 @@ private function parseFilenameAsUuidFromPath(string $path): false|UuidInterface return Uuid::fromString($name); } + /** + * A skipped file leaves its element with hasFile=true but without S3 object, so it is reported as error on the + * console and in the logs. + */ + private function reportFileError(string $message): void + { + $this->logger->error($message); + $this->io->error($message); + } + + /** + * Problems with a single file are reported instead of thrown, so that one bad file does not abort the restore. + */ + private function loadFile(string $path, UuidInterface $fileId): bool + { + $element = $this->elementManager->getElement($fileId); + if (null === $element) { + $this->reportFileError(sprintf( + 'Found file in backup without corresponding element; can not import file: %s', + $path + )); + + return false; + } + + try { + $contentLength = $this->backupStorage->fileSize($path); + if ($this->fileSizeLimitService->exceedsMaxFileSize($contentLength)) { + $this->reportFileError(sprintf( + "File is %d bytes, which exceeds the configured 'file.maxFileSizeInBytes' of %d; skipping file: %s", + $contentLength, + $this->fileSizeLimitService->getMaxFileSizeInBytes(), + $path + )); + + return false; + } + + $hashError = $this->skipVerify ? null : $this->verifyFileHashes($element, $path); + if (null !== $hashError) { + $this->reportFileError(sprintf('%s; skipping file: %s', $hashError, $path)); + + return false; + } + + $resource = $this->backupStorage->readStream($path); + $uploadFileOperation = $this->s3OperationFactory->createUploadFileOperationFromElementAndResource($element, $resource, $contentLength); + $this->s3Service->uploadFile($uploadFileOperation); + } catch (Throwable $e) { + $this->reportFileError(sprintf( + 'Failed to upload file %s: %s', + $path, + $e->getMessage() + )); + + return false; + } + + return true; + } + + /** + * Verifies the backup file against the `file.hash.` values stored on its element. + * + * @return string|null error message, or null if all hashes match + */ + private function verifyFileHashes(NodeElementInterface|RelationElementInterface $element, string $path): ?string + { + $expectedHashes = $this->fileHashService->getVerifiableHashesFromFileProperty( + $element->hasProperty('file') ? $element->getProperty('file') : null + ); + if ([] === $expectedHashes) { + return "Element has no verifiable 'file.hash' property"; + } + + $resource = $this->backupStorage->readStream($path); + try { + $actualHashes = $this->fileHashService->calculateHashesFromResource($resource, array_keys($expectedHashes)); + } finally { + \Safe\fclose($resource); + } + + foreach ($expectedHashes as $algorithm => $expectedHash) { + if (!hash_equals($expectedHash, $actualHashes[$algorithm])) { + return sprintf( + "File content does not match 'file.hash.%s' (expected %s, got %s)", + $algorithm, + $expectedHash, + $actualHashes[$algorithm] + ); + } + } + + return null; + } + private function loadFiles(): void { $this->io->startSection('Step 3 of 4: Loading Files'); + if ($this->skipVerify) { + $this->io->warning("File hash verification is disabled (--skip-verify); file contents are not checked against 'file.hash'."); + } $progressBar = $this->io->createProgressBarInInteractiveTerminal($this->fileCount); $progressBar?->display(); $files = $this->backupStorage->listContents($this->backupName.'/file/', true); $pageCount = 0; $totalCount = 0; + $failedCount = 0; foreach ($files as $file) { if (!$file->isFile()) { continue; } $path = $file->path(); - $fileId = $this->parseFilenameAsUuidFromPath($path); + // files without extension are stored without a trailing dot + $fileId = $this->parseFilenameAsUuidFromPath($path, true); if (false === $fileId) { continue; } - $element = $this->elementManager->getElement($fileId); - if (null === $element) { - $this->io->warning(sprintf( - 'Found file in backup without corresponding element; can not import file: %s', - $path - )); + if (!$this->loadFile($path, $fileId)) { + ++$failedCount; continue; } - $resource = $this->backupStorage->readStream($path); - $uploadFileOperation = $this->uploadFileOperationFactory->createUploadFileOperationFromElementAndResource($element, $resource); - $this->s3Service->uploadFile($uploadFileOperation); - ++$pageCount; if ($pageCount >= $this->pageSize) { $progressBar?->advance($pageCount); @@ -226,6 +336,15 @@ private function loadFiles(): void $progressBar?->advance($pageCount); $progressBar?->clear(); $totalCount += $pageCount; + if ($failedCount > 0) { + $this->io->stopSection(sprintf( + 'Loaded %d files, %d could not be loaded and their elements still reference a missing file (see errors above).', + $totalCount, + $failedCount + )); + + return; + } $this->io->stopSection(sprintf( 'Loaded %d files.', $totalCount diff --git a/src/Command/Cron/DeleteExpiredUploadsCommand.php b/src/Command/Cron/DeleteExpiredUploadsCommand.php index cff63b48..0c326f61 100755 --- a/src/Command/Cron/DeleteExpiredUploadsCommand.php +++ b/src/Command/Cron/DeleteExpiredUploadsCommand.php @@ -6,21 +6,23 @@ use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Factory\Type\UploadFactory; +use App\Service\CronExecutionGateService; use App\Service\ElementManager; +use App\Service\ExpiredUploadDeletionAttemptService; use App\Service\UploadService; use App\Style\EmberNexusStyle; use DateInterval; use EmberNexusBundle\Service\EmberNexusConfiguration; use Laudis\Neo4j\Databags\Statement; -use LogicException; +use Psr\Log\LoggerInterface; use Ramsey\Uuid\Uuid; use Safe\DateTime; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; +use Throwable; /** * @psalm-suppress PropertyNotSetInConstructor $io @@ -28,16 +30,20 @@ #[AsCommand(name: 'cron:delete-expired-uploads', description: 'Deletes uploads which were not completed during their lifetime.')] class DeleteExpiredUploadsCommand extends Command { + private const int PAGE_SIZE = 100; + private EmberNexusStyle $io; public function __construct( - private ParameterBagInterface $bag, + private CronExecutionGateService $cronExecutionGateService, private EmberNexusConfiguration $emberNexusConfiguration, private CypherEntityManager $cypherEntityManager, private ElementManager $elementManager, private UploadFactory $uploadFactory, private UploadService $uploadService, private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, + private ExpiredUploadDeletionAttemptService $expiredUploadDeletionAttemptService, + private LoggerInterface $logger, ) { parent::__construct(); } @@ -48,19 +54,41 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->title('Cron'); - $isCronDisabled = $this->bag->get('isCronDisabled'); - if (!is_bool($isCronDisabled)) { - throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); - } - if ($isCronDisabled) { + if ($this->cronExecutionGateService->shouldSkipExecution()) { $this->io->finalMessage('Cron is disabled; this command terminates early.'); return Command::SUCCESS; } - $expiredUploadIds = $this->getExpiredUploadIds(); + $deletedCount = 0; + $failedCount = 0; + $givenUpCount = 0; + $deferredCount = 0; + $lastUploadId = null; + do { + $expiredUploadIds = $this->getExpiredUploadIds($lastUploadId); + foreach ($expiredUploadIds as $expiredUploadId) { + $lastUploadId = $expiredUploadId; + if ($this->expiredUploadDeletionAttemptService->isDeferred($expiredUploadId)) { + ++$deferredCount; + continue; + } + try { + $this->deleteExpiredUpload($expiredUploadId); + $this->expiredUploadDeletionAttemptService->clear($expiredUploadId); + ++$deletedCount; + } catch (Throwable $throwable) { + if ($this->handleFailedDeletion($expiredUploadId, $throwable)) { + ++$givenUpCount; + } else { + ++$failedCount; + } + } + } + // keyset pagination: the id strictly increases, so every upload is visited once and the loop always ends + } while (count($expiredUploadIds) >= self::PAGE_SIZE); - if (0 === count($expiredUploadIds)) { + if (0 === $deletedCount + $failedCount + $givenUpCount + $deferredCount) { $this->io->writeln(' No expired uploads found.'); $this->io->newLine(); $this->io->finalMessage('Finished.'); @@ -68,33 +96,76 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - $this->io->writeln(sprintf( - ' Found %d expired upload(s), deleting them...', - count($expiredUploadIds) + $this->io->newLine(); + $this->io->finalMessage(sprintf( + 'Deleted %d expired upload(s), %d failed (will be retried), %d given up, %d waiting for a retry.', + $deletedCount, + $failedCount, + $givenUpCount, + $deferredCount )); - foreach ($expiredUploadIds as $expiredUploadId) { - $this->deleteExpiredUpload($expiredUploadId); + return Command::SUCCESS; + } + + /** + * @return bool true if the upload was given up + */ + private function handleFailedDeletion(string $uploadId, Throwable $throwable): bool + { + $attempts = $this->expiredUploadDeletionAttemptService->recordFailure($uploadId); + if ($attempts < ExpiredUploadDeletionAttemptService::MAX_ATTEMPTS) { + $this->logger->error(sprintf( + "Failed to delete expired upload '%s' (attempt %d of %d), will retry later: %s", + $uploadId, + $attempts, + ExpiredUploadDeletionAttemptService::MAX_ATTEMPTS, + $throwable->getMessage() + )); + $this->io->writeln(sprintf(' Failed to delete expired upload %s, will retry later.', $uploadId)); + + return false; } - $this->io->newLine(); - $this->io->finalMessage(sprintf('Deleted %d expired upload(s).', count($expiredUploadIds))); + // final attempt: remove at least the Upload node, so that it does not fail forever; its chunks (if any) are left + // to a future scan for S3 objects without database counterpart + try { + $uploadElement = $this->elementManager->getElement(Uuid::fromString($uploadId)); + if (null !== $uploadElement) { + $this->elementManager->delete($uploadElement); + $this->elementManager->flush(); + } + $this->expiredUploadDeletionAttemptService->clear($uploadId); + $outcome = 'The upload node was removed, chunks in S3 may be left behind.'; + } catch (Throwable $cleanupThrowable) { + $outcome = sprintf('Removing the upload node failed as well: %s', $cleanupThrowable->getMessage()); + } + $this->logger->error(sprintf( + "Giving up deleting expired upload '%s' after %d failed attempts: %s %s", + $uploadId, + $attempts, + $throwable->getMessage(), + $outcome + )); + $this->io->writeln(sprintf(' Gave up deleting expired upload %s.', $uploadId)); - return Command::SUCCESS; + return true; } /** * @return string[] */ - private function getExpiredUploadIds(): array + private function getExpiredUploadIds(?string $afterUploadId): array { $gracePeriodInSeconds = $this->emberNexusConfiguration->getFileExpiredUploadCanBeDeletedAfterExpirationInSeconds(); $deletionThreshold = (new DateTime())->sub(new DateInterval(sprintf('PT%sS', $gracePeriodInSeconds))); $queryResult = $this->cypherEntityManager->getClient()->runStatement(new Statement( - 'MATCH (u:Upload) WHERE u.expires < $deletionThreshold RETURN u.id', + 'MATCH (u:Upload) WHERE u.expires < $deletionThreshold AND u.id IS NOT NULL AND ($afterUploadId IS NULL OR u.id > $afterUploadId) RETURN u.id ORDER BY u.id LIMIT $pageSize', [ 'deletionThreshold' => $deletionThreshold, + 'afterUploadId' => $afterUploadId, + 'pageSize' => self::PAGE_SIZE, ] )); diff --git a/src/Command/Cron/ReindexFilesCommand.php b/src/Command/Cron/ReindexFilesCommand.php index 701e57ef..6bd52b19 100755 --- a/src/Command/Cron/ReindexFilesCommand.php +++ b/src/Command/Cron/ReindexFilesCommand.php @@ -4,17 +4,16 @@ namespace App\Command\Cron; +use App\Service\CronExecutionGateService; use App\Service\ElementManager; use App\Service\QueueService; use App\Style\EmberNexusStyle; use App\Type\RabbitMQQueueType; -use LogicException; use Ramsey\Uuid\Uuid; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; /** * @psalm-suppress PropertyNotSetInConstructor $io @@ -25,7 +24,7 @@ class ReindexFilesCommand extends Command private EmberNexusStyle $io; public function __construct( - private ParameterBagInterface $bag, + private CronExecutionGateService $cronExecutionGateService, private QueueService $queueService, private ElementManager $elementManager, ) { @@ -38,11 +37,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->title('Cron'); - $isCronDisabled = $this->bag->get('isCronDisabled'); - if (!is_bool($isCronDisabled)) { - throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); - } - if ($isCronDisabled) { + if ($this->cronExecutionGateService->shouldSkipExecution()) { $this->io->finalMessage('Cron is disabled; this command terminates early.'); return Command::SUCCESS; diff --git a/src/Command/Cron/UpdateOwnershipCommand.php b/src/Command/Cron/UpdateOwnershipCommand.php index 7cec70c1..5899dd4c 100755 --- a/src/Command/Cron/UpdateOwnershipCommand.php +++ b/src/Command/Cron/UpdateOwnershipCommand.php @@ -4,25 +4,61 @@ namespace App\Command\Cron; +use App\Contract\NodeElementInterface; +use App\Contract\RelationElementInterface; +use App\Service\CronExecutionGateService; +use App\Service\ElementManager; +use App\Service\QueueService; +use App\Service\SearchAccessCalculatorService; use App\Style\EmberNexusStyle; -use LogicException; +use App\Type\RabbitMQQueueType; +use Laudis\Neo4j\Databags\Statement; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\OutputStyle; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; +use Syndesi\ElasticDataStructures\Type\Document; +use Syndesi\ElasticEntityManager\Type\EntityManager as ElasticEntityManager; /** + * Recalculates `_groupsWithSearchAccess`/`_usersWithSearchAccess` for elements affected by relation changes + * (`OWNS`/`HAS_SEARCH_ACCESS`/`IS_IN_GROUP`) published by {@see \App\EventSystem\EntityManager\EventListener\OwnershipChangeEventListener}. + * + * Recalculation only happens once this command drains the queue, so a stored element's search access can lag behind + * the graph by up to the interval this command is scheduled at (i.e. an eventual-consistency window). + * * @psalm-suppress PropertyNotSetInConstructor $io */ -#[AsCommand(name: 'cron:update-ownership', description: 'Updates element ownership data within Elasticsearch. Restricted to elements marked as updated.')] +#[AsCommand(name: 'cron:update-ownership', description: 'Recalculates search access ownership of elements affected by relation changes.')] class UpdateOwnershipCommand extends Command { - private OutputStyle $io; + /** + * Upper bound of elements recalculated (and, if changed, stored) per invocation, so a single cron tick can not + * run unbounded if a large subtree changed. Remaining work is deferred to the next run (the still-queued + * message is requeued via QueueService's own retry mechanism, see recalculateElementAndChildren()). + */ + private const int MAX_ELEMENTS_PER_RUN = 500; + + private EmberNexusStyle $io; + + /** + * @var array + */ + private array $visited = []; + + private int $updatedElementCount = 0; public function __construct( - private ParameterBagInterface $bag, + private CronExecutionGateService $cronExecutionGateService, + private QueueService $queueService, + private ElementManager $elementManager, + private SearchAccessCalculatorService $searchAccessCalculatorService, + private ElasticEntityManager $elasticEntityManager, + private CypherEntityManager $cypherEntityManager, ) { parent::__construct(); } @@ -30,26 +66,191 @@ public function __construct( protected function execute(InputInterface $input, OutputInterface $output): int { $this->io = new EmberNexusStyle($input, $output); + $this->visited = []; + $this->updatedElementCount = 0; $this->io->title('Cron'); - $this->io->writeln('This command is currently a placeholder.'); + if ($this->cronExecutionGateService->shouldSkipExecution()) { + $this->io->finalMessage('Cron is disabled; this command terminates early.'); + + return Command::SUCCESS; + } + + $this->io->writeln(' Recalculating ownership search access...'); + + $processedMessages = $this->queueService->consumeQueue( + RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE, + function (array $eventData): void { + $this->handleOwnershipChangeMessage($eventData); + } + ); + $this->io->newLine(); + $this->io->finalMessage(sprintf( + 'Processed %d queue message(s), updated search access of %d element(s).', + $processedMessages, + $this->updatedElementCount + )); - // todo: implement command, see https://github.com/ember-nexus/api/issues/438 + return Command::SUCCESS; + } - $isCronDisabled = $this->bag->get('isCronDisabled'); - if (!is_bool($isCronDisabled)) { - throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); + /** + * @param array $eventData + */ + private function handleOwnershipChangeMessage(array $eventData): void + { + // the relation's own id (relations have search access documents too) plus both of its endpoints can each be + // affected by the change; a deleted relation can no longer be reloaded, so all three ids travel in the + // payload itself rather than being looked up from the (possibly already gone) relation + foreach (['relationId', 'startId', 'endId'] as $key) { + $rawId = $eventData[$key] ?? null; + if (!is_string($rawId) || '' === $rawId) { + continue; + } + $this->recalculateElementAndChildren(Uuid::fromString($rawId)); } - if ($isCronDisabled) { - $this->io->finalMessage('Cron is disabled; this command terminates early.'); - return Command::SUCCESS; + // IS_IN_GROUP is special: it does not change the group's (endId's) own direct accessors (it grants the + // member the group's rights, not access to the group node itself), so the group's own delta above is always + // zero and the normal "cascade to children only on non-zero delta" rule would never reach what actually + // changed: every element the group (transitively) owns. So its children are always recalculated directly. + $endId = $eventData['endId'] ?? null; + if ('IS_IN_GROUP' === ($eventData['relationType'] ?? null) && is_string($endId) && '' !== $endId) { + foreach ($this->getChildrenIds(Uuid::fromString($endId)) as $childId) { + $this->recalculateElementAndChildren($childId); + } } + } - $this->io->finalMessage('Finished.'); + /** + * Work-list with a visited set (to cope with cycles in the ownership graph). Recomputes direct groups/users for + * each affected element, stores only if the set changed, and only then enqueues that element's children for the + * same recomputation: children are guaranteed unchanged when the parent's own delta was zero, so recursion stops + * there. + */ + private function recalculateElementAndChildren(UuidInterface $elementId): void + { + $workList = [$elementId->toString()]; + while ([] !== $workList) { + $currentIdString = array_shift($workList); + if (isset($this->visited[$currentIdString])) { + continue; + } + if ($this->updatedElementCount >= self::MAX_ELEMENTS_PER_RUN) { + // batch cap reached: throwing here (instead of just returning) makes QueueService requeue this + // message via its normal try-count mechanism, so this remaining work is not silently dropped + throw new RuntimeException(sprintf('Batch cap of %d element(s) per run reached; remaining work is deferred to the next cron run.', self::MAX_ELEMENTS_PER_RUN)); + } + $this->visited[$currentIdString] = true; - return Command::SUCCESS; + $currentId = Uuid::fromString($currentIdString); + $element = $this->elementManager->getElement($currentId); + if (null === $element) { + // element was already deleted in the meantime, nothing left to recalculate + continue; + } + + if (!$this->recalculateSearchAccess($element)) { + continue; + } + ++$this->updatedElementCount; + + foreach ($this->getChildrenIds($currentId) as $childId) { + if (!isset($this->visited[$childId->toString()])) { + $workList[] = $childId->toString(); + } + } + } + } + + /** + * Recomputes the direct groups/users with search access for the given element (reusing the exact same + * calculation {@see \App\EventSystem\EntityManager\EventListener\CalculateSearchAccessEventListener} uses on + * create), compares it against the currently stored Elasticsearch document as sets, and stores it only if it + * changed. Returns whether it changed. + */ + private function recalculateSearchAccess(NodeElementInterface|RelationElementInterface $element): bool + { + $elementId = $element->getId(); + if (null === $elementId) { + return false; + } + + $index = $this->searchAccessCalculatorService->getIndexForElement($element); + $access = $this->searchAccessCalculatorService->calculateSearchAccess($element); + + $existingDocument = $this->elasticEntityManager->getOneByIdentifier($index, $elementId->toString()); + $existingGroups = $this->toStringArray($existingDocument?->getProperty('_groupsWithSearchAccess')); + $existingUsers = $this->toStringArray($existingDocument?->getProperty('_usersWithSearchAccess')); + + if ($this->sameSet($existingGroups, $access['groups']) && $this->sameSet($existingUsers, $access['users'])) { + return false; + } + + $document = new Document(); + $document + ->setIdentifier($elementId->toString()) + ->setIndex($index) + ->addProperties([ + '_groupsWithSearchAccess' => $access['groups'], + '_usersWithSearchAccess' => $access['users'], + ]); + $this->elasticEntityManager->merge($document); + $this->elasticEntityManager->flush(); + + $this->io->writeln(sprintf(' Updated search access of element %s.', $elementId->toString())); + + return true; + } + + /** + * @return UuidInterface[] + */ + private function getChildrenIds(UuidInterface $parentId): array + { + // same "children of an element" graph semantics as EtagCalculatorService::calculateChildrenCollectionEtag(): + // nodes/relations directly OWNed by the element + $result = $this->cypherEntityManager->getClient()->runStatement(Statement::create( + "MATCH (parent {id: \$parentId})-[:OWNS]->(children)\n". + 'RETURN children.id AS id', + ['parentId' => $parentId->toString()] + )); + + $ids = []; + foreach ($result as $row) { + $rawId = $row['id']; + if (!is_string($rawId)) { + continue; + } + $ids[] = Uuid::fromString($rawId); + } + + return $ids; + } + + /** + * @return string[] + */ + private function toStringArray(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + return array_values(array_filter($value, 'is_string')); + } + + /** + * @param string[] $a + * @param string[] $b + */ + private function sameSet(array $a, array $b): bool + { + sort($a); + sort($b); + + return $a === $b; } } diff --git a/src/Command/CronCommand.php b/src/Command/CronCommand.php index a0eeb9aa..bdf05d5d 100755 --- a/src/Command/CronCommand.php +++ b/src/Command/CronCommand.php @@ -6,15 +6,17 @@ use App\Command\Cron\DeleteExpiredUploadsCommand; use App\Command\Cron\ReindexFilesCommand; +use App\Command\Cron\UpdateOwnershipCommand; +use App\Service\CronExecutionGateService; use App\Style\EmberNexusStyle; -use LogicException; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\OutputStyle; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Throwable; /** * @psalm-suppress PropertyNotSetInConstructor $io @@ -25,9 +27,11 @@ class CronCommand extends Command private OutputStyle $io; public function __construct( - private ParameterBagInterface $bag, + private CronExecutionGateService $cronExecutionGateService, private DeleteExpiredUploadsCommand $deleteExpiredUploadsCommand, private ReindexFilesCommand $reindexFilesCommand, + private UpdateOwnershipCommand $updateOwnershipCommand, + private LoggerInterface $logger, ) { parent::__construct(); } @@ -38,22 +42,35 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->title('Cron'); - $isCronDisabled = $this->bag->get('isCronDisabled'); - if (!is_bool($isCronDisabled)) { - throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); - } - if ($isCronDisabled) { + if ($this->cronExecutionGateService->shouldSkipExecution()) { $this->io->finalMessage('Cron is disabled; this command terminates early.'); return Command::SUCCESS; } - // Note: cron:update-ownership is intentionally not dispatched here yet, see - // https://github.com/ember-nexus/api/issues/438. + // a failing task must not prevent the following ones from running + $hasFailedTask = false; + foreach ([$this->deleteExpiredUploadsCommand, $this->reindexFilesCommand, $this->updateOwnershipCommand] as $taskCommand) { + try { + if (Command::SUCCESS !== $taskCommand->run(new ArrayInput([]), $output)) { + $hasFailedTask = true; + } + } catch (Throwable $throwable) { + $hasFailedTask = true; + $this->logger->error(sprintf( + "Cron task '%s' failed: %s", + $taskCommand->getName() ?? $taskCommand::class, + $throwable->getMessage() + ), ['exception' => $throwable]); + $this->io->writeln(sprintf(' Task %s failed: %s', $taskCommand->getName() ?? $taskCommand::class, $throwable->getMessage())); + } + } - $this->deleteExpiredUploadsCommand->run(new ArrayInput([]), $output); - $this->reindexFilesCommand->run(new ArrayInput([]), $output); + if ($hasFailedTask) { + $this->io->finalMessage('Finished, but at least one task failed.'); + return Command::FAILURE; + } $this->io->finalMessage('Finished.'); return Command::SUCCESS; diff --git a/src/Command/DatabaseDropCommand.php b/src/Command/DatabaseDropCommand.php index ac6f90de..38957a6d 100755 --- a/src/Command/DatabaseDropCommand.php +++ b/src/Command/DatabaseDropCommand.php @@ -11,6 +11,7 @@ use Laudis\Neo4j\Databags\Statement; use PhpAmqpLib\Connection\AMQPStreamConnection; use Predis\Client; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; @@ -29,6 +30,8 @@ #[AsCommand(name: 'database:drop', description: 'Resets all connected databases.')] class DatabaseDropCommand extends Command { + private const int MAX_S3_DELETE_ITERATIONS = 10000; + private EmberNexusStyle $io; public function __construct( @@ -136,26 +139,66 @@ private function deleteObjectStorage(): void private function deleteAllObjectsFromBucket(string $bucket): void { - do { + $this->abortAllMultipartUploadsFromBucket($bucket); + + for ($iteration = 0; $iteration < self::MAX_S3_DELETE_ITERATIONS; ++$iteration) { $objects = $this->s3Client->listObjectsV2([ 'Bucket' => $bucket, ]); - $keyCount = $objects->getKeyCount(); - if ($keyCount > 0) { - $objectsToBeDeleted = []; - foreach ($objects->getContents() as $object) { - $objectsToBeDeleted[] = [ - 'Key' => $object->getKey(), - ]; - } - $this->s3Client->deleteObjects([ + $objectsToBeDeleted = []; + foreach ($objects->getContents() as $object) { + $objectsToBeDeleted[] = [ + 'Key' => $object->getKey(), + ]; + } + if (0 === count($objectsToBeDeleted)) { + return; + } + // S3 accepts at most 1000 keys per request + foreach (array_chunk($objectsToBeDeleted, 1000) as $objectsChunk) { + $result = $this->s3Client->deleteObjects([ 'Bucket' => $bucket, 'Delete' => [ - 'Objects' => $objectsToBeDeleted, + 'Objects' => $objectsChunk, ], ]); + $errors = $result->getErrors(); + if (count($errors) > 0) { + $messages = []; + foreach ($errors as $error) { + $messages[] = sprintf('%s (%s)', $error->getKey() ?? '?', $error->getMessage() ?? $error->getCode() ?? 'unknown error'); + } + throw new RuntimeException(sprintf("Unable to delete objects from bucket '%s': %s", $bucket, join(', ', $messages))); + } + } + } + + throw new RuntimeException(sprintf("Bucket '%s' is not empty after %d delete rounds, aborting.", $bucket, self::MAX_S3_DELETE_ITERATIONS)); + } + + private function abortAllMultipartUploadsFromBucket(string $bucket): void + { + for ($iteration = 0; $iteration < self::MAX_S3_DELETE_ITERATIONS; ++$iteration) { + $abortedUploads = 0; + foreach ($this->s3Client->listMultipartUploads(['Bucket' => $bucket])->getUploads() as $upload) { + $key = $upload->getKey(); + $uploadId = $upload->getUploadId(); + if (null === $key || null === $uploadId) { + continue; + } + $this->s3Client->abortMultipartUpload([ + 'Bucket' => $bucket, + 'Key' => $key, + 'UploadId' => $uploadId, + ]); + ++$abortedUploads; + } + if (0 === $abortedUploads) { + return; } - } while ($keyCount > 0); + } + + throw new RuntimeException(sprintf("Unable to abort all unfinished multipart uploads of bucket '%s'.", $bucket)); } private function deleteElastic(): void diff --git a/src/Contract/S3/S3TechnicalLimitsInterface.php b/src/Contract/S3/S3TechnicalLimitsInterface.php index 52eedd10..28ae31b6 100644 --- a/src/Contract/S3/S3TechnicalLimitsInterface.php +++ b/src/Contract/S3/S3TechnicalLimitsInterface.php @@ -5,18 +5,8 @@ namespace App\Contract\S3; /** - * Hard technical limits imposed by an S3(-compatible) storage backend itself, as opposed to - * {@see \EmberNexusBundle\Service\EmberNexusConfiguration}'s operator-configurable `file.*` settings. - * - * These values are not meant to be operator-configurable: they describe what the underlying storage protocol - * actually allows, not what an operator wants to allow. Operator configuration may only be more restrictive than - * these limits, never less; see {@see \App\Service\S3TechnicalLimitsValidator}. - * - * Only one implementation exists today ({@see \App\Type\S3\S3TechnicalLimits}, hardcoded to the standard AWS S3 - * multipart upload limits), as only a single S3 connection is supported. This interface exists so that, once - * multiple storage backends/connections with differing technical limits are supported, additional - * implementations can be introduced (and selected per connection) without changing any consumer of this - * interface. + * Hard limits of the S3 storage backend itself. Operator configuration (`file.*`) may only be more restrictive, + * see {@see \App\Service\S3TechnicalLimitsValidator}. */ interface S3TechnicalLimitsInterface { @@ -25,4 +15,9 @@ public function getMinChunkSizeInBytes(): int; public function getMaxChunkCount(): int; public function getMaxObjectSizeInBytes(): int; + + /** + * Also limits a single server-side copy; larger objects require a multipart upload. + */ + public function getMaxSinglePutSizeInBytes(): int; } diff --git a/src/Contract/UploadInterface.php b/src/Contract/UploadInterface.php index b1a6961b..fd4b283e 100644 --- a/src/Contract/UploadInterface.php +++ b/src/Contract/UploadInterface.php @@ -21,6 +21,18 @@ public function getUploadTarget(): UuidInterface; public function getAlreadyUploadedChunks(): int; + /** + * Ids of the accepted chunk objects in upload order, see {@see \App\Service\FileService::getUploadBucketKey()}. + * + * @return list + */ + public function getChunkIds(): array; + + /** + * Id of the chunk which was accepted last, null if there is none; unique per chunk attempt. + */ + public function getLastChunkId(): ?string; + public function getUploadOwner(): UuidInterface; public function getExtension(): string; @@ -28,9 +40,14 @@ public function getExtension(): string; public function getExpires(): DateTime; /** - * Base64-encoded, serialized {@see \HashContext}: the running hash of every chunk uploaded for this upload - * so far, computed as each chunk was streamed to S3, resumed from here on the next chunk. Null until the - * first chunk with actual content has been uploaded. + * Base64-encoded, serialized {@see \HashContext} of all chunks uploaded so far; null until the first + * non-empty chunk. */ public function getHashState(): ?string; + + /** + * Whether the target already had a file when this upload was created (a resumable replace started through + * `PUT`). + */ + public function targetHadFileAtCreation(): bool; } diff --git a/src/Controller/Element/DeleteElementController.php b/src/Controller/Element/DeleteElementController.php index 0c9e921e..be3961c9 100644 --- a/src/Controller/Element/DeleteElementController.php +++ b/src/Controller/Element/DeleteElementController.php @@ -9,6 +9,7 @@ use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; +use App\Service\ElementFileDeletionService; use App\Service\ElementManager; use App\Service\UploadService; use App\Type\AccessType; @@ -26,6 +27,7 @@ public function __construct( private AuthProvider $authProvider, private AccessChecker $accessChecker, private UploadService $uploadService, + private ElementFileDeletionService $elementFileDeletionService, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -49,15 +51,17 @@ public function deleteElement(string $id): Response } $element = $this->elementManager->getElementOrFail($elementId); + $fileOperations = $this->elementFileDeletionService->getFileOperationsForDeletionOfElement($element); - // deleted, and flushed, as its own separate step before the element itself - see - // UploadService::deleteUploadsTargeting() for why this can not be done via an event listener instead + // separate flush before deleting the element, see UploadService::deleteUploadsTargeting() $this->uploadService->deleteUploadsTargeting($elementId); $this->elementManager->flush(); $this->elementManager->delete($element); $this->elementManager->flush(); + $this->elementFileDeletionService->deleteFiles($fileOperations); + return new NoContentResponse(); } } diff --git a/src/Controller/Element/GetChildrenController.php b/src/Controller/Element/GetChildrenController.php index 144f88b1..153f58c6 100644 --- a/src/Controller/Element/GetChildrenController.php +++ b/src/Controller/Element/GetChildrenController.php @@ -100,7 +100,7 @@ public function getChildren(string $id): Response " (\n". " relation.onCreatedByUser IS NULL\n". " OR\n". - " (child)<-[:CREATED_BY*]-(user)\n". + " (child)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". diff --git a/src/Controller/Element/GetParentsController.php b/src/Controller/Element/GetParentsController.php index 6e89e10e..b5cf2043 100644 --- a/src/Controller/Element/GetParentsController.php +++ b/src/Controller/Element/GetParentsController.php @@ -100,7 +100,7 @@ public function getParents(string $id): Response " (\n". " relation.onCreatedByUser IS NULL\n". " OR\n". - " (parent)<-[:CREATED_BY*]-(user)\n". + " (parent)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". diff --git a/src/Controller/Element/GetRelatedController.php b/src/Controller/Element/GetRelatedController.php index a8baa4bd..c1326b7c 100644 --- a/src/Controller/Element/GetRelatedController.php +++ b/src/Controller/Element/GetRelatedController.php @@ -100,7 +100,7 @@ public function getRelated(string $id): Response " (\n". " relation.onCreatedByUser IS NULL\n". " OR\n". - " (outer)<-[:CREATED_BY*]-(user)\n". + " (outer)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". diff --git a/src/Controller/Element/PatchElementController.php b/src/Controller/Element/PatchElementController.php index 9b0ccc0d..f385d621 100644 --- a/src/Controller/Element/PatchElementController.php +++ b/src/Controller/Element/PatchElementController.php @@ -10,6 +10,7 @@ use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\ElementManager; +use App\Service\RequestContentService; use App\Service\UpdateElementFromRawDataService; use App\Type\AccessType; use App\Type\EtagType; @@ -28,6 +29,7 @@ public function __construct( private AccessChecker $accessChecker, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, private UpdateElementFromRawDataService $updateElementFromRawDataService, + private RequestContentService $requestContentService, ) { } @@ -54,7 +56,7 @@ public function patchElement(string $id, Request $request): Response /** * @var array $rawData */ - $rawData = \Safe\json_decode($request->getContent(), true); + $rawData = \Safe\json_decode($this->requestContentService->getContent($request), true); $element = $this->updateElementFromRawDataService->updateElementFromRawData($element, $rawData); diff --git a/src/Controller/Element/PostElementController.php b/src/Controller/Element/PostElementController.php index 14c02f0d..9e705543 100644 --- a/src/Controller/Element/PostElementController.php +++ b/src/Controller/Element/PostElementController.php @@ -12,6 +12,7 @@ use App\Security\AuthProvider; use App\Service\CreateElementFromRawDataService; use App\Service\ElementManager; +use App\Service\RequestContentService; use App\Type\AccessType; use App\Type\ElementType; use App\Type\RelationElement; @@ -34,6 +35,7 @@ public function __construct( private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, private CreateElementFromRawDataService $createElementFromRawDataService, + private RequestContentService $requestContentService, ) { } @@ -60,7 +62,7 @@ public function postElement(string $id, Request $request): Response throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } - $body = \Safe\json_decode($request->getContent(), true); + $body = \Safe\json_decode($this->requestContentService->getContent($request), true); if (array_key_exists('start', $body)) { // owns-relation can only target nodes diff --git a/src/Controller/Element/PostIndexController.php b/src/Controller/Element/PostIndexController.php index c669995b..78675f47 100644 --- a/src/Controller/Element/PostIndexController.php +++ b/src/Controller/Element/PostIndexController.php @@ -13,6 +13,7 @@ use App\Security\AuthProvider; use App\Service\CreateElementFromRawDataService; use App\Service\ElementManager; +use App\Service\RequestContentService; use App\Type\AccessType; use App\Type\RelationElement; use App\Type\Response\CreatedResponse; @@ -39,6 +40,7 @@ public function __construct( private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, private CreateElementFromRawDataService $createElementFromRawDataService, + private RequestContentService $requestContentService, ) { } @@ -58,7 +60,7 @@ public function postIndex(Request $request): Response } } - $body = \Safe\json_decode($request->getContent(), true); + $body = \Safe\json_decode($this->requestContentService->getContent($request), true); if (array_key_exists('id', $body)) { $elementId = UuidV4::fromString($body['id']); diff --git a/src/Controller/Element/PutElementController.php b/src/Controller/Element/PutElementController.php index 9d68f883..5511931a 100644 --- a/src/Controller/Element/PutElementController.php +++ b/src/Controller/Element/PutElementController.php @@ -10,6 +10,7 @@ use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\ElementManager; +use App\Service\RequestContentService; use App\Service\ResetElementPropertiesService; use App\Service\UpdateElementFromRawDataService; use App\Type\AccessType; @@ -30,6 +31,7 @@ public function __construct( private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, private UpdateElementFromRawDataService $updateElementFromRawDataService, private ResetElementPropertiesService $resetElementPropertiesService, + private RequestContentService $requestContentService, ) { } @@ -56,7 +58,7 @@ public function putElement(string $id, Request $request): Response /** * @var array $rawData */ - $rawData = \Safe\json_decode($request->getContent(), true); + $rawData = \Safe\json_decode($this->requestContentService->getContent($request), true); $element = $this->resetElementPropertiesService->resetElementProperties($element); $element = $this->updateElementFromRawDataService->updateElementFromRawData($element, $rawData); diff --git a/src/Controller/Error/assets/error-405-method-not-allowed.txt b/src/Controller/Error/assets/error-405-method-not-allowed.txt new file mode 100644 index 00000000..27773c99 --- /dev/null +++ b/src/Controller/Error/assets/error-405-method-not-allowed.txt @@ -0,0 +1,7 @@ + Error 405 - Method Not Allowed + + The requested route exists, but does not support the used HTTP method. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 405 error code: https://http.dev/405 diff --git a/src/Controller/Error/assets/error-408-request-timeout.txt b/src/Controller/Error/assets/error-408-request-timeout.txt new file mode 100644 index 00000000..9713dad3 --- /dev/null +++ b/src/Controller/Error/assets/error-408-request-timeout.txt @@ -0,0 +1,9 @@ + Error 408 - Request Timeout + + The HTTP 408 Request Timeout client error response status code indicates that the server did not receive the complete + request body, e.g. because the connection was interrupted or because the request body was not sent within the time + limit of the server. Retry the request, if necessary with a smaller request body, e.g. by uploading smaller chunks. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 408 error code: https://http.dev/408 diff --git a/src/Controller/Error/assets/error-503-service-unavailable.txt b/src/Controller/Error/assets/error-503-service-unavailable.txt new file mode 100644 index 00000000..319ec924 --- /dev/null +++ b/src/Controller/Error/assets/error-503-service-unavailable.txt @@ -0,0 +1,9 @@ + Error 503 - Service Unavailable + + The HTTP 503 Service Unavailable server error response status code indicates that the server is not ready to handle + the request, e.g. because it is overloaded or because a component of the service is not reachable. Retry the request + after a short pause. + + Link to the documentation: https://ember-nexus.github.io/api + + Link to http.dev's explanation of the 503 error code: https://http.dev/503 diff --git a/src/Controller/File/DeleteElementFileController.php b/src/Controller/File/DeleteElementFileController.php index 97195518..cb8650af 100644 --- a/src/Controller/File/DeleteElementFileController.php +++ b/src/Controller/File/DeleteElementFileController.php @@ -7,12 +7,13 @@ use App\Attribute\EndpointSupportsEtag; use App\EventSystem\ElementFileDelete\Event\ElementFileDeleteEvent; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Factory\Type\S3\FileOperationFactory; +use App\Factory\Type\S3\S3OperationFactory; use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; +use App\Service\ElementFileDeletionService; use App\Service\ElementManager; -use App\Service\S3Service; +use App\Service\ElementService; use App\Type\AccessType; use App\Type\EtagType; use App\Type\Response\NoContentResponse; @@ -30,10 +31,11 @@ class DeleteElementFileController extends AbstractController public function __construct( private AuthProvider $authProvider, private AccessChecker $accessChecker, - private S3Service $s3Service, + private ElementFileDeletionService $elementFileDeletionService, private ElementManager $elementManager, + private ElementService $elementService, private EventDispatcherInterface $eventDispatcher, - private FileOperationFactory $fileOperationFactory, + private S3OperationFactory $s3OperationFactory, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -57,14 +59,20 @@ public function deleteElementFile(string $id): Response } $element = $this->elementManager->getElementOrFail($elementId); - $deleteFileOperation = $this->fileOperationFactory->createFileOperationFromElement($element); - $this->s3Service->deleteFile($deleteFileOperation); + if (!$this->elementService->hasFile($element)) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + $deleteFileOperation = $this->s3OperationFactory->createFileOperationFromElement($element); + + // graph first: a failed flush leaves the file untouched, a failed S3 delete only leaves an orphaned object $element->addProperty('file', null); $element->addProperty('hasFile', false); $this->elementManager->merge($element); $this->elementManager->flush(); + $this->elementFileDeletionService->deleteFile($deleteFileOperation); + $this->eventDispatcher->dispatch(new ElementFileDeleteEvent($elementId)); return new NoContentResponse(); diff --git a/src/Controller/File/GetElementFileController.php b/src/Controller/File/GetElementFileController.php index 124c465f..d9b04184 100644 --- a/src/Controller/File/GetElementFileController.php +++ b/src/Controller/File/GetElementFileController.php @@ -8,13 +8,14 @@ use App\Contract\NodeElementInterface; use App\Contract\RelationElementInterface; use App\Factory\Exception\Client404NotFoundExceptionFactory; -use App\Factory\Type\S3\FileOperationFactory; +use App\Factory\Type\S3\S3OperationFactory; use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\DigestService; use App\Service\ElementManager; use App\Service\ElementService; +use App\Service\EtagService; use App\Service\FileRangeService; use App\Service\FileService; use App\Service\S3Service; @@ -27,6 +28,9 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; +/** + * @SuppressWarnings("PHPMD.ExcessiveParameterList") + */ class GetElementFileController extends AbstractController { public function __construct( @@ -35,10 +39,11 @@ public function __construct( private ElementManager $elementManager, private ElementService $elementService, private FileService $fileService, - private FileOperationFactory $fileOperationFactory, + private S3OperationFactory $s3OperationFactory, private S3Service $s3Service, private FileRangeService $fileRangeService, private DigestService $digestService, + private EtagService $etagService, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -62,11 +67,14 @@ public function getElementFile(string $id, Request $request): BinaryStreamRespon } $element = $this->elementManager->getElementOrFail($elementId); + if (!$this->elementService->hasFile($element)) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } $fileName = $this->elementService->getFileName($element); $fileNameFallback = $this->fileService->getAsciiSafeFileName($fileName); - $fileOperation = $this->fileOperationFactory->createFileOperationFromElement($element); + $fileOperation = $this->s3OperationFactory->createFileOperationFromElement($element); $doesFileExist = $this->s3Service->existsFile($fileOperation); if (false === $doesFileExist) { @@ -77,7 +85,11 @@ public function getElementFile(string $id, Request $request): BinaryStreamRespon $contentType = $this->getStoredContentType($element); $rangeHeader = $request->headers->get('Range'); - if (null !== $rangeHeader) { + // a failed `If-Range` precondition ignores the `Range` header and answers the full file + if (null !== $rangeHeader && $this->fileRangeService->isRangeConditionSatisfied( + $request->headers->get('If-Range'), + $this->etagService->getCurrentRequestEtag()?->getEtag() + )) { $totalContentLength = $this->s3Service->getContentLength($fileOperation); $range = $this->fileRangeService->parseRangeHeader($rangeHeader, $totalContentLength); $object = $this->s3Service->getFileByteRange($fileOperation, $range->getStart(), $range->getEnd()); @@ -113,8 +125,7 @@ private function getReprDigestHeaderValue(NodeElementInterface|RelationElementIn return null; } $fileProperty = $element->getProperty('file'); - // 'file' is read back from MongoDB; nested values (like 'hash') may still be BSONDocument/ArrayAccess - // instances rather than plain arrays at this point, so array-offset access is used instead of is_array(). + // nested values from MongoDB may still be BSONDocument instances instead of plain arrays if (!is_array($fileProperty) && !($fileProperty instanceof ArrayAccess)) { return null; } diff --git a/src/Controller/File/PostElementFileController.php b/src/Controller/File/PostElementFileController.php index 0b55f4dc..ab9c35e2 100644 --- a/src/Controller/File/PostElementFileController.php +++ b/src/Controller/File/PostElementFileController.php @@ -4,14 +4,19 @@ namespace App\Controller\File; +use App\Attribute\EndpointSupportsEtag; use App\Factory\Exception\Client404NotFoundExceptionFactory; use App\Factory\Exception\Client409ConflictExceptionFactory; use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\ElementManager; +use App\Service\ElementService; +use App\Service\FileCreationLockService; use App\Service\UploadCreationService; +use App\Service\UploadService; use App\Type\AccessType; +use App\Type\EtagType; use Ramsey\Uuid\Rfc4122\UuidV4; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -19,8 +24,6 @@ use Symfony\Component\Routing\Attribute\Route; /** - * @todo add support for etags - * * @SuppressWarnings("PHPMD.UnusedFormalParameter") */ class PostElementFileController extends AbstractController @@ -30,8 +33,11 @@ public function __construct( private AccessChecker $accessChecker, private UploadCreationService $uploadCreationService, private ElementManager $elementManager, + private ElementService $elementService, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, private Client409ConflictExceptionFactory $client409ConflictExceptionFactory, + private FileCreationLockService $fileCreationLockService, + private UploadService $uploadService, ) { } @@ -43,6 +49,7 @@ public function __construct( ], methods: ['POST'] )] + #[EndpointSupportsEtag(EtagType::FILE)] public function postElementFile(string $id, Request $request): Response { $elementId = UuidV4::fromString($id); @@ -52,12 +59,25 @@ public function postElementFile(string $id, Request $request): Response throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } - $element = $this->elementManager->getElementOrFail($elementId); - $properties = $element->getProperties(); - if (array_key_exists('file', $properties)) { - throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf("Element with id '%s' already has an associated file; can not create new file. Delete existing file first or replace it with PUT.", $element->getId()?->toString() ?? 'missing element id')); + // POST creates the file: only one creation per element at a time, the lock is released as soon as the request + // ends (also on errors), or expires on its own if the process dies + $lockToken = $this->fileCreationLockService->acquire($elementId); + if (null === $lockToken) { + throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf("Another request is currently creating the file of element with id '%s'.", $elementId->toString())); } - return $this->uploadCreationService->handleUploadCreationFromRequest($elementId, $request); + try { + $element = $this->elementManager->getElementOrFail($elementId); + if ($this->elementService->hasFile($element)) { + throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf("Element with id '%s' already has an associated file; can not create new file. Delete existing file first or replace it with PUT.", $element->getId()?->toString() ?? 'missing element id')); + } + if ($this->uploadService->hasUploadsTargeting($elementId)) { + throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf("An upload for element with id '%s' is in progress; can not create new file. Finish or delete the upload first.", $elementId->toString())); + } + + return $this->uploadCreationService->handleUploadCreationFromRequest($elementId, $request); + } finally { + $this->fileCreationLockService->release($elementId, $lockToken); + } } } diff --git a/src/Controller/File/PutElementFileController.php b/src/Controller/File/PutElementFileController.php index d30da8a2..4dfdf115 100644 --- a/src/Controller/File/PutElementFileController.php +++ b/src/Controller/File/PutElementFileController.php @@ -6,9 +6,11 @@ use App\Attribute\EndpointSupportsEtag; use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Factory\Exception\Client409ConflictExceptionFactory; use App\Helper\Regex; use App\Security\AccessChecker; use App\Security\AuthProvider; +use App\Service\FileCreationLockService; use App\Service\UploadCreationService; use App\Type\AccessType; use App\Type\EtagType; @@ -28,6 +30,8 @@ public function __construct( private AccessChecker $accessChecker, private UploadCreationService $uploadCreationService, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, + private Client409ConflictExceptionFactory $client409ConflictExceptionFactory, + private FileCreationLockService $fileCreationLockService, ) { } @@ -49,6 +53,11 @@ public function putElementFile(string $id, Request $request): Response throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } + // parallel PUT requests may race each other, but not a running POST which creates the file + if ($this->fileCreationLockService->isLocked($elementId)) { + throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf("Another request is currently creating the file of element with id '%s'.", $elementId->toString())); + } + return $this->uploadCreationService->handleUploadCreationFromRequest($elementId, $request); } } diff --git a/src/Controller/Search/PostSearchController.php b/src/Controller/Search/PostSearchController.php index 625152e8..bef99678 100644 --- a/src/Controller/Search/PostSearchController.php +++ b/src/Controller/Search/PostSearchController.php @@ -9,6 +9,7 @@ use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client400MissingPropertyExceptionFactory; use App\Factory\Exception\Server500InternalServerErrorExceptionFactory; +use App\Service\RequestContentService; use App\Type\Response\JsonResponse; use App\Type\SearchStepType; use Psr\EventDispatcher\EventDispatcherInterface; @@ -25,6 +26,7 @@ public function __construct( private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, private Client400MissingPropertyExceptionFactory $client400MissingPropertyExceptionFactory, private Server500InternalServerErrorExceptionFactory $server500InternalServerErrorExceptionFactory, + private RequestContentService $requestContentService, ) { } @@ -136,7 +138,7 @@ private function getStepParametersFromStepDefinition(array $step, int $stepIndex )] public function postSearch(Request $request): Response { - $body = \Safe\json_decode($request->getContent(), true); + $body = \Safe\json_decode($this->requestContentService->getContent($request), true); $debug = $this->getIsDebugFromBody($body); $globalParameters = $this->getGlobalParametersFromBody($body); diff --git a/src/Controller/Upload/DeleteUploadController.php b/src/Controller/Upload/DeleteUploadController.php index 177cb454..3eeab9e3 100644 --- a/src/Controller/Upload/DeleteUploadController.php +++ b/src/Controller/Upload/DeleteUploadController.php @@ -5,10 +5,12 @@ namespace App\Controller\Upload; use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Factory\Exception\Client409ConflictExceptionFactory; use App\Factory\Type\UploadFactory; use App\Helper\Regex; use App\Security\AuthProvider; use App\Service\ElementManager; +use App\Service\UploadLockService; use App\Service\UploadService; use App\Type\Response\NoContentResponse; use Exception; @@ -25,7 +27,9 @@ public function __construct( private LoggerInterface $logger, private UploadFactory $uploadFactory, private UploadService $uploadService, + private UploadLockService $uploadLockService, private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, + private Client409ConflictExceptionFactory $client409ConflictExceptionFactory, ) { } @@ -42,7 +46,7 @@ public function deleteUpload(string $id): NoContentResponse $uploadElement = $this->elementManager->getElementOrFail(UuidV4::fromString($id)); try { $upload = $this->uploadFactory->createUploadFromElement($uploadElement); - } catch (Exception $e) { + } catch (Exception) { throw $this->client404NotFoundExceptionFactory->createFromTemplate(); } @@ -58,8 +62,20 @@ public function deleteUpload(string $id): NoContentResponse ] ); - $this->uploadService->deleteUploadAndChunks($upload); - $this->elementManager->flush(); + // must not race an append, which would recreate chunks or the node after the deletion + $lockToken = $this->uploadLockService->acquire($upload->getId()); + if (null === $lockToken) { + throw $this->client409ConflictExceptionFactory->createFromDetail('Another request is currently modifying this upload, please retry once it has finished.'); + } + + try { + // an append may have finished while waiting for the lock, chunk count has to be fresh + $upload = $this->uploadFactory->createUploadFromElement($this->elementManager->getElementOrFail(UuidV4::fromString($id))); + $this->uploadService->deleteUploadAndChunks($upload); + $this->elementManager->flush(); + } finally { + $this->uploadLockService->release($upload->getId(), $lockToken); + } return new NoContentResponse(); } diff --git a/src/Controller/Upload/HeadUploadController.php b/src/Controller/Upload/HeadUploadController.php index 120fba8c..8f280e3f 100644 --- a/src/Controller/Upload/HeadUploadController.php +++ b/src/Controller/Upload/HeadUploadController.php @@ -4,16 +4,9 @@ namespace App\Controller\Upload; -use App\Factory\Exception\Client404NotFoundExceptionFactory; use App\Factory\Type\Response\NoContentResponseFactory; -use App\Factory\Type\UploadFactory; use App\Helper\Regex; -use App\Security\AccessChecker; -use App\Security\AuthProvider; -use App\Service\ElementManager; -use App\Type\AccessType; -use Exception; -use Ramsey\Uuid\Rfc4122\UuidV4; +use App\Service\UploadAccessService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -21,12 +14,8 @@ class HeadUploadController extends AbstractController { public function __construct( - private AuthProvider $authProvider, - private AccessChecker $accessChecker, - private ElementManager $elementManager, + private UploadAccessService $uploadAccessService, private NoContentResponseFactory $noContentResponseFactory, - private UploadFactory $uploadFactory, - private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -40,23 +29,8 @@ public function __construct( )] public function headUpload(string $id): Response { - $uploadElement = $this->elementManager->getElementOrFail(UuidV4::fromString($id)); - try { - $upload = $this->uploadFactory->createUploadFromElement($uploadElement); - } catch (Exception $e) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } - - $userId = $this->authProvider->getUserId(); - if ($upload->getUploadOwner()->toString() !== $userId->toString()) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } - // an upload whose target element is gone (e.g. deleted mid-upload) or no longer accessible can never be - // completed - report it the same way PATCH already would, instead of an upload that looks healthy - if (!$this->accessChecker->hasAccessToElement($userId, $upload->getUploadTarget(), AccessType::UPDATE)) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } - - return $this->noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload); + return $this->noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload( + $this->uploadAccessService->loadAuthorizedUpload($id) + ); } } diff --git a/src/Controller/Upload/PatchUploadController.php b/src/Controller/Upload/PatchUploadController.php index 47efdc1c..5a2903ed 100644 --- a/src/Controller/Upload/PatchUploadController.php +++ b/src/Controller/Upload/PatchUploadController.php @@ -4,65 +4,27 @@ namespace App\Controller\Upload; -use App\Contract\UploadInterface; -use App\EventSystem\ElementFileReplace\Event\ElementFileReplaceEvent; -use App\Exception\Client400BadContentException; -use App\Factory\Exception\Client400BadContentExceptionFactory; -use App\Factory\Exception\Client404NotFoundExceptionFactory; use App\Factory\Exception\Client409ConflictExceptionFactory; -use App\Factory\Exception\Client410GoneExceptionFactory; use App\Factory\Type\Request\PartialUploadRequestFactory; use App\Factory\Type\Response\NoContentResponseFactory; -use App\Factory\Type\S3\MergeFileChunksOperationFactory; -use App\Factory\Type\S3\UploadFileChunkOperationFactory; -use App\Factory\Type\UploadFactory; use App\Helper\Regex; -use App\Security\AccessChecker; -use App\Security\AuthProvider; -use App\Service\DigestService; -use App\Service\ElementManager; -use App\Service\FileHashService; -use App\Service\IncrementalHashService; -use App\Service\S3Service; -use App\Service\UploadService; -use App\Type\AccessType; -use App\Type\Response\JsonResponse; -use EmberNexusBundle\Service\EmberNexusConfiguration; -use Exception; -use Ramsey\Uuid\Rfc4122\UuidV4; -use Safe\DateTime; +use App\Service\UploadAccessService; +use App\Service\UploadAppendService; +use App\Service\UploadLockService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -/** - * @SuppressWarnings("PHPMD.ExcessiveParameterList") - * @SuppressWarnings("PHPMD.CyclomaticComplexity") - * @SuppressWarnings("PHPMD.NPathComplexity") - */ class PatchUploadController extends AbstractController { public function __construct( - private AuthProvider $authProvider, - private AccessChecker $accessChecker, - private ElementManager $elementManager, - private EmberNexusConfiguration $emberNexusConfiguration, - private EventDispatcherInterface $eventDispatcher, + private UploadAccessService $uploadAccessService, + private UploadAppendService $uploadAppendService, + private UploadLockService $uploadLockService, private PartialUploadRequestFactory $partialUploadRequestFactory, private NoContentResponseFactory $noContentResponseFactory, - private UploadFactory $uploadFactory, - private UploadService $uploadService, - private UploadFileChunkOperationFactory $uploadFileChunkOperationFactory, - private MergeFileChunksOperationFactory $mergeFileChunksOperationFactory, - private S3Service $s3Service, - private IncrementalHashService $incrementalHashService, - private DigestService $digestService, - private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, - private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, private Client409ConflictExceptionFactory $client409ConflictExceptionFactory, - private Client410GoneExceptionFactory $client410GoneExceptionFactory, ) { } @@ -76,137 +38,27 @@ public function __construct( )] public function patchUpload(string $id, Request $request): Response { - $uploadElement = $this->elementManager->getElementOrFail(UuidV4::fromString($id)); - try { - $upload = $this->uploadFactory->createUploadFromElement($uploadElement); - } catch (Exception $e) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } - - $userId = $this->authProvider->getUserId(); - if ($upload->getUploadOwner()->toString() !== $userId->toString()) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } - if (!$this->accessChecker->hasAccessToElement($userId, $upload->getUploadTarget(), AccessType::UPDATE)) { - throw $this->client404NotFoundExceptionFactory->createFromTemplate(); - } - - if ($upload->getExpires() < new DateTime()) { - throw $this->client410GoneExceptionFactory->createFromTemplate(); - } - - $partialUploadRequest = $this->partialUploadRequestFactory->createPartialUploadRequestFromRequest($request); - - if ($partialUploadRequest->getUploadOffset() !== $upload->getUploadOffset()) { - throw $this->client409ConflictExceptionFactory->createFromDetail('Offset from request does not match offset of resource.', additionalDetails: ['expected-offset' => $upload->getUploadOffset(), 'provided-offset' => $partialUploadRequest->getUploadOffset()]); - } - - // the chunk's resource is hashed once, locally, then rewound, before S3Service ever sees it - see - // IncrementalHashService for why this is not done via a persistently-attached stream filter instead. - $resource = $partialUploadRequest->getContent(); - $hashContext = null !== $upload->getHashState() - ? $this->incrementalHashService->unserializeContextFromStorage($upload->getHashState()) - : $this->incrementalHashService->createContext(FileHashService::ALGORITHM); - $this->incrementalHashService->updateFromResource($hashContext, $resource); - - $uploadFileChunkOperation = $this->uploadFileChunkOperationFactory->createUploadFileChunkOperationFromPartialUploadRequest($partialUploadRequest, $upload); - $chunkLength = $this->s3Service->uploadFileChunk($uploadFileChunkOperation); - if (is_resource($resource)) { - \Safe\fclose($resource); - } - - /** - * mirrors the same check for an upload's very first chunk (see UploadCreationService): a chunk has to be - * at least bytes long, unless it is the chunk which completes the upload, which may be of any - * length - including zero, e.g. to just close out an upload that already has all its data. - */ - if (false === $partialUploadRequest->isUploadComplete() && $chunkLength < $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()) { - throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at least %d bytes long, got %d.', $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes(), $chunkLength)); - } - if ($chunkLength > $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()) { - throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at most %d bytes long, got %d.', $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(), $chunkLength)); - } - - if (null !== $upload->getUploadLength()) { - if ($upload->getUploadLength() < $upload->getUploadOffset() + $chunkLength) { - throw $this->client409ConflictExceptionFactory->createFromDetail('Already uploaded data exceeds defined upload length.'); - } - } + // cheap checks first, so that only the owner of an upload can block it with the lock + $upload = $this->uploadAccessService->loadAuthorizedUpload($id); - if ($partialUploadRequest->isUploadComplete()) { - // the final chunk was successfully uploaded -> we can create the file. The hash is already complete - // at this point - no need to store its (now finalized, no longer resumable) state on the upload. - $finalHash = $this->incrementalHashService->finalize($hashContext); - $upload = $this->uploadFactory->addNewChunkToUpload($upload, $chunkLength); - $upload = $this->uploadFactory->markUploadAsComplete($upload); - $this->uploadService->mergeUploadElement($upload); - $requestDigestHeaderValue = $request->headers->get('Repr-Digest') ?? $request->headers->get('Content-Digest'); - $this->createFile($upload, $finalHash, $requestDigestHeaderValue); - } else { - $hashState = $this->incrementalHashService->serializeContextForStorage($hashContext); - $upload = $this->uploadFactory->addNewChunkToUpload($upload, $chunkLength, $hashState); - $this->uploadService->mergeUploadElement($upload); + // draft-ietf-httpbis-resumable-upload: concurrent appends must not corrupt the upload + $lockToken = $this->uploadLockService->acquire($upload->getId()); + if (null === $lockToken) { + throw $this->client409ConflictExceptionFactory->createFromDetail('Another request is currently modifying this upload, please retry once it has finished.'); } - $this->elementManager->flush(); - - return $this->noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload); - } - - public function createFile(UploadInterface $upload, string $hash, ?string $requestDigestHeaderValue = null): Response - { - $element = $this->elementManager->getElementOrFail($upload->getUploadTarget()); - - // building the operation only resolves storage keys, it does not touch S3 - so the hash, already known - // from the incremental chunk hashing above, can be verified before the chunks are merged into the - // storage bucket. On a mismatch, this means an existing file at this element is never - // replaced/overwritten in the first place; only the now-unneeded uploaded chunks need cleaning up. - $mergeFileChunksOperation = $this->mergeFileChunksOperationFactory->createMergeFileOperationFromUpload($upload); - - if (null !== $requestDigestHeaderValue) { - try { - $this->verifyRequestDigest($requestDigestHeaderValue, $hash); - } catch (Client400BadContentException $exception) { - $this->s3Service->deleteFileChunks($mergeFileChunksOperation); - - throw $exception; - } - } - - $mergedContentLength = $this->s3Service->mergeFileChunks($mergeFileChunksOperation); - $mergedMimeType = $this->s3Service->getMimeTypeFromMergeFileChunksOperation($mergeFileChunksOperation); - - $element->addProperty('file', [ - 'contentLength' => $mergedContentLength, - 'extension' => $upload->getExtension(), - 'mimeType' => $mergedMimeType, - 'hash' => [ - FileHashService::ALGORITHM => $hash, - ], - ]); - $element->addProperty('hasFile', true); - $this->elementManager->merge($element); - $this->elementManager->flush(); - - $this->s3Service->deleteFileChunks($mergeFileChunksOperation); - $this->uploadService->deleteUpload($upload); - $this->elementManager->flush(); - - $this->eventDispatcher->dispatch(new ElementFileReplaceEvent($upload->getUploadTarget())); - - return new JsonResponse([ - 'upload' => 'complete', - ]); - } - - private function verifyRequestDigest(string $requestDigestHeaderValue, string $actualHash): void - { - $expectedHash = $this->digestService->parseSha256HexFromHeaderValue($requestDigestHeaderValue); - if (null === $expectedHash) { - throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not verify upload: 'Repr-Digest'/'Content-Digest' header '%s' does not declare a supported digest algorithm; only 'sha-256' is supported.", $requestDigestHeaderValue)); - } - if ($expectedHash !== $actualHash) { - throw $this->client400BadContentExceptionFactory->createFromDetail('Could not verify upload: the declared digest does not match the uploaded file\'s actual content.'); + try { + // state may have changed while waiting for the lock, so the offset check needs fresh data + $upload = $this->uploadAccessService->loadAuthorizedUpload($id); + $upload = $this->uploadAppendService->append( + $upload, + $this->partialUploadRequestFactory->createPartialUploadRequestFromRequest($request), + $request->headers->get('Repr-Digest') + ); + + return $this->noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload); + } finally { + $this->uploadLockService->release($upload->getId(), $lockToken); } } } diff --git a/src/Controller/User/DeleteTokenController.php b/src/Controller/User/DeleteTokenController.php index db8f6ca9..79f81ee1 100644 --- a/src/Controller/User/DeleteTokenController.php +++ b/src/Controller/User/DeleteTokenController.php @@ -6,7 +6,9 @@ use App\Factory\Exception\Client401UnauthorizedExceptionFactory; use App\Security\AuthProvider; +use App\Service\ElementFileDeletionService; use App\Service\ElementManager; +use App\Service\UploadService; use App\Type\Response\NoContentResponse; use LogicException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -18,6 +20,8 @@ class DeleteTokenController extends AbstractController public function __construct( private ElementManager $elementManager, private AuthProvider $authProvider, + private UploadService $uploadService, + private ElementFileDeletionService $elementFileDeletionService, private Client401UnauthorizedExceptionFactory $client401UnauthorizedExceptionFactory, ) { } @@ -44,9 +48,17 @@ public function deleteToken(): Response } $tokenElement = $this->elementManager->getElementOrFail($tokenId); + $fileOperations = $this->elementFileDeletionService->getFileOperationsForDeletionOfElement($tokenElement); + + // separate flush before deleting the element, see UploadService::deleteUploadsTargeting() + $this->uploadService->deleteUploadsTargeting($tokenId); + $this->elementManager->flush(); + $this->elementManager->delete($tokenElement); $this->elementManager->flush(); + $this->elementFileDeletionService->deleteFiles($fileOperations); + return new NoContentResponse(); } } diff --git a/src/Controller/User/PostChangePasswordController.php b/src/Controller/User/PostChangePasswordController.php index aa4057f2..942a32a8 100644 --- a/src/Controller/User/PostChangePasswordController.php +++ b/src/Controller/User/PostChangePasswordController.php @@ -6,6 +6,7 @@ use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; +use App\Service\RequestContentService; use App\Service\RequestUtilService; use App\Service\SecurityUtilService; use App\Type\Response\NoContentResponse; @@ -21,6 +22,7 @@ public function __construct( private SecurityUtilService $securityUtilService, private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, private Client401UnauthorizedExceptionFactory $client401UnauthorizedExceptionFactory, + private RequestContentService $requestContentService, ) { } @@ -31,8 +33,7 @@ public function __construct( )] public function postChangePassword(Request $request): Response { - $body = \Safe\json_decode($request->getContent(), true); - $data = $this->requestUtilService->getDataFromBody($body); + $body = \Safe\json_decode($this->requestContentService->getContent($request), true); $this->requestUtilService->validateTypeFromBody('ActionChangePassword', $body); @@ -40,7 +41,7 @@ public function postChangePassword(Request $request): Response $currentPassword = $this->requestUtilService->getStringFromBody('currentPassword', $body); $this->validateNewPasswordIsDifferentFromCurrentPassword($newPassword, $currentPassword); - $uniqueUserIdentifier = $this->requestUtilService->getUniqueUserIdentifierFromBodyAndData($body, $data); + $uniqueUserIdentifier = $this->requestUtilService->getUniqueUserIdentifierFromBodyAndData($body); $userNode = $this->securityUtilService->findUserByUniqueUserIdentifier($uniqueUserIdentifier); $userId = $userNode->getId(); diff --git a/src/Controller/User/PostRegisterController.php b/src/Controller/User/PostRegisterController.php index 35c16291..421ed5d1 100644 --- a/src/Controller/User/PostRegisterController.php +++ b/src/Controller/User/PostRegisterController.php @@ -10,6 +10,7 @@ use App\Security\UserPasswordHasher; use App\Service\CreateElementFromRawDataService; use App\Service\ElementManager; +use App\Service\RequestContentService; use App\Service\RequestUtilService; use App\Type\Response\CreatedResponse; use EmberNexusBundle\Service\EmberNexusConfiguration; @@ -39,6 +40,7 @@ public function __construct( private Client400ReservedIdentifierExceptionFactory $client400ReservedIdentifierExceptionFactory, private Client403ForbiddenExceptionFactory $client403ForbiddenExceptionFactory, private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, + private RequestContentService $requestContentService, ) { } @@ -53,20 +55,16 @@ public function postRegister(Request $request): Response throw $this->client403ForbiddenExceptionFactory->createFromTemplate(); } - $body = \Safe\json_decode($request->getContent(), true); + $body = \Safe\json_decode($this->requestContentService->getContent($request), true); $rawData = $this->requestUtilService->getDataFromBody($body); $this->requestUtilService->validateTypeFromBody('User', $body); $userId = UuidV4::uuid4(); $password = $this->requestUtilService->getStringFromBody('password', $body); - $uniqueUserIdentifier = $this->requestUtilService->getUniqueUserIdentifierFromBodyAndData($body, $rawData); + $uniqueUserIdentifier = $this->requestUtilService->getUniqueUserIdentifierFromBodyAndData($body); $this->checkForDuplicateUniqueUserIdentifier($uniqueUserIdentifier); $uniqueIdentifier = $this->emberNexusConfiguration->getRegisterUniqueIdentifier(); - if (array_key_exists($uniqueIdentifier, $rawData)) { - // remove unique identifier from data payload, was required in releases before 0.1.6 - unset($rawData[$uniqueIdentifier]); - } $userNode = $this->createElementFromRawDataService->createElementFromRawData( $userId, diff --git a/src/Controller/User/PostTokenController.php b/src/Controller/User/PostTokenController.php index e5fa7a3e..d60e08f0 100644 --- a/src/Controller/User/PostTokenController.php +++ b/src/Controller/User/PostTokenController.php @@ -8,6 +8,7 @@ use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; use App\Security\TokenGenerator; +use App\Service\RequestContentService; use App\Service\RequestUtilService; use App\Service\SecurityUtilService; use App\Type\Response\JsonResponse; @@ -24,6 +25,7 @@ public function __construct( private Client401UnauthorizedExceptionFactory $client401UnauthorizedExceptionFactory, private RequestUtilService $requestUtilService, private SecurityUtilService $securityUtilService, + private RequestContentService $requestContentService, ) { } @@ -34,11 +36,10 @@ public function __construct( )] public function postToken(Request $request): Response { - $body = \Safe\json_decode($request->getContent(), true); - $data = $this->requestUtilService->getDataFromBody($body); + $body = \Safe\json_decode($this->requestContentService->getContent($request), true); $this->requestUtilService->validateTypeFromBody('Token', $body); - $uniqueUserIdentifier = $this->requestUtilService->getUniqueUserIdentifierFromBodyAndData($body, $data); + $uniqueUserIdentifier = $this->requestUtilService->getUniqueUserIdentifierFromBodyAndData($body); $userElement = $this->securityUtilService->findUserByUniqueUserIdentifier($uniqueUserIdentifier); $userId = $userElement->getId(); diff --git a/src/EventSystem/Controller/EventListener/IfMatchControllerEventListener.php b/src/EventSystem/Controller/EventListener/IfMatchControllerEventListener.php index e7ff2249..cfd39424 100644 --- a/src/EventSystem/Controller/EventListener/IfMatchControllerEventListener.php +++ b/src/EventSystem/Controller/EventListener/IfMatchControllerEventListener.php @@ -8,6 +8,7 @@ use App\Factory\Exception\Client412PreconditionFailedExceptionFactory; use App\Service\EtagService; use App\Type\Etag; +use App\Type\EtagType; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ControllerEvent; @@ -19,7 +20,9 @@ public function __construct( ) { } - #[AsEventListener(priority: 128)] + // RFC 9110, Section 13.2.2: If-Match is evaluated before If-None-Match, so this needs a higher priority than + // IfNoneMatchControllerEventListener (a failing If-Match must win with 412 even if If-None-Match would match) + #[AsEventListener(priority: 192)] public function onKernelController(ControllerEvent $event): void { $attributes = $event->getAttributes(EndpointSupportsEtag::class); @@ -27,10 +30,20 @@ public function onKernelController(ControllerEvent $event): void return; } $currentRequestEtag = $this->etagService->getCurrentRequestEtag(); - if (null === $currentRequestEtag) { + if (!$event->getRequest()->headers->has('If-Match')) { return; } - if (!$event->getRequest()->headers->has('If-Match')) { + if (null === $currentRequestEtag) { + // An element without file has no file representation, so nothing can match If-Match (RFC 9110). For other + // types a missing ETag means 'not calculable', e.g. too large collections, which has to be ignored. + $attribute = $attributes[0]; + /** + * @var EndpointSupportsEtag $attribute + */ + if (EtagType::FILE === $attribute->getEtagType()) { + throw $this->client412PreconditionFailedExceptionFactory->createFromTemplate(); + } + return; } $rawEtags = $event->getRequest()->headers->get('If-Match'); @@ -41,6 +54,10 @@ public function onKernelController(ControllerEvent $event): void $etags = []; foreach ($rawEtags as $rawEtag) { $rawEtag = trim($rawEtag); + if ('*' === $rawEtag) { + // matches every current representation, and there is one as the current ETag is not null + return; + } if (str_starts_with($rawEtag, 'W/')) { // If a listed ETag has the W/ prefix indicating a weak entity tag, this comparison algorithm will never match it. continue; diff --git a/src/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListener.php b/src/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListener.php index aab7ca93..1022d3dd 100644 --- a/src/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListener.php +++ b/src/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListener.php @@ -19,7 +19,9 @@ public function __construct( ) { } - #[AsEventListener(priority: 192)] + // RFC 9110, Section 13.2.2: If-Match is evaluated first, so this needs a lower priority than + // IfMatchControllerEventListener + #[AsEventListener(priority: 128)] public function onKernelController(ControllerEvent $event): void { $attributes = $event->getAttributes(EndpointSupportsEtag::class); @@ -39,8 +41,14 @@ public function onKernelController(ControllerEvent $event): void } $rawEtags = explode(',', $rawEtags); $etags = []; + $matchesAnyEtag = false; foreach ($rawEtags as $rawEtag) { $rawEtag = trim($rawEtag); + if ('*' === $rawEtag) { + // matches every current representation; an endpoint without one has no current ETag and never gets here + $matchesAnyEtag = true; + continue; + } // The comparison with the stored ETag uses the weak comparison algorithm, meaning two files are // considered identical if the content is equivalent — they don't have to be identical byte by byte. // For example, two pages that differ by their creation date in the footer would still be considered @@ -51,7 +59,7 @@ public function onKernelController(ControllerEvent $event): void $rawEtag = trim($rawEtag, '"'); $etags[] = $rawEtag; } - if (in_array($currentRequestEtag->getEtag(), $etags)) { + if ($matchesAnyEtag || in_array($currentRequestEtag->getEtag(), $etags)) { if (in_array($event->getRequest()->getMethod(), ['GET', 'HEAD'])) { $event->setController(function () { return new NotModifiedResponse(); diff --git a/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php b/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php index da378e73..85510df4 100644 --- a/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php +++ b/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php @@ -113,7 +113,7 @@ private function handleEvent(NodeElementFragmentizeEvent|RelationElementFragment $elasticFragment->addProperty($name, $value); continue; } - throw $this->server500InternalServerErrorExceptionFactory->createFromTemplate(sprintf("Unknown data type with value '%s'.", $value), ['value' => $value]); + throw $this->server500InternalServerErrorExceptionFactory->createFromTemplate(sprintf("Unknown data type '%s'.", get_debug_type($value)), ['value' => $value]); } } } diff --git a/src/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListener.php b/src/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListener.php index d10a82fc..0c8cc73c 100644 --- a/src/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListener.php +++ b/src/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListener.php @@ -9,17 +9,10 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener; /** - * Writes 'hasFile' to all three backing stores unconditionally, mirroring NamePropertyElementFragmentizeEventListener. - * Unlike 'file' (a nested object, only stored in MongoDB), 'hasFile' is a plain boolean, so it can live on the - * Cypher node/relation itself and in Elasticsearch - which is what lets search filter on, and listings display, - * whether an element has a file at all. + * Stores 'hasFile' in all three databases, so that Cypher and Elasticsearch queries can filter on it. */ class HasFilePropertyElementFragmentizeEventListener { - public function __construct() - { - } - #[AsEventListener] public function onNodeElementFragmentizeEvent(NodeElementFragmentizeEvent $event): void { diff --git a/src/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListener.php b/src/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListener.php index e87e1cff..af2a3b12 100644 --- a/src/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListener.php +++ b/src/EventSystem/ElementPropertyChange/EventListener/FileElementPropertyChangeEventListener.php @@ -9,9 +9,7 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener; /** - * The 'file' property is only ever set by the file upload/delete endpoints, never directly by a client. Allowing - * direct writes would let a client forge file metadata (contentLength, hash, ...) for a file which does not - * actually exist, or does not match what was actually uploaded. + * 'file' is managed by the file endpoints only, clients must not be able to forge its metadata. */ class FileElementPropertyChangeEventListener { diff --git a/src/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListener.php b/src/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListener.php index cdf57b9c..1ea790a0 100644 --- a/src/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListener.php +++ b/src/EventSystem/ElementPropertyChange/EventListener/HasFileElementPropertyChangeEventListener.php @@ -9,7 +9,7 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener; /** - * The 'hasFile' property is computed automatically alongside 'file', never set directly by a client. + * 'hasFile' is managed by the file endpoints only. */ class HasFileElementPropertyChangeEventListener { diff --git a/src/EventSystem/ElementPropertyChange/EventListener/UploadElementPropertyChangeEventListener.php b/src/EventSystem/ElementPropertyChange/EventListener/UploadElementPropertyChangeEventListener.php new file mode 100644 index 00000000..8f60b36b --- /dev/null +++ b/src/EventSystem/ElementPropertyChange/EventListener/UploadElementPropertyChangeEventListener.php @@ -0,0 +1,32 @@ +getLabelOrType()) { + return; + } + foreach (array_keys($event->getChangedProperties()) as $propertyName) { + throw $this->client400ForbiddenPropertyExceptionFactory->createFromTemplate((string) $propertyName); + } + } +} diff --git a/src/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListener.php b/src/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListener.php index 71434bcd..7e0e3b46 100644 --- a/src/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListener.php +++ b/src/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListener.php @@ -8,8 +8,7 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener; /** - * A PUT (full data replace) must not wipe an element's associated file; files are managed through the dedicated - * file/upload endpoints, independently of an element's 'data'. + * PUT must not remove an element's file, files are managed by the file endpoints only. */ class FileElementPropertyResetEventListener { diff --git a/src/EventSystem/EntityManager/EventListener/CalculateSearchAccessEventListener.php b/src/EventSystem/EntityManager/EventListener/CalculateSearchAccessEventListener.php index b8b3f802..0fd55797 100644 --- a/src/EventSystem/EntityManager/EventListener/CalculateSearchAccessEventListener.php +++ b/src/EventSystem/EntityManager/EventListener/CalculateSearchAccessEventListener.php @@ -4,15 +4,11 @@ namespace App\EventSystem\EntityManager\EventListener; -use App\Contract\NodeElementInterface; -use App\Contract\RelationElementInterface; use App\EventSystem\EntityManager\Event\ElementPostCreateEvent; use App\EventSystem\EntityManager\Event\ElementUpdateAfterBackupLoadEvent; -use App\Security\AccessChecker; use App\Service\AppStateService; -use App\Type\AccessType; +use App\Service\SearchAccessCalculatorService; use App\Type\AppStateType; -use Ramsey\Uuid\UuidInterface; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Syndesi\ElasticDataStructures\Type\Document; use Syndesi\ElasticEntityManager\Type\EntityManager as ElasticEntityManager; @@ -20,9 +16,9 @@ class CalculateSearchAccessEventListener { public function __construct( - private AccessChecker $accessChecker, private AppStateService $appStateService, private ElasticEntityManager $elasticEntityManager, + private SearchAccessCalculatorService $searchAccessCalculatorService, ) { } @@ -45,77 +41,22 @@ public function onElementUpdateAfterBackupLoadEvent(ElementUpdateAfterBackupLoad public function handleEvent(ElementPostCreateEvent|ElementUpdateAfterBackupLoadEvent $event): void { $element = $event->getElement(); - if ($element instanceof RelationElementInterface) { - $this->handleRelation($element); - } else { - $this->handleNode($element); - } - } - - private function handleNode(NodeElementInterface $element): void - { $elementId = $element->getId(); if (!$elementId) { return; } - $groupsWithSearchAccess = $this->accessChecker->getDirectGroupsWithAccessToNode($elementId, AccessType::SEARCH); - $usersWithSearchAccess = $this->accessChecker->getDirectUsersWithAccessToNode($elementId, AccessType::SEARCH); - $groupsWithSearchAccess = $this->convertArrayOfUuidsToArrayOfStrings($groupsWithSearchAccess); - $usersWithSearchAccess = $this->convertArrayOfUuidsToArrayOfStrings($usersWithSearchAccess); - - $document = new Document(); - $document - ->setIdentifier($element->getId()?->toString()) - ->setIndex(sprintf( - 'node_%s', - strtolower($element->getLabel() ?? '') - )) - ->addProperties([ - '_groupsWithSearchAccess' => $groupsWithSearchAccess, - '_usersWithSearchAccess' => $usersWithSearchAccess, - ]); - - $this->elasticEntityManager->merge($document); - } - private function handleRelation(RelationElementInterface $element): void - { - $elementId = $element->getId(); - if (!$elementId) { - return; - } - $groupsWithSearchAccess = $this->accessChecker->getDirectGroupsWithAccessToRelation($elementId, AccessType::SEARCH); - $usersWithSearchAccess = $this->accessChecker->getDirectUsersWithAccessToRelation($elementId, AccessType::SEARCH); - $groupsWithSearchAccess = $this->convertArrayOfUuidsToArrayOfStrings($groupsWithSearchAccess); - $usersWithSearchAccess = $this->convertArrayOfUuidsToArrayOfStrings($usersWithSearchAccess); + $access = $this->searchAccessCalculatorService->calculateSearchAccess($element); $document = new Document(); $document - ->setIdentifier($element->getId()?->toString()) - ->setIndex(sprintf( - 'relation_%s', - strtolower($element->getType() ?? '') - )) + ->setIdentifier($elementId->toString()) + ->setIndex($this->searchAccessCalculatorService->getIndexForElement($element)) ->addProperties([ - '_groupsWithSearchAccess' => $groupsWithSearchAccess, - '_usersWithSearchAccess' => $usersWithSearchAccess, + '_groupsWithSearchAccess' => $access['groups'], + '_usersWithSearchAccess' => $access['users'], ]); $this->elasticEntityManager->merge($document); } - - /** - * @param UuidInterface[] $ids - * - * @return string[] - */ - private function convertArrayOfUuidsToArrayOfStrings(array $ids): array - { - $output = []; - foreach ($ids as $id) { - $output[] = $id->toString(); - } - - return $output; - } } diff --git a/src/EventSystem/EntityManager/EventListener/CancelUploadsOnAccessLossEventListener.php b/src/EventSystem/EntityManager/EventListener/CancelUploadsOnAccessLossEventListener.php new file mode 100644 index 00000000..98b4a5d0 --- /dev/null +++ b/src/EventSystem/EntityManager/EventListener/CancelUploadsOnAccessLossEventListener.php @@ -0,0 +1,75 @@ +getElement(); + if ($element instanceof RelationElementInterface) { + $this->markForReviewIfAccessRelevant($element); + } + } + + #[AsEventListener] + public function onElementPostDelete(ElementPostDeleteEvent $event): void + { + $this->markForReviewIfAccessRelevant($event->getElement()); + } + + #[AsEventListener('kernel.response')] + public function onResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest() || !$this->reviewPending) { + return; + } + $this->reviewPending = false; + $this->uploadCancellationService->cancelUploadsWithoutAccess(); + } + + private function markForReviewIfAccessRelevant(NodeElementInterface|RelationElementInterface $element): void + { + if ($element instanceof RelationElementInterface) { + $this->reviewPending = $this->reviewPending || in_array($element->getType(), self::ACCESS_RELATION_TYPES, true); + + return; + } + // deleting a user or group detaches all of its relations without further events + $this->reviewPending = $this->reviewPending || in_array($element->getLabel(), self::ACCESS_NODE_LABELS, true); + } +} diff --git a/src/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListener.php b/src/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListener.php index 0c2e3e48..a4ee205d 100644 --- a/src/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListener.php +++ b/src/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListener.php @@ -5,6 +5,7 @@ namespace App\EventSystem\EntityManager\EventListener; use App\EventSystem\EntityManager\Event\ElementPostCreateEvent; +use App\EventSystem\EntityManager\Event\ElementPostDeleteEvent; use App\EventSystem\EntityManager\Event\ElementPostMergeEvent; use App\EventSystem\EntityManager\Event\ElementPreDeleteEvent; use App\Factory\Exception\Server500LogicErrorExceptionFactory; @@ -20,6 +21,15 @@ class ExpireEtagOnChangeEventListener { + /** + * Keys which have to be expired after an element was deleted, by element id. They are expired only after the + * delete, as a concurrent read before that would cache the ETag again. The related elements can not be looked up + * anymore after the delete, therefore the keys are collected before it and remembered here. + * + * @var array + */ + private array $redisEtagKeysToExpireAfterDelete = []; + public function __construct( private Client $redisClient, private CypherEntityManager $cypherEntityManager, @@ -46,6 +56,24 @@ public function onElementPreDeleteEvent(ElementPreDeleteEvent $event): void $this->handleEvent($event); } + #[AsEventListener] + public function onElementPostDeleteEvent(ElementPostDeleteEvent $event): void + { + $elementId = $event->getElement()->getId(); + if (null === $elementId) { + return; + } + $key = $elementId->toString(); + $redisEtagKeysToExpire = $this->redisEtagKeysToExpireAfterDelete[$key] ?? [ + $this->redisKeyTypeFactory->getEtagElementRedisKey($elementId), + $this->redisKeyTypeFactory->getEtagFileRedisKey($elementId), + ]; + unset($this->redisEtagKeysToExpireAfterDelete[$key]); + foreach ($redisEtagKeysToExpire as $redisEtagKeyToExpire) { + $this->redisClient->expire((string) $redisEtagKeyToExpire, 0); + } + } + /** * @SuppressWarnings("PHPMD.CyclomaticComplexity") * @SuppressWarnings("PHPMD.NPathComplexity") @@ -62,6 +90,8 @@ private function handleEvent(ElementPostCreateEvent|ElementPostMergeEvent|Elemen * @var RedisKey[] $redisEtagKeysToExpire */ $redisEtagKeysToExpire[] = $this->redisKeyTypeFactory->getEtagElementRedisKey($elementId); + // the file ETag depends on the element's name, its file property and its ETag + $redisEtagKeysToExpire[] = $this->redisKeyTypeFactory->getEtagFileRedisKey($elementId); $result = $this->cypherEntityManager->getClient()->runStatement(Statement::create( "MATCH (node {id: \$elementId})\n". @@ -151,6 +181,12 @@ private function handleEvent(ElementPostCreateEvent|ElementPostMergeEvent|Elemen } } + if ($event instanceof ElementPreDeleteEvent) { + $this->redisEtagKeysToExpireAfterDelete[$elementId->toString()] = $redisEtagKeysToExpire; + + return; + } + foreach ($redisEtagKeysToExpire as $redisEtagKeyToExpire) { $this->redisClient->expire((string) $redisEtagKeyToExpire, 0); } diff --git a/src/EventSystem/EntityManager/EventListener/OwnershipChangeEventListener.php b/src/EventSystem/EntityManager/EventListener/OwnershipChangeEventListener.php index d14d91a1..bc4ab572 100644 --- a/src/EventSystem/EntityManager/EventListener/OwnershipChangeEventListener.php +++ b/src/EventSystem/EntityManager/EventListener/OwnershipChangeEventListener.php @@ -8,50 +8,61 @@ use App\EventSystem\EntityManager\Event\ElementPostCreateEvent; use App\EventSystem\EntityManager\Event\ElementPostDeleteEvent; use App\EventSystem\EntityManager\Event\ElementPostMergeEvent; +use App\Service\AppStateService; use App\Service\QueueService; +use App\Type\AppStateType; use App\Type\RabbitMQQueueType; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; class OwnershipChangeEventListener { /** + * Relation types which can change which groups/users have (direct or indirect) search access to an element. + * `HAS_*_ACCESS` types other than search, `CREATED`, property-based rules and implicit rules are intentionally + * excluded: they either don't feed search access at all, or enqueuing on them would risk an unbounded cascade. + * * @var string[] */ private array $relationshipTypesWhichCanTriggerOwnershipChange = [ 'OWNS', - 'CREATED', - 'HAS_READ_ACCESS', - 'HAS_CREATE_ACCESS', - 'HAS_UPDATE_ACCESS', - 'HAS_DELETE_ACCESS', 'HAS_SEARCH_ACCESS', + 'IS_IN_GROUP', ]; public function __construct( private QueueService $queueService, + private AppStateService $appStateService, ) { } #[AsEventListener] public function onElementPostCreate(ElementPostCreateEvent $event): void { - $this->handleEvent($event); + $this->handleEvent($event, 'create'); } #[AsEventListener] public function onElementPostMerge(ElementPostMergeEvent $event): void { - $this->handleEvent($event); + $this->handleEvent($event, 'merge'); } #[AsEventListener] public function onElementPostDelete(ElementPostDeleteEvent $event): void { - $this->handleEvent($event); + $this->handleEvent($event, 'delete'); } - private function handleEvent(ElementPostCreateEvent|ElementPostMergeEvent|ElementPostDeleteEvent $event): void - { + private function handleEvent( + ElementPostCreateEvent|ElementPostMergeEvent|ElementPostDeleteEvent $event, + string $eventKind, + ): void { + if (AppStateType::LOADING_BACKUP === $this->appStateService->getAppState()) { + // relations created/changed while a backup is only partially loaded would enqueue recalculation against + // an incomplete graph; ElementUpdateAfterBackupLoadEvent already recalculates search access for every + // element once the load has finished, so queuing anything here would be pointless (or wrong) meanwhile + return; + } $element = $event->getElement(); if (!($element instanceof RelationElementInterface)) { return; @@ -60,12 +71,13 @@ private function handleEvent(ElementPostCreateEvent|ElementPostMergeEvent|Elemen if (null === $type) { return; } - if (array_key_exists($type, $this->relationshipTypesWhichCanTriggerOwnershipChange)) { - $this->handleOwnershipChange($element); + if (!in_array($type, $this->relationshipTypesWhichCanTriggerOwnershipChange, true)) { + return; } + $this->handleOwnershipChange($element, $eventKind); } - public function handleOwnershipChange(RelationElementInterface $element): void + public function handleOwnershipChange(RelationElementInterface $element, string $eventKind): void { $elementId = $element->getId(); if (!$elementId) { @@ -74,7 +86,15 @@ public function handleOwnershipChange(RelationElementInterface $element): void $this->queueService->publishEvent( RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE, [ - 'element' => $elementId->toString(), + 'relationId' => $elementId->toString(), + 'relationType' => $element->getType(), + 'startId' => $element->getStart()?->toString(), + 'endId' => $element->getEnd()?->toString(), + 'eventKind' => $eventKind, + // kept for parity with elements of the payload that describe the event itself; actual retry/drop + // behavior is driven solely by QueueService::consumeQueue()'s AMQP header based try counter, so this + // is not incremented or read anywhere, to avoid two separate/conflicting counters + 'tries' => 0, ] ); } diff --git a/src/EventSystem/Exception/EventListener/ExceptionEventListener.php b/src/EventSystem/Exception/EventListener/ExceptionEventListener.php index c76c6a53..17d32c17 100644 --- a/src/EventSystem/Exception/EventListener/ExceptionEventListener.php +++ b/src/EventSystem/Exception/EventListener/ExceptionEventListener.php @@ -6,6 +6,7 @@ use App\Exception\ProblemJsonException; use App\Factory\Exception\Server500InternalServerErrorExceptionFactory; +use App\Service\RequestIdService; use App\Type\Response\ProblemJsonResponse; use Exception; use Psr\Log\LoggerInterface; @@ -16,11 +17,17 @@ class ExceptionEventListener { + /** + * Names of the members of every problem response; additional properties can not replace them. + */ + private const array RESERVED_PROPERTY_NAMES = ['type', 'title', 'status', 'instance', 'detail', 'exception']; + public function __construct( private UrlGeneratorInterface $urlGenerator, private KernelInterface $kernel, private LoggerInterface $logger, private Server500InternalServerErrorExceptionFactory $server500InternalServerErrorExceptionFactory, + private RequestIdService $requestIdService, ) { } @@ -51,19 +58,22 @@ public function onKernelException(ExceptionEvent $event): void } catch (Exception $e) { } + $additionalProperties = array_diff_key( + $extendedException->getAdditionalProperties(), + array_flip(self::RESERVED_PROPERTY_NAMES) + ); + $data = [ 'type' => $extendedException->getType(), 'title' => $extendedException->getTitle(), 'status' => $extendedException->getStatus(), - 'instance' => $instanceLink, + // identifies this occurrence of the problem, the id is also part of the logs (`requestId` of the + // application, `request_id` of the web server); see docker/Caddyfile for the errors of the web server + 'instance' => $instanceLink ?? sprintf('urn:uuid:%s', $this->requestIdService->getRequestId()->toString()), 'detail' => $extendedException->getDetail(), - ...$extendedException->getAdditionalProperties(), + ...$additionalProperties, ]; - if (null === $instanceLink) { - unset($data['instance']); - } - if ('' === $data['detail']) { unset($data['detail']); } @@ -74,16 +84,19 @@ public function onKernelException(ExceptionEvent $event): void 'trace' => $originalException->getTrace(), ]; } + // getMessage() is always empty (ProblemJsonException never forwards it to the parent Exception), getDetail() + // carries the actual explanation, e.g. the received/announced byte counts of a 408 request timeout $this->logger->error(sprintf( '%s %s: %s', $extendedException->getType(), $extendedException->getTitle(), - $extendedException->getMessage() + $extendedException->getDetail() )); $event->setResponse(new ProblemJsonResponse( $data, - $data['status'] + $data['status'], + $extendedException->getHeaders() )); /** * @infection-ignore-all diff --git a/src/EventSystem/Exception/EventListener/NoRouteFoundExceptionEventListener.php b/src/EventSystem/Exception/EventListener/NoRouteFoundExceptionEventListener.php index 98f973b0..f87ec3c2 100644 --- a/src/EventSystem/Exception/EventListener/NoRouteFoundExceptionEventListener.php +++ b/src/EventSystem/Exception/EventListener/NoRouteFoundExceptionEventListener.php @@ -5,14 +5,17 @@ namespace App\EventSystem\Exception\EventListener; use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Factory\Exception\Client405MethodNotAllowedExceptionFactory; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class NoRouteFoundExceptionEventListener { public function __construct( private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, + private Client405MethodNotAllowedExceptionFactory $client405MethodNotAllowedExceptionFactory, ) { } @@ -20,6 +23,9 @@ public function __construct( public function onKernelException(ExceptionEvent $event): void { $throwable = $event->getThrowable(); + if ($throwable instanceof MethodNotAllowedHttpException) { + throw $this->client405MethodNotAllowedExceptionFactory->createFromTemplate()->setHeaders(array_map(strval(...), $throwable->getHeaders())); + } if (!($throwable instanceof NotFoundHttpException)) { return; } diff --git a/src/EventSystem/Response/EventListener/AllowHeaderResponseEventListener.php b/src/EventSystem/Response/EventListener/AllowHeaderResponseEventListener.php new file mode 100644 index 00000000..f9bcca37 --- /dev/null +++ b/src/EventSystem/Response/EventListener/AllowHeaderResponseEventListener.php @@ -0,0 +1,97 @@ +|null Allow header value by route path + */ + private ?array $allowHeaderByPath = null; + + public function __construct( + private RouterInterface $router, + ) { + } + + #[AsEventListener] + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + $response = $event->getResponse(); + if ($response->headers->has('Allow')) { + return; + } + $routeName = $event->getRequest()->attributes->get('_route'); + if (!is_string($routeName)) { + return; + } + $route = $this->router->getRouteCollection()->get($routeName); + if (null === $route) { + return; + } + $allowHeader = $this->getAllowHeaderByPath()[$route->getPath()] ?? null; + if (null === $allowHeader) { + return; + } + $response->headers->set('Allow', $allowHeader); + } + + /** + * @return array + */ + private function getAllowHeaderByPath(): array + { + if (null !== $this->allowHeaderByPath) { + return $this->allowHeaderByPath; + } + + /** + * @var array $methodsByPath null marks a path with a route which accepts any method + */ + $methodsByPath = []; + foreach ($this->router->getRouteCollection() as $route) { + $path = $route->getPath(); + $methods = $route->getMethods(); + if (0 === count($methods) || (array_key_exists($path, $methodsByPath) && null === $methodsByPath[$path])) { + $methodsByPath[$path] = null; + continue; + } + $methodsByPath[$path] = [...($methodsByPath[$path] ?? []), ...$methods]; + } + + $this->allowHeaderByPath = []; + foreach ($methodsByPath as $path => $methods) { + if (null === $methods) { + continue; + } + $methods = array_map('strtoupper', $methods); + if (in_array('GET', $methods, true)) { + // Symfony matches HEAD requests to routes of GET + $methods[] = 'HEAD'; + } + // the preflight of every URL is answered by public/index.php + $methods[] = 'OPTIONS'; + $methods = array_values(array_unique($methods)); + sort($methods); + $this->allowHeaderByPath[$path] = implode(', ', $methods); + } + + return $this->allowHeaderByPath; + } +} diff --git a/src/Exception/Client408RequestTimeoutException.php b/src/Exception/Client408RequestTimeoutException.php new file mode 100644 index 00000000..e4a0b7ac --- /dev/null +++ b/src/Exception/Client408RequestTimeoutException.php @@ -0,0 +1,21 @@ + $additionalDetails + * @param array $additionalProperties */ public function __construct( string $type, @@ -18,8 +18,8 @@ public function __construct( string $detail = 'Operation can not be performed due to some sort of conflict.', ?string $instance = null, ?Throwable $previous = null, - array $additionalDetails = [], + array $additionalProperties = [], ) { - parent::__construct($type, $title, $status, $detail, $instance, $previous, $additionalDetails); + parent::__construct($type, $title, $status, $detail, $instance, $previous, $additionalProperties); } } diff --git a/src/Exception/Client416RangeNotSatisfiableException.php b/src/Exception/Client416RangeNotSatisfiableException.php index 73840eaf..0e9da1bb 100644 --- a/src/Exception/Client416RangeNotSatisfiableException.php +++ b/src/Exception/Client416RangeNotSatisfiableException.php @@ -9,7 +9,7 @@ class Client416RangeNotSatisfiableException extends ProblemJsonException { /** - * @param array $additionalDetails + * @param array $additionalProperties */ public function __construct( string $type, @@ -18,8 +18,8 @@ public function __construct( string $detail = 'The requested range can not be satisfied for the target resource.', ?string $instance = null, ?Throwable $previous = null, - array $additionalDetails = [], + array $additionalProperties = [], ) { - parent::__construct($type, $title, $status, $detail, $instance, $previous, $additionalDetails); + parent::__construct($type, $title, $status, $detail, $instance, $previous, $additionalProperties); } } diff --git a/src/Exception/ProblemJsonException.php b/src/Exception/ProblemJsonException.php index 8ca27357..4999323e 100644 --- a/src/Exception/ProblemJsonException.php +++ b/src/Exception/ProblemJsonException.php @@ -19,6 +19,10 @@ class ProblemJsonException extends Exception implements ExceptionInterface * @var array */ protected array $additionalProperties; + /** + * @var array + */ + protected array $headers = []; /** * @param string $type A URI reference [RFC3986] that identifies the @@ -79,4 +83,22 @@ public function getAdditionalProperties(): array { return $this->additionalProperties; } + + /** + * @param array $headers HTTP headers which will be added to the resulting Problem JSON response + */ + public function setHeaders(array $headers): static + { + $this->headers = $headers; + + return $this; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } } diff --git a/src/Factory/Exception/Client408RequestTimeoutExceptionFactory.php b/src/Factory/Exception/Client408RequestTimeoutExceptionFactory.php new file mode 100644 index 00000000..0e7d652f --- /dev/null +++ b/src/Factory/Exception/Client408RequestTimeoutExceptionFactory.php @@ -0,0 +1,45 @@ +urlGenerator->generate( + 'exception-detail', + [ + 'code' => '408', + 'name' => 'request-timeout', + ], + UrlGeneratorInterface::ABSOLUTE_URL + ), + detail: $detail + ); + } + + /** + * Returns an exception in the format of: "The request body is incomplete, received %d of %d bytes announced by the + * Content-Length header. The connection was interrupted or the request body was not sent within the time limit of + * the server.". The server cuts off slow request bodies silently, so a shorter body is the only sign of it. + */ + public function createFromIncompleteBody(int $receivedLength, int $announcedLength): Client408RequestTimeoutException + { + return $this->createFromDetail(sprintf( + 'The request body is incomplete, received %d of %d bytes announced by the Content-Length header. The connection was interrupted or the request body was not sent within the time limit of the server.', + $receivedLength, + $announcedLength + )); + } +} diff --git a/src/Factory/Exception/Client409ConflictExceptionFactory.php b/src/Factory/Exception/Client409ConflictExceptionFactory.php index e06d6995..9f2f259a 100644 --- a/src/Factory/Exception/Client409ConflictExceptionFactory.php +++ b/src/Factory/Exception/Client409ConflictExceptionFactory.php @@ -15,9 +15,9 @@ public function __construct( } /** - * @param array $additionalDetails + * @param array $additionalProperties */ - public function createFromDetail(string $detail, array $additionalDetails = []): Client409ConflictException + public function createFromDetail(string $detail, array $additionalProperties = []): Client409ConflictException { return new Client409ConflictException( $this->urlGenerator->generate( @@ -29,7 +29,7 @@ public function createFromDetail(string $detail, array $additionalDetails = []): UrlGeneratorInterface::ABSOLUTE_URL ), detail: $detail, - additionalDetails: $additionalDetails + additionalProperties: $additionalProperties ); } } diff --git a/src/Factory/Exception/Client416RangeNotSatisfiableExceptionFactory.php b/src/Factory/Exception/Client416RangeNotSatisfiableExceptionFactory.php index c46c1be7..4baccd54 100644 --- a/src/Factory/Exception/Client416RangeNotSatisfiableExceptionFactory.php +++ b/src/Factory/Exception/Client416RangeNotSatisfiableExceptionFactory.php @@ -15,11 +15,11 @@ public function __construct( } /** - * @param array $additionalDetails + * @param array $additionalProperties */ - public function createFromDetail(string $detail, array $additionalDetails = []): Client416RangeNotSatisfiableException + public function createFromDetail(string $detail, array $additionalProperties = [], ?int $totalLength = null): Client416RangeNotSatisfiableException { - return new Client416RangeNotSatisfiableException( + $exception = new Client416RangeNotSatisfiableException( $this->urlGenerator->generate( 'exception-detail', [ @@ -29,7 +29,13 @@ public function createFromDetail(string $detail, array $additionalDetails = []): UrlGeneratorInterface::ABSOLUTE_URL ), detail: $detail, - additionalDetails: $additionalDetails + additionalProperties: $additionalProperties ); + if (null !== $totalLength) { + // RFC 9110, 15.5.17: unsatisfied-range, tells the client the current length of the selected representation + $exception->setHeaders(['Content-Range' => sprintf('bytes */%d', $totalLength)]); + } + + return $exception; } } diff --git a/src/Factory/Type/RedisKeyFactory.php b/src/Factory/Type/RedisKeyFactory.php index e0cc061c..19bc8483 100644 --- a/src/Factory/Type/RedisKeyFactory.php +++ b/src/Factory/Type/RedisKeyFactory.php @@ -76,4 +76,28 @@ public function getEtagFileRedisKey(UuidInterface $elementId): RedisKey $elementId->toString() ); } + + public function getUploadLockRedisKey(UuidInterface $uploadId): RedisKey + { + return new RedisKey( + RedisPrefixType::UPLOAD_LOCK, + $uploadId->toString() + ); + } + + public function getFileCreationLockRedisKey(UuidInterface $elementId): RedisKey + { + return new RedisKey( + RedisPrefixType::FILE_CREATION_LOCK, + $elementId->toString() + ); + } + + public function getCronDeleteExpiredUploadRedisKey(string $uploadId): RedisKey + { + return new RedisKey( + RedisPrefixType::CRON_DELETE_EXPIRED_UPLOAD, + $uploadId + ); + } } diff --git a/src/Factory/Type/Request/PartialUploadRequestFactory.php b/src/Factory/Type/Request/PartialUploadRequestFactory.php index 8e4a1c3c..6ffc30eb 100644 --- a/src/Factory/Type/Request/PartialUploadRequestFactory.php +++ b/src/Factory/Type/Request/PartialUploadRequestFactory.php @@ -5,7 +5,9 @@ namespace App\Factory\Type\Request; use App\Contract\Request\PartialUploadRequestInterface; +use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Service\HeaderParseService; +use App\Service\UploadBodyLimitService; use App\Type\Request\PartialUploadRequest; use Symfony\Component\HttpFoundation\Request; @@ -13,6 +15,8 @@ class PartialUploadRequestFactory { public function __construct( private HeaderParseService $headerParseService, + private UploadBodyLimitService $uploadBodyLimitService, + private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, ) { } @@ -23,8 +27,13 @@ public function createPartialUploadRequestFromRequest(Request $request): Partial $contentType = $this->headerParseService->getContentTypeFromHeaders($headers, 'application/partial-upload'); $uploadOffset = $this->headerParseService->getUploadOffsetFromHeaders($headers); $isUploadComplete = $this->headerParseService->isUploadCompleteFromHeaders($headers); + if (null === $isUploadComplete) { + // mandatory on every append request, see draft-ietf-httpbis-resumable-upload section 4.4.1 + throw $this->client400BadContentExceptionFactory->createFromDetail("Header 'Upload-Complete' is required when appending to an upload."); + } $contentLength = $this->headerParseService->getContentLengthFromHeaders($headers); - $content = $request->getContent(true); + $this->uploadBodyLimitService->assertDeclaredLengthWithinLimit($contentLength); + $content = $this->uploadBodyLimitService->boundContent($request->getContent(true), $contentLength); return new PartialUploadRequest( $content, diff --git a/src/Factory/Type/Request/ResumableUploadRequestFactory.php b/src/Factory/Type/Request/ResumableUploadRequestFactory.php index 8cdfcbc0..59c4b085 100644 --- a/src/Factory/Type/Request/ResumableUploadRequestFactory.php +++ b/src/Factory/Type/Request/ResumableUploadRequestFactory.php @@ -7,6 +7,7 @@ use App\Contract\Request\ResumableUploadRequestInterface; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Service\HeaderParseService; +use App\Service\UploadBodyLimitService; use App\Type\Request\ResumableUploadRequest; use Ramsey\Uuid\UuidInterface; use Symfony\Component\HttpFoundation\Request; @@ -15,6 +16,7 @@ class ResumableUploadRequestFactory { public function __construct( private HeaderParseService $headerParseService, + private UploadBodyLimitService $uploadBodyLimitService, private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, ) { } @@ -27,7 +29,8 @@ public function createResumableUploadRequestFromRequest(Request $request, UuidIn $uploadLength = $this->headerParseService->getUploadLengthFromHeaders($headers); $contentLength = $this->headerParseService->getContentLengthFromHeaders($headers); $extension = $this->headerParseService->getExtensionFromHeaders($headers); - $content = $request->getContent(true); + $this->uploadBodyLimitService->assertDeclaredLengthWithinLimit($contentLength); + $content = $this->uploadBodyLimitService->boundContent($request->getContent(true), $contentLength); if (null !== $uploadLength && null !== $contentLength && $uploadLength !== $contentLength && true === $isUploadComplete) { throw $this->client400BadContentExceptionFactory->createFromDetail("Inconsistent length values provided in headers 'Content-Length' and 'Upload-Length'."); diff --git a/src/Factory/Type/Response/NoContentResponseFactory.php b/src/Factory/Type/Response/NoContentResponseFactory.php index 8cbd3b16..ecb79730 100644 --- a/src/Factory/Type/Response/NoContentResponseFactory.php +++ b/src/Factory/Type/Response/NoContentResponseFactory.php @@ -33,17 +33,19 @@ public function createNoContentResponseWithResumableUploadHeadersFromUpload(Uplo $headers->set('Upload-Length', sprintf('%d', $uploadLength)); } + $expires = $upload->getExpires(); $headers->set( 'Upload-Limit', sprintf( 'max-age=%d, max-size=%d, min-append-size=%d, max-append-size=%d', - $this->emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest(), + // remaining lifetime, consistent with the `Expires` header below + max(0, $expires->getTimestamp() - time()), $this->emberNexusConfiguration->getFileMaxFileSizeInBytes(), $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes(), $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(), ) ); - $headers->set('Expires', $upload->getExpires()->setTimezone(new DateTimeZone('UTC'))->format('D, d M Y H:i:s \\G\\M\\T')); + $headers->set('Expires', $expires->setTimezone(new DateTimeZone('UTC'))->format('D, d M Y H:i:s \\G\\M\\T')); $headers->set('Cache-Control', 'no-store, private'); return $response; diff --git a/src/Factory/Type/S3/FileOperationFactory.php b/src/Factory/Type/S3/FileOperationFactory.php deleted file mode 100644 index da800fdc..00000000 --- a/src/Factory/Type/S3/FileOperationFactory.php +++ /dev/null @@ -1,48 +0,0 @@ -emberNexusConfiguration->getFileS3UploadBucket(), - $this->fileService->getUploadBucketKey($upload->getId(), $chunk) - ); - } - - public function createFileOperationFromElement(NodeElementInterface|RelationElementInterface $element): FileOperationInterface - { - $elementId = $element->getId(); - if (null === $elementId) { - throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Expected elementId to be not null.'); - } - $extension = $this->elementService->getFileNameExtension($element); - - return new FileOperation( - $this->emberNexusConfiguration->getFileS3StorageBucket(), - $this->fileService->getStorageBucketKey($elementId, $extension) - ); - } -} diff --git a/src/Factory/Type/S3/MergeFileChunksOperationFactory.php b/src/Factory/Type/S3/MergeFileChunksOperationFactory.php deleted file mode 100644 index 29172dd9..00000000 --- a/src/Factory/Type/S3/MergeFileChunksOperationFactory.php +++ /dev/null @@ -1,56 +0,0 @@ -isUploadComplete()) { - throw $this->client400BadContentExceptionFactory->createFromDetail("'MergeFileChunksOperation' requires 'Upload' to be complete."); - } - - $uploadKeys = []; - $uploadId = $upload->getId(); - $alreadyUploadedChunks = $upload->getAlreadyUploadedChunks(); - for ($i = 0; $i < $alreadyUploadedChunks; ++$i) { - $uploadKeys[] = $this->fileService->getUploadBucketKey($uploadId, $i + 1); - } - - $element = $this->elementManager->getElementOrFail($upload->getUploadTarget()); - - $previousStorageKey = null; - if ($element->hasProperty('file')) { - $previousExtension = $this->elementService->getFileNameExtension($element); - $previousStorageKey = $this->fileService->getStorageBucketKey($upload->getUploadTarget(), $previousExtension); - } - - return new MergeFileChunksOperation( - $this->emberNexusConfiguration->getFileS3UploadBucket(), - $uploadKeys, - $this->emberNexusConfiguration->getFileS3StorageBucket(), - $previousStorageKey, - $this->fileService->getStorageBucketKey($upload->getUploadTarget(), $upload->getExtension()) - ); - } -} diff --git a/src/Factory/Type/S3/S3OperationFactory.php b/src/Factory/Type/S3/S3OperationFactory.php new file mode 100644 index 00000000..0085cef1 --- /dev/null +++ b/src/Factory/Type/S3/S3OperationFactory.php @@ -0,0 +1,176 @@ +emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($upload->getId(), $chunk, $chunkId) + ); + } + + public function createFileOperationFromElement(NodeElementInterface|RelationElementInterface $element): FileOperationInterface + { + $elementId = $element->getId(); + if (null === $elementId) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Expected elementId to be not null.'); + } + $extension = $this->elementService->getFileNameExtension($element); + + return new FileOperation( + $this->emberNexusConfiguration->getFileS3StorageBucket(), + $this->fileService->getStorageBucketKey($elementId, $extension) + ); + } + + public function createUploadFileOperationFromResumableUploadRequest(ResumableUploadRequestInterface $resumableUploadRequest): UploadFileOperationInterface + { + if (false === $resumableUploadRequest->isUploadComplete()) { + throw $this->client400BadContentExceptionFactory->createFromDetail("'UploadFileOperation' requires 'ResumableUploadRequest' to contain the whole content, i.e. be a non-chunked upload request."); + } + + $elementId = $resumableUploadRequest->getElementId(); + $element = $this->elementManager->getElementOrFail($elementId); + + $resource = $resumableUploadRequest->getContent(); + + return new UploadFileOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($elementId, 0), + $this->emberNexusConfiguration->getFileS3StorageBucket(), + $this->elementService->getStorageKeyOfFile($element), + $this->fileService->getStorageBucketKey($elementId, $resumableUploadRequest->getExtension()), + $resource, + $resumableUploadRequest->getContentLength(), + $this->mimeTypeService->getMimeTypeFromResource($resource) + ); + } + + /** + * @param resource $resource + * @param int|null $contentLength enables multipart uploads for large files in {@see \App\Service\S3Service::uploadFile()} + */ + public function createUploadFileOperationFromElementAndResource(NodeElementInterface|RelationElementInterface $element, $resource, ?int $contentLength = null): UploadFileOperationInterface + { + $elementId = $element->getId(); + if (null === $elementId) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Expected element.id to not be null.'); + } + + $extension = $this->elementService->getFileNameExtension($element); + + return new UploadFileOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($elementId, 0), + $this->emberNexusConfiguration->getFileS3StorageBucket(), + null, + $this->fileService->getStorageBucketKey($elementId, $extension), + $resource, + $contentLength, + $this->mimeTypeService->getMimeTypeFromResource($resource) + ); + } + + public function createUploadFileChunkOperationFromResumableUploadRequest(ResumableUploadRequestInterface $resumableUploadRequest, UuidInterface $uploadId, string $chunkId): UploadFileChunkOperationInterface + { + if (true === $resumableUploadRequest->isUploadComplete()) { + throw $this->client400BadContentExceptionFactory->createFromDetail("'UploadFileChunkOperation' requires 'ResumableUploadRequest' to contain partial content, i.e. be a chunked upload request."); + } + + return new UploadFileChunkOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($uploadId, 1, $chunkId), + $resumableUploadRequest->getContent(), + $resumableUploadRequest->getContentLength(), + 'application/octet-stream' + ); + } + + public function createUploadFileChunkOperationFromPartialUploadRequest(PartialUploadRequestInterface $partialUploadRequest, UploadInterface $upload, string $chunkId): UploadFileChunkOperationInterface + { + return new UploadFileChunkOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $this->fileService->getUploadBucketKey($upload->getId(), $upload->getAlreadyUploadedChunks() + 1, $chunkId), + $partialUploadRequest->getContent(), + $partialUploadRequest->getContentLength(), + 'application/octet-stream' + ); + } + + public function createUploadFileChunkOperationFromUploadFileOperation(UploadFileOperationInterface $uploadFileOperation): UploadFileChunkOperationInterface + { + return new UploadFileChunkOperation( + $uploadFileOperation->getUploadBucket(), + $uploadFileOperation->getUploadKey(), + $uploadFileOperation->getContent(), + $uploadFileOperation->getContentLength(), + $uploadFileOperation->getMimeType() + ); + } + + public function createMergeFileOperationFromUpload(UploadInterface $upload): MergeFileChunksOperationInterface + { + if (false === $upload->isUploadComplete()) { + throw $this->client400BadContentExceptionFactory->createFromDetail("'MergeFileChunksOperation' requires 'Upload' to be complete."); + } + + $uploadKeys = []; + $uploadId = $upload->getId(); + foreach ($upload->getChunkIds() as $index => $chunkId) { + $uploadKeys[] = $this->fileService->getUploadBucketKey($uploadId, $index + 1, $chunkId); + } + + $element = $this->elementManager->getElementOrFail($upload->getUploadTarget()); + + return new MergeFileChunksOperation( + $this->emberNexusConfiguration->getFileS3UploadBucket(), + $uploadKeys, + $this->emberNexusConfiguration->getFileS3StorageBucket(), + $this->elementService->getStorageKeyOfFile($element), + $this->fileService->getStorageBucketKey($upload->getUploadTarget(), $upload->getExtension()) + ); + } +} diff --git a/src/Factory/Type/S3/UploadFileChunkOperationFactory.php b/src/Factory/Type/S3/UploadFileChunkOperationFactory.php deleted file mode 100644 index 9f84c3f7..00000000 --- a/src/Factory/Type/S3/UploadFileChunkOperationFactory.php +++ /dev/null @@ -1,63 +0,0 @@ -isUploadComplete()) { - throw $this->client400BadContentExceptionFactory->createFromDetail("'UploadFileChunkOperation' requires 'ResumableUploadRequest' to contain partial content, i.e. be a chunked upload request."); - } - - return new UploadFileChunkOperation( - $this->emberNexusConfiguration->getFileS3UploadBucket(), - $this->fileService->getUploadBucketKey($uploadId, 1), - $resumableUploadRequest->getContent(), - $resumableUploadRequest->getContentLength(), - 'application/octet-stream' - ); - } - - public function createUploadFileChunkOperationFromPartialUploadRequest(PartialUploadRequestInterface $partialUploadRequest, UploadInterface $upload): UploadFileChunkOperationInterface - { - return new UploadFileChunkOperation( - $this->emberNexusConfiguration->getFileS3UploadBucket(), - $this->fileService->getUploadBucketKey($upload->getId(), $upload->getAlreadyUploadedChunks() + 1), - $partialUploadRequest->getContent(), - $partialUploadRequest->getContentLength(), - 'application/octet-stream' - ); - } - - public function createUploadFileChunkOperationFromUploadFileOperation(UploadFileOperationInterface $uploadFileOperation): UploadFileChunkOperationInterface - { - return new UploadFileChunkOperation( - $uploadFileOperation->getUploadBucket(), - $uploadFileOperation->getUploadKey(), - $uploadFileOperation->getContent(), - $uploadFileOperation->getContentLength(), - $uploadFileOperation->getMimeType() - ); - } -} diff --git a/src/Factory/Type/S3/UploadFileOperationFactory.php b/src/Factory/Type/S3/UploadFileOperationFactory.php deleted file mode 100644 index ee86a8a2..00000000 --- a/src/Factory/Type/S3/UploadFileOperationFactory.php +++ /dev/null @@ -1,85 +0,0 @@ -isUploadComplete()) { - throw $this->client400BadContentExceptionFactory->createFromDetail("'UploadFileOperation' requires 'ResumableUploadRequest' to contain the whole content, i.e. be a non-chunked upload request."); - } - - $elementId = $resumableUploadRequest->getElementId(); - $element = $this->elementManager->getElementOrFail($elementId); - - $previousStorageKey = null; - if ($element->hasProperty('file')) { - $previousExtension = $this->elementService->getFileNameExtension($element); - $previousStorageKey = $this->fileService->getStorageBucketKey($elementId, $previousExtension); - } - - $resource = $resumableUploadRequest->getContent(); - - return new UploadFileOperation( - $this->emberNexusConfiguration->getFileS3UploadBucket(), - $this->fileService->getUploadBucketKey($elementId, 0), - $this->emberNexusConfiguration->getFileS3StorageBucket(), - $previousStorageKey, - $this->fileService->getStorageBucketKey($elementId, $resumableUploadRequest->getExtension()), - $resource, - $resumableUploadRequest->getContentLength(), - $this->mimeTypeService->getMimeTypeFromResource($resource) - ); - } - - /** - * @param resource $resource - */ - public function createUploadFileOperationFromElementAndResource(NodeElementInterface|RelationElementInterface $element, $resource): UploadFileOperationInterface - { - $elementId = $element->getId(); - if (null === $elementId) { - throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Expected element.id to not be null.'); - } - - $extension = $this->elementService->getFileNameExtension($element); - - return new UploadFileOperation( - $this->emberNexusConfiguration->getFileS3UploadBucket(), - $this->fileService->getUploadBucketKey($elementId, 0), - $this->emberNexusConfiguration->getFileS3StorageBucket(), - null, - $this->fileService->getStorageBucketKey($elementId, $extension), - $resource, - null, - $this->mimeTypeService->getMimeTypeFromResource($resource) - ); - } -} diff --git a/src/Factory/Type/UploadFactory.php b/src/Factory/Type/UploadFactory.php index c53d023a..b535ecf8 100644 --- a/src/Factory/Type/UploadFactory.php +++ b/src/Factory/Type/UploadFactory.php @@ -42,11 +42,12 @@ public function createUploadFromElement(NodeElementInterface|RelationElementInte $this->propertyParseService->getUploadOffsetFromProperties($properties), $this->propertyParseService->getIsUploadCompleteFromProperties($properties), $this->propertyParseService->getUploadTargetFromProperties($properties), - $this->propertyParseService->getAlreadyUploadedChunksFromProperties($properties), + $this->propertyParseService->getChunkIdsFromProperties($properties), $this->propertyParseService->getUploadOwnerFromProperties($properties), $this->propertyParseService->getExtensionFromProperties($properties), $this->propertyParseService->getExpiresFromProperties($properties), - $this->propertyParseService->getHashStateFromProperties($properties) + $this->propertyParseService->getHashStateFromProperties($properties), + $this->propertyParseService->getTargetHadFileAtCreationFromProperties($properties) ); } @@ -58,15 +59,16 @@ public function markUploadAsComplete(UploadInterface $upload): UploadInterface $upload->getUploadOffset(), true, $upload->getUploadTarget(), - $upload->getAlreadyUploadedChunks(), + $upload->getChunkIds(), $upload->getUploadOwner(), $upload->getExtension(), $upload->getExpires(), - $upload->getHashState() + $upload->getHashState(), + $upload->targetHadFileAtCreation() ); } - public function addNewChunkToUpload(UploadInterface $upload, int $chunkLength, ?string $hashState = null): UploadInterface + public function addNewChunkToUpload(UploadInterface $upload, int $chunkLength, string $chunkId, ?string $hashState = null): UploadInterface { return new Upload( $upload->getId(), @@ -74,11 +76,12 @@ public function addNewChunkToUpload(UploadInterface $upload, int $chunkLength, ? $upload->getUploadOffset() + $chunkLength, $upload->isUploadComplete(), $upload->getUploadTarget(), - $upload->getAlreadyUploadedChunks() + 1, + [...$upload->getChunkIds(), $chunkId], $upload->getUploadOwner(), $upload->getExtension(), $upload->getExpires(), - $hashState ?? $upload->getHashState() + $hashState ?? $upload->getHashState(), + $upload->targetHadFileAtCreation() ); } } diff --git a/src/Logger/TerminalConsoleHandler.php b/src/Logger/TerminalConsoleHandler.php new file mode 100644 index 00000000..1fd96f89 --- /dev/null +++ b/src/Logger/TerminalConsoleHandler.php @@ -0,0 +1,98 @@ + $verbosityLevelMap + * @param (Closure(): bool)|null $isTerminal + */ + public function __construct(array $verbosityLevelMap = [], ?Closure $isTerminal = null) + { + $this->consoleHandler = new ConsoleHandler( + null, + false, + [] !== $verbosityLevelMap ? $verbosityLevelMap : [ + OutputInterface::VERBOSITY_QUIET => Level::Error, + OutputInterface::VERBOSITY_NORMAL => Level::Notice, + OutputInterface::VERBOSITY_VERBOSE => Level::Notice, + OutputInterface::VERBOSITY_VERY_VERBOSE => Level::Info, + OutputInterface::VERBOSITY_DEBUG => Level::Debug, + ] + ); + // false means that there is no terminal, which is a valid result and not an error as the Safe variant expects + $this->isTerminal = $isTerminal ?? static fn (): bool => defined('STDERR') && stream_isatty(STDERR); // @phpstan-ignore theCodingMachineSafe.function + } + + public function isHandling(LogRecord $record): bool + { + return $this->consoleHandler->isHandling($record); + } + + public function handle(LogRecord $record): bool + { + return $this->consoleHandler->handle($record); + } + + public function handleBatch(array $records): void + { + foreach ($records as $record) { + $this->handle($record); + } + } + + public function close(): void + { + $this->consoleHandler->close(); + } + + public function onCommand(ConsoleCommandEvent $event): void + { + if (!($this->isTerminal)()) { + return; + } + + $this->consoleHandler->onCommand($event); + } + + public function onTerminate(ConsoleTerminateEvent $event): void + { + $this->consoleHandler->onTerminate($event); + } + + public static function getSubscribedEvents(): array + { + return [ + ConsoleEvents::COMMAND => ['onCommand', 255], + ConsoleEvents::TERMINATE => ['onTerminate', -255], + ]; + } +} diff --git a/src/Security/AccessChecker.php b/src/Security/AccessChecker.php index 8b783f64..43804f53 100644 --- a/src/Security/AccessChecker.php +++ b/src/Security/AccessChecker.php @@ -101,7 +101,7 @@ public function hasReadAccessToNode(UuidInterface $userId, UuidInterface $elemen " (\n". " relation.onCreatedByUser IS NULL\n". " OR\n". - " (element)<-[:CREATED_BY*]-(user)\n". + " (element)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". @@ -162,7 +162,7 @@ public function hasGeneralAccessToNode(UuidInterface $userId, UuidInterface $ele " (\n". " relation.onCreatedByUser IS NULL\n". " OR\n". - " (element)<-[:CREATED_BY*]-(user)\n". + " (element)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". @@ -225,7 +225,7 @@ public function hasReadAccessToRelation(UuidInterface $userId, UuidInterface $el " (\n". " startRelation.onCreatedByUser IS NULL\n". " OR\n". - " (start)<-[:CREATED_BY*]-(user)\n". + " (start)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". @@ -265,7 +265,7 @@ public function hasReadAccessToRelation(UuidInterface $userId, UuidInterface $el " (\n". " endRelation.onCreatedByUser IS NULL\n". " OR\n". - " (end)<-[:CREATED_BY*]-(user)\n". + " (end)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". @@ -337,7 +337,7 @@ public function hasGeneralAccessToRelation(UuidInterface $userId, UuidInterface " (\n". " startRelation.onCreatedByUser IS NULL\n". " OR\n". - " (start)<-[:CREATED_BY*]-(user)\n". + " (start)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". @@ -377,7 +377,7 @@ public function hasGeneralAccessToRelation(UuidInterface $userId, UuidInterface " (\n". " endRelation.onCreatedByUser IS NULL\n". " OR\n". - " (end)<-[:CREATED_BY*]-(user)\n". + " (end)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". @@ -677,7 +677,7 @@ public function getDirectUsersWithAccessToNode(UuidInterface $elementId, AccessT " (\n". " relation.onCreatedByUser IS NULL\n". " OR\n". - " (element)<-[:CREATED_BY*]-(user)\n". + " (element)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". @@ -688,7 +688,7 @@ public function getDirectUsersWithAccessToNode(UuidInterface $elementId, AccessT " ANY(relation in relations WHERE\n". ' type(relation) = "HAS_'.$accessType->value."_ACCESS\"\n". " AND\n". - " (element)<-[:CREATED_BY*]-(user)\n". + " (element)<-[:CREATED*]-(user)\n". " )\n". " )\n". " )\n". @@ -758,7 +758,7 @@ public function getDirectUsersWithAccessToRelation(UuidInterface $elementId, Acc " (\n". " relation.onCreatedByUser IS NULL\n". " OR\n". - " (start)<-[:CREATED_BY*]-(startUser)\n". + " (start)<-[:CREATED*]-(startUser)\n". " )\n". " )\n". " )\n". @@ -769,7 +769,7 @@ public function getDirectUsersWithAccessToRelation(UuidInterface $elementId, Acc " ANY(relation in startRelations WHERE\n". ' type(relation) = "HAS_'.$accessType->value."_ACCESS\"\n". " AND\n". - " (start)<-[:CREATED_BY*]-(startUser)\n". + " (start)<-[:CREATED*]-(startUser)\n". " )\n". " )\n". " )\n". @@ -810,7 +810,7 @@ public function getDirectUsersWithAccessToRelation(UuidInterface $elementId, Acc " (\n". " relation.onCreatedByUser IS NULL\n". " OR\n". - " (end)<-[:CREATED_BY*]-(endUser)\n". + " (end)<-[:CREATED*]-(endUser)\n". " )\n". " )\n". " )\n". @@ -825,7 +825,7 @@ public function getDirectUsersWithAccessToRelation(UuidInterface $elementId, Acc ' type(relation) = "HAS_'.$accessType->value."_ACCESS\"\n". " )\n". " AND\n". - " (end)<-[:CREATED_BY*]-(endUser)\n". + " (end)<-[:CREATED*]-(endUser)\n". " )\n". " )\n". " )\n". @@ -978,7 +978,7 @@ public function checkUserAccessToMultipleElements(UuidInterface $userId, array $ AND ( relation.onCreatedByUser IS NULL - OR (element)<-[:CREATED_BY*]-(user) + OR (element)<-[:CREATED*]-(user) ) ) ) diff --git a/src/Service/CreateElementFromRawDataService.php b/src/Service/CreateElementFromRawDataService.php index 4c4d5948..57be85cb 100644 --- a/src/Service/CreateElementFromRawDataService.php +++ b/src/Service/CreateElementFromRawDataService.php @@ -71,7 +71,8 @@ public function createElementFromRawData( $this->eventDispatcher->dispatch($elementPropertyChangeEvent); $verifiedData = $elementPropertyChangeEvent->getChangedProperties(); - if ($startNodeId && $endNodeId) { + // start and end node ids are either both set or both null, see checks above + if (null !== $startNodeId) { $element = new RelationElement(); $element->setStart($startNodeId); $element->setEnd($endNodeId); diff --git a/src/Service/CronExecutionGateService.php b/src/Service/CronExecutionGateService.php new file mode 100644 index 00000000..3af30b9f --- /dev/null +++ b/src/Service/CronExecutionGateService.php @@ -0,0 +1,36 @@ +bag->get('isCronDisabled'); + if (!is_bool($isCronDisabled)) { + throw new LogicException(sprintf('Expected "isCronDisabled" to be of type boolean, got %s.', get_debug_type($isCronDisabled))); + } + if (!$isCronDisabled) { + return false; + } + + return !$this->ttyDetectorService->isInteractive(); + } +} diff --git a/src/Service/DigestService.php b/src/Service/DigestService.php index bbd97d46..21649320 100644 --- a/src/Service/DigestService.php +++ b/src/Service/DigestService.php @@ -7,13 +7,8 @@ use Throwable; /** - * Implements the parts of RFC 9530 ("Digest Fields") relevant to file integrity: formatting a `Repr-Digest` / - * `Content-Digest` response header from a stored hash, and parsing a client-supplied `Repr-Digest` / - * `Content-Digest` request header for verification against an uploaded file. - * - * Only the `sha-256` algorithm (RFC 9530's registered label for our stored `file.hash.sha256`) is supported. - * Digest field values are RFC 8941 Dictionaries of `=::` members; only that single - * member is looked for, other (unsupported) algorithm members in the same header are ignored. + * Formats and parses RFC 9530 `Repr-Digest` / `Content-Digest` header values. Only `sha-256` is supported, other + * algorithm members are ignored. */ class DigestService { @@ -25,8 +20,7 @@ public function formatDigestHeaderValue(string $hexHash): string } /** - * Returns the hex-encoded sha-256 hash declared in the header value, or null if the header does not declare a - * usable sha-256 member (either only unsupported algorithms were offered, or the sha-256 value is malformed). + * Returns the hex-encoded sha-256 hash, or null if the header contains no valid sha-256 member. */ public function parseSha256HexFromHeaderValue(string $headerValue): ?string { diff --git a/src/Service/ElementFileDeletionService.php b/src/Service/ElementFileDeletionService.php new file mode 100644 index 00000000..61ddc341 --- /dev/null +++ b/src/Service/ElementFileDeletionService.php @@ -0,0 +1,133 @@ +getId()) { + foreach ($this->getIdsOfAttachedRelationsWithFile($element->getId()->toString()) as $relationId) { + $elements[] = $this->elementManager->getElementOrFail(UuidV4::fromString($relationId)); + } + } + + $fileOperations = []; + foreach ($elements as $elementWithPossibleFile) { + if ($this->elementService->hasFile($elementWithPossibleFile)) { + $fileOperations[] = $this->s3OperationFactory->createFileOperationFromElement($elementWithPossibleFile); + } + } + + return $fileOperations; + } + + /** + * @param FileOperationInterface[] $fileOperations + */ + public function deleteFiles(array $fileOperations): void + { + foreach ($fileOperations as $fileOperation) { + $this->deleteFile($fileOperation); + } + } + + /** + * Deletes the object a replaced file was stored in before, if the new file is stored under a different key (e.g. + * changed extension). To be called after the element was flushed and therefore points to the new object; a + * failing delete is logged and never fails the request. Overwrites of the same key can not be rolled back. + */ + public function deletePreviousFileAfterReplace(UploadFileOperationInterface|MergeFileChunksOperationInterface $operation): void + { + $previousStorageKey = $operation->getPreviousStorageKey(); + if (null === $previousStorageKey || $previousStorageKey === $operation->getStorageKey()) { + return; + } + $this->deleteFile(new FileOperation($operation->getStorageBucket(), $previousStorageKey)); + } + + /** + * Never throws: the element (or its file reference) is already gone, so a failing S3 delete must not fail the + * request. The remaining object is an orphan. + */ + public function deleteFile(FileOperationInterface $fileOperation): void + { + try { + $this->s3Service->deleteFile($fileOperation); + } catch (Throwable $throwable) { + $this->logger->error( + sprintf( + "Unable to delete file '%s' from bucket '%s', the object stays behind as an orphan: %s", + $fileOperation->getKey(), + $fileOperation->getBucket(), + $throwable->getMessage() + ), + ['bucket' => $fileOperation->getBucket(), 'key' => $fileOperation->getKey()] + ); + } + } + + /** + * @return string[] + */ + private function getIdsOfAttachedRelationsWithFile(string $nodeId): array + { + $queryResult = $this->cypherEntityManager->getClient()->runStatement(new Statement( + 'MATCH ({id: $nodeId})-[r]-() WHERE r.hasFile = true RETURN DISTINCT r.id', + [ + 'nodeId' => $nodeId, + ] + )); + + $relationIds = []; + foreach ($queryResult as $queryResultLine) { + $relationId = $queryResultLine['r.id']; + if (!is_string($relationId)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property r.id as string, not %s.', get_debug_type($relationId))); // @codeCoverageIgnore + } + $relationIds[] = $relationId; + } + + return $relationIds; + } +} diff --git a/src/Service/ElementService.php b/src/Service/ElementService.php index 657365bd..e9f54bdb 100644 --- a/src/Service/ElementService.php +++ b/src/Service/ElementService.php @@ -18,6 +18,18 @@ public function __construct( ) { } + /** + * Key of the stored file of the element in the storage bucket, or null if the element has no file. + */ + public function getStorageKeyOfFile(NodeElementInterface|RelationElementInterface $element): ?string + { + if (!$this->hasFile($element)) { + return null; + } + + return $this->fileService->getStorageBucketKey($this->getElementId($element), $this->getFileNameExtension($element)); + } + public function getElementId(NodeElementInterface|RelationElementInterface $element): UuidInterface { $elementId = $element->getId(); @@ -28,6 +40,14 @@ public function getElementId(NodeElementInterface|RelationElementInterface $elem return $elementId; } + /** + * Uses the 'hasFile' flag, which is managed by the file endpoints and always kept in sync with the file. + */ + public function hasFile(NodeElementInterface|RelationElementInterface $element): bool + { + return $element->hasProperty('hasFile') && true === $element->getProperty('hasFile'); + } + public function getFileName(NodeElementInterface|RelationElementInterface $element): string { $base = $this->getFileNameBase($element); diff --git a/src/Service/ElementToRawService.php b/src/Service/ElementToRawService.php index 6efe9931..88139656 100755 --- a/src/Service/ElementToRawService.php +++ b/src/Service/ElementToRawService.php @@ -18,9 +18,16 @@ public function __construct( } /** + * @param bool $applyPropertyBlacklist Whether to dispatch {@see ElementPropertyReturnEvent} and honor the + * property blacklists it collects (e.g. a Token's `hash` or a User's + * `_passwordHash`). These blacklists exist to keep such properties out of + * HTTP responses; callers that need the full, unfiltered element data for + * internal purposes (e.g. `backup:create`) must pass `false` so the + * resulting data can be restored without losing those properties. + * * @return array */ - public function elementToRaw(NodeElementInterface|RelationElementInterface $element): array + public function elementToRaw(NodeElementInterface|RelationElementInterface $element, bool $applyPropertyBlacklist = true): array { $rawData = [ 'type' => null, @@ -42,9 +49,13 @@ public function elementToRaw(NodeElementInterface|RelationElementInterface $elem $rawData['end'] = $element->getEnd()?->toString(); } - $elementPropertyReturnEvent = new ElementPropertyReturnEvent($element); - $this->eventDispatcher->dispatch($elementPropertyReturnEvent); - $normalizedProperties = $elementPropertyReturnEvent->getElementPropertiesWhichAreNotOnBlacklist(); + if ($applyPropertyBlacklist) { + $elementPropertyReturnEvent = new ElementPropertyReturnEvent($element); + $this->eventDispatcher->dispatch($elementPropertyReturnEvent); + $normalizedProperties = $elementPropertyReturnEvent->getElementPropertiesWhichAreNotOnBlacklist(); + } else { + $normalizedProperties = $element->getProperties(); + } foreach ($normalizedProperties as $normalizedPropertyName => $normalizedPropertyValue) { $normalizedValueToRawValueEvent = new NormalizedValueToRawValueEvent($normalizedPropertyValue); diff --git a/src/Service/EtagCalculatorService.php b/src/Service/EtagCalculatorService.php index c5d3dbd0..a2ffcf59 100644 --- a/src/Service/EtagCalculatorService.php +++ b/src/Service/EtagCalculatorService.php @@ -365,29 +365,26 @@ public function calculateFileEtag(UuidInterface $elementId): ?Etag ); $element = $this->elementManager->getElementOrFail($elementId); - $rawFileProperties = $element->hasProperty('file') ? $element->getProperty('file') : null; - $fileEtag = $this->extractPreferredHashFromFileProperties($rawFileProperties); - - if (null === $fileEtag) { - // no stored hash at all (e.g. a pre-hash-rollout record): fall back to the element's own identity - // instead of an S3 round trip, since there is nothing file-specific left to distinguish it by anyway. - $fileEtag = $this->calculateElementEtag($elementId); - if (null === $fileEtag) { - return null; - } - $fileEtag = (string) $fileEtag; + if (!$element->hasProperty('hasFile') || true !== $element->getProperty('hasFile')) { + // an element without file has no file representation, so conditional requests on it are not evaluated + return null; } - $fileProperties = \Safe\json_encode($rawFileProperties); + $elementEtag = $this->calculateElementEtag($elementId); + if (null === $elementEtag) { + return null; + } - $name = $element->hasProperty('name') ? $element->getProperty('name') : null; - $name = \Safe\json_encode($name); + $rawFileProperties = $element->hasProperty('file') ? $element->getProperty('file') : null; + $fileHash = $this->extractPreferredHashFromFileProperties($rawFileProperties); $etagCalculator = new EtagCalculator($this->emberNexusConfiguration->getCacheEtagSeed()); - $etagCalculator->addUuid($elementId); - $etagCalculator->addString($fileEtag); - $etagCalculator->addString($fileProperties); - $etagCalculator->addString($name); + $etagCalculator->addString((string) $elementEtag); + if (null !== $fileHash) { + $etagCalculator->addString($fileHash); + } + // no stored hash, e.g. files uploaded before hashing was introduced: the element etag already captures + // identity and recency, so it is not folded into itself again as a fake hash component $etag = $etagCalculator->getEtag(); $this->logger->debug( @@ -402,10 +399,10 @@ public function calculateFileEtag(UuidInterface $elementId): ?Etag } /** - * Prefers {@see FileHashService::ALGORITHM}, since that is the algorithm this codebase actually writes; if it - * is missing but some other algorithm is present (e.g. a future 'blake3'), the alphanumerically first one is - * used instead, purely to pick deterministically among otherwise-equal options. Returns null if no usable - * hash is stored at all. + * Prefers {@see FileHashService::ALGORITHM}, otherwise uses the alphabetically first algorithm for determinism. + * + * @SuppressWarnings("PHPMD.CyclomaticComplexity") + * @SuppressWarnings("PHPMD.NPathComplexity") */ private function extractPreferredHashFromFileProperties(mixed $rawFileProperties): ?string { diff --git a/src/Service/EtagService.php b/src/Service/EtagService.php index 285e7b5f..2b385952 100644 --- a/src/Service/EtagService.php +++ b/src/Service/EtagService.php @@ -10,7 +10,10 @@ use App\EventSystem\Etag\Event\IndexCollectionEtagEvent; use App\EventSystem\Etag\Event\ParentsCollectionEtagEvent; use App\EventSystem\Etag\Event\RelatedCollectionEtagEvent; +use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Security\AccessChecker; use App\Security\AuthProvider; +use App\Type\AccessType; use App\Type\Etag; use App\Type\EtagType; use Exception; @@ -25,6 +28,8 @@ class EtagService public function __construct( private EventDispatcherInterface $eventDispatcher, private AuthProvider $authProvider, + private AccessChecker $accessChecker, + private Client404NotFoundExceptionFactory $client404NotFoundExceptionFactory, ) { } @@ -37,6 +42,11 @@ public function setCurrentRequestEtagFromRequestAndEtagType(Request $request, Et throw new Exception('Route should have attribute id.'); } $requestId = Uuid::fromString($request->attributes->get('id')); + // Conditional requests must not reveal whether an element exists or changed, so the user needs the same + // access as the controller requires; answers exactly like the controller does without access. + if (!$this->accessChecker->hasAccessToElement($this->authProvider->getUserId(), $requestId, $this->getRequiredAccessType($request, $etagType))) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } switch ($etagType) { case EtagType::ELEMENT: $event = new ElementEtagEvent($requestId); @@ -62,6 +72,18 @@ public function setCurrentRequestEtagFromRequestAndEtagType(Request $request, Et return $this; } + private function getRequiredAccessType(Request $request, EtagType $etagType): AccessType + { + if (in_array($request->getMethod(), ['GET', 'HEAD'], true)) { + return AccessType::READ; + } + if (EtagType::ELEMENT === $etagType && 'DELETE' === $request->getMethod()) { + return AccessType::DELETE; + } + + return AccessType::UPDATE; + } + public function getCurrentRequestEtag(): ?Etag { return $this->currentRequestEtag; diff --git a/src/Service/ExpiredUploadDeletionAttemptService.php b/src/Service/ExpiredUploadDeletionAttemptService.php new file mode 100644 index 00000000..5fdb7fe5 --- /dev/null +++ b/src/Service/ExpiredUploadDeletionAttemptService.php @@ -0,0 +1,94 @@ + delay in seconds until the next attempt, indexed by the number of failed attempts + */ + private const array RETRY_DELAYS_IN_SECONDS = [ + 1 => 3600, + 2 => 86400, + ]; + + public function __construct( + private RedisClient $redisClient, + private RedisKeyFactory $redisKeyFactory, + ) { + } + + /** + * True if a previous attempt failed and its retry delay has not passed yet. + */ + public function isDeferred(string $uploadId): bool + { + $notBefore = $this->read($uploadId)['notBefore']; + + return $notBefore > time(); + } + + /** + * @return int number of failed attempts including this one + */ + public function recordFailure(string $uploadId): int + { + $attempts = $this->read($uploadId)['attempts'] + 1; + $notBefore = time() + (self::RETRY_DELAYS_IN_SECONDS[$attempts] ?? 0); + $this->redisClient->set( + (string) $this->redisKeyFactory->getCronDeleteExpiredUploadRedisKey($uploadId), + \Safe\json_encode(['attempts' => $attempts, 'notBefore' => $notBefore]), + 'EX', + self::TTL_IN_SECONDS + ); + + return $attempts; + } + + public function clear(string $uploadId): void + { + $this->redisClient->del([(string) $this->redisKeyFactory->getCronDeleteExpiredUploadRedisKey($uploadId)]); + } + + /** + * @return array{attempts: int, notBefore: int} + */ + private function read(string $uploadId): array + { + $value = $this->redisClient->get((string) $this->redisKeyFactory->getCronDeleteExpiredUploadRedisKey($uploadId)); + if (!is_string($value)) { + return ['attempts' => 0, 'notBefore' => 0]; + } + try { + $data = \Safe\json_decode($value, true); + } catch (JsonException) { + return ['attempts' => 0, 'notBefore' => 0]; + } + if (!is_array($data)) { + return ['attempts' => 0, 'notBefore' => 0]; + } + $attempts = $data['attempts'] ?? 0; + $notBefore = $data['notBefore'] ?? 0; + + return [ + 'attempts' => is_int($attempts) ? $attempts : 0, + 'notBefore' => is_int($notBefore) ? $notBefore : 0, + ]; + } +} diff --git a/src/Service/FileCreationLockService.php b/src/Service/FileCreationLockService.php new file mode 100644 index 00000000..614a65cf --- /dev/null +++ b/src/Service/FileCreationLockService.php @@ -0,0 +1,52 @@ +/file` holds the lock while it runs, so concurrent + * `POST` requests can not both create the file. `PUT` requests do not take the lock (parallel replacements are + * allowed), but respect it. + * + * The lock is a Redis key holding a random token; it expires on its own so that a crashed request can not block the + * element forever, and is only released by its owner (compare-and-delete). + */ +class FileCreationLockService +{ + // a single request upload may take 15 minutes at most (`read_body` in the Caddyfile) + public const int TTL_IN_MILLISECONDS = 900000; + + private const string RELEASE_SCRIPT = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end'; + + public function __construct( + private RedisClient $redisClient, + private RedisKeyFactory $redisKeyFactory, + ) { + } + + /** + * @return string|null lock token which has to be passed to {@see release()}, null if the element is already locked + */ + public function acquire(UuidInterface $elementId): ?string + { + $token = bin2hex(random_bytes(16)); + $result = $this->redisClient->set((string) $this->redisKeyFactory->getFileCreationLockRedisKey($elementId), $token, 'PX', self::TTL_IN_MILLISECONDS, 'NX'); + + return null === $result ? null : $token; + } + + public function isLocked(UuidInterface $elementId): bool + { + return 0 !== (int) $this->redisClient->exists((string) $this->redisKeyFactory->getFileCreationLockRedisKey($elementId)); + } + + public function release(UuidInterface $elementId, string $token): void + { + $this->redisClient->eval(self::RELEASE_SCRIPT, 1, (string) $this->redisKeyFactory->getFileCreationLockRedisKey($elementId), $token); + } +} diff --git a/src/Service/FileHashService.php b/src/Service/FileHashService.php index b06b1595..2b9744d4 100644 --- a/src/Service/FileHashService.php +++ b/src/Service/FileHashService.php @@ -4,36 +4,85 @@ namespace App\Service; +use ArrayAccess; +use Traversable; + /** - * Computes a cryptographic content hash of an uploaded file. - * - * S3 does not provide a usable, standardized content hash: the `ETag` of a simple upload happens to be its MD5, - * but the `ETag` of a file assembled via a multipart upload (i.e. every resumable upload completed through - * `S3Service::mergeFileChunks()`) is a hash of the parts' ETags, not a hash of the file's actual content, and MD5 - * is not collision-resistant regardless. There is therefore no way around reading the assembled file once, after - * it was written to the storage bucket, to compute a real content hash. + * S3 ETags can not be used as content hashes: multipart upload ETags are derived from the parts' ETags, and MD5 + * is not collision-resistant anyway. * - * SHA-256 is used for now: it is cryptographically secure, and, backed by PHP's `hash` extension (which uses - * OpenSSL's optimized, often hardware-accelerated implementation), comfortably exceeds the throughput of a - * 1 GiB/s uplink on commodity hardware. BLAKE3 is preferred long-term, but the only pure-PHP implementation - * available today is several orders of magnitude too slow to be usable; a high-performance BLAKE3 (native PHP - * extension or FFI binding to a native library) needs a Docker image change first. - * - * The `file.hash` property is stored as `{: }` rather than a flat pair, specifically so a - * future algorithm can be added (or this one replaced) without a data migration. + * `file.hash` is stored as `{: }`, so that algorithms can be added or replaced later (e.g. + * BLAKE3, once a fast implementation is available) without a data migration. */ class FileHashService { public const string ALGORITHM = 'sha256'; /** + * `file.hash` entries of other algorithms are ignored. + */ + public const array SUPPORTED_ALGORITHMS = [self::ALGORITHM]; + private const int READ_CHUNK_SIZE = 1024 * 1024; + + /** + * Calculates the hashes of all given algorithms in a single pass over $resource. + * * @param resource $resource + * @param string[] $algorithms + * + * @return array algorithm => lowercase hex digest + */ + public function calculateHashesFromResource($resource, array $algorithms): array + { + $contexts = []; + foreach ($algorithms as $algorithm) { + $contexts[$algorithm] = hash_init($algorithm); + } + while (!feof($resource)) { + $chunk = \Safe\fread($resource, self::READ_CHUNK_SIZE); + foreach ($contexts as $context) { + hash_update($context, $chunk); + } + } + + $hashes = []; + foreach ($contexts as $algorithm => $context) { + $hashes[$algorithm] = hash_final($context); + } + + return $hashes; + } + + /** + * Returns the supported entries of `file.hash`; other and malformed entries are ignored. + * Values read from MongoDB can be BSONDocument instances instead of plain arrays. + * + * @return array algorithm => lowercase hex digest */ - public function calculateHashFromResource($resource): string + public function getVerifiableHashesFromFileProperty(mixed $fileProperty): array { - $context = hash_init(self::ALGORITHM); - hash_update_stream($context, $resource); + if (!is_array($fileProperty) && !($fileProperty instanceof ArrayAccess)) { + return []; + } + $hash = $fileProperty['hash'] ?? null; + if ($hash instanceof Traversable) { + $hash = iterator_to_array($hash); + } + if (!is_array($hash)) { + return []; + } + + $hashes = []; + foreach ($hash as $algorithm => $value) { + if (!is_string($algorithm) || !is_string($value)) { + continue; + } + if (!in_array($algorithm, self::SUPPORTED_ALGORITHMS, true)) { + continue; + } + $hashes[$algorithm] = strtolower($value); + } - return hash_final($context); + return $hashes; } } diff --git a/src/Service/FilePropertyService.php b/src/Service/FilePropertyService.php index d06e7d51..0698f027 100644 --- a/src/Service/FilePropertyService.php +++ b/src/Service/FilePropertyService.php @@ -27,7 +27,6 @@ public function parseFilePropertyFromElement(NodeElementInterface|RelationElemen $rawFileProperties = $element->getProperty('file'); if (!is_array($rawFileProperties)) { - // todo: make sure that the property 'file' is restricted, i.e. users can not directly change these properties throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Expected property 'file' of element %s to be of type array, got %s.", $element->getId()?->toString() ?? 'null', get_debug_type($rawFileProperties))); } @@ -41,14 +40,10 @@ public function parseFilePropertyFromElement(NodeElementInterface|RelationElemen */ protected function parseExtensionPropertyFromRawFileProperties(array $rawFileProperties, ?UuidInterface $elementId): string { - if (!array_key_exists('extension', $rawFileProperties)) { - return FileService::DEFAULT_EXTENSION; - } - $extensionProperty = $rawFileProperties['extension']; + // a missing, null or non-string extension falls back to the default; an empty string is valid and means that the + // file has no extension at all + $extensionProperty = $rawFileProperties['extension'] ?? null; if (!is_string($extensionProperty)) { - throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Expected property 'file.extension' of element %s to be of type string, got %s.", $elementId?->toString() ?? 'null', get_debug_type($extensionProperty))); - } - if (0 === strlen($extensionProperty)) { return FileService::DEFAULT_EXTENSION; } diff --git a/src/Service/FileRangeService.php b/src/Service/FileRangeService.php index 83c348d2..4798a0d0 100644 --- a/src/Service/FileRangeService.php +++ b/src/Service/FileRangeService.php @@ -9,18 +9,8 @@ use App\Type\ByteRange; /** - * Parses the HTTP `Range` request header (RFC 9110, Section 14.1.2) for the single-range case. - * - * Only a single byte range is supported, as multiple ranges within one request ("multipart/byteranges") are not - * implemented, and are not planned to be. The following forms are supported: - * - * - `bytes=-`: an explicit, inclusive range. - * - `bytes=-`: from `` to the end of the resource. - * - `bytes=-`: the last `` bytes of the resource. - * - * A syntactically invalid `Range` header, or one requesting multiple ranges, results in a 400 Bad Request: the - * server does not understand what was asked for. A syntactically valid header which can not be satisfied against - * the resource (e.g. a `start` beyond the end of the file) results in a 416 Range Not Satisfiable instead. + * Parses the HTTP `Range` header (RFC 9110, Section 14.1.2). Only a single range is supported, multiple ranges + * result in a 400 Bad Request. Valid but unsatisfiable ranges result in a 416 Range Not Satisfiable. */ class FileRangeService { @@ -30,10 +20,32 @@ public function __construct( ) { } + /** + * Evaluates the `If-Range` precondition (RFC 9110, Section 13.1.5) of a request which contains a `Range` header: + * the range is only served if the client still holds the current representation, otherwise the full file has to + * be answered with `200`. Only strong entity tags are compared, a weak tag never matches. HTTP dates never + * match, as no `Last-Modified` header is served for files, so the client can not have got one from this API. + */ + public function isRangeConditionSatisfied(?string $ifRangeHeader, ?string $currentEtag): bool + { + if (null === $ifRangeHeader) { + return true; + } + $ifRangeHeader = trim($ifRangeHeader); + if (!str_starts_with($ifRangeHeader, '"') || null === $currentEtag) { + return false; + } + + return trim($ifRangeHeader, '"') === $currentEtag; + } + + /** + * @SuppressWarnings("PHPMD.CyclomaticComplexity") + */ public function parseRangeHeader(string $rangeHeader, int $totalContentLength): ByteRange { if (1 !== \Safe\preg_match('/^bytes=(\d*)-(\d*)$/', trim($rangeHeader), $matches)) { - // syntactically invalid, or multiple ranges requested (unsupported, and not planned to be) + // syntactically invalid, or multiple ranges requested throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not parse 'Range' header: '%s'. Only a single range of the form 'bytes=-', 'bytes=-' or 'bytes=-' is supported.", $rangeHeader)); } @@ -45,14 +57,14 @@ public function parseRangeHeader(string $rangeHeader, int $totalContentLength): } if ($totalContentLength <= 0) { - throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail('Requested range can not be satisfied, as the resource is empty.', ['total-length' => $totalContentLength]); + throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail('Requested range can not be satisfied, as the resource is empty.', ['totalLength' => $totalContentLength], $totalContentLength); } if ('' === $rawStart) { // suffix range: last bytes $suffixLength = (int) $rawEnd; if ($suffixLength <= 0) { - throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail('Requested suffix range must request at least 1 byte.', ['total-length' => $totalContentLength]); + throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail('Requested suffix range must request at least 1 byte.', ['totalLength' => $totalContentLength], $totalContentLength); } $start = max(0, $totalContentLength - $suffixLength); @@ -60,20 +72,20 @@ public function parseRangeHeader(string $rangeHeader, int $totalContentLength): } else { $start = (int) $rawStart; if ($start >= $totalContentLength) { - throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail(sprintf('Requested range start (%d) is beyond the end of the resource (%d bytes long).', $start, $totalContentLength), ['requested-start' => $start, 'total-length' => $totalContentLength]); + throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail(sprintf('Requested range start (%d) is beyond the end of the resource (%d bytes long).', $start, $totalContentLength), ['requestedStart' => $start, 'totalLength' => $totalContentLength], $totalContentLength); } if ('' === $rawEnd) { - // open range: from to the end of the resource ("infinity") + // open range: from to the end of the resource $end = $totalContentLength - 1; } else { - // end can not exceed the resource's actual length, it is clamped instead of rejected + // clamped instead of rejected, see RFC 9110 $end = min((int) $rawEnd, $totalContentLength - 1); } } if ($end < $start) { - throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail('Requested range must span at least 1 byte.', ['requested-start' => $start, 'requested-end' => $end, 'total-length' => $totalContentLength]); + throw $this->client416RangeNotSatisfiableExceptionFactory->createFromDetail('Requested range must span at least 1 byte.', ['requestedStart' => $start, 'requestedEnd' => $end, 'totalLength' => $totalContentLength], $totalContentLength); } return new ByteRange($start, $end, $totalContentLength); diff --git a/src/Service/FileService.php b/src/Service/FileService.php index 076b9ee6..10d076a9 100644 --- a/src/Service/FileService.php +++ b/src/Service/FileService.php @@ -7,11 +7,12 @@ use App\Factory\Exception\Server500LogicErrorExceptionFactory; use EmberNexusBundle\Service\EmberNexusConfiguration; use Ramsey\Uuid\UuidInterface; +use Tuupola\Base58; class FileService { public const int MAX_FILENAME_LENGTH = 255; - public const int MAX_EXTENSION_LENGTH = 16; + public const int MAX_EXTENSION_LENGTH = 64; public const string DEFAULT_EXTENSION = 'bin'; public const string UPLOAD_EXTENSION = 'wip'; @@ -26,6 +27,8 @@ public function getAsciiSafeFileName(string $fileName): string { $fileName = $this->stringService->getAsciiSafeString($fileName); $fileName = $this->removeReservedCharactersFromFileName($fileName); + // Symfony rejects '%' in Content-Disposition filename fallbacks + $fileName = str_replace('%', '', $fileName); $parts = explode('.', $fileName, 2); if (2 === count($parts)) { @@ -51,19 +54,33 @@ public function removeReservedCharactersFromFileName(string $fileName): string )); } + /** + * An empty extension means that the file has no extension at all: the name is returned as it is, without a trailing dot. + */ public function buildFileNameFromParts(string $name, string $extension): string { /** @psalm-suppress PossiblyInvalidArgument */ $extension = substr(\Safe\preg_replace('/\s+/', '', $extension), 0, self::MAX_EXTENSION_LENGTH); + if ('' === $extension) { + return trim(substr(trim($name), 0, self::MAX_FILENAME_LENGTH)); + } $name = trim(substr(trim($name), 0, self::MAX_FILENAME_LENGTH - strlen($extension) - 1)); return sprintf('%s.%s', $name, $extension); } + /** + * Appends the extension to a path or key; an empty extension means that the file has no extension at all, so no + * trailing dot is added. + */ + public function appendExtension(string $pathWithoutExtension, string $extension): string + { + return '' === $extension ? $pathWithoutExtension : sprintf('%s.%s', $pathWithoutExtension, $extension); + } + public function getStorageBucketKey(UuidInterface $id, string $extension): string { - return sprintf( - '%s.%s', + return $this->appendExtension( $this->uuidToNestedFolderStructure( $id, $this->emberNexusConfiguration->getFileS3StorageBucketLevels(), @@ -73,8 +90,23 @@ public function getStorageBucketKey(UuidInterface $id, string $extension): strin ); } - public function getUploadBucketKey(UuidInterface $id, int $chunkIndex): string + /** + * Every attempt to upload a chunk generates its own id (base58 of 128 random bits, like tokens), so that concurrent attempts for the same chunk index can + * never overwrite each other's object; the id of the accepted attempt is stored on the `Upload` element. + */ + public function generateUploadChunkId(): string { + return (new Base58())->encode(random_bytes(16)); + } + + /** + * @param string|null $chunkId null for uploads which consist of a single object and have no chunk attempts + */ + public function getUploadBucketKey(UuidInterface $id, int $chunkIndex, ?string $chunkId = null): string + { + if (null !== $chunkId && 1 !== \Safe\preg_match('/^[0-9A-Za-z]{1,64}$/', $chunkId)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Chunk id has to be an alphanumeric string with at most 64 characters.'); + } $digits = $this->emberNexusConfiguration->getFileUploadChunkDigitsLength(); if ($chunkIndex < 0) { throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Chunk index can not be less than 0.'); @@ -85,13 +117,14 @@ public function getUploadBucketKey(UuidInterface $id, int $chunkIndex): string } return sprintf( - '%s-%s.%s', + '%s-%s%s.%s', $this->uuidToNestedFolderStructure( $id, $this->emberNexusConfiguration->getFileS3UploadBucketLevels(), $this->emberNexusConfiguration->getFileS3UploadBucketLevelLength(), ), str_pad((string) $chunkIndex, $digits, '0', STR_PAD_LEFT), + null === $chunkId ? '' : '-'.$chunkId, self::UPLOAD_EXTENSION ); } diff --git a/src/Service/FileSizeLimitService.php b/src/Service/FileSizeLimitService.php new file mode 100644 index 00000000..5ae9321b --- /dev/null +++ b/src/Service/FileSizeLimitService.php @@ -0,0 +1,39 @@ +emberNexusConfiguration->getFileMaxFileSizeInBytes(); + } + + public function exceedsMaxFileSize(int $contentLengthInBytes): bool + { + return $contentLengthInBytes > $this->getMaxFileSizeInBytes(); + } + + public function assertWithinMaxFileSize(int $contentLengthInBytes): void + { + if (!$this->exceedsMaxFileSize($contentLengthInBytes)) { + return; + } + + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded file has to be at most %d bytes long, got %d.', $this->getMaxFileSizeInBytes(), $contentLengthInBytes)); + } +} diff --git a/src/Service/HeaderParseService.php b/src/Service/HeaderParseService.php index 36fbefad..b61796da 100644 --- a/src/Service/HeaderParseService.php +++ b/src/Service/HeaderParseService.php @@ -55,11 +55,13 @@ public function getExtensionFromHeaders(HeaderBag $headers): string $fileName = basename($fileName); $fileName = $this->fileService->removeReservedCharactersFromFileName($fileName); $extension = pathinfo($fileName, PATHINFO_EXTENSION); - if (empty($extension)) { - throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not parse a file extension from the filename in 'Content-Disposition': '%s'.", $fileName)); + if ('' === $extension) { + // files without extension are valid (e.g. 'Makefile'); an empty extension is stored, and the file is + // stored and named on download without any extension + return ''; } - return $extension; + return substr($extension, 0, FileService::MAX_EXTENSION_LENGTH); } public function getUploadOffsetFromHeaders(HeaderBag $headers): int diff --git a/src/Service/IncrementalHashService.php b/src/Service/IncrementalHashService.php index e2d3f873..b25bc4e8 100644 --- a/src/Service/IncrementalHashService.php +++ b/src/Service/IncrementalHashService.php @@ -9,21 +9,11 @@ use Throwable; /** - * Computes a hash of an upload incrementally, chunk by chunk, across separate requests: the running - * `HashContext`'s serialized state is persisted on the `Upload` element between chunks (see UploadService), so - * the final hash falls out of the upload itself once the last chunk is processed, without ever having to read - * the finished file back from S3 afterwards. + * Hashes an upload chunk by chunk across requests; the serialized hash context is stored on the `Upload` element + * between chunks, so the finished file never has to be read back from S3. * - * Each chunk's resource is hashed here, then rewound, before being handed to S3Service. This is a local - * (memory/temp-file) read, not a network round trip - and not even the only such local read: AsyncAws's own S3 - * request signer already reads the whole resource twice on its own (once for a `content-md5` header, once for - * `x-amz-content-sha256`), rewinding each time, before ever streaming it for real. That existing behavior is - * also why this hashes explicitly instead of via a stream filter attached once and left in place: a filter would - * see all of those extra internal reads too, not just the one that matters, and would hash the content multiple - * times over. - * - * `HashContext` (as returned by `hash_init()`) is not a documented, BC-promised serialization target, but does - * currently survive PHP's standard `serialize()`/`unserialize()` and resumes hashing correctly. + * Chunks are hashed explicitly instead of via a stream filter, as AsyncAws's request signer reads the resource + * multiple times, which a filter would hash as well. */ class IncrementalHashService { @@ -38,8 +28,7 @@ public function createContext(string $algorithm): HashContext } /** - * Hashes $resource (from its current position to EOF) into $context, then rewinds $resource, so that - * whatever reads it next (e.g. S3Service, for the actual upload) sees the same, untouched content. + * Rewinds $resource afterwards, so that it can be uploaded next. * * @param resource $resource */ @@ -55,10 +44,7 @@ public function finalize(HashContext $context): string } /** - * Serializes a still-open (not yet finalized) hash context for storage between requests, e.g. as a property - * on the `Upload` element. Base64-encoded, since the serialized form embeds the hash's raw internal buffer - * (which may contain arbitrary bytes, not just printable ones) and must round-trip safely through storage - * layers that expect text. + * Base64-encoded, as the serialized context contains binary data. */ public function serializeContextForStorage(HashContext $context): string { @@ -66,18 +52,14 @@ public function serializeContextForStorage(HashContext $context): string } /** - * Restores a hash context previously serialized by {@see serializeContextForStorage()}. Since a client never - * supplies this value directly (it is purely server-generated and stored in our own database), this is not - * an "unserialize untrusted data" concern - but the serialized form of a HashContext is an undocumented PHP - * internal representation with no BC promise, so decoding failure (e.g. after a PHP/OpenSSL version change - * mid-upload, which is not realistically reproducible in a test) is handled explicitly rather than left to - * crash. The same error path is also reachable, and is covered by a test, for plain data corruption. + * The value is server-generated, but the serialization format of HashContext has no BC promise (e.g. across + * PHP updates), so failures are handled explicitly. */ public function unserializeContextFromStorage(string $serialized): HashContext { try { $binary = \Safe\base64_decode($serialized, true); - $context = \Safe\unserialize($binary); + $context = \Safe\unserialize($binary, ['allowed_classes' => [HashContext::class]]); } catch (Throwable) { $context = null; } diff --git a/src/Service/PropertyParseService.php b/src/Service/PropertyParseService.php index 41e9db66..6fe7cea8 100644 --- a/src/Service/PropertyParseService.php +++ b/src/Service/PropertyParseService.php @@ -68,6 +68,25 @@ public function getIsUploadCompleteFromProperties(mixed $properties): bool return $uploadComplete; } + /** + * Absent on an upload created before this property existed: defaults to `true` so the `hasFile` conflict checks + * are skipped rather than risk rejecting a legitimate, already-in-progress replace. + * + * @param mixed[] $properties + */ + public function getTargetHadFileAtCreationFromProperties(mixed $properties): bool + { + if (!array_key_exists('targetHadFileAtCreation', $properties)) { + return true; + } + $targetHadFileAtCreation = $properties['targetHadFileAtCreation']; + if (!is_bool($targetHadFileAtCreation)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property targetHadFileAtCreation to be bool.'); + } + + return $targetHadFileAtCreation; + } + /** * @param mixed[] $properties */ @@ -89,21 +108,25 @@ public function getUploadTargetFromProperties(mixed $properties): UuidInterface /** * @param mixed[] $properties + * + * @return list */ - public function getAlreadyUploadedChunksFromProperties(mixed $properties): int + public function getChunkIdsFromProperties(mixed $properties): array { - if (!array_key_exists('alreadyUploadedChunks', $properties)) { - return 0; + if (!array_key_exists('chunkIds', $properties)) { + return []; } - $alreadyUploadedChunks = $properties['alreadyUploadedChunks']; - if (!is_int($alreadyUploadedChunks)) { - throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property alreadyUploadedChunks to be int.'); + $chunkIds = $properties['chunkIds']; + if (!is_array($chunkIds) || !array_is_list($chunkIds)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property chunkIds to be a list of strings.'); } - if ($alreadyUploadedChunks < 0) { - throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property alreadyUploadedChunks to be positive int.'); + foreach ($chunkIds as $chunkId) { + if (!is_string($chunkId)) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property chunkIds to be a list of strings.'); + } } - return $alreadyUploadedChunks; + return $chunkIds; } /** @@ -130,12 +153,10 @@ public function getUploadOwnerFromProperties(mixed $properties): UuidInterface */ public function getExtensionFromProperties(mixed $properties): string { - if (!array_key_exists('extension', $properties)) { - return FileService::DEFAULT_EXTENSION; - } - $extension = $properties['extension']; + // a missing, null or non-string extension falls back to the default; an empty string means no extension + $extension = $properties['extension'] ?? null; if (!is_string($extension)) { - throw $this->client400BadContentExceptionFactory->createFromDetail('Upload expects property extension to be string.'); + return FileService::DEFAULT_EXTENSION; } return $extension; diff --git a/src/Service/QueueService.php b/src/Service/QueueService.php index 4e9b8fca..21390f04 100644 --- a/src/Service/QueueService.php +++ b/src/Service/QueueService.php @@ -5,13 +5,35 @@ namespace App\Service; use App\Type\RabbitMQQueueType; +use PhpAmqpLib\Channel\AMQPChannel; use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Exception\AMQPProtocolChannelException; use PhpAmqpLib\Message\AMQPMessage; +use PhpAmqpLib\Wire\AMQPTable; +use Psr\Log\LoggerInterface; +use Throwable; class QueueService { + public const int MAX_TRIES = 3; + private const string TRY_COUNT_HEADER = 'x-try-count'; + + /** + * Queue types which are declared durable, with persistent messages and with a bounded length + dead-letter + * target. Every other queue type keeps the historical non-durable, non-persistent, unbounded declaration, so + * this list is opt-in per queue rather than a blanket change. + * + * @var RabbitMQQueueType[] + */ + private const array DURABLE_QUEUE_TYPES = [ + RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE, + ]; + + private const int MAX_QUEUE_LENGTH = 100_000; + public function __construct( private AMQPStreamConnection $AMQPStreamConnection, + private LoggerInterface $logger, ) { } @@ -19,34 +41,141 @@ public function publishEvent(RabbitMQQueueType $queueType, mixed $eventData): vo { $channel = $this->AMQPStreamConnection->channel(); $queue = $queueType->value; - $channel->queue_declare($queue, false, false, false, false); + $channel = $this->declareQueue($channel, $queueType); $jsonMessage = \Safe\json_encode($eventData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $message = new AMQPMessage($jsonMessage); + $properties = $this->isDurable($queueType) ? ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT] : []; + $message = new AMQPMessage($jsonMessage, $properties); $channel->basic_publish($message, '', $queue); $channel->close(); } /** * Drains all messages currently waiting in the given queue, calling $handler for each decoded message. - * Messages are only acknowledged once $handler returns without throwing, so a failure leaves the message - * queued for the next run. Returns the number of successfully processed messages. + * Messages are acknowledged once $handler returns without throwing. A message which fails (or can not be decoded) is + * logged, republished at the end of the queue with an increased try counter (after the queue was drained, so it is + * not consumed again in this run) and acknowledged; after MAX_TRIES failed tries it is dropped and this is logged. + * Returns the number of successfully processed messages. */ public function consumeQueue(RabbitMQQueueType $queueType, callable $handler): int { $channel = $this->AMQPStreamConnection->channel(); $queue = $queueType->value; - $channel->queue_declare($queue, false, false, false, false); + $channel = $this->declareQueue($channel, $queueType); $processedMessages = 0; + /** @var array $messagesToRequeue */ + $messagesToRequeue = []; while (null !== ($message = $channel->basic_get($queue))) { - $eventData = \Safe\json_decode($message->getBody(), true); - $handler($eventData); + try { + $eventData = \Safe\json_decode($message->getBody(), true); + $handler($eventData); + ++$processedMessages; + } catch (Throwable $throwable) { + $tries = $this->getTryCount($message) + 1; + if ($tries >= self::MAX_TRIES) { + $this->logger->error(sprintf( + "Dropping message of queue '%s' after %d failed tries: %s. Message body: %s", + $queue, + $tries, + $throwable->getMessage(), + $message->getBody() + )); + } else { + $this->logger->error(sprintf( + "Failed to process message of queue '%s' (try %d of %d), requeueing it: %s. Message body: %s", + $queue, + $tries, + self::MAX_TRIES, + $throwable->getMessage(), + $message->getBody() + )); + $messagesToRequeue[] = ['body' => $message->getBody(), 'tries' => $tries]; + } + } $message->ack(); - ++$processedMessages; + } + + $isDurable = $this->isDurable($queueType); + foreach ($messagesToRequeue as $messageToRequeue) { + $properties = [ + 'application_headers' => new AMQPTable([self::TRY_COUNT_HEADER => $messageToRequeue['tries']]), + ]; + if ($isDurable) { + $properties['delivery_mode'] = AMQPMessage::DELIVERY_MODE_PERSISTENT; + } + $channel->basic_publish(new AMQPMessage($messageToRequeue['body'], $properties), '', $queue); } $channel->close(); return $processedMessages; } + + private function getTryCount(AMQPMessage $message): int + { + if (!$message->has('application_headers')) { + return 0; + } + $headers = $message->get('application_headers'); + if (!($headers instanceof AMQPTable)) { + return 0; + } + $tries = $headers->getNativeData()[self::TRY_COUNT_HEADER] ?? 0; + + return is_int($tries) ? $tries : 0; + } + + private function isDurable(RabbitMQQueueType $queueType): bool + { + return in_array($queueType, self::DURABLE_QUEUE_TYPES, true); + } + + /** + * Declares the given queue, using durable/persistent/bounded-length + dead-letter arguments for the queue types + * opted into that via {@see self::DURABLE_QUEUE_TYPES}, or the historical plain declaration otherwise. + * + * If a queue of the same name was already declared elsewhere with different arguments (e.g. left over from + * before this queue type became durable), RabbitMQ refuses the redeclaration with a channel-level + * `PRECONDITION_FAILED` (406) error, which also closes the channel. Since nothing has ever consumed this queue + * for real yet (verified: no production consumer exists), it is safe to drop and recreate it with the new + * arguments on a fresh channel rather than requiring a queue rename/migration. + */ + private function declareQueue(AMQPChannel $channel, RabbitMQQueueType $queueType): AMQPChannel + { + $queue = $queueType->value; + if (!$this->isDurable($queueType)) { + $channel->queue_declare($queue, false, false, false, false); + + return $channel; + } + + $deadLetterQueue = $queue.'.dead-letter'; + $arguments = new AMQPTable([ + 'x-max-length' => self::MAX_QUEUE_LENGTH, + 'x-dead-letter-exchange' => '', + 'x-dead-letter-routing-key' => $deadLetterQueue, + ]); + + try { + $channel->queue_declare($deadLetterQueue, false, true, false, false, false, new AMQPTable(['x-max-length' => self::MAX_QUEUE_LENGTH])); + $channel->queue_declare($queue, false, true, false, false, false, $arguments); + + return $channel; + } catch (AMQPProtocolChannelException $exception) { + if (406 !== $exception->amqp_reply_code) { + throw $exception; + } + $this->logger->warning(sprintf( + "Queue '%s' or its dead-letter queue already existed with different arguments; deleting and redeclaring both with the current arguments.", + $queue + )); + $freshChannel = $this->AMQPStreamConnection->channel(); + $freshChannel->queue_delete($deadLetterQueue); + $freshChannel->queue_delete($queue); + $freshChannel->queue_declare($deadLetterQueue, false, true, false, false, false, new AMQPTable(['x-max-length' => self::MAX_QUEUE_LENGTH])); + $freshChannel->queue_declare($queue, false, true, false, false, false, $arguments); + + return $freshChannel; + } + } } diff --git a/src/Service/RawToElementService.php b/src/Service/RawToElementService.php index 45404309..33144709 100755 --- a/src/Service/RawToElementService.php +++ b/src/Service/RawToElementService.php @@ -26,8 +26,6 @@ public function __construct( } /** - * todo: make sure that $supportFile is never true in API calls. add feature tests. - * * @param array $rawData */ public function rawToElement(array $rawData, bool $supportFile = false): NodeElementInterface|RelationElementInterface diff --git a/src/Service/RequestContentService.php b/src/Service/RequestContentService.php new file mode 100644 index 00000000..bb3a6b79 --- /dev/null +++ b/src/Service/RequestContentService.php @@ -0,0 +1,33 @@ +getContent(); + $announcedLength = $request->headers->get('Content-Length'); + if (null !== $announcedLength && ctype_digit($announcedLength) && strlen($content) < (int) $announcedLength) { + throw $this->client408RequestTimeoutExceptionFactory->createFromIncompleteBody(strlen($content), (int) $announcedLength); + } + + return $content; + } +} diff --git a/src/Service/RequestIdService.php b/src/Service/RequestIdService.php index 99098387..273f075b 100644 --- a/src/Service/RequestIdService.php +++ b/src/Service/RequestIdService.php @@ -6,18 +6,40 @@ use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; +use Symfony\Component\HttpFoundation\RequestStack; +/** + * The web server sets the request header `X-Request-Id` for every request (docker/Caddyfile), which is also part of + * its access log and, as `instance` (`urn:uuid:`), of every problem json response, so that a client can find the + * log lines of its request. Without the header, e.g. on the console, a new id is generated. + */ class RequestIdService { - private UuidInterface $requestId; + public const string HEADER_NAME = 'X-Request-Id'; - public function __construct() - { - $this->requestId = Uuid::uuid4(); + private ?UuidInterface $requestId = null; + + public function __construct( + private RequestStack $requestStack, + ) { } public function getRequestId(): UuidInterface { + if (null === $this->requestId) { + $this->requestId = $this->getRequestIdFromHeader() ?? Uuid::uuid4(); + } + return $this->requestId; } + + private function getRequestIdFromHeader(): ?UuidInterface + { + $headerValue = $this->requestStack->getMainRequest()?->headers->get(self::HEADER_NAME); + if (null === $headerValue || !Uuid::isValid($headerValue)) { + return null; + } + + return Uuid::fromString($headerValue); + } } diff --git a/src/Service/RequestUtilService.php b/src/Service/RequestUtilService.php index f3a079ea..054c2325 100644 --- a/src/Service/RequestUtilService.php +++ b/src/Service/RequestUtilService.php @@ -8,13 +8,10 @@ use App\Exception\Client400MissingPropertyException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client400MissingPropertyExceptionFactory; -use EmberNexusBundle\Service\EmberNexusConfiguration; -use Exception; class RequestUtilService { public function __construct( - private EmberNexusConfiguration $emberNexusConfiguration, private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, private Client400MissingPropertyExceptionFactory $client400MissingPropertyExceptionFactory, ) { @@ -40,43 +37,13 @@ public function validateTypeFromBody(string $expectedType, array $body): void } } - /** - * @deprecated will be removed in version 0.2.0 - * @see GitHub issue #280 - * - * @param array $body - * @param array $data - * - * @throws Client400BadContentException - * @throws Client400MissingPropertyException - */ - protected function getUniqueUserIdentifierFromBodyAndDataOld(array $body, array $data): ?string - { - if (array_key_exists('user', $body)) { - $uniqueUserIdentifier = $body['user']; - if (is_string($uniqueUserIdentifier)) { - return $uniqueUserIdentifier; - } - } - $uniqueIdentifier = $this->emberNexusConfiguration->getRegisterUniqueIdentifier(); - if (!array_key_exists($uniqueIdentifier, $data)) { - throw $this->client400MissingPropertyExceptionFactory->createFromTemplate(sprintf('data.%s', $uniqueIdentifier), 'string'); - } - $uniqueUserIdentifier = $data[$uniqueIdentifier]; - if (!is_string($uniqueUserIdentifier)) { - throw $this->client400BadContentExceptionFactory->createFromTemplate(sprintf('data.%s', $uniqueIdentifier), 'string', $uniqueUserIdentifier); - } - - return $uniqueUserIdentifier; - } - /** * @param array $body * * @throws Client400BadContentException * @throws Client400MissingPropertyException */ - protected function getUniqueUserIdentifierFromBodyNew(array $body): string + public function getUniqueUserIdentifierFromBodyAndData(array $body): string { if (!array_key_exists('uniqueUserIdentifier', $body)) { throw $this->client400MissingPropertyExceptionFactory->createFromTemplate('uniqueUserIdentifier', 'string'); @@ -89,37 +56,6 @@ protected function getUniqueUserIdentifierFromBodyNew(array $body): string return $uniqueUserIdentifier; } - /** - * Function's content will be replaced by the content of function self::getUniqueUserIdentifierFromBodyNew with the - * release of version 0.2.0. - * - * @param array $body - * @param array $data - * - * @SuppressWarnings("PHPMD.EmptyCatchBlock") - * - * @throws Client400BadContentException - * @throws Client400MissingPropertyException - */ - public function getUniqueUserIdentifierFromBodyAndData(array $body, array $data): string - { - $uniqueUserIdentifier = null; - if (!$this->emberNexusConfiguration->isFeatureFlag280OldUniqueUserIdentifierDisabled()) { - try { - /** - * @psalm-suppress DeprecatedMethod - */ - $uniqueUserIdentifier = $this->getUniqueUserIdentifierFromBodyAndDataOld($body, $data); - } catch (Exception $e) { - } - } - if (null === $uniqueUserIdentifier) { - $uniqueUserIdentifier = $this->getUniqueUserIdentifierFromBodyNew($body); - } - - return $uniqueUserIdentifier; - } - /** * @param array $body * diff --git a/src/Service/S3Service.php b/src/Service/S3Service.php index 8f555a4b..38884d33 100644 --- a/src/Service/S3Service.php +++ b/src/Service/S3Service.php @@ -6,29 +6,50 @@ use App\Contract\S3\FileOperationInterface; use App\Contract\S3\MergeFileChunksOperationInterface; +use App\Contract\S3\S3TechnicalLimitsInterface; use App\Contract\S3\UploadFileChunkOperationInterface; use App\Contract\S3\UploadFileOperationInterface; +use App\Exception\Client400BadContentException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Server500LogicErrorExceptionFactory; -use App\Factory\Type\S3\UploadFileChunkOperationFactory; +use App\Factory\Type\S3\S3OperationFactory; use App\Type\S3\FileOperation; use App\Wrapper\S3ClientWrapper; use AsyncAws\S3\Result\GetObjectOutput; use AsyncAws\S3\S3Client; +use Psr\Log\LoggerInterface; use Throwable; +/** + * @SuppressWarnings("PHPMD.ExcessiveParameterList") + * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") + */ class S3Service { + /** + * Kept well below the backend's single PUT limit, as a failed single PUT has to be retried completely. + */ + public const int MULTIPART_UPLOAD_THRESHOLD_IN_BYTES = 500 * 1024 * 1024; + + /** + * Minimum part size, increased for large files to stay within the backend's maximum part count. + */ + public const int MULTIPART_UPLOAD_PART_SIZE_IN_BYTES = 100 * 1024 * 1024; + public function __construct( private S3Client $s3Client, - private UploadFileChunkOperationFactory $uploadFileChunkOperationFactory, + private S3OperationFactory $s3OperationFactory, private S3ClientWrapper $s3ClientWrapper, private MimeTypeService $mimeTypeService, private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, + private S3TechnicalLimitsInterface $s3TechnicalLimits, + private LoggerInterface $logger, S3TechnicalLimitsValidator $s3TechnicalLimitsValidator, + private int $multipartUploadThresholdInBytes = self::MULTIPART_UPLOAD_THRESHOLD_IN_BYTES, + private int $multipartUploadPartSizeInBytes = self::MULTIPART_UPLOAD_PART_SIZE_IN_BYTES, ) { - $s3TechnicalLimitsValidator->validate(); + $s3TechnicalLimitsValidator->validate($this->multipartUploadThresholdInBytes); } /** @@ -101,11 +122,7 @@ public function mergeFileChunks(MergeFileChunksOperationInterface $mergeFileChun ], ]); } catch (Throwable $e) { - $this->s3Client->abortMultipartUpload([ - 'Bucket' => $mergeFileChunksOperation->getStorageBucket(), - 'Key' => $mergeFileChunksOperation->getStorageKey(), - 'UploadId' => $multipartUploadId, - ]); + $this->tryAbortMultipartUpload($mergeFileChunksOperation->getStorageBucket(), $mergeFileChunksOperation->getStorageKey(), $multipartUploadId); throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Caught exception '%s' during multipart upload.", $e->getMessage()), previous: $e); } @@ -115,17 +132,31 @@ public function mergeFileChunks(MergeFileChunksOperationInterface $mergeFileChun $mergeFileChunksOperation->getStorageKey() )); - $previousStorageKey = $mergeFileChunksOperation->getPreviousStorageKey(); - if (null !== $previousStorageKey && $previousStorageKey !== $mergeFileChunksOperation->getStorageKey()) { - $this->deleteFile(new FileOperation( - $mergeFileChunksOperation->getStorageBucket(), - $previousStorageKey - )); - } + // the previous object (different extension) is deleted by the caller once the element points to the new one return $mergedContentLength; } + /** + * Total size of all chunks which would be merged; null if a chunk is missing or unreadable. + */ + public function getChunksContentLength(MergeFileChunksOperationInterface $mergeFileChunksOperation): ?int + { + $totalContentLength = 0; + foreach ($mergeFileChunksOperation->getUploadKeys() as $uploadKey) { + try { + $totalContentLength += $this->getContentLength(new FileOperation( + $mergeFileChunksOperation->getUploadBucket(), + $uploadKey + )); + } catch (Throwable) { + return null; + } + } + + return $totalContentLength; + } + public function deleteFileChunks(MergeFileChunksOperationInterface $mergeFileChunksOperation): void { foreach ($mergeFileChunksOperation->getUploadKeys() as $uploadKey) { @@ -164,14 +195,17 @@ public function uploadFileChunk(UploadFileChunkOperationInterface $uploadFileChu } /** - * Writes the whole file with a single PUT, which caps it at S3's 5 GiB single-object-upload limit. Requests - * reaching this are already bounded well below that by `post_max_size`, but `BackupLoadCommand` is not: it - * restores files straight from a backup archive, so a backup holding a file larger than 5 GiB fails to load. - * Lifting that needs multipart, tracked in https://github.com/ember-nexus/api/issues/452. + * Files from {@see MULTIPART_UPLOAD_THRESHOLD_IN_BYTES} upwards are streamed directly into the storage bucket as + * a multipart upload, smaller ones are copied over from the upload bucket. */ public function uploadFile(UploadFileOperationInterface $uploadFileOperation): int { - $uploadFileChunkOperation = $this->uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation($uploadFileOperation); + $contentLength = $uploadFileOperation->getContentLength(); + if (null !== $contentLength && $contentLength >= $this->multipartUploadThresholdInBytes) { + return $this->uploadFileViaMultipartUpload($uploadFileOperation, $contentLength); + } + + $uploadFileChunkOperation = $this->s3OperationFactory->createUploadFileChunkOperationFromUploadFileOperation($uploadFileOperation); $contentLength = $this->uploadFileChunk($uploadFileChunkOperation); $copyResult = $this->s3Client->copyObject([ @@ -188,14 +222,7 @@ public function uploadFile(UploadFileOperationInterface $uploadFileOperation): i try { $this->s3ClientWrapper->resolveCopyObjectOutput($copyResult); - $previousStorageKey = $uploadFileOperation->getPreviousStorageKey(); - if (null !== $previousStorageKey && $previousStorageKey !== $uploadFileOperation->getStorageKey()) { - $this->deleteFile(new FileOperation( - $uploadFileOperation->getStorageBucket(), - $previousStorageKey - )); - } - + // the previous object (different extension) is deleted by the caller once the element points to the new one $this->deleteFile(new FileOperation( $uploadFileOperation->getUploadBucket(), $uploadFileOperation->getUploadKey() @@ -207,6 +234,117 @@ public function uploadFile(UploadFileOperationInterface $uploadFileOperation): i return $contentLength; } + /** + * Skips the upload bucket, as `copyObject` has the same size limit as a single PUT. Only one part is held in + * memory at a time. + * + * @SuppressWarnings("PHPMD.CyclomaticComplexity") + */ + private function uploadFileViaMultipartUpload(UploadFileOperationInterface $uploadFileOperation, int $contentLength): int + { + $storageBucket = $uploadFileOperation->getStorageBucket(); + $storageKey = $uploadFileOperation->getStorageKey(); + $partSizeInBytes = $this->getMultipartUploadPartSizeInBytes($contentLength); + + $multipartUploadId = $this->s3Client->createMultipartUpload([ + 'Bucket' => $storageBucket, + 'Key' => $storageKey, + 'ContentType' => $uploadFileOperation->getMimeType(), + ])->getUploadId(); + + if (null === $multipartUploadId) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to create multipart upload.'); + } + + $uploadedContentLength = 0; + try { + $parts = []; + $resource = $uploadFileOperation->getContent(); + // mime type detection and hashing have already consumed part of the stream + \Safe\rewind($resource); + while (!feof($resource)) { + $body = \Safe\stream_get_contents($resource, $partSizeInBytes); + if ('' === $body) { + break; + } + + $partNumber = count($parts) + 1; + $etag = $this->s3Client->uploadPart([ + 'Bucket' => $storageBucket, + 'Key' => $storageKey, + 'UploadId' => $multipartUploadId, + 'PartNumber' => $partNumber, + 'Body' => $body, + ])->getETag(); + + if (null === $etag) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Unable to read etag of uploaded part %d.', $partNumber)); + } + + $parts[] = [ + 'PartNumber' => $partNumber, + 'ETag' => $etag, + ]; + $uploadedContentLength += strlen($body); + } + + // verified before completing, as a completed multipart upload would already replace the previous file + if ($uploadedContentLength !== $contentLength) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Inconsistent length values between provided content-length (%d) and actual content length (%d) detected.', $contentLength, $uploadedContentLength)); + } + + $this->s3Client->completeMultipartUpload([ + 'Bucket' => $storageBucket, + 'Key' => $storageKey, + 'UploadId' => $multipartUploadId, + 'MultipartUpload' => [ + 'Parts' => $parts, + ], + ]); + } catch (Throwable $e) { + $this->tryAbortMultipartUpload($storageBucket, $storageKey, $multipartUploadId); + + if ($e instanceof Client400BadContentException) { + throw $e; + } + + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Caught exception '%s' during multipart upload.", $e->getMessage()), previous: $e); + } + + // the previous object (different extension) is deleted by the caller once the element points to the new one + + return $uploadedContentLength; + } + + /** + * Failures are only logged, so that they do not replace the original exception. + */ + private function tryAbortMultipartUpload(string $bucket, string $key, string $multipartUploadId): void + { + try { + // AsyncAws requests are lazy; resolve() forces a failure to be thrown here instead of on destruction + $this->s3ClientWrapper->resolveAbortMultipartUploadOutput($this->s3Client->abortMultipartUpload([ + 'Bucket' => $bucket, + 'Key' => $key, + 'UploadId' => $multipartUploadId, + ])); + } catch (Throwable $e) { + $this->logger->warning('Unable to abort multipart upload.', [ + 'bucket' => $bucket, + 'key' => $key, + 'uploadId' => $multipartUploadId, + 'exception' => $e, + ]); + } + } + + private function getMultipartUploadPartSizeInBytes(int $contentLength): int + { + $partSizeRequiredByMaxChunkCount = (int) ceil($contentLength / $this->s3TechnicalLimits->getMaxChunkCount()); + + return max($this->multipartUploadPartSizeInBytes, $partSizeRequiredByMaxChunkCount); + } + public function deleteFile(FileOperationInterface $fileOperation): void { $objectConfig = [ @@ -248,8 +386,7 @@ public function getFile(FileOperationInterface $fileOperation): GetObjectOutput } /** - * Reads only the given inclusive byte range [$start, $end] from S3, via the `Range` request header, instead - * of downloading the whole file. + * Reads the inclusive byte range [$start, $end]. */ public function getFileByteRange(FileOperationInterface $fileOperation, int $start, int $end): GetObjectOutput { @@ -280,16 +417,19 @@ public function getFileAsResource(FileOperationInterface $fileOperation) */ public function getFileRangeAsResource(FileOperationInterface $fileOperation, int $maxContentLength) { + if ($maxContentLength <= 0) { + return \Safe\fopen('php://memory', 'r'); + } $contentLength = $this->getContentLength($fileOperation); - if (0 === $contentLength) { - // a byte-range request against an empty object has no satisfiable range (S3 rejects it with a 416), - // and there is nothing to fetch either way - an empty resource is the correct result directly. + $lengthToRead = min($contentLength, $maxContentLength); + if ($lengthToRead <= 0) { + // S3 rejects range requests against empty objects with a 416 return \Safe\fopen('php://memory', 'r'); } $result = $this->s3Client->getObject([ 'Bucket' => $fileOperation->getBucket(), 'Key' => $fileOperation->getKey(), - 'Range' => sprintf('bytes=0-%d', min($contentLength, $maxContentLength)), + 'Range' => sprintf('bytes=0-%d', $lengthToRead - 1), ]); return $result->getBody()->getContentAsResource(); @@ -334,8 +474,7 @@ public function getMimeTypeFromFile(FileOperationInterface $fileOperation): stri } /** - * Assumes that the first chunk a) exists and is b) sufficiently long to correctly determine the MimeType. This is - * currently the case, as S3's minimum chunk length is 5MB - sufficient for MimeType detection. + * Only the first chunk is used for detection, which is sufficient due to the minimum chunk size of 5 MiB. */ public function getMimeTypeFromMergeFileChunksOperation(MergeFileChunksOperationInterface $mergeFileChunksOperation): string { diff --git a/src/Service/S3TechnicalLimitsValidator.php b/src/Service/S3TechnicalLimitsValidator.php index 6dd29391..d40ebdac 100644 --- a/src/Service/S3TechnicalLimitsValidator.php +++ b/src/Service/S3TechnicalLimitsValidator.php @@ -9,10 +9,7 @@ use EmberNexusBundle\Service\EmberNexusConfiguration; /** - * Validates the operator-configurable `file.*` upload/storage settings ({@see EmberNexusConfiguration}) against - * the hard technical limits of the underlying storage backend ({@see S3TechnicalLimitsInterface}). Configuration - * may only be more restrictive than the technical limits, never less; a violation is a startup-time - * configuration error, not a runtime one. + * Ensures that the `file.*` configuration does not exceed the technical limits of the storage backend. */ class S3TechnicalLimitsValidator { @@ -23,11 +20,12 @@ public function __construct( ) { } - public function validate(): void + public function validate(int $multipartUploadThresholdInBytes = S3Service::MULTIPART_UPLOAD_THRESHOLD_IN_BYTES): void { $this->validateMinChunkSize(); $this->validateMaxChunkCount(); $this->validateMaxObjectSize(); + $this->validateMultipartUploadThreshold($multipartUploadThresholdInBytes); } private function validateMinChunkSize(): void @@ -51,6 +49,15 @@ private function validateMaxChunkCount(): void } } + private function validateMultipartUploadThreshold(int $multipartUploadThreshold): void + { + $technicalMaxSinglePutSize = $this->s3TechnicalLimits->getMaxSinglePutSizeInBytes(); + + if ($multipartUploadThreshold > $technicalMaxSinglePutSize) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf("Multipart upload threshold (%d) can not be larger than the storage backend's technical maximum single PUT size (%d).", $multipartUploadThreshold, $technicalMaxSinglePutSize)); + } + } + private function validateMaxObjectSize(): void { $configuredMaxFileSize = $this->emberNexusConfiguration->getFileMaxFileSizeInBytes(); diff --git a/src/Service/SearchAccessCalculatorService.php b/src/Service/SearchAccessCalculatorService.php new file mode 100644 index 00000000..920ff8bd --- /dev/null +++ b/src/Service/SearchAccessCalculatorService.php @@ -0,0 +1,73 @@ +getId(); + if (null === $elementId) { + return ['groups' => [], 'users' => []]; + } + + if ($element instanceof RelationElementInterface) { + $groups = $this->accessChecker->getDirectGroupsWithAccessToRelation($elementId, AccessType::SEARCH); + $users = $this->accessChecker->getDirectUsersWithAccessToRelation($elementId, AccessType::SEARCH); + } else { + $groups = $this->accessChecker->getDirectGroupsWithAccessToNode($elementId, AccessType::SEARCH); + $users = $this->accessChecker->getDirectUsersWithAccessToNode($elementId, AccessType::SEARCH); + } + + return [ + 'groups' => $this->convertArrayOfUuidsToArrayOfStrings($groups), + 'users' => $this->convertArrayOfUuidsToArrayOfStrings($users), + ]; + } + + public function getIndexForElement(NodeElementInterface|RelationElementInterface $element): string + { + if ($element instanceof RelationElementInterface) { + return sprintf('relation_%s', strtolower($element->getType() ?? '')); + } + + return sprintf('node_%s', strtolower($element->getLabel() ?? '')); + } + + /** + * @param UuidInterface[] $ids + * + * @return string[] + */ + private function convertArrayOfUuidsToArrayOfStrings(array $ids): array + { + $output = []; + foreach ($ids as $id) { + $output[] = $id->toString(); + } + + return $output; + } +} diff --git a/src/Service/TtyDetectorService.php b/src/Service/TtyDetectorService.php new file mode 100644 index 00000000..dcc18bc2 --- /dev/null +++ b/src/Service/TtyDetectorService.php @@ -0,0 +1,25 @@ +elementManager->getElementOrFail(UuidV4::fromString($id)); + try { + $upload = $this->uploadFactory->createUploadFromElement($uploadElement); + } catch (Exception) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + $userId = $this->authProvider->getUserId(); + if ($upload->getUploadOwner()->toString() !== $userId->toString()) { + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + if (!$this->accessChecker->hasAccessToElement($userId, $upload->getUploadTarget(), AccessType::UPDATE)) { + // the owner lost access to the target, so the upload is cancelled as well + $this->uploadCancellationService->cancelUpload($upload); + + throw $this->client404NotFoundExceptionFactory->createFromTemplate(); + } + + // an expired upload is gone even if the cron job did not remove it yet + if ($upload->getExpires() < new DateTime()) { + throw $this->client410GoneExceptionFactory->createFromTemplate(); + } + $this->uploadConsistencyService->assertConsistent($uploadElement, $upload); + + return $upload; + } +} diff --git a/src/Service/UploadAppendService.php b/src/Service/UploadAppendService.php new file mode 100644 index 00000000..f660b8af --- /dev/null +++ b/src/Service/UploadAppendService.php @@ -0,0 +1,154 @@ +targetHadFileAtCreation()) { + $targetElement = $this->elementManager->getElementOrFail($upload->getUploadTarget()); + if ($this->elementService->hasFile($targetElement)) { + throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf("Element with id '%s' already has an associated file; this upload can no longer complete and should be discarded.", $upload->getUploadTarget()->toString())); + } + } + + if ($partialUploadRequest->getUploadOffset() !== $upload->getUploadOffset()) { + throw $this->client409ConflictExceptionFactory->createFromDetail('Offset from request does not match offset of resource.', additionalProperties: ['expectedOffset' => $upload->getUploadOffset(), 'providedOffset' => $partialUploadRequest->getUploadOffset()]); + } + + $isFinalChunk = true === $partialUploadRequest->isUploadComplete(); + $declaredChunkLength = $partialUploadRequest->getContentLength(); + $isEmptyChunk = 0 === ($declaredChunkLength ?? $this->getBufferedContentLength($partialUploadRequest)); + + if ($isEmptyChunk && !$isFinalChunk) { + // a no-op which just reports the current state, e.g. to check the offset + $this->closeResource($partialUploadRequest->getContent()); + + return $upload; + } + + // reject obviously invalid chunks before anything is sent to S3 + if (null !== $declaredChunkLength) { + $this->uploadChunkValidator->assertValidChunk($declaredChunkLength, $isFinalChunk, $upload->getUploadOffset(), $upload->getUploadLength()); + } + + // hashed before the upload to S3, see IncrementalHashService + $resource = $partialUploadRequest->getContent(); + $hashState = $upload->getHashState(); + $hashContext = null !== $hashState + ? $this->incrementalHashService->unserializeContextFromStorage($hashState) + : $this->incrementalHashService->createContext(FileHashService::ALGORITHM); + $this->incrementalHashService->updateFromResource($hashContext, $resource); + + $chunkId = null; + $chunkLength = 0; + if ($isEmptyChunk && [] !== $upload->getChunkIds()) { + $this->closeResource($resource); + } else { + // every attempt writes its own object, so that a concurrent attempt for the same chunk can not overwrite it + $chunkId = $this->fileService->generateUploadChunkId(); + $chunkLength = $this->s3Service->uploadFileChunk($this->s3OperationFactory->createUploadFileChunkOperationFromPartialUploadRequest($partialUploadRequest, $upload, $chunkId)); + $this->closeResource($resource); + } + + try { + // the declared Content-Length was already checked before the upload, this is the authoritative check + $this->uploadChunkValidator->assertValidChunk($chunkLength, $isFinalChunk, $upload->getUploadOffset(), $upload->getUploadLength()); + $nextUpload = $this->createNextUpload($upload, $isFinalChunk, $chunkLength, $chunkId, $hashContext); + $this->assertOffsetUnchanged($upload, $nextUpload); + } catch (Throwable $throwable) { + // the chunk is not part of the upload, so nothing else references its object + if (null !== $chunkId) { + $this->s3Service->deleteFile($this->s3OperationFactory->createFileOperationFromUpload($upload, $upload->getAlreadyUploadedChunks() + 1, $chunkId)); + } + + throw $throwable; + } + + if ($isFinalChunk) { + $this->uploadFinalizationService->finalize($nextUpload, $hashContext, $reprDigestHeaderValue); + } + + return $nextUpload; + } + + private function createNextUpload(UploadInterface $upload, bool $isFinalChunk, int $chunkLength, ?string $chunkId, HashContext $hashContext): UploadInterface + { + $nextUpload = $upload; + if (null !== $chunkId) { + // the hash state is only kept for chunks which are followed by another one, the finalization uses the hash + // context directly + $nextUpload = $this->uploadFactory->addNewChunkToUpload($upload, $chunkLength, $chunkId, $isFinalChunk ? null : $this->incrementalHashService->serializeContextForStorage($hashContext)); + } + + return $isFinalChunk ? $this->uploadFactory->markUploadAsComplete($nextUpload) : $nextUpload; + } + + // defense in depth, the lock may have expired while this request was still uploading its chunk + private function assertOffsetUnchanged(UploadInterface $upload, UploadInterface $nextUpload): void + { + if (!$this->uploadService->appendChunkIfOffsetMatches($upload, $nextUpload)) { + throw $this->client409ConflictExceptionFactory->createFromDetail('Upload was modified by another request while this chunk was uploaded, please check the offset and retry.'); + } + } + + // the request body is buffered by the request factory, so its size is known even without `Content-Length` + private function getBufferedContentLength(PartialUploadRequestInterface $partialUploadRequest): int + { + return \Safe\fstat($partialUploadRequest->getContent())['size']; + } + + /** + * The S3 client may already have closed the resource while uploading it. + * + * @param resource $resource + */ + private function closeResource($resource): void + { + /** @psalm-suppress RedundantConditionGivenDocblockType */ + if (is_resource($resource)) { + \Safe\fclose($resource); + } + } +} diff --git a/src/Service/UploadBodyLimitService.php b/src/Service/UploadBodyLimitService.php new file mode 100644 index 00000000..48458c6a --- /dev/null +++ b/src/Service/UploadBodyLimitService.php @@ -0,0 +1,71 @@ +emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(); + if (null !== $declaredContentLength && $declaredContentLength > $maxLength) { + throw $this->createTooLongException($maxLength, (string) $declaredContentLength); + } + } + + /** + * Returns a seekable copy of at most the maximum length, spilling to disk for large bodies. + * + * @param resource $resource + * + * @return resource + */ + public function boundContent($resource, ?int $declaredContentLength): mixed + { + $this->assertDeclaredLengthWithinLimit($declaredContentLength); + + $maxLength = $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(); + $bounded = \Safe\fopen('php://temp', 'r+'); + $copiedLength = \Safe\stream_copy_to_stream($resource, $bounded, $maxLength + 1); + if ($copiedLength > $maxLength) { + \Safe\fclose($bounded); + + throw $this->createTooLongException($maxLength, 'more'); + } + // e.g. a body which was cut off by a request timeout must not be stored as a shorter file + if (null !== $declaredContentLength && $copiedLength !== $declaredContentLength) { + \Safe\fclose($bounded); + if ($copiedLength < $declaredContentLength) { + throw $this->client408RequestTimeoutExceptionFactory->createFromIncompleteBody($copiedLength, $declaredContentLength); + } + + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Request body has %d bytes, but header 'Content-Length' declares %d bytes.", $copiedLength, $declaredContentLength)); + } + \Safe\rewind($bounded); + + return $bounded; + } + + private function createTooLongException(int $maxLength, string $actual): Client400BadContentException + { + return $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at most %d bytes long, got %s.', $maxLength, $actual)); + } +} diff --git a/src/Service/UploadCancellationService.php b/src/Service/UploadCancellationService.php new file mode 100644 index 00000000..0a9992c1 --- /dev/null +++ b/src/Service/UploadCancellationService.php @@ -0,0 +1,107 @@ +getUploadIds() as $uploadId) { + $uploadElement = $this->elementManager->getElement(UuidV4::fromString($uploadId)); + if (null === $uploadElement) { + continue; + } + try { + $upload = $this->uploadFactory->createUploadFromElement($uploadElement); + } catch (Exception) { + continue; + } + if ($this->accessChecker->hasAccessToElement($upload->getUploadOwner(), $upload->getUploadTarget(), AccessType::UPDATE)) { + continue; + } + if ($this->cancelUpload($upload)) { + ++$cancelledUploads; + } + } + + return $cancelledUploads; + } + + /** + * Returns false if the upload is currently locked by another request; it is then cancelled by a later review. + */ + public function cancelUpload(UploadInterface $upload): bool + { + $lockToken = $this->uploadLockService->acquire($upload->getId()); + if (null === $lockToken) { + return false; + } + + try { + // an append may have finished while waiting for the lock, chunk count has to be fresh + $uploadElement = $this->elementManager->getElement($upload->getId()); + if (null !== $uploadElement) { + $this->uploadService->deleteUploadAndChunks($this->uploadFactory->createUploadFromElement($uploadElement)); + $this->elementManager->flush(); + } + } finally { + $this->uploadLockService->release($upload->getId(), $lockToken); + } + + return true; + } + + /** + * @return string[] + */ + private function getUploadIds(): array + { + $queryResult = $this->cypherEntityManager->getClient()->runStatement(Statement::create('MATCH (u:Upload) RETURN u.id')); + + $uploadIds = []; + foreach ($queryResult as $queryResultLine) { + $uploadId = $queryResultLine['u.id']; + if (!is_string($uploadId)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate(sprintf('Expected cypher response to return property u.id as string, not %s.', get_debug_type($uploadId))); // @codeCoverageIgnore + } + $uploadIds[] = $uploadId; + } + + return $uploadIds; + } +} diff --git a/src/Service/UploadChunkValidator.php b/src/Service/UploadChunkValidator.php new file mode 100644 index 00000000..a9b50c8b --- /dev/null +++ b/src/Service/UploadChunkValidator.php @@ -0,0 +1,69 @@ +assertChunkSize($chunkLength, $isFinalChunk, $uploadOffset); + $this->assertWithinDeclaredLength($chunkLength, $isFinalChunk, $uploadOffset, $uploadLength); + } + + /** + * Limits of the server: only the final chunk may be shorter than the minimum chunk size (S3 has a minimum part + * size), no chunk may exceed the maximum chunk size, and the running total may not exceed the maximum file size. + */ + public function assertChunkSize(int $chunkLength, bool $isFinalChunk, int $uploadOffset): void + { + $minChunkSize = $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes(); + if (!$isFinalChunk && $chunkLength < $minChunkSize) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at least %d bytes long, got %d.', $minChunkSize, $chunkLength)); + } + $maxChunkSize = $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(); + if ($chunkLength > $maxChunkSize) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at most %d bytes long, got %d.', $maxChunkSize, $chunkLength)); + } + + // rejected as soon as the running total exceeds the limit, not only on completion + $this->fileSizeLimitService->assertWithinMaxFileSize($uploadOffset + $chunkLength); + } + + /** + * A request which contradicts the length declared by the client is rejected on its own; the upload keeps its + * state and can be continued by a corrected request. + */ + public function assertWithinDeclaredLength(int $chunkLength, bool $isFinalChunk, int $uploadOffset, ?int $uploadLength): void + { + if (null === $uploadLength) { + return; + } + + $totalLength = $uploadOffset + $chunkLength; + if ($totalLength > $uploadLength) { + throw $this->client409ConflictExceptionFactory->createFromDetail('Already uploaded data exceeds defined upload length.'); + } + if ($isFinalChunk && $totalLength < $uploadLength) { + throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf('Completed upload has %d bytes, but the defined upload length is %d bytes.', $totalLength, $uploadLength)); + } + } +} diff --git a/src/Service/UploadConsistencyService.php b/src/Service/UploadConsistencyService.php new file mode 100644 index 00000000..067387bf --- /dev/null +++ b/src/Service/UploadConsistencyService.php @@ -0,0 +1,67 @@ +hasProperty('lastChunkId') ? $uploadElement->getProperty('lastChunkId') : null; + if (null !== $storedLastChunkId && !is_string($storedLastChunkId)) { + $storedLastChunkId = false; + } + + $chunkIds = $upload->getChunkIds(); + $isConsistent = $storedLastChunkId === $upload->getLastChunkId() + // without any chunk there can not be any offset + && !([] === $chunkIds && $upload->getUploadOffset() > 0); + if ($isConsistent) { + return; + } + + $this->logger->error(sprintf( + 'Upload %s is inconsistent (last chunk id in graph: %s, last entry of chunk list: %s, offset: %d, chunks: %d), deleting it.', + $upload->getId()->toString(), + is_string($storedLastChunkId) ? $storedLastChunkId : 'invalid', + $upload->getLastChunkId() ?? 'none', + $upload->getUploadOffset(), + count($chunkIds) + )); + // the chunk which was written last may be missing in the list, but its object exists + if (is_string($storedLastChunkId) && !in_array($storedLastChunkId, $chunkIds, true)) { + $this->s3Service->deleteFile($this->s3OperationFactory->createFileOperationFromUpload($upload, count($chunkIds) + 1, $storedLastChunkId)); + } + $this->uploadService->deleteUploadAndChunks($upload); + $this->elementManager->flush(); + + throw $this->client409ConflictExceptionFactory->createFromDetail('Upload state is inconsistent and was deleted, please restart the upload.'); + } +} diff --git a/src/Service/UploadCreationService.php b/src/Service/UploadCreationService.php index ae33458e..4a9c0cf3 100755 --- a/src/Service/UploadCreationService.php +++ b/src/Service/UploadCreationService.php @@ -11,8 +11,7 @@ use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Type\Request\ResumableUploadRequestFactory; use App\Factory\Type\Response\NoContentResponseFactory; -use App\Factory\Type\S3\UploadFileChunkOperationFactory; -use App\Factory\Type\S3\UploadFileOperationFactory; +use App\Factory\Type\S3\S3OperationFactory; use App\Security\AuthProvider; use App\Type\Response\CreatedResponse; use App\Type\Upload; @@ -38,14 +37,19 @@ public function __construct( private IncrementalHashService $incrementalHashService, private DigestService $digestService, private ElementManager $elementManager, - private UploadFileOperationFactory $uploadFileOperationFactory, - private UploadFileChunkOperationFactory $uploadFileChunkOperationFactory, + private S3OperationFactory $s3OperationFactory, private ResumableUploadRequestFactory $resumableUploadRequestFactory, private EventDispatcherInterface $eventDispatcher, private NoContentResponseFactory $noContentResponseFactory, private UrlGeneratorInterface $urlGenerator, private UploadService $uploadService, + private FileSizeLimitService $fileSizeLimitService, + private UploadBodyLimitService $uploadBodyLimitService, + private FileService $fileService, private Client400BadContentExceptionFactory $client400BadContentExceptionFactory, + private UploadChunkValidator $uploadChunkValidator, + private ElementFileDeletionService $elementFileDeletionService, + private ElementService $elementService, ) { } @@ -56,45 +60,61 @@ public function handleUploadCreationFromRequest(UuidInterface $elementId, Reques $resumableUploadRequest = $this->resumableUploadRequestFactory->createResumableUploadRequestFromRequest($request, $elementId); if (false === $resumableUploadRequest->isUploadComplete()) { - return $this->createNewResumableUpload($resumableUploadRequest); + return $this->createNewResumableUpload($resumableUploadRequest, $this->elementService->hasFile($element)); } - $requestDigestHeaderValue = $request->headers->get('Repr-Digest') ?? $request->headers->get('Content-Digest'); + // the body of a single request is the whole file, so `Repr-Digest` and `Content-Digest` describe the same bytes + // and every supplied one has to match + $requestDigestHeaderValues = []; + foreach (['Repr-Digest', 'Content-Digest'] as $headerName) { + $headerValue = $request->headers->get($headerName); + if (null !== $headerValue) { + $requestDigestHeaderValues[$headerName] = $headerValue; + } + } - return $this->setOrReplaceElementFileDirectly($element, $resumableUploadRequest, $requestDigestHeaderValue); + return $this->setOrReplaceElementFileDirectly($element, $resumableUploadRequest, $requestDigestHeaderValues); } + /** + * @param array $requestDigestHeaderValues digest header values by header name + */ private function setOrReplaceElementFileDirectly( NodeElementInterface|RelationElementInterface $element, ResumableUploadRequestInterface $resumableUploadRequest, - ?string $requestDigestHeaderValue, + array $requestDigestHeaderValues, ): Response { - // the request body is hashed once, locally, then rewound, before anything else (including - // UploadFileOperationFactory's own mime type sniffing) reads it - see IncrementalHashService for why this - // is not done via a persistently-attached stream filter instead. + $contentLength = $resumableUploadRequest->getContentLength(); + if (null !== $contentLength) { + $this->fileSizeLimitService->assertWithinMaxFileSize($contentLength); + } + // a single request is bound by the maximum chunk size; the content itself is bound in the request factory + $this->uploadBodyLimitService->assertDeclaredLengthWithinLimit($contentLength); + $resource = $resumableUploadRequest->getContent(); + // hashed before the upload, so that a digest mismatch never replaces an existing file $hashContext = $this->incrementalHashService->createContext(FileHashService::ALGORITHM); $this->incrementalHashService->updateFromResource($hashContext, $resource); $hash = $this->incrementalHashService->finalize($hashContext); - // building the operation only sniffs the mime type locally; it does not touch S3 - so the digest, now - // already known, can be verified before the storage bucket is ever written to. On a mismatch, this means - // an existing file at this element is never replaced/overwritten in the first place. - $uploadFileOperation = $this->uploadFileOperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest); + $uploadFileOperation = $this->s3OperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest); - if (null !== $requestDigestHeaderValue) { - $this->verifyRequestDigest($requestDigestHeaderValue, $hash); + foreach ($requestDigestHeaderValues as $headerName => $requestDigestHeaderValue) { + $this->verifyRequestDigest($headerName, $requestDigestHeaderValue, $hash); } - $this->s3Service->uploadFile($uploadFileOperation); + // authoritative length, the Content-Length header is optional (e.g. chunked transfer encoding) + $uploadedContentLength = $this->s3Service->uploadFile($uploadFileOperation); + // the S3 client may already have closed the resource while uploading it + /** @psalm-suppress RedundantConditionGivenDocblockType */ if (is_resource($resource)) { \Safe\fclose($resource); } - $this->eventDispatcher->dispatch(new ElementFileReplaceEvent($resumableUploadRequest->getElementId())); - + // the new object is written first and the element is flushed before the previous object is deleted: a failing + // flush leaves the old file readable (an overwrite of the same key can not be rolled back) $element->addProperty('file', [ - 'contentLength' => $uploadFileOperation->getContentLength(), + 'contentLength' => $uploadedContentLength, 'extension' => $resumableUploadRequest->getExtension(), 'mimeType' => $uploadFileOperation->getMimeType(), 'hash' => [ @@ -105,57 +125,64 @@ private function setOrReplaceElementFileDirectly( $this->elementManager->merge($element); $this->elementManager->flush(); + $this->elementFileDeletionService->deletePreviousFileAfterReplace($uploadFileOperation); + $this->eventDispatcher->dispatch(new ElementFileReplaceEvent($resumableUploadRequest->getElementId())); + return new CreatedResponse(); } - private function verifyRequestDigest(string $requestDigestHeaderValue, string $actualHash): void + private function verifyRequestDigest(string $headerName, string $requestDigestHeaderValue, string $actualHash): void { $expectedHash = $this->digestService->parseSha256HexFromHeaderValue($requestDigestHeaderValue); if (null === $expectedHash) { - throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not verify upload: 'Repr-Digest'/'Content-Digest' header '%s' does not declare a supported digest algorithm; only 'sha-256' is supported.", $requestDigestHeaderValue)); + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not verify upload: '%s' header '%s' does not declare a supported digest algorithm; only 'sha-256' is supported.", $headerName, $requestDigestHeaderValue)); } if ($expectedHash !== $actualHash) { - throw $this->client400BadContentExceptionFactory->createFromDetail('Could not verify upload: the declared digest does not match the uploaded file\'s actual content.'); + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Could not verify upload: the digest declared in \'%s\' does not match the uploaded file\'s actual content.', $headerName)); } } - private function createNewResumableUpload(ResumableUploadRequestInterface $resumableUploadRequest): Response + private function createNewResumableUpload(ResumableUploadRequestInterface $resumableUploadRequest, bool $targetHadFileAtCreation): Response { + $uploadLength = $resumableUploadRequest->getUploadLength(); + if (null !== $uploadLength) { + $this->fileSizeLimitService->assertWithinMaxFileSize($uploadLength); + } + $uploadId = Uuid::uuid4(); $uploadOffset = 0; - $alreadyUploadedChunks = 0; + $chunkIds = []; $hashState = null; - if (0 !== $resumableUploadRequest->getContentLength()) { - // the chunk is hashed once, locally, then rewound, before S3Service ever sees it; the running hash - // state is persisted on the Upload element for the next chunk to resume from, so the final hash never - // requires reading the file back from S3 at all. - $resource = $resumableUploadRequest->getContent(); + $resource = $resumableUploadRequest->getContent(); + // the body is buffered by the request factory, so its size is known even without `Content-Length` + $contentLength = $resumableUploadRequest->getContentLength() ?? \Safe\fstat($resource)['size']; + if (0 === $contentLength) { + // an empty first chunk only creates the upload, S3 does not accept empty parts + /** @psalm-suppress RedundantConditionGivenDocblockType */ + if (is_resource($resource)) { + \Safe\fclose($resource); + } + } else { + // rejected before anything is sent to S3 + $this->uploadChunkValidator->assertWithinDeclaredLength($contentLength, false, 0, $uploadLength); $hashContext = $this->incrementalHashService->createContext(FileHashService::ALGORITHM); $this->incrementalHashService->updateFromResource($hashContext, $resource); - $uploadFileChunkOperation = $this->uploadFileChunkOperationFactory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId); + $chunkId = $this->fileService->generateUploadChunkId(); + $uploadFileChunkOperation = $this->s3OperationFactory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId, $chunkId); $uploadOffset = $this->s3Service->uploadFileChunk($uploadFileChunkOperation); + // the S3 client may already have closed the resource while uploading it + /** @psalm-suppress RedundantConditionGivenDocblockType */ if (is_resource($resource)) { \Safe\fclose($resource); } $hashState = $this->incrementalHashService->serializeContextForStorage($hashContext); - $alreadyUploadedChunks = 1; - if ($uploadOffset < $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()) { - /** - * file chunk has to be bigger than length, unless: - * - it is the last file chunk, which can contain data of arbitrary length (max limit still applies) - * - it is of zero length -> no actual content / client just asks for upload limits & starts upload process - */ - if (false === $resumableUploadRequest->isUploadComplete()) { - throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at least %d bytes long, got %d.', $this->emberNexusConfiguration->getFileUploadMinChunkSizeInBytes(), $uploadOffset)); - } - } - if ($uploadOffset > $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()) { - throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf('Uploaded chunk has to be at most %d bytes long, got %d.', $this->emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes(), $uploadOffset)); - } + $chunkIds = [$chunkId]; + // a single request completes the upload, so this is never the final chunk + $this->uploadChunkValidator->assertChunkSize($uploadOffset, false, 0); } $expires = (new DateTime())->add(new DateInterval(sprintf('PT%sS', $this->emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest()))); @@ -165,11 +192,12 @@ private function createNewResumableUpload(ResumableUploadRequestInterface $resum $uploadOffset, $resumableUploadRequest->isUploadComplete() ?? false, $resumableUploadRequest->getElementId(), - $alreadyUploadedChunks, + $chunkIds, $this->authProvider->getUserId(), $resumableUploadRequest->getExtension(), $expires, - $hashState + $hashState, + $targetHadFileAtCreation ); $this->uploadService->mergeUploadElement($upload); diff --git a/src/Service/UploadFinalizationService.php b/src/Service/UploadFinalizationService.php new file mode 100644 index 00000000..f5600994 --- /dev/null +++ b/src/Service/UploadFinalizationService.php @@ -0,0 +1,183 @@ +incrementalHashService->serializeContextForStorage($hashContext); + $hash = $this->incrementalHashService->finalize($hashContext); + + $element = $this->elementManager->getElementOrFail($upload->getUploadTarget()); + + // size and digest are verified before merging, so an existing file is never overwritten on a mismatch + $mergeFileChunksOperation = $this->s3OperationFactory->createMergeFileOperationFromUpload($upload); + + try { + $this->fileSizeLimitService->assertWithinMaxFileSize($upload->getUploadOffset()); + } catch (Client400BadContentException $exception) { + $this->discardUpload($upload, $mergeFileChunksOperation); + + throw $exception; + } + + // the stored chunks must add up to the offset which was hashed, otherwise the merged file can not be the upload + $chunksContentLength = $this->s3Service->getChunksContentLength($mergeFileChunksOperation); + if ($chunksContentLength !== $upload->getUploadOffset()) { + $this->logger->error(sprintf( + 'Upload %s is inconsistent: stored chunks have %s bytes, but the upload offset is %d bytes, deleting it.', + $upload->getId()->toString(), + $chunksContentLength ?? 'an unknown number of', + $upload->getUploadOffset() + )); + $this->discardUpload($upload, $mergeFileChunksOperation); + + throw $this->client409ConflictExceptionFactory->createFromDetail('Upload state is inconsistent and was deleted, please restart the upload.'); + } + + if (null !== $reprDigestHeaderValue) { + try { + $this->verifyReprDigest($reprDigestHeaderValue, $hash); + } catch (Client400BadContentException $exception) { + $this->discardUpload($upload, $mergeFileChunksOperation); + + throw $exception; + } + } + + // the same lock POST /{id}/file uses, so the merge and property write below can not race a concurrent file + // creation for the same element; a PUT is not affected, its overwrite is intentionally lock-free + $lockToken = $this->fileCreationLockService->acquire($upload->getUploadTarget()); + if (null === $lockToken) { + $this->discardUpload($upload, $mergeFileChunksOperation); + + throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf("Another request is currently creating the file of element with id '%s'; upload was deleted, please restart it.", $upload->getUploadTarget()->toString())); + } + + try { + // re-checked as tightly as possible before the merge: something else may have created a file for the + // target while this upload's chunks were being appended. An upload which already replaces an existing + // file (started by PUT) is expected to still find hasFile === true, so it is not checked here. + if (!$upload->targetHadFileAtCreation() && $this->elementService->hasFile($element)) { + $this->discardUpload($upload, $mergeFileChunksOperation); + + throw $this->client409ConflictExceptionFactory->createFromDetail(sprintf("Element with id '%s' already has an associated file; upload was deleted, please restart it.", $upload->getUploadTarget()->toString())); + } + + try { + $mergedContentLength = $this->s3Service->mergeFileChunks($mergeFileChunksOperation); + $mergedMimeType = $this->s3Service->getMimeTypeFromMergeFileChunksOperation($mergeFileChunksOperation); + + $element->addProperty('file', [ + 'contentLength' => $mergedContentLength, + 'extension' => $upload->getExtension(), + 'mimeType' => $mergedMimeType, + 'hash' => [ + FileHashService::ALGORITHM => $hash, + ], + ]); + $element->addProperty('hasFile', true); + $this->elementManager->merge($element); + $this->elementManager->flush(); + } catch (Throwable $throwable) { + $this->markUploadAsUnfinalized($upload, $hashState); + + throw $throwable; + } + } finally { + $this->fileCreationLockService->release($upload->getUploadTarget(), $lockToken); + } + + // only after the flush the element points to the merged object, so the previous one can go + $this->elementFileDeletionService->deletePreviousFileAfterReplace($mergeFileChunksOperation); + $this->s3Service->deleteFileChunks($mergeFileChunksOperation); + $this->uploadService->deleteUpload($upload); + $this->elementManager->flush(); + + $this->eventDispatcher->dispatch(new ElementFileReplaceEvent($upload->getUploadTarget())); + } + + // best effort, the failure which is being handled is the one which matters + private function markUploadAsUnfinalized(UploadInterface $upload, string $hashState): void + { + try { + $isUnfinalized = $this->uploadService->markUploadAsUnfinalized($upload, $hashState); + if (!$isUnfinalized) { + $this->logger->error(sprintf('Unable to put upload %s back to unfinalized after a failed finalization, it does not exist or is not complete anymore.', $upload->getId()->toString())); + } + } catch (Throwable $throwable) { + $this->logger->error(sprintf('Unable to put upload %s back to unfinalized after a failed finalization: %s', $upload->getId()->toString(), $throwable->getMessage())); + } + } + + // a completed upload which is invalid as a whole (digest, size limit, inconsistent state) leaves nothing to resume, + // so chunks and upload node are removed together + private function discardUpload(UploadInterface $upload, MergeFileChunksOperationInterface $mergeFileChunksOperation): void + { + $this->s3Service->deleteFileChunks($mergeFileChunksOperation); + $this->uploadService->deleteUpload($upload); + $this->elementManager->flush(); + } + + private function verifyReprDigest(string $reprDigestHeaderValue, string $actualHash): void + { + $expectedHash = $this->digestService->parseSha256HexFromHeaderValue($reprDigestHeaderValue); + if (null === $expectedHash) { + throw $this->client400BadContentExceptionFactory->createFromDetail(sprintf("Could not verify upload: 'Repr-Digest' header '%s' does not declare a supported digest algorithm; only 'sha-256' is supported.", $reprDigestHeaderValue)); + } + if ($expectedHash !== $actualHash) { + throw $this->client400BadContentExceptionFactory->createFromDetail('Could not verify upload: the declared digest does not match the uploaded file\'s actual content.'); + } + } +} diff --git a/src/Service/UploadLockService.php b/src/Service/UploadLockService.php new file mode 100644 index 00000000..d46305a0 --- /dev/null +++ b/src/Service/UploadLockService.php @@ -0,0 +1,46 @@ +redisClient->set((string) $this->redisKeyFactory->getUploadLockRedisKey($uploadId), $token, 'PX', self::TTL_IN_MILLISECONDS, 'NX'); + + return null === $result ? null : $token; + } + + public function release(UuidInterface $uploadId, string $token): void + { + $this->redisClient->eval(self::RELEASE_SCRIPT, 1, (string) $this->redisKeyFactory->getUploadLockRedisKey($uploadId), $token); + } +} diff --git a/src/Service/UploadService.php b/src/Service/UploadService.php index c97852aa..9797cdf3 100755 --- a/src/Service/UploadService.php +++ b/src/Service/UploadService.php @@ -7,7 +7,7 @@ use App\Contract\NodeElementInterface; use App\Contract\UploadInterface; use App\Factory\Exception\Server500LogicErrorExceptionFactory; -use App\Factory\Type\S3\FileOperationFactory; +use App\Factory\Type\S3\S3OperationFactory; use App\Factory\Type\UploadFactory; use App\Type\NodeElement; use Exception; @@ -24,7 +24,7 @@ class UploadService public function __construct( private ElementManager $elementManager, private Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory, - private FileOperationFactory $fileOperationFactory, + private S3OperationFactory $s3OperationFactory, private S3Service $s3Service, private UploadFactory $uploadFactory, private CypherEntityManager $cypherEntityManager, @@ -51,15 +51,72 @@ public function mergeUploadElement(UploadInterface $upload): void $element->addProperty('uploadOffset', $upload->getUploadOffset()); $element->addProperty('uploadComplete', $upload->isUploadComplete()); $element->addProperty('uploadTarget', $upload->getUploadTarget()->toString()); - $element->addProperty('alreadyUploadedChunks', $upload->getAlreadyUploadedChunks()); + // the list itself is stored in MongoDB, only the last id is a graph property, see appendChunkIfOffsetMatches() + $element->addProperty('chunkIds', $upload->getChunkIds()); + $element->addProperty('lastChunkId', $upload->getLastChunkId()); $element->addProperty('uploadOwner', $upload->getUploadOwner()->toString()); $element->addProperty('extension', $upload->getExtension()); $element->addProperty('expires', $upload->getExpires()); $element->addProperty('hashState', $upload->getHashState()); + $element->addProperty('targetHadFileAtCreation', $upload->targetHadFileAtCreation()); $this->elementManager->merge($element); } + /** + * Compare-and-set on `uploadOffset` and `lastChunkId`, the only graph properties of the chunk list (the list + * itself is stored in MongoDB, and an empty final chunk does not move the offset): appends the chunk of $next + * only if the stored upload is still incomplete and in the state of $expected, i.e. no other request appended + * a chunk in the meantime. Chunk ids are unique per attempt, so `lastChunkId` identifies the state exactly. + * The graph update is atomic, the list is written by the following merge, which is flushed. Returns false if the + * upload was modified concurrently. + */ + public function appendChunkIfOffsetMatches(UploadInterface $expected, UploadInterface $next): bool + { + $queryResult = $this->cypherEntityManager->getClient()->runStatement(new Statement( + 'MATCH (u:Upload {id: $id}) WHERE u.uploadOffset = $expectedOffset AND coalesce(u.lastChunkId, "") = $expectedLastChunkId AND u.uploadComplete = false '. + 'SET u.uploadOffset = $uploadOffset, u.uploadComplete = $uploadComplete, u.lastChunkId = $lastChunkId, u.hashState = $hashState '. + 'RETURN u.id', + [ + 'id' => $expected->getId()->toString(), + 'expectedOffset' => $expected->getUploadOffset(), + 'expectedLastChunkId' => $expected->getLastChunkId() ?? '', + 'uploadOffset' => $next->getUploadOffset(), + 'uploadComplete' => $next->isUploadComplete(), + 'lastChunkId' => $next->getLastChunkId(), + 'hashState' => $next->getHashState(), + ] + )); + if (0 === $queryResult->count()) { + return false; + } + + $this->mergeUploadElement($next); + $this->elementManager->flush(); + + return true; + } + + /** + * Puts a completed upload back to incomplete, e.g. after its finalization failed, so that the client can complete + * it again. Only touches the graph, as nothing else is needed and the entity manager may hold state of the failed + * finalization. Returns false if the upload does not exist or is not complete. + */ + public function markUploadAsUnfinalized(UploadInterface $upload, string $hashState): bool + { + $queryResult = $this->cypherEntityManager->getClient()->runStatement(new Statement( + 'MATCH (u:Upload {id: $id}) WHERE u.uploadComplete = true '. + 'SET u.uploadComplete = false, u.hashState = $hashState '. + 'RETURN u.id', + [ + 'id' => $upload->getId()->toString(), + 'hashState' => $hashState, + ] + )); + + return 0 !== $queryResult->count(); + } + public function deleteUpload(UploadInterface $upload): void { $element = $this->elementManager->getElementOrFail($upload->getId()); @@ -68,13 +125,13 @@ public function deleteUpload(UploadInterface $upload): void } /** - * Deletes an upload's already-uploaded chunks from S3, then the `Upload` element itself. Does not flush - - * that is left to the caller, matching {@see deleteUpload()}. + * Does not flush, same as {@see deleteUpload()}. */ public function deleteUploadAndChunks(UploadInterface $upload): void { - for ($chunk = 0; $chunk <= $upload->getAlreadyUploadedChunks(); ++$chunk) { - $deleteChunkOperation = $this->fileOperationFactory->createFileOperationFromUpload($upload, $chunk); + // chunk keys start at 1; rejected chunk attempts are not part of the upload and are deleted by their request + foreach ($upload->getChunkIds() as $index => $chunkId) { + $deleteChunkOperation = $this->s3OperationFactory->createFileOperationFromUpload($upload, $index + 1, $chunkId); $this->s3Service->deleteFile($deleteChunkOperation); } @@ -82,26 +139,17 @@ public function deleteUploadAndChunks(UploadInterface $upload): void } /** - * Deletes any in-progress resumable upload(s) still targeting `$elementId`, and their already-uploaded S3 - * chunks - meant to be called (and flushed) *before* the target element itself is deleted. `uploadTarget` - * (see {@see mergeUploadElement()}) is a plain property, not a graph edge, so deleting an element does not - * take any upload(s) still targeting it along with it on its own; without this, they would linger - still - * HEAD-able, still occupying storage - until the `cron:delete-expired-uploads` grace period eventually - * catches up with them. - * - * Deliberately not wired up as an event listener on element deletion: an `Upload` element deleted by this - * method would itself re-enter `ElementManager::delete()`/`flush()` while the *original* deletion's own - * `flush()` for the target element is still on the call stack (event listeners fire from within `flush()`) - - * `ElementManager` and the underlying entity managers are not reentrant, and a nested `flush()` call - * corrupts the outer one's still-in-flight batch. Calling this as an explicit, separate, sequential step - * before the target's own delete+flush avoids that entirely. + * Deletes uploads targeting the element (for nodes also the ones targeting attached relations, as these are deleted + * together with the node), as `uploadTarget` is a plain property and not a relation. Must be + * called and flushed before the element itself is deleted; it is not an event listener because nested + * `flush()` calls are not supported. */ public function deleteUploadsTargeting(UuidInterface $elementId): void { foreach ($this->getUploadIdsTargeting($elementId) as $uploadId) { $uploadElement = $this->elementManager->getElement(UuidV4::fromString($uploadId)); if (null === $uploadElement) { - continue; // already gone (e.g. deleted or expired in the meantime), nothing left to do + continue; } try { @@ -114,13 +162,23 @@ public function deleteUploadsTargeting(UuidInterface $elementId): void } } + /** + * Whether an upload (e.g. a started resumable upload) currently targets the element. + */ + public function hasUploadsTargeting(UuidInterface $elementId): bool + { + return [] !== $this->getUploadIdsTargeting($elementId); + } + /** * @return string[] */ private function getUploadIdsTargeting(UuidInterface $elementId): array { $queryResult = $this->cypherEntityManager->getClient()->runStatement(new Statement( - 'MATCH (u:Upload) WHERE u.uploadTarget = $elementId RETURN u.id', + 'MATCH (u:Upload) '. + 'WHERE u.uploadTarget = $elementId OR u.uploadTarget IN [({id: $elementId})-[r]-() | r.id] '. + 'RETURN DISTINCT u.id', [ 'elementId' => $elementId->toString(), ] diff --git a/src/Style/EmberNexusStyle.php b/src/Style/EmberNexusStyle.php index 4f198c97..5c0dd748 100755 --- a/src/Style/EmberNexusStyle.php +++ b/src/Style/EmberNexusStyle.php @@ -24,7 +24,7 @@ class EmberNexusStyle extends SymfonyStyle private bool $isInSection = false; public function __construct( - private InputInterface $input, /** @phpstan-ignore-line */ + private InputInterface $input, private OutputInterface $output, ) { if ($output instanceof ConsoleOutput) { @@ -161,12 +161,16 @@ public function getLineLength(): int return $this->lineLength; } + /** + * Progress bars are written to stderr and are redrawn in place. This only works in an interactive terminal; in + * all other cases (e.g. ci, output redirected into a file) every redraw would end up as its own log line, without + * any context. Therefore, a progress bar is only returned if the error output is decorated, i.e. it is a terminal (or + * colors are forced through --ansi). + */ public function createProgressBarInInteractiveTerminal(int $max = 0): ?ProgressBar { - /** - * @psalm-suppress RiskyTruthyFalsyComparison - */ - if (getenv('TERM')) { + $errorOutput = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output; + if (!$errorOutput->isDecorated()) { return null; } diff --git a/src/Type/RedisPrefixType.php b/src/Type/RedisPrefixType.php index a9120713..e4a1cffa 100644 --- a/src/Type/RedisPrefixType.php +++ b/src/Type/RedisPrefixType.php @@ -15,4 +15,8 @@ enum RedisPrefixType: string case ETAG_RELATED_COLLECTION = 'etag:related:'; case ETAG_INDEX_COLLECTION = 'etag:index:'; case ETAG_FILE = 'etag:file:'; + + case UPLOAD_LOCK = 'upload-lock:'; + case FILE_CREATION_LOCK = 'file:create:'; + case CRON_DELETE_EXPIRED_UPLOAD = 'cron:delete-expired-upload:'; } diff --git a/src/Type/Response/BinaryStreamResponse.php b/src/Type/Response/BinaryStreamResponse.php index 4fcde1d7..4ae7a9e6 100644 --- a/src/Type/Response/BinaryStreamResponse.php +++ b/src/Type/Response/BinaryStreamResponse.php @@ -15,6 +15,9 @@ class BinaryStreamResponse extends StreamedResponse implements EtagCapableRespon { public const int STREAM_CHUNK_SIZE = 65536; + /** + * @psalm-suppress UninitializedProperty, PossiblyNullReference + */ public function __construct(GetObjectOutput $object, string $fileName, string $fileNameFallback, ?ByteRange $range = null, ?string $reprDigestHeaderValue = null, ?string $contentType = null) { parent::__construct(); @@ -22,6 +25,8 @@ public function __construct(GetObjectOutput $object, string $fileName, string $f $stream = $object->getBody()->getContentAsResource(); $this->headers->set('Accept-Ranges', 'bytes'); + // the content type was detected by the server, browsers must not second-guess it + $this->headers->set('X-Content-Type-Options', 'nosniff'); if (null !== $range) { $this->setStatusCode(206); @@ -32,11 +37,8 @@ public function __construct(GetObjectOutput $object, string $fileName, string $f } if (null !== $reprDigestHeaderValue) { - // Repr-Digest describes the full underlying resource, regardless of Range, so it applies to both full - // and partial responses alike. Content-Digest is deliberately not set: for a full response it would - // always be identical to Repr-Digest (no extra information), and for a partial response it would - // require hashing just the returned range, which is not done, so it is correctly left out rather than - // set to a value describing the wrong bytes. + // Repr-Digest describes the whole file, so it is valid for partial responses too. Content-Digest is + // omitted, as it would require hashing the returned range. $this->headers->set('Repr-Digest', $reprDigestHeaderValue); } diff --git a/src/Type/S3/S3TechnicalLimits.php b/src/Type/S3/S3TechnicalLimits.php index c616033f..e7434db6 100644 --- a/src/Type/S3/S3TechnicalLimits.php +++ b/src/Type/S3/S3TechnicalLimits.php @@ -16,6 +16,7 @@ private const int MIN_CHUNK_SIZE_IN_BYTES = 5 * 1024 * 1024; // 5 MiB, does not apply to the last part of an upload private const int MAX_CHUNK_COUNT = 10_000; private const int MAX_OBJECT_SIZE_IN_BYTES = 5 * 1024 * 1024 * 1024 * 1024; // 5 TiB + private const int MAX_SINGLE_PUT_SIZE_IN_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB, also the limit of a single copyObject public function getMinChunkSizeInBytes(): int { @@ -31,4 +32,9 @@ public function getMaxObjectSizeInBytes(): int { return self::MAX_OBJECT_SIZE_IN_BYTES; } + + public function getMaxSinglePutSizeInBytes(): int + { + return self::MAX_SINGLE_PUT_SIZE_IN_BYTES; + } } diff --git a/src/Type/Upload.php b/src/Type/Upload.php index 016ded5b..4d0403fe 100644 --- a/src/Type/Upload.php +++ b/src/Type/Upload.php @@ -13,17 +13,21 @@ */ final readonly class Upload implements UploadInterface { + /** + * @param list $chunkIds + */ public function __construct( private UuidInterface $id, private ?int $uploadLength, private int $uploadOffset, private bool $uploadComplete, private UuidInterface $uploadTarget, - private int $alreadyUploadedChunks, + private array $chunkIds, private UuidInterface $uploadOwner, private string $extension, private DateTime $expires, private ?string $hashState = null, + private bool $targetHadFileAtCreation = false, ) { } @@ -54,7 +58,20 @@ public function getUploadTarget(): UuidInterface public function getAlreadyUploadedChunks(): int { - return $this->alreadyUploadedChunks; + return count($this->chunkIds); + } + + /** + * @return list + */ + public function getChunkIds(): array + { + return $this->chunkIds; + } + + public function getLastChunkId(): ?string + { + return [] === $this->chunkIds ? null : $this->chunkIds[array_key_last($this->chunkIds)]; } public function getUploadOwner(): UuidInterface @@ -76,4 +93,15 @@ public function getHashState(): ?string { return $this->hashState; } + + /** + * Whether the target already had a file when this upload was created (a resumable replace started through + * `PUT`). Such an upload is expected to still find `hasFile === true` right before it completes, so the + * `hasFile` conflict checks in {@see \App\Service\UploadAppendService} and + * {@see \App\Service\UploadFinalizationService} only apply when this is false. + */ + public function targetHadFileAtCreation(): bool + { + return $this->targetHadFileAtCreation; + } } diff --git a/src/Wrapper/S3ClientWrapper.php b/src/Wrapper/S3ClientWrapper.php index e5b99f30..a24e4314 100644 --- a/src/Wrapper/S3ClientWrapper.php +++ b/src/Wrapper/S3ClientWrapper.php @@ -4,6 +4,7 @@ namespace App\Wrapper; +use AsyncAws\S3\Result\AbortMultipartUploadOutput; use AsyncAws\S3\Result\CopyObjectOutput; use AsyncAws\S3\Result\ObjectExistsWaiter; @@ -24,4 +25,9 @@ public function resolveCopyObjectOutput(CopyObjectOutput $copyObjectOutput): boo { return $copyObjectOutput->resolve(); } + + public function resolveAbortMultipartUploadOutput(AbortMultipartUploadOutput $abortMultipartUploadOutput): bool + { + return $abortMultipartUploadOutput->resolve(); + } } diff --git a/taskfile.yml b/taskfile.yml index 6ae30ba7..b09170eb 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -20,4 +20,4 @@ tasks: generate:swagger: cmds: - - ./tools/generate-swagger.sh \ No newline at end of file + - ./tools/generate-swagger.sh diff --git a/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php b/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php index 81f75e03..228d8a5e 100644 --- a/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php +++ b/tests/ExampleGenerationCommand/Backup/BackupFetchTest.php @@ -5,10 +5,12 @@ namespace App\Tests\ExampleGenerationCommand\Backup; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; +use Symfony\Component\Dotenv\Dotenv; class BackupFetchTest extends BaseCommandTestCase { private const string PATH_TO_ROOT = __DIR__.'/../../../'; + private const string FALLBACK_REFERENCE_DATASET_VERSION = '0.0.33'; public function testBackupFetchHelp(): void { @@ -28,8 +30,9 @@ public function testBackupFetchHelp(): void public function testBackupFetch(): void { $commandOutput = $this->runCommand(sprintf( - 'APP_ENV=prod VERSION=%s php bin/console backup:fetch --ansi reference-dataset https://github.com/ember-nexus/reference-dataset/archive/refs/tags/0.0.19.zip | aha -s --black --css "./cli-style.css"', - $this->getCurrentVersion() + 'APP_ENV=prod VERSION=%s php bin/console backup:fetch --ansi reference-dataset https://github.com/ember-nexus/reference-dataset/archive/refs/tags/%s.zip | aha -s --black --css "./cli-style.css"', + $this->getCurrentVersion(), + $this->getReferenceDatasetVersion() )); $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput( self::PATH_TO_ROOT, @@ -40,4 +43,25 @@ public function testBackupFetch(): void ] ); } + + /** + * Uses the reference dataset version which is also used by the other tests (see bin/test-feature-prepare), so + * that this test stays up to date automatically. A real environment variable wins over the value in `.env`. + */ + private function getReferenceDatasetVersion(): string + { + $version = getenv('REFERENCE_DATASET_VERSION'); + if (false !== $version && '' !== $version) { + return $version; + } + $envFile = self::PATH_TO_ROOT.'.env'; + if (is_file($envFile)) { + $values = (new Dotenv())->parse(\Safe\file_get_contents($envFile)); + if (is_string($values['REFERENCE_DATASET_VERSION'] ?? null) && '' !== $values['REFERENCE_DATASET_VERSION']) { + return $values['REFERENCE_DATASET_VERSION']; + } + } + + return self::FALLBACK_REFERENCE_DATASET_VERSION; + } } diff --git a/tests/ExampleGenerationCommand/Backup/BackupFilesTest.php b/tests/ExampleGenerationCommand/Backup/BackupFilesTest.php new file mode 100644 index 00000000..2aede4ed --- /dev/null +++ b/tests/ExampleGenerationCommand/Backup/BackupFilesTest.php @@ -0,0 +1,173 @@ +runCommand(sprintf('rm -rf %s', escapeshellarg(self::BACKUP_DIRECTORY.$name))); + } + // The database is intentionally left as-is (not dropped, not reloaded), see the class docblock: the next + // test method builds on it, and neither BackupListTest (runs next, does not touch the database) nor + // BackupLoadTest (runs last, drops and loads reference-dataset itself) needs it in any particular state. + } + + private function getFileCountFromCreateOutput(string $output): int + { + $this->assertSame(1, preg_match('/Successfully backed up (\d+) files\./', $output, $matches), $output); + + return (int) $matches[1]; + } + + public function testBackupCreateAndLoadRoundTripIncludesFiles(): void + { + $this->runCommand('php bin/console database:drop -f'); + $this->runCommand('php bin/console backup:load reference-dataset'); + + $createOutput = $this->runCommand(sprintf('php bin/console backup:create --no-ansi %s', self::ROUND_TRIP_BACKUP)); + $fileCount = $this->getFileCountFromCreateOutput($createOutput); + $this->assertGreaterThan(0, $fileCount); + + $this->runCommand('php bin/console database:drop -f'); + $loadOutput = $this->runCommand(sprintf('php bin/console backup:load --no-ansi %s', self::ROUND_TRIP_BACKUP)); + + $this->assertStringContainsString(sprintf('Loaded %d files.', $fileCount), $loadOutput); + $this->assertStringNotContainsString('could not be loaded', $loadOutput); + + // the relation with a file (not only nodes) has to be part of the backup and has to be restored + $this->assertFileExists( + self::BACKUP_DIRECTORY.self::ROUND_TRIP_BACKUP.'/file/'.self::RELATION_WITH_FILE_ID.'.txt', + 'The file of a relation must be exported by backup:create.' + ); + $this->assertTrue( + $this->isFileInStorage(self::RELATION_WITH_FILE_ID), + 'The file of a relation must be restored into the storage bucket by backup:load.' + ); + } + + public function testBackupCreateAndLoadRoundTripIncludesFilesWithoutExtension(): void + { + foreach (self::EXTENSIONLESS_FILE_IDS as $fileId) { + if (!is_file(self::BACKUP_DIRECTORY.'reference-dataset/file/general/extensionlessFile/'.$fileId)) { + $this->markTestSkipped('The used reference dataset does not contain the scenario general.extensionlessFile yet.'); + } + } + + // the database is already loaded, with data equivalent to reference-dataset, see the class docblock + $createOutput = $this->runCommand(sprintf('php bin/console backup:create --no-ansi %s', self::EXTENSIONLESS_ROUND_TRIP_BACKUP)); + $fileCount = $this->getFileCountFromCreateOutput($createOutput); + + foreach (self::EXTENSIONLESS_FILE_IDS as $fileId) { + $this->assertFileExists( + self::BACKUP_DIRECTORY.self::EXTENSIONLESS_ROUND_TRIP_BACKUP.'/file/'.$fileId, + 'Files without extension must be exported without trailing dot.' + ); + } + + $this->runCommand('php bin/console database:drop -f'); + $loadOutput = $this->runCommand(sprintf('php bin/console backup:load --no-ansi %s', self::EXTENSIONLESS_ROUND_TRIP_BACKUP)); + $this->assertStringContainsString(sprintf('Loaded %d files.', $fileCount), $loadOutput); + $this->assertStringNotContainsString('could not be loaded', $loadOutput); + + foreach (self::EXTENSIONLESS_FILE_IDS as $fileId) { + $this->assertTrue($this->isFileInStorage($fileId), 'Files without extension must be restored into the storage bucket.'); + $this->assertFalse($this->isFileInStorageWithTrailingDot($fileId), 'Storage keys of files without extension must not end with a dot.'); + } + } + + private function isFileInStorageWithTrailingDot(string $elementId): bool + { + $s3Client = (new S3ClientFactory($_ENV['S3_ENDPOINT'], $_ENV['S3_ACCESS_KEY_ID'], $_ENV['S3_SECRET_ACCESS_KEY']))->createS3Client(); + foreach ($s3Client->listObjectsV2(['Bucket' => 'api-storage']) as $object) { + if (str_contains((string) $object->getKey(), $elementId) && str_ends_with((string) $object->getKey(), '.')) { + return true; + } + } + + return false; + } + + private function isFileInStorage(string $elementId): bool + { + $s3Client = (new S3ClientFactory($_ENV['S3_ENDPOINT'], $_ENV['S3_ACCESS_KEY_ID'], $_ENV['S3_SECRET_ACCESS_KEY']))->createS3Client(); + foreach ($s3Client->listObjectsV2(['Bucket' => 'api-storage']) as $object) { + if (str_contains((string) $object->getKey(), $elementId)) { + return true; + } + } + + return false; + } + + public function testBackupLoadSkipsFileWithMismatchingHashUnlessSkipVerify(): void + { + // the database is already loaded, with data equivalent to reference-dataset, see the class docblock + $createOutput = $this->runCommand(sprintf('php bin/console backup:create --no-ansi %s', self::TAMPERED_BACKUP)); + $fileCount = $this->getFileCountFromCreateOutput($createOutput); + $this->assertGreaterThan(0, $fileCount); + $this->tamperWithOneBackupFile(self::TAMPERED_BACKUP); + + $this->runCommand('php bin/console database:drop -f'); + $verifiedOutput = $this->runCommand(sprintf('php bin/console backup:load --no-ansi %s', self::TAMPERED_BACKUP)); + $this->assertStringContainsString(sprintf('Loaded %d files, 1 could not be loaded', $fileCount - 1), $verifiedOutput); + // the reason of the skipped file is reported as error, not only counted + $this->assertStringContainsString('[ERROR]', $verifiedOutput); + $this->assertStringContainsString("File content does not match 'file.hash.sha256'", preg_replace('/\s+/', ' ', $verifiedOutput)); + $this->assertStringContainsString('skipping file:', $verifiedOutput); + + $this->runCommand('php bin/console database:drop -f'); + $unverifiedOutput = $this->runCommand(sprintf('php bin/console backup:load --no-ansi --skip-verify %s', self::TAMPERED_BACKUP)); + $this->assertStringContainsString(sprintf('Loaded %d files.', $fileCount), $unverifiedOutput); + } + + private function tamperWithOneBackupFile(string $backupName): void + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(self::BACKUP_DIRECTORY.$backupName.'/file', FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if ($file->isFile()) { + \Safe\file_put_contents($file->getPathname(), 'tampered', FILE_APPEND); + + return; + } + } + $this->fail('Backup contains no file which could be tampered with.'); + } +} diff --git a/tests/ExampleGenerationCommand/Backup/BackupLoadTest.php b/tests/ExampleGenerationCommand/Backup/BackupLoadTest.php index 8da8f677..0ace1ec6 100644 --- a/tests/ExampleGenerationCommand/Backup/BackupLoadTest.php +++ b/tests/ExampleGenerationCommand/Backup/BackupLoadTest.php @@ -10,7 +10,7 @@ class BackupLoadTest extends BaseCommandTestCase { private const string PATH_TO_ROOT = __DIR__.'/../../../'; - public function testBackupListHelp(): void + public function testBackupLoadHelp(): void { $commandOutput = $this->runCommand(sprintf( 'APP_ENV=prod VERSION=%s php bin/console backup:load --ansi --help | aha -s --black --css "./cli-style.css"', @@ -25,7 +25,7 @@ public function testBackupListHelp(): void ); } - public function testBackupList(): void + public function testBackupLoad(): void { $this->runCommand('php bin/console database:drop -f'); $commandOutput = $this->runCommand(sprintf( diff --git a/tests/ExampleGenerationCommand/BaseCommandTestCase.php b/tests/ExampleGenerationCommand/BaseCommandTestCase.php index a26a691b..6ff4cce6 100644 --- a/tests/ExampleGenerationCommand/BaseCommandTestCase.php +++ b/tests/ExampleGenerationCommand/BaseCommandTestCase.php @@ -9,13 +9,28 @@ abstract class BaseCommandTestCase extends TestCase { + /** + * Runs the command and returns its stdout. Stderr is captured as well: it contains e.g. progress bars, which would + * otherwise end up in the test log without any context. It is only shown if the command fails. + */ public function runCommand(string $command): string { + if (!str_contains($command, 'DISABLE_CRON=')) { + // this runs without a tty; a container-wide DISABLE_CRON=true would otherwise make cron commands + // no-op instead of producing the documented example output + $command = 'DISABLE_CRON=0 '.$command; + } + $output = []; $resultCode = 0; - \Safe\exec($command, $output, $resultCode); - if (0 !== $resultCode) { - throw new Exception(sprintf('Result code of command should be 0, got %d.', $resultCode)); + $stderrFile = \Safe\tempnam(sys_get_temp_dir(), 'ember-nexus-stderr-'); + try { + \Safe\exec(sprintf('( %s ) 2>%s', $command, escapeshellarg($stderrFile)), $output, $resultCode); + if (0 !== $resultCode) { + throw new Exception(sprintf("Result code of command should be 0, got %d.\nCommand: %s\nStderr: %s", $resultCode, $command, substr(\Safe\file_get_contents($stderrFile), -2000))); + } + } finally { + @unlink($stderrFile); } return implode("\n", $output); diff --git a/tests/ExampleGenerationCommand/Cache/CacheClearEtagTest.php b/tests/ExampleGenerationCommand/Cache/CacheClearEtagTest.php index dfb46e00..e5c7fb7d 100644 --- a/tests/ExampleGenerationCommand/Cache/CacheClearEtagTest.php +++ b/tests/ExampleGenerationCommand/Cache/CacheClearEtagTest.php @@ -10,7 +10,7 @@ class CacheClearEtagTest extends BaseCommandTestCase { private const string PATH_TO_ROOT = __DIR__.'/../../../'; - public function testUserCreateHelp(): void + public function testCacheClearEtagHelp(): void { $commandOutput = $this->runCommand(sprintf( 'APP_ENV=prod VERSION=%s php bin/console cache:clear:etag --ansi --help | aha -s --black --css "./cli-style.css"', @@ -19,7 +19,7 @@ public function testUserCreateHelp(): void $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput(self::PATH_TO_ROOT, 'docs/commands/assets/cache-clear-etag-help.html', $commandOutput); } - public function testUserCreate(): void + public function testCacheClearEtag(): void { $commandOutput = $this->runCommand(sprintf( 'APP_ENV=prod VERSION=%s php bin/console cache:clear:etag --ansi | aha -s --black --css "./cli-style.css"', diff --git a/tests/ExampleGenerationCommand/Database/DatabaseDropTest.php b/tests/ExampleGenerationCommand/Database/DatabaseDropTest.php index 1a00dab8..0a3c1147 100644 --- a/tests/ExampleGenerationCommand/Database/DatabaseDropTest.php +++ b/tests/ExampleGenerationCommand/Database/DatabaseDropTest.php @@ -10,7 +10,7 @@ class DatabaseDropTest extends BaseCommandTestCase { private const string PATH_TO_ROOT = __DIR__.'/../../../'; - public function testUserCreateHelp(): void + public function testDatabaseDropHelp(): void { $commandOutput = $this->runCommand(sprintf( 'APP_ENV=prod VERSION=%s php bin/console database:drop --ansi --help | aha -s --black --css "./cli-style.css"', @@ -19,7 +19,7 @@ public function testUserCreateHelp(): void $this->assertCommandOutputIsIdenticalToDocumentedCommandOutput(self::PATH_TO_ROOT, 'docs/commands/assets/database-drop-help.html', $commandOutput); } - public function testUserCreate(): void + public function testDatabaseDrop(): void { $commandOutput = $this->runCommand(sprintf( 'APP_ENV=prod VERSION=%s php bin/console database:drop -f --ansi | aha -s --black --css "./cli-style.css"', diff --git a/tests/ExampleGenerationCommand/Other/ConfigDumpReferenceEmberNexusTest.php b/tests/ExampleGenerationCommand/Other/ConfigDumpReferenceEmberNexusTest.php index 43079560..a32e1923 100644 --- a/tests/ExampleGenerationCommand/Other/ConfigDumpReferenceEmberNexusTest.php +++ b/tests/ExampleGenerationCommand/Other/ConfigDumpReferenceEmberNexusTest.php @@ -10,7 +10,7 @@ class ConfigDumpReferenceEmberNexusTest extends BaseCommandTestCase { private const string PATH_TO_ROOT = __DIR__.'/../../../'; - public function testUserCreateHelp(): void + public function testConfigDumpReferenceEmberNexusHelp(): void { $commandOutput = $this->runCommand( 'APP_ENV=dev php bin/console config:dump-reference ember_nexus' diff --git a/tests/ExampleGenerationCommand/docker-compose.yml b/tests/ExampleGenerationCommand/docker-compose.yml index f50cadf0..3af2d9da 100644 --- a/tests/ExampleGenerationCommand/docker-compose.yml +++ b/tests/ExampleGenerationCommand/docker-compose.yml @@ -3,8 +3,19 @@ services: api: container_name: api image: api:dev-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio expose: - "80" + environment: + # cron commands would otherwise run whenever the scheduler triggers them during the test run; + # BaseCommandTestCase::runCommand() overrides this per invocation for the cron example commands themselves + DISABLE_CRON: 'true' volumes: - ../../:/var/www/html - ./well-known-security.txt:/well-known-security.txt @@ -26,6 +37,7 @@ services: environment: - MONGO_INITDB_ROOT_USERNAME=mongodb - MONGO_INITDB_ROOT_PASSWORD=password + - GLIBC_TUNABLES=glibc.pthread.rseq=1 elasticsearch: container_name: elasticsearch @@ -56,7 +68,7 @@ services: minio: container_name: minio - image: quay.io/minio/minio:latest + image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z expose: - "9000" - "9001" @@ -66,7 +78,7 @@ services: command: server /data --console-address ":9001" minio-init: - image: quay.io/minio/mc:latest + image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z depends_on: - minio entrypoint: > diff --git a/tests/ExampleGenerationController/BaseRequestTestCase.php b/tests/ExampleGenerationController/BaseRequestTestCase.php index f310dcfa..ade4eece 100644 --- a/tests/ExampleGenerationController/BaseRequestTestCase.php +++ b/tests/ExampleGenerationController/BaseRequestTestCase.php @@ -13,11 +13,35 @@ abstract class BaseRequestTestCase extends \App\Tests\FeatureTests\BaseRequestTe */ private const array IGNORED_HEADERS = ['Date', 'Etag', 'Location', 'Expires']; + /** + * @var string[] ELASTICSEARCH_SCORE_KEYS + */ + private const array ELASTICSEARCH_SCORE_KEYS = ['score', 'maxScore']; + /** * @var string[] REMOVED_HEADERS */ private const array REMOVED_HEADERS = ['X-Debug-Token', 'X-Debug-Token-Link']; + /** + * If the environment variable FIX_CONTROLLER_OUTPUT is set, differing documentation files are updated + * automatically instead of failing the test. + */ + private function isFixControllerOutputEnabled(): bool + { + return array_key_exists('FIX_CONTROLLER_OUTPUT', $_ENV); + } + + private function fixDocumentationFile(string $pathToProjectRoot, string $pathToDocumentationFile, string $content): void + { + echo sprintf( + "\nAutomatically updated file %s.\n", + $pathToDocumentationFile + ); + \Safe\file_put_contents($pathToProjectRoot.$pathToDocumentationFile, $content); + $this->assertTrue(true); + } + public function getHeadersFromRequest(ResponseInterface $response): string { $cleanedHeaders = []; @@ -38,8 +62,32 @@ public function getHeadersFromRequest(ResponseInterface $response): string return implode("\n", $cleanedHeaders); } + private function removeFramingHeaders(string $headers): string + { + return implode("\n", array_filter( + explode("\n", $headers), + static fn (string $header): bool => !str_starts_with($header, 'Content-Length:') && !str_starts_with($header, 'Transfer-Encoding:') + )); + } + public function checkHeadersAreIdentical(string $headers1, string $headers2): bool { + // the remaining lifetime of an upload depends on the timing of the test run, allow a deviation of +-5 seconds + if ( + 1 === preg_match('/^Upload-Limit: max-age=(\d+),/m', $headers1, $matches1) + && 1 === preg_match('/^Upload-Limit: max-age=(\d+),/m', $headers2, $matches2) + && abs((int) $matches1[1] - (int) $matches2[1]) <= 5 + ) { + $headers2 = str_replace(sprintf('max-age=%s,', $matches2[1]), sprintf('max-age=%s,', $matches1[1]), $headers2); + } + + // the development server streams responses (`Transfer-Encoding: chunked`), the production image answers with a + // `Content-Length`; the framing is not part of what the examples document, so it is not compared if it differs + if (str_contains($headers1, 'Transfer-Encoding: chunked') xor str_contains($headers2, 'Transfer-Encoding: chunked')) { + $headers1 = $this->removeFramingHeaders($headers1); + $headers2 = $this->removeFramingHeaders($headers2); + } + $headers1 = explode("\n", $headers1); $headers2 = explode("\n", $headers2); @@ -66,8 +114,14 @@ public function assertHeadersInDocumentationAreIdenticalToHeadersFromRequest(str { $headers = $this->getHeadersFromRequest($response); $documentationHeaders = file_get_contents($pathToProjectRoot.$pathToDocumentationFile); + $areHeadersIdentical = $this->checkHeadersAreIdentical($documentationHeaders, $headers); + if (!$areHeadersIdentical && $this->isFixControllerOutputEnabled()) { + $this->fixDocumentationFile($pathToProjectRoot, $pathToDocumentationFile, $headers); + + return; + } $this->assertTrue( - $this->checkHeadersAreIdentical($documentationHeaders, $headers), + $areHeadersIdentical, sprintf( "Content of file %s should be as following:\n\n%s\n", $pathToDocumentationFile, @@ -76,6 +130,12 @@ public function assertHeadersInDocumentationAreIdenticalToHeadersFromRequest(str ); } + private function isProblemJsonResponse(ResponseInterface $response): bool + { + return $response->getStatusCode() >= 400 + && str_starts_with($response->getHeaderLine('Content-Type'), 'application/problem+json'); + } + public function assertBodyInDocumentationIsIdenticalToBodyFromRequest( string $pathToProjectRoot, string $pathToDocumentationFile, @@ -87,6 +147,10 @@ public function assertBodyInDocumentationIsIdenticalToBodyFromRequest( if ($isJson) { $body = $this->getFormattedResponseBodyAsJsonString($response); } + if ($this->isProblemJsonResponse($response)) { + // every problem json response identifies its request as `urn:uuid:`, which differs per request + $ignoreLinesContainingString[] = '"instance": "urn:uuid:'; + } $documentationBody = file_get_contents($pathToProjectRoot.$pathToDocumentationFile); $filteredBody = []; @@ -109,8 +173,14 @@ public function assertBodyInDocumentationIsIdenticalToBodyFromRequest( $filteredDocumentationBody[] = $line; } + $isBodyIdentical = $filteredDocumentationBody === $filteredBody; + if (!$isBodyIdentical && $this->isFixControllerOutputEnabled()) { + $this->fixDocumentationFile($pathToProjectRoot, $pathToDocumentationFile, $body); + + return; + } $this->assertTrue( - $filteredDocumentationBody === $filteredBody, + $isBodyIdentical, sprintf( "Content of file %s should be as following:\n\n%s\n", $pathToDocumentationFile, @@ -139,6 +209,80 @@ public function getSignatureOfPathResult(array $path): string return implode('-', $parts); } + /** + * Updates the documentation of a search result. If the documented result differs from the response (ignoring + * volatile values), the whole file is replaced. Otherwise, only the Elasticsearch scores are updated in place, so + * that unrelated values like timestamps are not changed. + */ + private function fixSearchResultDocumentation( + string $pathToProjectRoot, + string $pathToDocumentationFile, + array $rawResponseData, + string $prettyPrintedRawResponse, + bool $isIdenticalIgnoringScores, + ): void { + if (!$isIdenticalIgnoringScores) { + $this->fixDocumentationFile($pathToProjectRoot, $pathToDocumentationFile, $prettyPrintedRawResponse); + + return; + } + + $scores = $this->collectElasticsearchScores($rawResponseData['debug'] ?? []); + $documentation = file_get_contents($pathToProjectRoot.$pathToDocumentationFile); + $replacedScores = 0; + $updatedDocumentation = preg_replace_callback( + '/("(?:score|maxScore)": )(-?[0-9.eE+-]+)/', + function (array $matches) use ($scores, &$replacedScores): string { + return $matches[1].json_encode($scores[$replacedScores++] ?? null); + }, + $documentation + ); + if ($replacedScores !== count($scores)) { + $this->fixDocumentationFile($pathToProjectRoot, $pathToDocumentationFile, $prettyPrintedRawResponse); + + return; + } + if ($updatedDocumentation !== $documentation) { + $this->fixDocumentationFile($pathToProjectRoot, $pathToDocumentationFile, $updatedDocumentation); + + return; + } + $this->assertTrue(true); + } + + /** + * @return array + */ + public function collectElasticsearchScores(array $data): array + { + $scores = []; + foreach ($data as $key => $value) { + if (in_array($key, self::ELASTICSEARCH_SCORE_KEYS, true)) { + $scores[] = $value; + } elseif (is_array($value)) { + array_push($scores, ...$this->collectElasticsearchScores($value)); + } + } + + return $scores; + } + + /** + * Elasticsearch scores are not stable between runs (e.g. they depend on shard statistics), therefore they are + * removed before comparing responses. + */ + public function removeElasticsearchScores(array &$data): void + { + foreach (self::ELASTICSEARCH_SCORE_KEYS as $scoreKey) { + unset($data[$scoreKey]); + } + foreach ($data as &$value) { + if (is_array($value)) { + $this->removeElasticsearchScores($value); + } + } + } + public function assertSearchResultInDocumentationIsIdenticalToSearchResultFromRequest( string $pathToProjectRoot, string $pathToDocumentationFile, @@ -155,6 +299,7 @@ public function assertSearchResultInDocumentationIsIdenticalToSearchResultFromRe unset($debug['start']); unset($debug['end']); unset($debug['duration']); + $this->removeElasticsearchScores($debug); foreach ($debug['input']['parameters']['stepResults'] as &$stepResult) { if (array_key_exists('paths', $stepResult)) { usort($stepResult['paths'], function ($a, $b) { @@ -174,6 +319,7 @@ public function assertSearchResultInDocumentationIsIdenticalToSearchResultFromRe unset($debug['start']); unset($debug['end']); unset($debug['duration']); + $this->removeElasticsearchScores($debug); foreach ($debug['input']['parameters']['stepResults'] as &$stepResult) { if (array_key_exists('paths', $stepResult)) { usort($stepResult['paths'], function ($a, $b) { @@ -189,6 +335,18 @@ public function assertSearchResultInDocumentationIsIdenticalToSearchResultFromRe ); $prettyPrintedRawResponse = str_replace(' ', ' ', $prettyPrintedRawResponse); + if ($this->isFixControllerOutputEnabled()) { + $this->fixSearchResultDocumentation( + $pathToProjectRoot, + $pathToDocumentationFile, + $rawResponseData, + $prettyPrintedRawResponse, + $responseData == $documentationData + ); + + return; + } + $this->assertEquals( $responseData, $documentationData, diff --git a/tests/ExampleGenerationController/Element/GetChildrenTest.php b/tests/ExampleGenerationController/Element/GetChildrenTest.php index 7914cfd5..79216ab8 100644 --- a/tests/ExampleGenerationController/Element/GetChildrenTest.php +++ b/tests/ExampleGenerationController/Element/GetChildrenTest.php @@ -96,9 +96,16 @@ public function testGetChildrenFailure404(): void ); } - public function testGetChildrenFailure412(): void + public function testGetChildrenFailure404WithIfMatch(): void { + // without access to the element (here: it does not exist), conditional requests answer 404 instead of 412 $response = $this->runGetRequest(sprintf('/%s/children', self::ELEMENT_WHICH_DOES_NOT_EXIST), self::TOKEN, ['If-Match' => '"etagDoesNotExist"']); + $this->assertIsProblemResponse($response, 404); + } + + public function testGetChildrenFailure412(): void + { + $response = $this->runGetRequest(sprintf('/%s/children', self::PARENT_UUID), self::TOKEN, ['If-Match' => '"etagDoesNotExist"']); $this->assertIsProblemResponse($response, 412); $documentationHeadersPath = 'docs/api-endpoints/element/get-children/412-response-header.txt'; $documentationBodyPath = 'docs/api-endpoints/element/get-children/412-response-body.json'; diff --git a/tests/ExampleGenerationController/Element/GetParentsTest.php b/tests/ExampleGenerationController/Element/GetParentsTest.php index 936fddc1..1ec58cd6 100644 --- a/tests/ExampleGenerationController/Element/GetParentsTest.php +++ b/tests/ExampleGenerationController/Element/GetParentsTest.php @@ -96,9 +96,16 @@ public function testGetParentsFailure404(): void ); } - public function testGetParentsFailure412(): void + public function testGetParentsFailure404WithIfMatch(): void { + // without access to the element (here: it does not exist), conditional requests answer 404 instead of 412 $response = $this->runGetRequest(sprintf('/%s/parents', self::ELEMENT_WHICH_DOES_NOT_EXIST), self::TOKEN, ['If-Match' => '"etagDoesNotExist"']); + $this->assertIsProblemResponse($response, 404); + } + + public function testGetParentsFailure412(): void + { + $response = $this->runGetRequest(sprintf('/%s/parents', self::CHILD_UUID), self::TOKEN, ['If-Match' => '"etagDoesNotExist"']); $this->assertIsProblemResponse($response, 412); $documentationHeadersPath = 'docs/api-endpoints/element/get-parents/412-response-header.txt'; $documentationBodyPath = 'docs/api-endpoints/element/get-parents/412-response-body.json'; diff --git a/tests/ExampleGenerationController/Element/GetRelatedTest.php b/tests/ExampleGenerationController/Element/GetRelatedTest.php index 166a5219..0f77d163 100644 --- a/tests/ExampleGenerationController/Element/GetRelatedTest.php +++ b/tests/ExampleGenerationController/Element/GetRelatedTest.php @@ -96,9 +96,16 @@ public function testGetRelatedFailure404(): void ); } - public function testGetRelatedFailure412(): void + public function testGetRelatedFailure404WithIfMatch(): void { + // without access to the element (here: it does not exist), conditional requests answer 404 instead of 412 $response = $this->runGetRequest(sprintf('/%s/related', self::ELEMENT_WHICH_DOES_NOT_EXIST), self::TOKEN, ['If-Match' => '"etagDoesNotExist"']); + $this->assertIsProblemResponse($response, 404); + } + + public function testGetRelatedFailure412(): void + { + $response = $this->runGetRequest(sprintf('/%s/related', self::ELEMENT_UUID), self::TOKEN, ['If-Match' => '"etagDoesNotExist"']); $this->assertIsProblemResponse($response, 412); $documentationHeadersPath = 'docs/api-endpoints/element/get-related/412-response-header.txt'; $documentationBodyPath = 'docs/api-endpoints/element/get-related/412-response-body.json'; diff --git a/tests/ExampleGenerationController/Error/Get408RequestTimeoutTest.php b/tests/ExampleGenerationController/Error/Get408RequestTimeoutTest.php new file mode 100644 index 00000000..de126d1f --- /dev/null +++ b/tests/ExampleGenerationController/Error/Get408RequestTimeoutTest.php @@ -0,0 +1,30 @@ +runGetRequest('/error/408/request-timeout', null); + $documentationHeadersPath = 'docs/api-endpoints/error/get-408-request-timeout/200-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/error/get-408-request-timeout/200-response-body.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response, + false + ); + } +} diff --git a/tests/ExampleGenerationController/Error/Get503ServiceUnavailableTest.php b/tests/ExampleGenerationController/Error/Get503ServiceUnavailableTest.php new file mode 100644 index 00000000..b39cb7aa --- /dev/null +++ b/tests/ExampleGenerationController/Error/Get503ServiceUnavailableTest.php @@ -0,0 +1,30 @@ +runGetRequest('/error/503/service-unavailable', null); + $documentationHeadersPath = 'docs/api-endpoints/error/get-503-service-unavailable/200-response-header.txt'; + $documentationBodyPath = 'docs/api-endpoints/error/get-503-service-unavailable/200-response-body.txt'; + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $documentationHeadersPath, + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $documentationBodyPath, + $response, + false + ); + } +} diff --git a/tests/ExampleGenerationController/File/GetElementFileTest.php b/tests/ExampleGenerationController/File/GetElementFileTest.php index cea21c86..7d25666a 100644 --- a/tests/ExampleGenerationController/File/GetElementFileTest.php +++ b/tests/ExampleGenerationController/File/GetElementFileTest.php @@ -125,4 +125,89 @@ public function testGetElementFileFailure416(): void $response ); } + + /** + * Creates a small element with a short text file, to showcase `If-Range` with readable data. + * + * @return string the current file ETag + */ + private function createElementWithShortTextFile(string $elementId, string $name): string + { + $response = $this->runPostRequest('/', self::TOKEN, [ + 'id' => $elementId, + 'type' => 'Data', + 'data' => ['name' => $name], + ]); + $this->assertIsCreatedResponse($response, false); + + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + 'Hello, Ember Nexus!', + self::TOKEN, + [ + 'Content-Type' => 'text/plain', + 'Content-Disposition' => 'attachment; filename=hello.txt', + ] + ); + $this->assertIsCreatedResponse($response, false); + + $response = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame(200, $response->getStatusCode()); + + return $response->getHeader('ETag')[0]; + } + + public function testGetElementFileSuccess206WithMatchingIfRange(): void + { + $elementId = '3c4d5e6f-7a8b-4c9d-8e0f-2a3b4c5d6e7f'; + $etag = $this->createElementWithShortTextFile($elementId, 'get-element-file-if-range-match'); + + // the ETag of the file still matches: the client resumes its download, the requested range is returned + $response = $this->runGetRequest( + sprintf('/%s/file', $elementId), + self::TOKEN, + ['Range' => 'bytes=7-11', 'If-Range' => $etag] + ); + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame('Ember', (string) $response->getBody()); + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + 'docs/api-endpoints/file/get-element-file/206-if-range-match-response-header.txt', + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + 'docs/api-endpoints/file/get-element-file/206-if-range-match-response-body.txt', + $response, + false + ); + } + + public function testGetElementFileSuccess200WithNonMatchingIfRange(): void + { + $elementId = '4d5e6f7a-8b9c-4d0e-9f1a-3b4c5d6e7f8a'; + $this->createElementWithShortTextFile($elementId, 'get-element-file-if-range-mismatch'); + + // the file changed since the client started its download: the Range header is ignored and the full file is + // returned, so the client never mixes parts of two different versions + $response = $this->runGetRequest( + sprintf('/%s/file', $elementId), + self::TOKEN, + ['Range' => 'bytes=7-11', 'If-Range' => '"outdated-etag"'] + ); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('Hello, Ember Nexus!', (string) $response->getBody()); + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + 'docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-header.txt', + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + 'docs/api-endpoints/file/get-element-file/200-if-range-mismatch-response-body.txt', + $response, + false + ); + } } diff --git a/tests/ExampleGenerationController/User/PostChangePasswordOldTest.php b/tests/ExampleGenerationController/User/PostChangePasswordOldTest.php deleted file mode 100644 index 6c344278..00000000 --- a/tests/ExampleGenerationController/User/PostChangePasswordOldTest.php +++ /dev/null @@ -1,127 +0,0 @@ -runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'currentPassword' => '1234', - 'newPassword' => 'abcd', - 'data' => [ - 'email' => 'user@changePassword.user.endpoint.localhost.dev', - ], - ] - ); - $this->assertNoContentResponse($response); - $documentationHeadersPath = 'docs/api-endpoints/user/post-change-password-old/204-response-header.txt'; - $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( - self::PATH_TO_ROOT, - $documentationHeadersPath, - $response - ); - } - - public function testChangePasswordError400(): void - { - $this->markTestSkipped('Error message changed due to deprecation.'); - $response = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'NotActionChangePassword', - 'currentPassword' => '1234', - 'newPassword' => 'abcd', - 'data' => [ - 'email' => 'user@changePassword.user.endpoint.localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($response, 400); - $documentationHeadersPath = 'docs/api-endpoints/user/post-change-password-old/400-response-header.txt'; - $documentationBodyPath = 'docs/api-endpoints/user/post-change-password-old/400-response-body.json'; - $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( - self::PATH_TO_ROOT, - $documentationHeadersPath, - $response - ); - $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( - self::PATH_TO_ROOT, - $documentationBodyPath, - $response - ); - } - - public function testChangePasswordError401(): void - { - $this->markTestSkipped('Error message changed due to deprecation.'); - $response = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'currentPassword' => '1234', - 'newPassword' => 'abcd', - 'data' => [ - 'email' => 'this-user-does-not-exist@localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($response, 401); - $documentationHeadersPath = 'docs/api-endpoints/user/post-change-password-old/401-response-header.txt'; - $documentationBodyPath = 'docs/api-endpoints/user/post-change-password-old/401-response-body.json'; - $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( - self::PATH_TO_ROOT, - $documentationHeadersPath, - $response - ); - $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( - self::PATH_TO_ROOT, - $documentationBodyPath, - $response - ); - } - - public function testChangePasswordError403(): void - { - $this->markTestSkipped('Error message changed due to deprecation.'); - $response = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'currentPassword' => '1234', - 'newPassword' => 'abcd', - 'data' => [ - 'email' => 'anonymous-user@localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($response, 403); - $documentationHeadersPath = 'docs/api-endpoints/user/post-change-password-old/403-response-header.txt'; - $documentationBodyPath = 'docs/api-endpoints/user/post-change-password-old/403-response-body.json'; - $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( - self::PATH_TO_ROOT, - $documentationHeadersPath, - $response - ); - $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( - self::PATH_TO_ROOT, - $documentationBodyPath, - $response - ); - } -} diff --git a/tests/ExampleGenerationController/User/PostRegisterOldTest.php b/tests/ExampleGenerationController/User/PostRegisterOldTest.php deleted file mode 100644 index 8827ed27..00000000 --- a/tests/ExampleGenerationController/User/PostRegisterOldTest.php +++ /dev/null @@ -1,65 +0,0 @@ -runPostRequest( - '/register', - null, - [ - 'type' => 'User', - 'password' => '1234', - 'data' => [ - 'email' => 'test@localhost.dev', - ], - ] - ); - $this->assertIsCreatedResponse($response); - $documentationHeadersPath = 'docs/api-endpoints/user/post-register-old/201-response-header.txt'; - $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( - self::PATH_TO_ROOT, - $documentationHeadersPath, - $response - ); - } - - public function testRegisterError400(): void - { - $this->markTestSkipped('Error message changed due to deprecation.'); - $response = $this->runPostRequest( - '/register', - null, - [ - 'type' => 'NotAUser', - 'password' => '1234', - 'data' => [ - 'email' => 'test2@localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($response, 400); - $documentationHeadersPath = 'docs/api-endpoints/user/post-register-old/400-response-header.txt'; - $documentationBodyPath = 'docs/api-endpoints/user/post-register-old/400-response-body.json'; - $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( - self::PATH_TO_ROOT, - $documentationHeadersPath, - $response - ); - $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( - self::PATH_TO_ROOT, - $documentationBodyPath, - $response - ); - } -} diff --git a/tests/ExampleGenerationController/User/PostTokenOldTest.php b/tests/ExampleGenerationController/User/PostTokenOldTest.php deleted file mode 100644 index d5726ac2..00000000 --- a/tests/ExampleGenerationController/User/PostTokenOldTest.php +++ /dev/null @@ -1,96 +0,0 @@ -runPostRequest( - '/token', - null, - [ - 'type' => 'Token', - 'user' => self::EMAIL, - 'password' => self::PASSWORD, - ] - ); - $documentationHeadersPath = 'docs/api-endpoints/user/post-token-old/201-response-header.txt'; - $documentationBodyPath = 'docs/api-endpoints/user/post-token-old/201-response-body.json'; - $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( - self::PATH_TO_ROOT, - $documentationHeadersPath, - $response - ); - $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( - self::PATH_TO_ROOT, - $documentationBodyPath, - $response, - true, - [ - 'token', - ] - ); - } - - public function testTokenFailure400(): void - { - $this->markTestSkipped('Error message changed due to deprecation.'); - $response = $this->runPostRequest( - '/token', - null, - [ - 'type' => 'Token', - 'user' => self::EMAIL, - ] - ); - $documentationHeadersPath = 'docs/api-endpoints/user/post-token-old/400-response-header.txt'; - $documentationBodyPath = 'docs/api-endpoints/user/post-token-old/400-response-body.json'; - $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( - self::PATH_TO_ROOT, - $documentationHeadersPath, - $response - ); - $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( - self::PATH_TO_ROOT, - $documentationBodyPath, - $response - ); - } - - public function testTokenFailure401(): void - { - $this->markTestSkipped('Error message changed due to deprecation.'); - $response = $this->runPostRequest( - '/token', - null, - [ - 'type' => 'Token', - 'user' => self::EMAIL, - 'password' => 'wrongPassword', - ] - ); - $documentationHeadersPath = 'docs/api-endpoints/user/post-token-old/401-response-header.txt'; - $documentationBodyPath = 'docs/api-endpoints/user/post-token-old/401-response-body.json'; - $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( - self::PATH_TO_ROOT, - $documentationHeadersPath, - $response - ); - $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( - self::PATH_TO_ROOT, - $documentationBodyPath, - $response - ); - } -} diff --git a/tests/ExampleGenerationController/docker-compose.verify-tmp.yml b/tests/ExampleGenerationController/docker-compose.verify-tmp.yml new file mode 100644 index 00000000..ef00af66 --- /dev/null +++ b/tests/ExampleGenerationController/docker-compose.verify-tmp.yml @@ -0,0 +1,136 @@ +# The production image only contains the application, the connections to the other services are configured through its +# environment (the repository is not mounted, so no `.env` file provides them). +x-api-environment: &api-environment + CYPHER_AUTH: bolt://neo4j:password@neo4j + MONGO_AUTH: mongodb://mongodb:password@mongodb:27017 + ELASTIC_AUTH: elasticsearch:9200 + REDIS_AUTH: tcp://redis?password=redis-password + RABBITMQ_AUTH: amqp://user:password@rabbitmq:5672 + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY_ID: accessKey + S3_SECRET_ACCESS_KEY: secretKey + +services: + + api-dev: + image: api:dev-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + volumes: + - ../../:/var/www/html + - ./well-known-security.txt:/well-known-security.txt + + api: + image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + environment: *api-environment + volumes: + # the repository is deliberately not mounted: the production image is tested as it is shipped + - ./well-known-security.txt:/well-known-security.txt + + # second API instance, only differs in its configuration file; used by the tests of + # tests/ExampleGenerationControllerWithDifferentConfiguration via API_DOMAIN=http://api-different-configuration + api-different-configuration: + image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + environment: *api-environment + volumes: + - ../ExampleGenerationControllerWithDifferentConfiguration/configuration.yml:/var/www/html/config/packages/ember_nexus.yaml:ro + - ./well-known-security.txt:/well-known-security.txt + + neo4j: + image: neo4j:5.11 + expose: + - "7474" + - "7687" + environment: + - NEO4J_AUTH=neo4j/password + + mongodb: + image: mongo:7 + expose: + - "27017" + environment: + - MONGO_INITDB_ROOT_USERNAME=mongodb + - MONGO_INITDB_ROOT_PASSWORD=password + - GLIBC_TUNABLES=glibc.pthread.rseq=1 + + elasticsearch: + image: elasticsearch:8.8.1 + expose: + - "9200" + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.security.enrollment.enabled=true + + redis: + image: redis:7-alpine + expose: + - "6379" + command: redis-server --save 20 1 --loglevel warning --requirepass redis-password + + rabbitmq: + image: rabbitmq:3-management + expose: + - "5672" + - "15672" + environment: + - RABBITMQ_DEFAULT_USER=user + - RABBITMQ_DEFAULT_PASS=password + + minio: + image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z + expose: + - "9000" + - "9001" + environment: + - MINIO_ROOT_USER=ROOTNAME + - MINIO_ROOT_PASSWORD=CHANGEME123 + command: server /data --console-address ":9001" + + minio-init: + image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set minio http://minio:9000 ROOTNAME CHANGEME123; do + echo 'Waiting for MinIO...'; + sleep 1; + done; + + mc mb --ignore-existing minio/api-storage; + mc mb --ignore-existing minio/api-upload; + + mc anonymous set none minio/api-storage; + mc anonymous set none minio/api-upload; + + mc admin user add minio accessKey secretKey || true; + mc admin policy attach minio readwrite --user accessKey; + + echo 'MinIO buckets and user configured'; + " diff --git a/tests/ExampleGenerationController/docker-compose.yml b/tests/ExampleGenerationController/docker-compose.yml index 531f529b..5e216e40 100644 --- a/tests/ExampleGenerationController/docker-compose.yml +++ b/tests/ExampleGenerationController/docker-compose.yml @@ -1,10 +1,34 @@ +# The production image only contains the application, the connections to the other services are configured through its +# environment (the repository is not mounted, so no `.env` file provides them). +x-api-environment: &api-environment + CYPHER_AUTH: bolt://neo4j:password@neo4j + MONGO_AUTH: mongodb://mongodb:password@mongodb:27017 + ELASTIC_AUTH: elasticsearch:9200 + REDIS_AUTH: tcp://redis?password=redis-password + RABBITMQ_AUTH: amqp://user:password@rabbitmq:5672 + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY_ID: accessKey + S3_SECRET_ACCESS_KEY: secretKey + # cron commands would otherwise run whenever the scheduler triggers them during the test run; the tty-bypass + # rule still lets a manually-`docker exec -t`'d cron command run despite this + DISABLE_CRON: 'true' + services: api-dev: container_name: api-dev image: api:dev-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio expose: - "80" + environment: + DISABLE_CRON: 'true' volumes: - ../../:/var/www/html - ./well-known-security.txt:/well-known-security.txt @@ -12,10 +36,37 @@ services: api: container_name: api image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio expose: - "80" + environment: *api-environment volumes: - - ../../:/var/www/html + # the repository is deliberately not mounted: the production image is tested as it is shipped + - ./well-known-security.txt:/well-known-security.txt + + # second API instance, only differs in its configuration file; used by the tests of + # tests/ExampleGenerationControllerWithDifferentConfiguration via API_DOMAIN=http://api-different-configuration + api-different-configuration: + container_name: api-different-configuration + image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + environment: *api-environment + volumes: + - ../ExampleGenerationControllerWithDifferentConfiguration/configuration.yml:/var/www/html/config/packages/ember_nexus.yaml:ro - ./well-known-security.txt:/well-known-security.txt neo4j: @@ -35,6 +86,7 @@ services: environment: - MONGO_INITDB_ROOT_USERNAME=mongodb - MONGO_INITDB_ROOT_PASSWORD=password + - GLIBC_TUNABLES=glibc.pthread.rseq=1 elasticsearch: container_name: elasticsearch @@ -65,7 +117,7 @@ services: minio: container_name: minio - image: quay.io/minio/minio:latest + image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z expose: - "9000" - "9001" @@ -75,7 +127,7 @@ services: command: server /data --console-address ":9001" minio-init: - image: quay.io/minio/mc:latest + image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z depends_on: - minio entrypoint: > diff --git a/tests/ExampleGenerationControllerWithDifferentConfiguration/File/MaxFileSizeTest.php b/tests/ExampleGenerationControllerWithDifferentConfiguration/File/MaxFileSizeTest.php new file mode 100644 index 00000000..795082e1 --- /dev/null +++ b/tests/ExampleGenerationControllerWithDifferentConfiguration/File/MaxFileSizeTest.php @@ -0,0 +1,154 @@ +getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => $name], + ])); + } + + /** + * @param array $headers + */ + private function sendFile(string $method, string $elementId, int $size, array $headers = []): mixed + { + return $this->runUploadRequest( + $method, + sprintf('/%s/file', $elementId), + str_repeat('a', $size), + self::TOKEN, + array_merge(['Content-Type' => 'application/octet-stream'], $headers) + ); + } + + private function assertHasNoFile(string $elementId): void + { + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + } + + public function testPostOversizedFileIsRejected(): void + { + $elementId = $this->createNode('max-file-size-post'); + + $response = $this->sendFile('POST', $elementId, self::MAX_FILE_SIZE + 1); + $this->assertIsProblemResponse($response, 400); + $this->assertStringContainsString((string) self::MAX_FILE_SIZE, $this->getBody($response)['detail']); + $this->assertHasNoFile($elementId); + + // exactly the limit is fine + $this->assertIsCreatedResponse($this->sendFile('POST', $elementId, self::MAX_FILE_SIZE), false); + $this->assertSame(self::MAX_FILE_SIZE, strlen((string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody())); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + public function testPutOversizedFileIsRejectedAndKeepsExistingFile(): void + { + $elementId = $this->createNode('max-file-size-put'); + $this->assertIsCreatedResponse($this->sendFile('POST', $elementId, 10), false); + + $response = $this->sendFile('PUT', $elementId, self::MAX_FILE_SIZE + 1); + $this->assertIsProblemResponse($response, 400); + $this->assertStringContainsString((string) self::MAX_FILE_SIZE, $this->getBody($response)['detail']); + $this->assertSame(10, strlen((string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody())); + + $this->assertIsCreatedResponse($this->sendFile('PUT', $elementId, self::MAX_FILE_SIZE), false); + $this->assertSame(self::MAX_FILE_SIZE, strlen((string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody())); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + public function testPostOversizedFileOnRelationIsRejected(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'max-file-size-relation'); + + $this->assertIsProblemResponse($this->sendFile('POST', $relationId, self::MAX_FILE_SIZE + 1), 400); + $this->assertIsProblemResponse($this->sendFile('PUT', $relationId, self::MAX_FILE_SIZE + 1), 400); + $this->assertHasNoFile($relationId); + $this->assertIsCreatedResponse($this->sendFile('POST', $relationId, self::MAX_FILE_SIZE), false); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } + + public function testOversizedUploadLengthIsRejectedOnUploadCreation(): void + { + $elementId = $this->createNode('max-file-size-upload-length'); + + $response = $this->sendFile('POST', $elementId, 0, [ + 'Upload-Complete' => '?0', + 'Upload-Length' => self::MAX_FILE_SIZE + 1, + ]); + $this->assertIsProblemResponse($response, 400); + $this->assertStringContainsString((string) self::MAX_FILE_SIZE, $this->getBody($response)['detail']); + + // no upload was created + $this->assertHasNoFile($elementId); + + // exactly the limit is fine + $response = $this->sendFile('POST', $elementId, 0, [ + 'Upload-Complete' => '?0', + 'Upload-Length' => self::MAX_FILE_SIZE, + ]); + $this->assertNoContentResponse($response, true); + $this->runDeleteRequest(sprintf('/upload/%s', $this->getUuidFromLocation($response)), self::TOKEN); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + public function testPatchCrossingTheLimitIsRejected(): void + { + $elementId = $this->createNode('max-file-size-patch'); + $createResponse = $this->sendFile('POST', $elementId, 0, ['Upload-Complete' => '?0']); + $this->assertNoContentResponse($createResponse, true); + $uploadUrl = sprintf('/upload/%s', $this->getUuidFromLocation($createResponse)); + + $patch = fn (int $size): mixed => $this->runUploadRequest( + 'PATCH', + $uploadUrl, + str_repeat('a', $size), + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + + $response = $patch(self::MAX_FILE_SIZE + 1); + $this->assertIsProblemResponse($response, 400); + $this->assertStringContainsString((string) self::MAX_FILE_SIZE, $this->getBody($response)['detail']); + + // the upload was not advanced and no file exists + $headResponse = $this->runHeadRequest($uploadUrl, self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame('0', $headResponse->getHeader('Upload-Offset')[0]); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + $this->assertHasNoFile($elementId); + + // exactly the limit is fine + $this->assertNoContentResponse($patch(self::MAX_FILE_SIZE)); + $this->assertSame(self::MAX_FILE_SIZE, strlen((string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody())); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } +} diff --git a/tests/ExampleGenerationControllerWithDifferentConfiguration/configuration.yml b/tests/ExampleGenerationControllerWithDifferentConfiguration/configuration.yml index 3c7f0275..d4540c9f 100644 --- a/tests/ExampleGenerationControllerWithDifferentConfiguration/configuration.yml +++ b/tests/ExampleGenerationControllerWithDifferentConfiguration/configuration.yml @@ -3,3 +3,5 @@ ember_nexus: enabled: false instanceConfiguration: enabled: false + file: + maxFileSizeInBytes: 1024 diff --git a/tests/FeatureTests/BaseRequestTestCase.php b/tests/FeatureTests/BaseRequestTestCase.php index 59ff6975..5f67b90a 100644 --- a/tests/FeatureTests/BaseRequestTestCase.php +++ b/tests/FeatureTests/BaseRequestTestCase.php @@ -4,6 +4,7 @@ namespace App\Tests\FeatureTests; +use App\Factory\S3ClientFactory; use GuzzleHttp\Client; use InvalidArgumentException; use PHPUnit\Framework\TestCase; @@ -405,6 +406,86 @@ public function getUuidFromLocation(ResponseInterface $response): string return array_reverse(explode('/', $location))[0]; } + /** + * Creates a relation of type 'Data' between two newly created 'Data' nodes, for tests which need a blank, + * short-lived relation. Remove it again with {@see deleteEphemeralRelation()}. + */ + public function createEphemeralRelation(string $token, string $name): string + { + $startNodeId = $this->getUuidFromLocation($this->runPostRequest('/', $token, [ + 'type' => 'Data', + 'data' => ['name' => $name.'-start'], + ])); + $endNodeId = $this->getUuidFromLocation($this->runPostRequest('/', $token, [ + 'type' => 'Data', + 'data' => ['name' => $name.'-end'], + ])); + $relationResponse = $this->runPostRequest('/', $token, [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => ['name' => $name], + ]); + $this->assertIsCreatedResponse($relationResponse); + + return $this->getUuidFromLocation($relationResponse); + } + + /** + * Removes a relation created by {@see createEphemeralRelation()}, including its start and end node. + */ + public function deleteEphemeralRelation(string $token, string $relationId): void + { + $relation = $this->getBody($this->runGetRequest(sprintf('/%s', $relationId), $token)); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $relationId), $token)); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $relation['start']), $token)); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $relation['end']), $token)); + } + + /** + * Checks the default storage bucket directly, as the file of an element which no longer exists can not be + * requested through the API. + */ + public function assertFileExistsInStorage(string $elementId): void + { + $this->assertTrue($this->isFileInStorage($elementId), sprintf('Expected file of element %s to exist in storage bucket.', $elementId)); + } + + public function assertFileDoesNotExistInStorage(string $elementId): void + { + $this->assertFalse($this->isFileInStorage($elementId), sprintf('Expected file of element %s to be removed from storage bucket.', $elementId)); + } + + private function isFileInStorage(string $elementId): bool + { + $s3Client = (new S3ClientFactory($_ENV['S3_ENDPOINT'], $_ENV['S3_ACCESS_KEY_ID'], $_ENV['S3_SECRET_ACCESS_KEY']))->createS3Client(); + // the object key ends with the element id, see FileService::getStorageBucketKey() + foreach ($s3Client->listObjectsV2(['Bucket' => 'api-storage']) as $object) { + if (str_contains((string) $object->getKey(), $elementId)) { + return true; + } + } + + return false; + } + + /** + * Counts the chunk objects of an upload in the upload bucket, as chunks are not accessible through the API. + */ + public function countUploadChunksInUploadBucket(string $uploadId): int + { + $s3Client = (new S3ClientFactory($_ENV['S3_ENDPOINT'], $_ENV['S3_ACCESS_KEY_ID'], $_ENV['S3_SECRET_ACCESS_KEY']))->createS3Client(); + $count = 0; + // the object key contains the upload id, see FileService::getUploadBucketKey() + foreach ($s3Client->listObjectsV2(['Bucket' => 'api-upload']) as $object) { + if (str_contains((string) $object->getKey(), $uploadId)) { + ++$count; + } + } + + return $count; + } + public function generateDeterministicFile(int $seed, int $targetSize, string $outputPath): void { $lineWidth = 120; diff --git a/tests/FeatureTests/Command/BackupFetchFolderStructureTest.php b/tests/FeatureTests/Command/BackupFetchFolderStructureTest.php index f0d12990..e9f61e21 100644 --- a/tests/FeatureTests/Command/BackupFetchFolderStructureTest.php +++ b/tests/FeatureTests/Command/BackupFetchFolderStructureTest.php @@ -9,10 +9,8 @@ use ZipArchive; /** - * `backup:fetch` supports two archive layouts: `node`/`relation`/`file`/`summary.json` directly at the root of - * the ZIP (e.g. what `git archive` produces without a `--prefix`), or wrapped inside a single top level folder - * (e.g. what GitHub's release archives produce). This verifies both layouts extract nested files to the correct - * path, since a nested file's directory structure was previously being lost/corrupted during extraction. + * Verifies that `backup:fetch` extracts nested files to the correct path, both for archives with the backup + * content at the ZIP root and for archives wrapped in a single top level folder (e.g. GitHub release archives). */ #[Group('command')] class BackupFetchFolderStructureTest extends BaseRequestTestCase @@ -62,7 +60,7 @@ private function assertNestedFileWasExtractedCorrectly(string $backupName): void /** * Builds a minimal fixture ZIP containing a `summary.json`, and `node`/`relation`/`file` folders, each with a - * file nested one directory deep, optionally wrapped inside a single top level folder. + * file nested two directories deep, optionally wrapped inside a single top level folder. */ private function buildFixtureZip(?string $wrapperFolder): string { diff --git a/tests/FeatureTests/Command/BaseCronTestCase.php b/tests/FeatureTests/Command/BaseCronTestCase.php new file mode 100644 index 00000000..1ab4f0dc --- /dev/null +++ b/tests/FeatureTests/Command/BaseCronTestCase.php @@ -0,0 +1,121 @@ +withDriver('bolt', $_ENV['CYPHER_AUTH']) + ->build(); + } + + protected function getRedisClient(): RedisClient + { + return new RedisClient($_ENV['REDIS_AUTH']); + } + + /** + * @param array $environment + * + * @return array{0: int, 1: string} exit code and output + */ + protected function runConsoleCommand(string $command, array $environment = []): array + { + // this spawns a subprocess without a tty, so under the tty-bypass rule a container-wide `DISABLE_CRON=true` + // would otherwise make these tests no-op; default it to disabled here unless a test explicitly wants to + // exercise the disabled path itself + $environment = ['DISABLE_CRON' => '0', ...$environment]; + + $prefix = ''; + foreach ($environment as $name => $value) { + $prefix .= sprintf('%s=%s ', $name, escapeshellarg($value)); + } + $output = []; + $exitCode = 0; + \Safe\exec(sprintf('%sphp bin/console %s 2>&1', $prefix, $command), $output, $exitCode); + + return [$exitCode, implode("\n", $output)]; + } + + protected function createNode(string $name): string + { + return $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => $name], + ])); + } + + /** + * Starts an unfinished upload with a single chunk, i.e. one chunk object exists in the upload bucket. + */ + protected function createUpload(string $elementId): string + { + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + str_repeat('a', self::CHUNK_SIZE), + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($response, true); + + return $this->getUuidFromLocation($response); + } + + protected function setUploadExpiration(string $uploadId, string $expirationExpression): void + { + $result = $this->getCypherClient()->run( + sprintf('MATCH (u:Upload {id: $id}) SET u.expires = %s RETURN count(u) AS count', $expirationExpression), + ['id' => $uploadId] + ); + $this->assertSame(1, $result->first()->get('count')); + } + + protected function setUploadProperty(string $uploadId, string $propertyExpression): void + { + $result = $this->getCypherClient()->run( + sprintf('MATCH (u:Upload {id: $id}) SET %s RETURN count(u) AS count', $propertyExpression), + ['id' => $uploadId] + ); + $this->assertSame(1, $result->first()->get('count')); + } + + protected function uploadNodeExists(string $uploadId): bool + { + $result = $this->getCypherClient()->run('MATCH (u:Upload {id: $id}) RETURN count(u) AS count', ['id' => $uploadId]); + + return 1 === $result->first()->get('count'); + } + + protected function deleteNode(string $elementId): void + { + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + protected function getRabbitMqConnection(): AMQPStreamConnection + { + $parts = \Safe\parse_url($_ENV['RABBITMQ_AUTH']); + + return new AMQPStreamConnection($parts['host'], $parts['port'] ?? 5672, $parts['user'], $parts['pass']); + } +} diff --git a/tests/FeatureTests/Command/CronDeleteExpiredUploadsTest.php b/tests/FeatureTests/Command/CronDeleteExpiredUploadsTest.php new file mode 100644 index 00000000..0579a5d4 --- /dev/null +++ b/tests/FeatureTests/Command/CronDeleteExpiredUploadsTest.php @@ -0,0 +1,133 @@ +getCronDeleteExpiredUploadRedisKey($uploadId); + } + + /** + * An element can only be the target of one unfinished upload at a time. + * + * @return array{0: string, 1: string} element id and upload id + */ + private function createElementWithUpload(string $name): array + { + $elementId = $this->createNode($name); + + return [$elementId, $this->createUpload($elementId)]; + } + + public function testOnlyUploadsWhichExpiredLongerThanTheGracePeriodAgoAreDeleted(): void + { + [$expiredElementId, $expiredUploadId] = $this->createElementWithUpload('cron-delete-expired-uploads-expired'); + [$withinGracePeriodElementId, $withinGracePeriodUploadId] = $this->createElementWithUpload('cron-delete-expired-uploads-grace'); + [$activeElementId, $activeUploadId] = $this->createElementWithUpload('cron-delete-expired-uploads-active'); + $this->setUploadExpiration($expiredUploadId, "datetime() - duration('PT2H')"); + // expired, but not yet for longer than the grace period of one hour + $this->setUploadExpiration($withinGracePeriodUploadId, "datetime() - duration('PT10M')"); + $this->assertSame(1, $this->countUploadChunksInUploadBucket($expiredUploadId)); + + [$exitCode, $output] = $this->runConsoleCommand('cron:delete-expired-uploads'); + + $this->assertSame(0, $exitCode, $output); + $this->assertMatchesRegularExpression('/Deleted [1-9]\d* expired upload\(s\)/', $output); + $this->assertFalse($this->uploadNodeExists($expiredUploadId)); + $this->assertSame(0, $this->countUploadChunksInUploadBucket($expiredUploadId)); + $this->assertTrue($this->uploadNodeExists($withinGracePeriodUploadId)); + $this->assertSame(1, $this->countUploadChunksInUploadBucket($withinGracePeriodUploadId)); + $this->assertTrue($this->uploadNodeExists($activeUploadId)); + $this->assertSame(204, $this->runHeadRequest(sprintf('/upload/%s', $activeUploadId), self::TOKEN)->getStatusCode()); + // the elements themselves are untouched + $this->assertSame(200, $this->runGetRequest(sprintf('/%s', $expiredElementId), self::TOKEN)->getStatusCode()); + + // deleting an element removes its remaining uploads and their chunks + $this->deleteNode($expiredElementId); + $this->deleteNode($withinGracePeriodElementId); + $this->deleteNode($activeElementId); + $this->assertFalse($this->uploadNodeExists($withinGracePeriodUploadId)); + $this->assertFalse($this->uploadNodeExists($activeUploadId)); + $this->assertSame(0, $this->countUploadChunksInUploadBucket($withinGracePeriodUploadId)); + $this->assertSame(0, $this->countUploadChunksInUploadBucket($activeUploadId)); + } + + public function testDisabledCronDoesNotDeleteAnything(): void + { + [$elementId, $uploadId] = $this->createElementWithUpload('cron-disabled'); + $this->setUploadExpiration($uploadId, "datetime() - duration('PT2H')"); + + [$exitCode, $output] = $this->runConsoleCommand('cron:delete-expired-uploads', ['DISABLE_CRON' => '1']); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('Cron is disabled', $output); + $this->assertTrue($this->uploadNodeExists($uploadId)); + $this->assertSame(1, $this->countUploadChunksInUploadBucket($uploadId)); + + $this->deleteNode($elementId); + } + + public function testBrokenUploadDoesNotBlockOthersIsRetriedWithBackoffAndGivenUpAfterThreeAttempts(): void + { + [$brokenElementId, $brokenUploadId] = $this->createElementWithUpload('cron-broken-upload'); + [$healthyElementId, $healthyUploadId] = $this->createElementWithUpload('cron-healthy-upload'); + $this->setUploadExpiration($brokenUploadId, "datetime() - duration('PT2H')"); + $this->setUploadExpiration($healthyUploadId, "datetime() - duration('PT2H')"); + // an upload which references a non-existing target can not be turned into an upload object + $this->setUploadProperty($brokenUploadId, "u.uploadTarget = 'not-a-uuid'"); + $redis = $this->getRedisClient(); + $attemptKey = $this->getAttemptKey($brokenUploadId); + $redis->del([$attemptKey]); + + // first attempt: fails, is remembered with a delay of one hour; the healthy upload is deleted anyway + [$exitCode, $output] = $this->runConsoleCommand('cron:delete-expired-uploads'); + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString($brokenUploadId, $output); + $this->assertStringContainsString('will retry later', $output); + $this->assertFalse($this->uploadNodeExists($healthyUploadId)); + $this->assertTrue($this->uploadNodeExists($brokenUploadId)); + $state = \Safe\json_decode((string) $redis->get($attemptKey), true); + $this->assertSame(1, $state['attempts']); + $this->assertGreaterThan(time() + 3000, $state['notBefore']); + + // second run: the upload waits for its retry, nothing is attempted or logged as failure + [$exitCode, $output] = $this->runConsoleCommand('cron:delete-expired-uploads'); + $this->assertSame(0, $exitCode, $output); + $this->assertStringNotContainsString('Failed to delete', $output); + $this->assertMatchesRegularExpression('/[1-9]\d* waiting for a retry/', $output); + $this->assertSame(1, \Safe\json_decode((string) $redis->get($attemptKey), true)['attempts']); + + // the delay passes, second failed attempt + $redis->set($attemptKey, \Safe\json_encode(['attempts' => 1, 'notBefore' => time() - 10]), 'EX', 600); + [$exitCode, $output] = $this->runConsoleCommand('cron:delete-expired-uploads'); + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('will retry later', $output); + $state = \Safe\json_decode((string) $redis->get($attemptKey), true); + $this->assertSame(2, $state['attempts']); + $this->assertGreaterThan(time() + 80000, $state['notBefore']); + $this->assertTrue($this->uploadNodeExists($brokenUploadId)); + + // third failure is final: the upload node is removed and the attempts are forgotten + $redis->set($attemptKey, \Safe\json_encode(['attempts' => 2, 'notBefore' => time() - 10]), 'EX', 600); + [$exitCode, $output] = $this->runConsoleCommand('cron:delete-expired-uploads'); + $this->assertSame(0, $exitCode, $output); + $this->assertMatchesRegularExpression('/[1-9]\d* given up/', $output); + $this->assertFalse($this->uploadNodeExists($brokenUploadId)); + $this->assertNull($redis->get($attemptKey)); + + $this->deleteNode($brokenElementId); + $this->deleteNode($healthyElementId); + } +} diff --git a/tests/FeatureTests/Command/CronReferenceDatasetTest.php b/tests/FeatureTests/Command/CronReferenceDatasetTest.php new file mode 100644 index 00000000..c7c237a0 --- /dev/null +++ b/tests/FeatureTests/Command/CronReferenceDatasetTest.php @@ -0,0 +1,60 @@ + + */ + private function getCounts(): array + { + $client = $this->getCypherClient(); + $counts = []; + foreach ([ + 'nodes without uploads' => 'MATCH (n) WHERE NOT n:Upload RETURN count(n) AS count', + 'relations' => 'MATCH ()-[r]->() RETURN count(r) AS count', + 'tokens' => 'MATCH (t:Token) RETURN count(t) AS count', + 'users' => 'MATCH (u:User) RETURN count(u) AS count', + 'elements with a file' => 'MATCH (n) WHERE n.hasFile = true RETURN count(n) AS count', + ] as $name => $query) { + $counts[$name] = $client->run($query)->first()->get('count'); + } + + return $counts; + } + + public function testNoElementOtherThanUploadsHasAnExpirationInThePast(): void + { + $result = $this->getCypherClient()->run( + 'MATCH (n) WHERE NOT n:Upload AND n.expires IS NOT NULL AND n.expires < datetime() RETURN count(n) AS count' + ); + + $this->assertSame(0, $result->first()->get('count')); + } + + public function testCronCommandKeepsAllDataWhichIsNotAnExpiredUpload(): void + { + $countsBefore = $this->getCounts(); + $filesBefore = $this->runGetRequest('/0fdd52ba-55da-430c-b015-3277a231e895/file', self::TOKEN); + $this->assertSame(200, $filesBefore->getStatusCode()); + + [$exitCode, $output] = $this->runConsoleCommand('cron'); + + $this->assertSame(0, $exitCode, $output); + $this->assertSame($countsBefore, $this->getCounts()); + $filesAfter = $this->runGetRequest('/0fdd52ba-55da-430c-b015-3277a231e895/file', self::TOKEN); + $this->assertSame(200, $filesAfter->getStatusCode()); + $this->assertSame((string) $filesBefore->getBody(), (string) $filesAfter->getBody()); + } +} diff --git a/tests/FeatureTests/Command/CronReindexFilesTest.php b/tests/FeatureTests/Command/CronReindexFilesTest.php new file mode 100644 index 00000000..6ca0a505 --- /dev/null +++ b/tests/FeatureTests/Command/CronReindexFilesTest.php @@ -0,0 +1,153 @@ +getRabbitMqConnection(); + $channel = $connection->channel(); + // same arguments as the application uses to declare the queue + $channel->queue_declare(self::QUEUE, false, false, false, false); + [, $messageCount] = $channel->queue_declare(self::QUEUE, true); + $channel->close(); + $connection->close(); + + return (int) $messageCount; + } + + private function publishRawMessage(string $body): void + { + $connection = $this->getRabbitMqConnection(); + $channel = $connection->channel(); + $channel->queue_declare(self::QUEUE, false, false, false, false); + $channel->basic_publish(new AMQPMessage($body), '', self::QUEUE); + $channel->close(); + $connection->close(); + } + + private function drainQueue(): void + { + [$exitCode, $output] = $this->runConsoleCommand('cron:reindex-files'); + $this->assertSame(0, $exitCode, $output); + // messages which fail are requeued, so poison messages of other tests are removed by their third try + for ($try = 0; $try < 3 && $this->getQueueMessageCount() > 0; ++$try) { + $this->runConsoleCommand('cron:reindex-files'); + } + $this->assertSame(0, $this->getQueueMessageCount()); + } + + private function putFile(string $elementId): void + { + $response = $this->runUploadRequest('PUT', sprintf('/%s/file', $elementId), 'reindex me', self::TOKEN, [ + 'Content-Type' => 'text/plain', + 'Content-Disposition' => 'attachment; filename=reindex.txt', + ]); + $this->assertIsCreatedResponse($response, false); + } + + public function testQueuedFileIsReindexedAndTheQueueIsEmptyAfterwards(): void + { + $this->drainQueue(); + $elementId = $this->createNode('cron-reindex-files'); + $this->putFile($elementId); + $this->assertGreaterThanOrEqual(1, $this->getQueueMessageCount()); + + [$exitCode, $output] = $this->runConsoleCommand('cron:reindex-files'); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString(sprintf('Reindexed file of element %s.', $elementId), $output); + $this->assertSame(0, $this->getQueueMessageCount()); + + [$exitCode, $output] = $this->runConsoleCommand('cron:reindex-files'); + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('Reindexed 0 element file(s).', $output); + + $this->deleteNode($elementId); + $this->drainQueue(); + } + + public function testFileOfElementWhichWasDeletedInTheMeantimeIsSkipped(): void + { + $this->drainQueue(); + $elementId = $this->createNode('cron-reindex-deleted-element'); + $this->putFile($elementId); + $this->deleteNode($elementId); + + [$exitCode, $output] = $this->runConsoleCommand('cron:reindex-files'); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringNotContainsString($elementId, $output); + $this->assertSame(0, $this->getQueueMessageCount()); + $this->drainQueue(); + } + + public function testDisabledCronLeavesTheQueueUntouched(): void + { + $this->drainQueue(); + $elementId = $this->createNode('cron-reindex-disabled'); + $this->putFile($elementId); + $messageCount = $this->getQueueMessageCount(); + $this->assertGreaterThanOrEqual(1, $messageCount); + + [$exitCode, $output] = $this->runConsoleCommand('cron:reindex-files', ['DISABLE_CRON' => '1']); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('Cron is disabled', $output); + $this->assertSame($messageCount, $this->getQueueMessageCount()); + + $this->deleteNode($elementId); + $this->drainQueue(); + } + + public function testUnprocessableMessageIsRequeuedTwiceAndDroppedOnTheThirdTry(): void + { + $this->drainQueue(); + // valid JSON without the element id the command needs + $this->publishRawMessage('{"unexpected":"message"}'); + $this->assertSame(1, $this->getQueueMessageCount()); + + // the message goes to the end of the queue after the first and the second failed try + [$exitCode, $output] = $this->runConsoleCommand('cron:reindex-files'); + $this->assertSame(0, $exitCode, $output); + $this->assertSame(1, $this->getQueueMessageCount()); + [$exitCode, $output] = $this->runConsoleCommand('cron:reindex-files'); + $this->assertSame(0, $exitCode, $output); + $this->assertSame(1, $this->getQueueMessageCount()); + + // third try: dropped + [$exitCode, $output] = $this->runConsoleCommand('cron:reindex-files'); + $this->assertSame(0, $exitCode, $output); + $this->assertSame(0, $this->getQueueMessageCount()); + } + + public function testMalformedJsonMessageDoesNotBlockValidMessages(): void + { + $this->drainQueue(); + $this->publishRawMessage('this is not json'); + $elementId = $this->createNode('cron-reindex-after-malformed-message'); + $this->putFile($elementId); + + [$exitCode, $output] = $this->runConsoleCommand('cron:reindex-files'); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString(sprintf('Reindexed file of element %s.', $elementId), $output); + // the malformed message is still waiting for its remaining tries + $this->assertSame(1, $this->getQueueMessageCount()); + + $this->deleteNode($elementId); + $this->drainQueue(); + } +} diff --git a/tests/FeatureTests/Command/CronUpdateOwnershipTest.php b/tests/FeatureTests/Command/CronUpdateOwnershipTest.php new file mode 100644 index 00000000..4338a184 --- /dev/null +++ b/tests/FeatureTests/Command/CronUpdateOwnershipTest.php @@ -0,0 +1,388 @@ +getRabbitMqConnection(); + $channel = $connection->channel(); + // same arguments as QueueService uses to declare this (durable, bounded, dead-lettered) queue + $channel->queue_declare(self::DEAD_LETTER_QUEUE, false, true, false, false, false, new AMQPTable([ + 'x-max-length' => self::QUEUE_MAX_LENGTH, + ])); + $channel->queue_declare(self::QUEUE, false, true, false, false, false, new AMQPTable([ + 'x-max-length' => self::QUEUE_MAX_LENGTH, + 'x-dead-letter-exchange' => '', + 'x-dead-letter-routing-key' => self::DEAD_LETTER_QUEUE, + ])); + $channel->close(); + $connection->close(); + } + + private function getQueueMessageCount(): int + { + $this->declareQueues(); + + $connection = $this->getRabbitMqConnection(); + $channel = $connection->channel(); + [, $messageCount] = $channel->queue_declare(self::QUEUE, true); + $channel->close(); + $connection->close(); + + return (int) $messageCount; + } + + private function publishRawMessage(string $body): void + { + $this->declareQueues(); + + $connection = $this->getRabbitMqConnection(); + $channel = $connection->channel(); + $channel->basic_publish(new AMQPMessage($body), '', self::QUEUE); + $channel->close(); + $connection->close(); + } + + private function drainQueue(): void + { + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership'); + $this->assertSame(0, $exitCode, $output); + // messages which fail are requeued, so poison messages of other tests are removed by their third try + for ($try = 0; $try < 3 && $this->getQueueMessageCount() > 0; ++$try) { + $this->runConsoleCommand('cron:update-ownership'); + } + $this->assertSame(0, $this->getQueueMessageCount()); + } + + /** + * Registers a fresh user and returns [userId, token], independent of any shared/reference fixtures. + * + * @return array{0: string, 1: string} + */ + private function registerUserWithToken(string $identifierPrefix): array + { + $email = sprintf('%s-%s@cron-update-ownership.localhost.dev', $identifierPrefix, bin2hex(random_bytes(6))); + $password = '1234'; + + $registerResponse = $this->runPostRequest('/register', null, [ + 'type' => 'User', + 'password' => $password, + 'uniqueUserIdentifier' => $email, + ]); + $this->assertIsCreatedResponse($registerResponse); + $userId = $this->getUuidFromLocation($registerResponse); + + $tokenResponse = $this->runPostRequest('/token', null, [ + 'type' => 'Token', + 'uniqueUserIdentifier' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $tokenResponse->getStatusCode()); + $token = \Safe\json_decode((string) $tokenResponse->getBody(), true)['token']; + + return [$userId, $token]; + } + + /** + * Resolves the user id behind a token, via its Token node's `OWNS`/`CREATED` relation (its start is the owning + * user; a self-registered user's token has `CREATED`, a backup-loaded fixture token has `OWNS`). + */ + private function getUserIdForToken(string $token): string + { + $tokenResponse = $this->runGetRequest('/token', $token); + $this->assertIsNodeResponse($tokenResponse, 'Token'); + $tokenId = $this->getBody($tokenResponse)['id']; + + $relatedResponse = $this->runGetRequest(sprintf('/%s/related', $tokenId), $token); + $this->assertIsCollectionResponse($relatedResponse); + $relatedData = $this->getBody($relatedResponse); + foreach ($relatedData['relations'] as $relation) { + if (in_array($relation['type'], ['OWNS', 'CREATED'], true)) { + return $relation['start']; + } + } + $this->fail(sprintf('Unable to determine the user id for token via %s.', $tokenId)); + } + + /** + * Creating a relation via the HTTP endpoint requires CREATE access on its start and READ access on its end + * (`PostIndexController`), so an independent, freshly registered user does not otherwise have any relationship + * to self::TOKEN's user or to elements it owns. This grants self::TOKEN's user direct `OWNS` access to the given + * (otherwise unrelated) user directly in the graph, as a *test precondition only* - it deliberately bypasses the + * application (no event is dispatched for it), the same way other feature tests poke Cypher/Mongo directly to + * set up a precondition (see e.g. BaseCronTestCase::setUploadProperty()). The relation actually under test in + * each scenario below is always created afterwards through the real HTTP endpoint, so it does exercise the + * event listener/queue/command end to end. + */ + private function grantAdminOwnershipOf(string $userId): void + { + $adminUserId = $this->getUserIdForToken(self::TOKEN); + $result = $this->getCypherClient()->run( + 'MATCH (admin:User {id: $adminUserId}), (target:User {id: $userId}) '. + 'CREATE (admin)-[r:OWNS {id: randomUUID(), created: datetime(), updated: datetime()}]->(target) '. + 'RETURN r.id AS id', + ['adminUserId' => $adminUserId, 'userId' => $userId] + ); + $this->assertNotNull($result->first()->get('id')); + } + + /** + * Creating a relation requires CREATE access on its start and READ access on its end; defaults to self::TOKEN, + * which has that for anything it (directly or transitively, via grantAdminOwnershipOf()) owns. + */ + private function createRelation(string $type, string $startId, string $endId, ?string $token = null): string + { + $response = $this->runPostRequest('/', $token ?? self::TOKEN, [ + 'type' => $type, + 'start' => $startId, + 'end' => $endId, + ]); + $this->assertIsCreatedResponse($response); + + return $this->getUuidFromLocation($response); + } + + /** + * Search access is only eventually consistent with the graph (it is only recalculated once + * `cron:update-ownership` drains the queue), and Elasticsearch itself only refreshes near-real-time, so this + * polls briefly rather than asserting on the very first attempt. + */ + private function assertSearchAccess(string $token, string $elementId, bool $expectedToBeFound): void + { + $found = false; + for ($attempt = 0; $attempt < 10; ++$attempt) { + $response = $this->runPostRequest('/search', $token, [ + 'steps' => [ + [ + 'type' => 'elasticsearch-query-dsl-mixin', + 'query' => [ + 'ids' => [ + 'values' => [$elementId], + ], + ], + 'parameters' => [ + 'nodeTypes' => ['Data'], + ], + ], + ], + ]); + $this->assertIsSearchResultResponse($response); + $data = $this->getBody($response); + $found = false; + foreach ($data['results']['elements'] as $result) { + if ($result['id'] === $elementId) { + $found = true; + break; + } + } + if ($found === $expectedToBeFound) { + break; + } + usleep(300_000); + } + $this->assertSame($expectedToBeFound, $found, sprintf( + 'Expected element %s to %sbe found via search for the given token.', + $elementId, + $expectedToBeFound ? '' : 'not ' + )); + } + + public function testNewOwnsEdgeGrantsSearchAccessAndItsDeletionRevokesItAgain(): void + { + $this->drainQueue(); + + [$newUserId, $newUserToken] = $this->registerUserWithToken('owns'); + $this->grantAdminOwnershipOf($newUserId); + $elementId = $this->createNode('cron-update-ownership-owns-target'); + + $this->assertSearchAccess($newUserToken, $elementId, false); + + $relationId = $this->createRelation('OWNS', $newUserId, $elementId); + $this->assertGreaterThanOrEqual(1, $this->getQueueMessageCount()); + + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership'); + $this->assertSame(0, $exitCode, $output); + $this->assertMatchesRegularExpression('/updated search access of [1-9]\d* element\(s\)/', $output); + $this->assertSame(0, $this->getQueueMessageCount()); + + $this->assertSearchAccess($newUserToken, $elementId, true); + + // running it again with no further relation changes recomputes the same values (delta zero) + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership'); + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('updated search access of 0 element(s)', $output); + + // delete the underlying OWNS relation: the node loses search access for that user again + $this->deleteNode($relationId); + $this->assertGreaterThanOrEqual(1, $this->getQueueMessageCount()); + + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership'); + $this->assertSame(0, $exitCode, $output); + $this->assertMatchesRegularExpression('/updated search access of [1-9]\d* element\(s\)/', $output); + + $this->assertSearchAccess($newUserToken, $elementId, false); + + $this->deleteNode($elementId); + $this->drainQueue(); + $this->deleteNode($newUserId); + $this->drainQueue(); + } + + /** + * A pure `OWNS`-owned-by-the-group scenario, as this test's name would suggest, is not actually how this + * application's access model behaves for *direct* per-user access: `AccessChecker::getDirectUsersWithAccessToNode()` + * only honors a group hop for a *user's own* `_usersWithSearchAccess` entry when the querying user also created + * the element themselves (see the existing `_99_01_IsInGroupAfterOwnsHaveNoEffectTest`, which documents that a + * plain `IS_IN_GROUP` + `OWNS` chain grants no *direct, per-user* access this way). That is not how group access + * to search actually works though: `_groupsWithSearchAccess` is calculated (and matched at query time against + * the searching user's own groups) independently of any of that - see + * testGroupMemberGainsSearchAccessViaGroupHasSearchAccessWithoutOwningOrCreatingTheElement() below, which covers + * that actual outcome. This test therefore only covers the `IS_IN_GROUP` relation-change mechanism itself (queued + * and drained without error), not a search-access outcome. + */ + public function testGroupMembershipChangeIsQueuedAndConsumedWithoutError(): void + { + $this->drainQueue(); + + [$memberUserId] = $this->registerUserWithToken('group-member'); + $this->grantAdminOwnershipOf($memberUserId); + + $groupNodeId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Group', + 'data' => ['name' => 'cron-update-ownership-group'], + ])); + + $this->createRelation('IS_IN_GROUP', $memberUserId, $groupNodeId); + $this->assertGreaterThanOrEqual(1, $this->getQueueMessageCount()); + + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership'); + $this->assertSame(0, $exitCode, $output); + $this->assertSame(0, $this->getQueueMessageCount()); + + $this->deleteNode($groupNodeId); + $this->deleteNode($memberUserId); + $this->drainQueue(); + } + + /** + * Covers the actual outcome of group-conferred search access: `AccessChecker::getDirectGroupsWithAccessToNode()` + * (unlike its per-user sibling `getDirectUsersWithAccessToNode()`) does *not* require the querying user to have + * created the element - a group's `HAS_SEARCH_ACCESS` relation is resolved once into `_groupsWithSearchAccess`, + * and `ElasticsearchQueryDslMixinSearchStepEventListener::buildCombinedQuery()` matches that field at query time + * against whichever groups the searching user is currently in (`AccessChecker::getUsersGroups()`). So a group + * member gains search access to everything the group has `HAS_SEARCH_ACCESS` to, even without ever having + * created or directly owned any of it themselves - unlike the (deliberately unresolved) direct, per-user + * `_usersWithSearchAccess` case covered by testGroupMembershipChangeIsQueuedAndConsumedWithoutError() above. + */ + public function testGroupMemberGainsSearchAccessViaGroupHasSearchAccessWithoutOwningOrCreatingTheElement(): void + { + $this->drainQueue(); + + [$memberUserId, $memberUserToken] = $this->registerUserWithToken('group-search-access-member'); + $this->grantAdminOwnershipOf($memberUserId); + + // created by the admin, not by the member: the member has no OWNS/CREATED relation to it whatsoever + $elementId = $this->createNode('cron-update-ownership-group-search-access-target'); + $this->assertSearchAccess($memberUserToken, $elementId, false); + + $groupNodeId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Group', + 'data' => ['name' => 'cron-update-ownership-group-search-access-group'], + ])); + $this->createRelation('IS_IN_GROUP', $memberUserId, $groupNodeId); + $relationId = $this->createRelation('HAS_SEARCH_ACCESS', $groupNodeId, $elementId); + $this->assertGreaterThanOrEqual(1, $this->getQueueMessageCount()); + + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership'); + $this->assertSame(0, $exitCode, $output); + $this->assertSame(0, $this->getQueueMessageCount()); + + $this->assertSearchAccess($memberUserToken, $elementId, true); + + // revoking the group's HAS_SEARCH_ACCESS relation removes the member's search access again + $this->deleteNode($relationId); + $this->assertGreaterThanOrEqual(1, $this->getQueueMessageCount()); + + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership'); + $this->assertSame(0, $exitCode, $output); + + $this->assertSearchAccess($memberUserToken, $elementId, false); + + $this->deleteNode($groupNodeId); + $this->deleteNode($elementId); + $this->drainQueue(); + $this->deleteNode($memberUserId); + $this->drainQueue(); + } + + public function testDisabledCronLeavesTheQueueUntouched(): void + { + $this->drainQueue(); + [$newUserId] = $this->registerUserWithToken('disabled'); + $this->grantAdminOwnershipOf($newUserId); + $elementId = $this->createNode('cron-update-ownership-disabled'); + $this->createRelation('OWNS', $newUserId, $elementId); + $messageCount = $this->getQueueMessageCount(); + $this->assertGreaterThanOrEqual(1, $messageCount); + + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership', ['DISABLE_CRON' => '1']); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('Cron is disabled', $output); + $this->assertSame($messageCount, $this->getQueueMessageCount()); + + $this->deleteNode($elementId); + $this->deleteNode($newUserId); + $this->drainQueue(); + } + + public function testUnprocessableMessageIsRequeuedTwiceAndDroppedOnTheThirdTry(): void + { + $this->drainQueue(); + // valid JSON, but without any of the ids the command looks for + $this->publishRawMessage('{"unexpected":"message"}'); + $this->assertSame(1, $this->getQueueMessageCount()); + + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership'); + $this->assertSame(0, $exitCode, $output); + $this->assertSame(0, $this->getQueueMessageCount()); + } + + public function testMalformedJsonMessageDoesNotBlockValidMessages(): void + { + $this->drainQueue(); + $this->publishRawMessage('this is not json'); + [$newUserId] = $this->registerUserWithToken('malformed'); + $this->grantAdminOwnershipOf($newUserId); + $elementId = $this->createNode('cron-update-ownership-after-malformed-message'); + $this->createRelation('OWNS', $newUserId, $elementId); + + [$exitCode, $output] = $this->runConsoleCommand('cron:update-ownership'); + + $this->assertSame(0, $exitCode, $output); + $this->assertMatchesRegularExpression('/updated search access of [1-9]\d* element\(s\)/', $output); + // the malformed message is still waiting for its remaining tries + $this->assertSame(1, $this->getQueueMessageCount()); + + $this->deleteNode($elementId); + $this->deleteNode($newUserId); + $this->drainQueue(); + } +} diff --git a/tests/FeatureTests/Endpoint/Element/PostReservedTypeTest.php b/tests/FeatureTests/Endpoint/Element/PostReservedTypeTest.php new file mode 100644 index 00000000..2825ccc7 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Element/PostReservedTypeTest.php @@ -0,0 +1,92 @@ + + */ + public static function reservedTypeProvider(): array + { + return [ + 'User' => ['User'], + 'Token' => ['Token'], + 'Upload' => ['Upload'], + ]; + } + + #[DataProvider('reservedTypeProvider')] + public function testPostIndexWithReservedTypeReturns400(string $type): void + { + $id = Uuid::uuid4()->toString(); + $response = $this->runPostRequest('/', self::TOKEN, [ + 'id' => $id, + 'type' => $type, + 'data' => ['name' => 'post-reserved-type-index-'.$type], + ]); + $this->assertIsProblemResponse($response, 400); + $this->assertStringContainsString('reserved-type', $this->getBody($response)['type']); + + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s', $id), self::TOKEN), 404); + } + + #[DataProvider('reservedTypeProvider')] + public function testPostElementWithReservedTypeReturns400(string $type): void + { + $parentId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'post-reserved-type-parent-'.$type], + ])); + $id = Uuid::uuid4()->toString(); + + $response = $this->runPostRequest(sprintf('/%s', $parentId), self::TOKEN, [ + 'id' => $id, + 'type' => $type, + 'data' => ['name' => 'post-reserved-type-child-'.$type], + ]); + $this->assertIsProblemResponse($response, 400); + $this->assertStringContainsString('reserved-type', $this->getBody($response)['type']); + + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s', $id), self::TOKEN), 404); + $childrenResponse = $this->runGetRequest(sprintf('/%s/children', $parentId), self::TOKEN); + $this->assertSame(0, $this->getBody($childrenResponse)['totalNodes']); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $parentId), self::TOKEN)); + } + + #[DataProvider('reservedTypeProvider')] + public function testPostIndexRelationWithReservedTypeReturns400(string $type): void + { + $startId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'post-reserved-type-relation-start-'.$type], + ])); + $endId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'post-reserved-type-relation-end-'.$type], + ])); + + $response = $this->runPostRequest('/', self::TOKEN, [ + 'type' => $type, + 'start' => $startId, + 'end' => $endId, + ]); + $this->assertIsProblemResponse($response, 400); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $startId), self::TOKEN)); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $endId), self::TOKEN)); + } +} diff --git a/tests/FeatureTests/Endpoint/Error/ProblemResponsesTest.php b/tests/FeatureTests/Endpoint/Error/ProblemResponsesTest.php new file mode 100644 index 00000000..a61592ac --- /dev/null +++ b/tests/FeatureTests/Endpoint/Error/ProblemResponsesTest.php @@ -0,0 +1,191 @@ + + */ + private function assertProblemShape(ResponseInterface $response, int $status, string $expectedTypeSuffix, string $expectedTitle): array + { + $this->assertIsProblemResponse($response, $status); + $body = \Safe\json_decode((string) $response->getBody(), true); + + $this->assertStringEndsWith($expectedTypeSuffix, $body['type']); + $this->assertSame($expectedTitle, $body['title']); + $this->assertSame($status, $body['status']); + $this->assertMatchesRegularExpression( + '/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', + $body['instance'] + ); + // extension members are camelCase, never kebab-case + foreach (array_keys($body) as $key) { + $this->assertStringNotContainsString('-', (string) $key); + } + + return $body; + } + + private function createNode(string $name): string + { + return $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => $name], + ])); + } + + private function putFile(string $elementId, string $content): ResponseInterface + { + return $this->runUploadRequest('PUT', sprintf('/%s/file', $elementId), $content, self::TOKEN, [ + 'Content-Type' => 'text/plain', + 'Content-Disposition' => 'attachment; filename=problem.txt', + ]); + } + + private function createUpload(string $elementId): string + { + $response = $this->runUploadRequest('POST', sprintf('/%s/file', $elementId), str_repeat('a', self::CHUNK_SIZE), self::TOKEN, [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ]); + $this->assertNoContentResponse($response, true); + + return $this->getUuidFromLocation($response); + } + + private function deleteNode(string $elementId): void + { + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + public function testConflictOfWrongUploadOffsetContainsBothOffsets(): void + { + $elementId = $this->createNode('problem-409-offset'); + $uploadId = $this->createUpload($elementId); + + $response = $this->runUploadRequest('PATCH', sprintf('/upload/%s', $uploadId), 'data', self::TOKEN, [ + 'Upload-Complete' => '?0', + 'Upload-Offset' => 1234, + 'Content-Type' => 'application/partial-upload', + ]); + + $body = $this->assertProblemShape($response, 409, '/error/409/conflict', 'Conflict'); + $this->assertSame(self::CHUNK_SIZE, $body['expectedOffset']); + $this->assertSame(1234, $body['providedOffset']); + + $this->deleteNode($elementId); + } + + public function testConflictOfCreatingFileOnElementWhichHasOne(): void + { + $elementId = $this->createNode('problem-409-has-file'); + $this->assertIsCreatedResponse($this->putFile($elementId, 'first'), false); + + $response = $this->runUploadRequest('POST', sprintf('/%s/file', $elementId), 'second', self::TOKEN, [ + 'Content-Type' => 'text/plain', + ]); + + $this->assertProblemShape($response, 409, '/error/409/conflict', 'Conflict'); + // the existing file was not touched + $this->assertSame('first', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + + $this->deleteNode($elementId); + } + + public function testGoneOfExpiredUpload(): void + { + $elementId = $this->createNode('problem-410'); + $uploadId = $this->createUpload($elementId); + $result = ClientBuilder::create() + ->withDriver('bolt', $_ENV['CYPHER_AUTH']) + ->build() + ->run("MATCH (u:Upload {id: \$id}) SET u.expires = datetime() - duration('PT1H') RETURN count(u) AS count", ['id' => $uploadId]); + $this->assertSame(1, $result->first()->get('count')); + + $patchResponse = $this->runUploadRequest('PATCH', sprintf('/upload/%s', $uploadId), 'data', self::TOKEN, [ + 'Upload-Complete' => '?0', + 'Upload-Offset' => self::CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + ]); + $this->assertProblemShape($patchResponse, 410, '/error/410/gone', 'Gone'); + + // HEAD responses have no body, the status has to match + $this->assertSame(410, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); + + $this->deleteNode($elementId); + } + + public function testPreconditionFailedOfIfMatchOnElementWithoutFile(): void + { + $elementId = $this->createNode('problem-412-without-file'); + + // an element without file has no file ETag, so no If-Match value can match + $response = $this->runUploadRequest('PUT', sprintf('/%s/file', $elementId), 'content', self::TOKEN, [ + 'Content-Type' => 'text/plain', + 'If-Match' => '"some-etag"', + ]); + + $this->assertProblemShape($response, 412, '/error/412/precondition-failed', 'Precondition Failed'); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + + $this->deleteNode($elementId); + } + + public function testPreconditionFailedOfStaleIfMatchOnDelete(): void + { + $elementId = $this->createNode('problem-412-stale'); + $this->assertIsCreatedResponse($this->putFile($elementId, 'content'), false); + + $response = $this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-Match' => '"stale-etag"']); + + $this->assertProblemShape($response, 412, '/error/412/precondition-failed', 'Precondition Failed'); + // the file is still there + $this->assertSame(200, $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getStatusCode()); + + $this->deleteNode($elementId); + } + + public function testRangeNotSatisfiableNamesTheLengthOfTheFile(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=999999999-'] + ); + + $body = $this->assertProblemShape($response, 416, '/error/416/range-not-satisfiable', 'Range Not Satisfiable'); + $this->assertSame(self::ROSE_CONTENT_LENGTH, $body['totalLength']); + $this->assertSame(999999999, $body['requestedStart']); + $this->assertSame([sprintf('bytes */%d', self::ROSE_CONTENT_LENGTH)], $response->getHeader('Content-Range')); + } + + public function testRangeNotSatisfiableOfEmptySuffixRange(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=-0'] + ); + + $body = $this->assertProblemShape($response, 416, '/error/416/range-not-satisfiable', 'Range Not Satisfiable'); + $this->assertSame(self::ROSE_CONTENT_LENGTH, $body['totalLength']); + $this->assertSame([sprintf('bytes */%d', self::ROSE_CONTENT_LENGTH)], $response->getHeader('Content-Range')); + } +} diff --git a/tests/FeatureTests/Endpoint/File/DeleteFileTest.php b/tests/FeatureTests/Endpoint/File/DeleteFileTest.php index cd85d055..d4d9d0c7 100644 --- a/tests/FeatureTests/Endpoint/File/DeleteFileTest.php +++ b/tests/FeatureTests/Endpoint/File/DeleteFileTest.php @@ -5,16 +5,13 @@ namespace App\Tests\FeatureTests\Endpoint\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; -#[Group('test')] class DeleteFileTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; public function testDeleteFileFromNode(): void { - // create new node for file upload $node = $this->runPostRequest( '/', self::TOKEN, @@ -27,7 +24,6 @@ public function testDeleteFileFromNode(): void ); $location = $node->getHeader('Location')[0]; - // upload file $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); $response = $this->runUploadRequest( 'POST', @@ -44,7 +40,6 @@ public function testDeleteFileFromNode(): void ); $this->assertIsBinaryStreamResponse($getFileResponse, 'image/jpeg'); - // delete file $deleteFileResponse = $this->runDeleteRequest( sprintf('%s/file', $location), self::TOKEN @@ -59,6 +54,23 @@ public function testDeleteFileFromNode(): void $this->assertIsProblemResponse($getFileResponse, 404); } + public function testFileCanBeCreatedAgainAfterDeletion(): void + { + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'file-can-be-created-again-after-deletion'], + ])); + + $this->assertIsCreatedResponse($this->runUploadRequest('POST', sprintf('/%s/file', $elementId), 'first content', self::TOKEN), false); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN)); + + // the deleted file must not block a new POST with 409 + $this->assertIsCreatedResponse($this->runUploadRequest('POST', sprintf('/%s/file', $elementId), 'second content', self::TOKEN), false); + $this->assertSame('second content', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + public function testDeleteFileFromRelation(): void { // create new relation for file upload @@ -100,7 +112,6 @@ public function testDeleteFileFromRelation(): void ); $location = $relation->getHeader('Location')[0]; - // upload file $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); $response = $this->runUploadRequest( 'PUT', @@ -135,7 +146,6 @@ public function testDeleteFileFromRelation(): void $data['file'] ); - // delete file $deleteFileResponse = $this->runDeleteRequest( sprintf('%s/file', $location), self::TOKEN diff --git a/tests/FeatureTests/Endpoint/File/FileEtagTest.php b/tests/FeatureTests/Endpoint/File/FileEtagTest.php new file mode 100644 index 00000000..f514b9ff --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/FileEtagTest.php @@ -0,0 +1,117 @@ +getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => $name], + ])); + } + + private function sendFile(string $method, string $elementId, string $content, array $headers = []): mixed + { + return $this->runUploadRequest( + $method, + sprintf('/%s/file', $elementId), + $content, + self::TOKEN, + array_merge(['Content-Type' => 'application/octet-stream'], $headers) + ); + } + + private function getFileEtag(string $elementId): string + { + $response = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame(200, $response->getStatusCode()); + $this->assertCount(1, $response->getHeader('ETag')); + + return $response->getHeader('ETag')[0]; + } + + private function assertFileEtagBehaviour(string $elementId): void + { + $this->assertIsCreatedResponse($this->sendFile('POST', $elementId, 'first content'), false); + $etag = $this->getFileEtag($elementId); + + // reading: matching ETag means not modified, other ETag delivers the file + $this->assertNotModifiedResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-None-Match' => $etag])); + $this->assertNotModifiedResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-None-Match' => self::STALE_ETAG.', '.$etag])); + $staleRead = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-None-Match' => self::STALE_ETAG]); + $this->assertSame(200, $staleRead->getStatusCode()); + $this->assertSame('first content', (string) $staleRead->getBody()); + + // writing with a stale If-Match is rejected and changes nothing + $this->assertIsProblemResponse($this->sendFile('PUT', $elementId, 'stale put', ['If-Match' => self::STALE_ETAG]), 412); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-Match' => self::STALE_ETAG]), 412); + $this->assertSame('first content', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + $this->assertSame($etag, $this->getFileEtag($elementId)); + + // writing with the current If-Match is accepted + $this->assertIsCreatedResponse($this->sendFile('PUT', $elementId, 'second content', ['If-Match' => $etag]), false); + $newEtag = $this->getFileEtag($elementId); + $this->assertNotSame($etag, $newEtag); + $this->assertSame('second content', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + + // the ETag of the replaced file is stale now + $this->assertIsProblemResponse($this->sendFile('PUT', $elementId, 'outdated put', ['If-Match' => $etag]), 412); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-Match' => $etag]), 412); + $this->assertSame(200, $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-None-Match' => $etag])->getStatusCode()); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-Match' => $newEtag])); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + } + + public function testFileEtagsOnNode(): void + { + $nodeId = $this->createNode('file-etag-node'); + + $this->assertFileEtagBehaviour($nodeId); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $nodeId), self::TOKEN)); + } + + public function testFileEtagsOnRelation(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'file-etag-relation'); + + $this->assertFileEtagBehaviour($relationId); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } + + /** + * `EtagCalculatorService::calculateFileEtag()` combines the element's own ETag (id + `updated` timestamp) with + * the file's hash. Any property write, including a name-only `PATCH`, changes the element's `updated` + * timestamp and therefore the file ETag too, even though the file's bytes are untouched. This pins that + * behaviour down: renaming the element changes the file ETag. + */ + public function testFileEtagChangesWhenTheElementIsRenamedEvenThoughTheFileItselfIsUnchanged(): void + { + $elementId = $this->createNode('file-etag-rename-node'); + $this->assertIsCreatedResponse($this->sendFile('POST', $elementId, 'unchanged file content'), false); + $etagBeforeRename = $this->getFileEtag($elementId); + + $this->assertNoContentResponse($this->runPatchRequest(sprintf('/%s', $elementId), self::TOKEN, ['name' => 'renamed-file-etag-node'])); + + $etagAfterRename = $this->getFileEtag($elementId); + $this->assertNotSame($etagBeforeRename, $etagAfterRename); + $this->assertSame('unchanged file content', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } +} diff --git a/tests/FeatureTests/Endpoint/File/GetFileRangeOnRelationTest.php b/tests/FeatureTests/Endpoint/File/GetFileRangeOnRelationTest.php new file mode 100644 index 00000000..2675bb46 --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/GetFileRangeOnRelationTest.php @@ -0,0 +1,228 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'get-file-range-on-relation-start', + ], + ] + ); + $startNodeId = $this->getUuidFromLocation($startNode); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'get-file-range-on-relation-end', + ], + ] + ); + $endNodeId = $this->getUuidFromLocation($endNode); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'get-file-range-on-relation', + ], + ] + ); + $relationId = $this->getUuidFromLocation($relation); + + $filePath = __DIR__.'/../../Asset/get-file-range-on-relation.bin'; + $this->generateDeterministicFile(24681012, self::CONTENT_LENGTH, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $uploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($uploadResponse, false); + unlink($filePath); + + self::$relationId = $relationId; + + return $relationId; + } + + private function getFullBody(): string + { + $response = $this->runGetRequest(sprintf('/%s/file', $this->getRelationId()), self::TOKEN); + $this->assertIsBinaryStreamResponse($response, 'text/plain'); + + return (string) $response->getBody(); + } + + public function testExplicitRangeReturnsPartialContent(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', $this->getRelationId()), + self::TOKEN, + ['Range' => 'bytes=0-99'] + ); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame(['100'], $response->getHeader('Content-Length')); + $this->assertSame([sprintf('bytes 0-99/%d', self::CONTENT_LENGTH)], $response->getHeader('Content-Range')); + $this->assertSame(['bytes'], $response->getHeader('Accept-Ranges')); + + $body = (string) $response->getBody(); + $this->assertSame(100, strlen($body)); + $this->assertSame(substr($this->getFullBody(), 0, 100), $body); + } + + public function testOpenEndedRangeReturnsBytesUntilEndOfFile(): void + { + $start = self::CONTENT_LENGTH - 50; + + $response = $this->runGetRequest( + sprintf('/%s/file', $this->getRelationId()), + self::TOKEN, + ['Range' => sprintf('bytes=%d-', $start)] + ); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame( + [sprintf('bytes %d-%d/%d', $start, self::CONTENT_LENGTH - 1, self::CONTENT_LENGTH)], + $response->getHeader('Content-Range') + ); + + $body = (string) $response->getBody(); + $this->assertSame(50, strlen($body)); + $this->assertSame(substr($this->getFullBody(), $start), $body); + } + + public function testSuffixRangeReturnsLastBytesOfFile(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', $this->getRelationId()), + self::TOKEN, + ['Range' => 'bytes=-64'] + ); + + $this->assertSame(206, $response->getStatusCode()); + $expectedStart = self::CONTENT_LENGTH - 64; + $this->assertSame( + [sprintf('bytes %d-%d/%d', $expectedStart, self::CONTENT_LENGTH - 1, self::CONTENT_LENGTH)], + $response->getHeader('Content-Range') + ); + + $body = (string) $response->getBody(); + $this->assertSame(64, strlen($body)); + $this->assertSame(substr($this->getFullBody(), -64), $body); + } + + public function testRangeStartBeyondEndOfFileReturnsRangeNotSatisfiable(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', $this->getRelationId()), + self::TOKEN, + ['Range' => 'bytes=999999999-'] + ); + + $this->assertIsProblemResponse($response, 416); + $body = \Safe\json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('totalLength', $body); + $this->assertSame(self::CONTENT_LENGTH, $body['totalLength']); + } + + public function testRequestWithoutRangeHeaderAdvertisesAcceptRanges(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', $this->getRelationId()), self::TOKEN); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['bytes'], $response->getHeader('Accept-Ranges')); + } + + public function testIfRangeWithCurrentEtagServesPartialContent(): void + { + $etag = $this->runGetRequest(sprintf('/%s/file', $this->getRelationId()), self::TOKEN)->getHeader('ETag')[0]; + + $response = $this->runGetRequest( + sprintf('/%s/file', $this->getRelationId()), + self::TOKEN, + ['Range' => 'bytes=0-99', 'If-Range' => $etag] + ); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame([sprintf('bytes 0-99/%d', self::CONTENT_LENGTH)], $response->getHeader('Content-Range')); + $this->assertSame(100, strlen((string) $response->getBody())); + } + + public function testIfRangeWithStaleEtagServesFullFile(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', $this->getRelationId()), + self::TOKEN, + ['Range' => 'bytes=0-99', 'If-Range' => '"stale-etag"'] + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([], $response->getHeader('Content-Range')); + $this->assertSame(self::CONTENT_LENGTH, strlen((string) $response->getBody())); + } + + public function testIfRangeWithWeakEtagOrDateServesFullFile(): void + { + $etag = $this->runGetRequest(sprintf('/%s/file', $this->getRelationId()), self::TOKEN)->getHeader('ETag')[0]; + + foreach (['W/'.$etag, 'Wed, 21 Oct 2015 07:28:00 GMT'] as $ifRange) { + $response = $this->runGetRequest( + sprintf('/%s/file', $this->getRelationId()), + self::TOKEN, + ['Range' => 'bytes=0-99', 'If-Range' => $ifRange] + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(self::CONTENT_LENGTH, strlen((string) $response->getBody())); + } + } + + public function testIfRangeWithoutRangeIsIgnored(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', $this->getRelationId()), + self::TOKEN, + ['If-Range' => '"stale-etag"'] + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(self::CONTENT_LENGTH, strlen((string) $response->getBody())); + } +} diff --git a/tests/FeatureTests/Endpoint/File/GetFileRangeTest.php b/tests/FeatureTests/Endpoint/File/GetFileRangeTest.php index 3e71c78e..58da2b6b 100644 --- a/tests/FeatureTests/Endpoint/File/GetFileRangeTest.php +++ b/tests/FeatureTests/Endpoint/File/GetFileRangeTest.php @@ -5,13 +5,11 @@ namespace App\Tests\FeatureTests\Endpoint\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** * Exercises the `Range` header on `GET //file` against the read-only "Rose" image from the * "general.botanicExample" reference dataset scenario (138937 bytes). */ -#[Group('test')] class GetFileRangeTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -113,8 +111,8 @@ public function testRangeStartBeyondEndOfFileReturnsRangeNotSatisfiable(): void $this->assertIsProblemResponse($response, 416); $body = \Safe\json_decode((string) $response->getBody(), true); - $this->assertArrayHasKey('total-length', $body); - $this->assertSame(self::ROSE_CONTENT_LENGTH, $body['total-length']); + $this->assertArrayHasKey('totalLength', $body); + $this->assertSame(self::ROSE_CONTENT_LENGTH, $body['totalLength']); } public function testMalformedRangeHeaderReturnsBadContent(): void @@ -146,4 +144,102 @@ public function testRequestWithoutRangeHeaderAdvertisesAcceptRanges(): void $this->assertSame(200, $response->getStatusCode()); $this->assertSame(['bytes'], $response->getHeader('Accept-Ranges')); } + + public function testIfRangeWithCurrentEtagServesPartialContent(): void + { + $etag = $this->runGetRequest(sprintf('/%s/file', self::ROSE_ID), self::TOKEN)->getHeader('ETag')[0]; + + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=0-99', 'If-Range' => $etag] + ); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame([sprintf('bytes 0-99/%d', self::ROSE_CONTENT_LENGTH)], $response->getHeader('Content-Range')); + $this->assertSame(100, strlen((string) $response->getBody())); + } + + public function testIfRangeWithStaleEtagServesFullFile(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=0-99', 'If-Range' => '"stale-etag"'] + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([], $response->getHeader('Content-Range')); + $this->assertSame(self::ROSE_CONTENT_LENGTH, strlen((string) $response->getBody())); + } + + public function testIfRangeWithWeakEtagOrDateServesFullFile(): void + { + $etag = $this->runGetRequest(sprintf('/%s/file', self::ROSE_ID), self::TOKEN)->getHeader('ETag')[0]; + + foreach (['W/'.$etag, 'Wed, 21 Oct 2015 07:28:00 GMT'] as $ifRange) { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=0-99', 'If-Range' => $ifRange] + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(self::ROSE_CONTENT_LENGTH, strlen((string) $response->getBody())); + } + } + + public function testIfRangeWithoutRangeIsIgnored(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['If-Range' => '"stale-etag"'] + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(self::ROSE_CONTENT_LENGTH, strlen((string) $response->getBody())); + } + + public function testPartialContentResponseCarriesTheSameFileEtagAsTheFullResponse(): void + { + $fullResponse = $this->runGetRequest(sprintf('/%s/file', self::ROSE_ID), self::TOKEN); + $fullEtag = $fullResponse->getHeader('ETag')[0]; + + $rangeResponse = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=0-99'] + ); + + $this->assertSame(206, $rangeResponse->getStatusCode()); + $this->assertCount(1, $rangeResponse->getHeader('ETag')); + $this->assertSame($fullEtag, $rangeResponse->getHeader('ETag')[0]); + } + + public function testRangeRequestWithMatchingIfNoneMatchReturnsNotModifiedInsteadOfPartialContent(): void + { + $etag = $this->runGetRequest(sprintf('/%s/file', self::ROSE_ID), self::TOKEN)->getHeader('ETag')[0]; + + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=0-99', 'If-None-Match' => $etag] + ); + + $this->assertNotModifiedResponse($response); + $this->assertSame([], $response->getHeader('Content-Range')); + } + + public function testRangeRequestWithNonMatchingIfNoneMatchStillReturnsPartialContent(): void + { + $response = $this->runGetRequest( + sprintf('/%s/file', self::ROSE_ID), + self::TOKEN, + ['Range' => 'bytes=0-99', 'If-None-Match' => '"staleEtagWhichNeverMatches"'] + ); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame(100, strlen((string) $response->getBody())); + } } diff --git a/tests/FeatureTests/Endpoint/File/GetFileTest.php b/tests/FeatureTests/Endpoint/File/GetFileTest.php index 1dc4d1b3..0474cd2a 100644 --- a/tests/FeatureTests/Endpoint/File/GetFileTest.php +++ b/tests/FeatureTests/Endpoint/File/GetFileTest.php @@ -5,9 +5,7 @@ namespace App\Tests\FeatureTests\Endpoint\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; -#[Group('test')] class GetFileTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -26,19 +24,100 @@ public function testGetFileOnNodeWithoutFile(): void $this->assertIsProblemResponse($response, 404); } - /** - * @todo - */ public function testGetFileOnRelationWithFile(): void { - $this->markTestSkipped(); + $startNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'get-file-on-relation-with-file-start', + ], + ] + ); + $startNodeId = $this->getUuidFromLocation($startNode); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'get-file-on-relation-with-file-end', + ], + ] + ); + $endNodeId = $this->getUuidFromLocation($endNode); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'get-file-on-relation-with-file', + ], + ] + ); + $relationId = $this->getUuidFromLocation($relation); + + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $uploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($uploadResponse, false); + + $response = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsBinaryStreamResponse($response, 'image/jpeg'); } - /** - * @todo - */ public function testGetFileOnRelationWithoutFile(): void { - $this->markTestSkipped(); + $startNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'get-file-on-relation-without-file-start', + ], + ] + ); + $startNodeId = $this->getUuidFromLocation($startNode); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'get-file-on-relation-without-file-end', + ], + ] + ); + $endNodeId = $this->getUuidFromLocation($endNode); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'get-file-on-relation-without-file', + ], + ] + ); + $relationId = $this->getUuidFromLocation($relation); + + $response = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsProblemResponse($response, 404); } } diff --git a/tests/FeatureTests/Endpoint/File/PostFileCreationLockTest.php b/tests/FeatureTests/Endpoint/File/PostFileCreationLockTest.php new file mode 100644 index 00000000..d1a8cab5 --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/PostFileCreationLockTest.php @@ -0,0 +1,159 @@ +/file` creates the file: it reserves the element in Redis (`file:create:`) while it runs and fails if + * the element has an upload in progress. `PUT //file` does not take the lock, but respects it. + */ +class PostFileCreationLockTest extends BaseRequestTestCase +{ + private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; + private const int MIN_CHUNK_SIZE = 5 * 1024 * 1024; + + private function createElement(): string + { + return $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'post-file-creation-lock'], + ])); + } + + private function deleteElement(string $elementId): void + { + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + private function getRedis(): RedisClient + { + return new RedisClient($_ENV['REDIS_AUTH']); + } + + private function lockKey(string $elementId): string + { + return (string) (new RedisKeyFactory())->getFileCreationLockRedisKey(Uuid::fromString($elementId)); + } + + /** + * @param array $headers + */ + private function upload(string $method, string $elementId, string $body, array $headers = []): ResponseInterface + { + return $this->runUploadRequest($method, sprintf('/%s/file', $elementId), $body, self::TOKEN, $headers); + } + + public function testPostWhileCreationLockIsHeldFails(): void + { + $elementId = $this->createElement(); + $redis = $this->getRedis(); + $redis->set($this->lockKey($elementId), 'other-request', 'PX', 60000); + + $response = $this->upload('POST', $elementId, 'some content'); + $this->assertIsProblemResponse($response, 409); + // the lock of the other request is untouched + $this->assertSame('other-request', $redis->get($this->lockKey($elementId))); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + + $redis->del([$this->lockKey($elementId)]); + $this->assertIsCreatedResponse($this->upload('POST', $elementId, 'some content'), false); + + $this->deleteElement($elementId); + } + + public function testPostReleasesLockAfterSuccess(): void + { + $elementId = $this->createElement(); + + $this->assertIsCreatedResponse($this->upload('POST', $elementId, 'some content'), false); + $this->assertSame(0, $this->getRedis()->exists($this->lockKey($elementId))); + + $this->deleteElement($elementId); + } + + public function testPostReleasesLockAfterFailedRequest(): void + { + $elementId = $this->createElement(); + + $wrongDigest = sprintf('sha-256=:%s:', base64_encode(str_repeat('a', 32))); + $response = $this->upload('POST', $elementId, 'some content', ['Repr-Digest' => $wrongDigest]); + $this->assertIsProblemResponse($response, 400); + + // no waiting for the lock to expire + $this->assertSame(0, $this->getRedis()->exists($this->lockKey($elementId))); + $this->assertIsCreatedResponse($this->upload('POST', $elementId, 'some content'), false); + + $this->deleteElement($elementId); + } + + public function testPostReleasesLockAfterElementAlreadyHasFile(): void + { + $elementId = $this->createElement(); + $this->assertIsCreatedResponse($this->upload('POST', $elementId, 'some content'), false); + + $this->assertIsProblemResponse($this->upload('POST', $elementId, 'other content'), 409); + $this->assertSame(0, $this->getRedis()->exists($this->lockKey($elementId))); + + $this->deleteElement($elementId); + } + + public function testPostWhileUploadTargetsElementFails(): void + { + $elementId = $this->createElement(); + + $createResponse = $this->upload('POST', $elementId, str_repeat('a', self::MIN_CHUNK_SIZE), [ + 'Upload-Complete' => '?0', + 'Upload-Length' => self::MIN_CHUNK_SIZE * 2, + 'Content-Type' => 'application/octet-stream', + ]); + $this->assertSame(204, $createResponse->getStatusCode()); + $uploadId = $this->getUuidFromLocation($createResponse); + // creating the upload does not leave a lock behind + $this->assertSame(0, $this->getRedis()->exists($this->lockKey($elementId))); + + $response = $this->upload('POST', $elementId, 'some content'); + $this->assertIsProblemResponse($response, 409); + $this->assertSame(0, $this->getRedis()->exists($this->lockKey($elementId))); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)); + $this->assertIsCreatedResponse($this->upload('POST', $elementId, 'some content'), false); + + $this->deleteElement($elementId); + } + + public function testPutWhileCreationLockIsHeldFails(): void + { + $elementId = $this->createElement(); + $redis = $this->getRedis(); + $redis->set($this->lockKey($elementId), 'other-request', 'PX', 60000); + + $this->assertIsProblemResponse($this->upload('PUT', $elementId, 'some content'), 409); + // PUT never takes or removes the lock + $this->assertSame('other-request', $redis->get($this->lockKey($elementId))); + + $redis->del([$this->lockKey($elementId)]); + $this->assertIsCreatedResponse($this->upload('PUT', $elementId, 'some content'), false); + + $this->deleteElement($elementId); + } + + public function testPutReplacesExistingFileWithoutLock(): void + { + $elementId = $this->createElement(); + $this->assertIsCreatedResponse($this->upload('POST', $elementId, 'some content'), false); + + $response = $this->upload('PUT', $elementId, 'replaced content'); + $this->assertContains($response->getStatusCode(), [200, 201, 204]); + $this->assertSame('replaced content', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + $this->assertSame(0, $this->getRedis()->exists($this->lockKey($elementId))); + + $this->deleteElement($elementId); + } +} diff --git a/tests/FeatureTests/Endpoint/File/PostFileEtagOnRelationTest.php b/tests/FeatureTests/Endpoint/File/PostFileEtagOnRelationTest.php new file mode 100644 index 00000000..28dbaa8b --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/PostFileEtagOnRelationTest.php @@ -0,0 +1,76 @@ + $additionalHeaders + */ + private function postFile(string $relationId, array $additionalHeaders = []): mixed + { + $filePath = __DIR__.'/../../Asset/post-file-etag-relation.bin'; + $this->generateDeterministicFile(91827364, 64, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN, + array_merge(['Content-Type' => 'application/octet-stream'], $additionalHeaders) + ); + unlink($filePath); + + return $response; + } + + public function testPostFileIsRejectedWhenIfMatchDoesNotMatchOnRelation(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'post-file-etag-if-match-mismatch-relation'); + + $this->assertIsProblemResponse($this->postFile($relationId, ['If-Match' => '"definitelyNotTheEtag"']), 412); + + // the precondition failed, so no file may have been created + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN), 404); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } + + public function testPostFileIsRejectedWhenIfMatchCarriesTheRelationEtag(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'post-file-etag-relation-etag-does-not-apply'); + + $relationEtag = $this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN)->getHeader('Etag')[0]; + + $this->assertIsProblemResponse($this->postFile($relationId, ['If-Match' => $relationEtag]), 412); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } + + public function testPostFileWithoutPreconditionHeadersStillWorksOnRelation(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'post-file-etag-no-precondition-relation'); + + $this->assertIsCreatedResponse($this->postFile($relationId), false); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } + + public function testPostFileProceedsWhenIfNoneMatchDoesNotMatchOnRelation(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'post-file-etag-if-none-match-relation'); + + $this->assertIsCreatedResponse($this->postFile($relationId, ['If-None-Match' => '"definitelyNotTheEtag"']), false); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } +} diff --git a/tests/FeatureTests/Endpoint/File/PostFileEtagTest.php b/tests/FeatureTests/Endpoint/File/PostFileEtagTest.php new file mode 100644 index 00000000..4a2ab8ef --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/PostFileEtagTest.php @@ -0,0 +1,101 @@ +/file` evaluates ETag preconditions. Unlike PUT/DELETE, POST only targets elements + * without a file, whose file etag a client can not obtain (`GET //file` answers 404), so `If-Match` can be + * rejected but never satisfied. + */ +class PostFileEtagTest extends BaseRequestTestCase +{ + private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; + + private function createElement(string $name): string + { + $response = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name, + ], + ] + ); + + return $this->getUuidFromLocation($response); + } + + /** + * @param array $additionalHeaders + */ + private function postFile(string $elementId, array $additionalHeaders = []): mixed + { + $filePath = __DIR__.'/../../Asset/post-file-etag.bin'; + $this->generateDeterministicFile(19283746, 64, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $file, + self::TOKEN, + array_merge(['Content-Type' => 'application/octet-stream'], $additionalHeaders) + ); + unlink($filePath); + + return $response; + } + + public function testPostFileIsRejectedWhenIfMatchDoesNotMatch(): void + { + $elementId = $this->createElement('post-file-etag-if-match-mismatch'); + + $response = $this->postFile($elementId, ['If-Match' => '"definitelyNotTheEtag"']); + $this->assertIsProblemResponse($response, 412); + + // the precondition failed, so no file may have been created + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testPostFileIsRejectedWhenIfMatchCarriesTheElementEtag(): void + { + $elementId = $this->createElement('post-file-etag-element-etag-does-not-apply'); + + $elementEtag = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN)->getHeader('Etag')[0]; + + // the element etag is not the file etag, even for an element which has no file: calculateFileEtag() + // derives its own value from the element etag rather than reusing it + $this->assertIsProblemResponse($this->postFile($elementId, ['If-Match' => $elementEtag]), 412); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testPostFileWithoutPreconditionHeadersStillWorks(): void + { + $elementId = $this->createElement('post-file-etag-no-precondition'); + + $this->assertIsCreatedResponse($this->postFile($elementId), false); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + /** + * A non-matching If-None-Match is a precondition which passes, so the request proceeds normally. + */ + public function testPostFileProceedsWhenIfNoneMatchDoesNotMatch(): void + { + $elementId = $this->createElement('post-file-etag-if-none-match'); + + $response = $this->postFile($elementId, ['If-None-Match' => '"definitelyNotTheEtag"']); + $this->assertIsCreatedResponse($response, false); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/Endpoint/File/PostFileInSingleRequestTest.php b/tests/FeatureTests/Endpoint/File/PostFileInSingleRequestTest.php index 6cf51347..297ef72c 100644 --- a/tests/FeatureTests/Endpoint/File/PostFileInSingleRequestTest.php +++ b/tests/FeatureTests/Endpoint/File/PostFileInSingleRequestTest.php @@ -5,16 +5,13 @@ namespace App\Tests\FeatureTests\Endpoint\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; -#[Group('test')] class PostFileInSingleRequestTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; public function testPostFileToNode(): void { - // create new node for file upload $node = $this->runPostRequest( '/', self::TOKEN, @@ -40,7 +37,6 @@ public function testPostFileToNode(): void public function testPostFileToNodeWithExistingFileFails(): void { - // create new node for file upload $node = $this->runPostRequest( '/', self::TOKEN, diff --git a/tests/FeatureTests/Endpoint/File/PutFileInSingleRequestTest.php b/tests/FeatureTests/Endpoint/File/PutFileInSingleRequestTest.php index 5819ebae..c2e7ed7b 100644 --- a/tests/FeatureTests/Endpoint/File/PutFileInSingleRequestTest.php +++ b/tests/FeatureTests/Endpoint/File/PutFileInSingleRequestTest.php @@ -5,16 +5,13 @@ namespace App\Tests\FeatureTests\Endpoint\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; -#[Group('test')] class PutFileInSingleRequestTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; public function testPutFileToNode(): void { - // create new node for file upload $node = $this->runPostRequest( '/', self::TOKEN, @@ -40,7 +37,6 @@ public function testPutFileToNode(): void public function testPutFileToNodeWithExistingFileWorks(): void { - // create new node for file upload $node = $this->runPostRequest( '/', self::TOKEN, diff --git a/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionOnRelationTest.php b/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionOnRelationTest.php new file mode 100644 index 00000000..74a79bdd --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionOnRelationTest.php @@ -0,0 +1,60 @@ +createEphemeralRelation(self::TOKEN, 'replace-file-with-different-extension-relation'); + + $originalFile = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $originalUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $originalFile, + self::TOKEN, + ['Content-Disposition' => 'attachment; filename=original.jpg'] + ); + $this->assertIsCreatedResponse($originalUploadResponse, false); + + $originalDownloadResponse = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsBinaryStreamResponse($originalDownloadResponse, 'image/jpeg'); + $this->assertStringContainsString('.jpg', $originalDownloadResponse->getHeader('Content-Disposition')[0]); + + $replacementFilePath = __DIR__.'/../../Asset/replace-file-with-different-extension-relation.bin'; + $this->generateDeterministicFile(13572468, 2048, $replacementFilePath); + $replacementContent = \Safe\file_get_contents($replacementFilePath); + $replacementFile = \Safe\fopen($replacementFilePath, 'r'); + + $replaceResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $relationId), + $replacementFile, + self::TOKEN, + ['Content-Disposition' => 'attachment; filename=replacement.txt'] + ); + unlink($replacementFilePath); + $this->assertIsCreatedResponse($replaceResponse, false); + + $replacementDownloadResponse = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsBinaryStreamResponse($replacementDownloadResponse, 'text/plain'); + $this->assertStringContainsString('.txt', $replacementDownloadResponse->getHeader('Content-Disposition')[0]); + $this->assertSame($replacementContent, (string) $replacementDownloadResponse->getBody()); + + $body = $this->getBody($this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN)); + $this->assertSame('txt', $body['file']['extension']); + $this->assertSame(hash('sha256', $replacementContent), $body['file']['hash']['sha256']); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } +} diff --git a/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionTest.php b/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionTest.php index 6187a0bd..f9f2a0c9 100644 --- a/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionTest.php +++ b/tests/FeatureTests/Endpoint/File/ReplaceFileWithDifferentExtensionTest.php @@ -5,18 +5,12 @@ namespace App\Tests\FeatureTests\Endpoint\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** * A file's storage key includes its extension (see FileService::getStorageBucketKey()), so replacing a file with - * one of a *different* extension writes to a different key than the original - the original has to be explicitly - * deleted (see UploadFileOperationFactory/S3Service), or it would be left as a permanent orphan, unlike a - * same-extension replace which naturally overwrites in place. This proves the client-observable side end-to-end: - * after such a replace, only the new content/type is ever served, under the new extension. Whether the old S3 - * object is actually gone (rather than merely unreferenced) isn't observable through the public API at all - that - * part is covered at the unit level instead (see S3ServiceTest::testUploadFileDeletesPreviousFileIfItExists()). + * one of a different extension writes to a new key. Verifies that only the new content and extension are served + * afterwards; deletion of the old S3 object is covered by S3ServiceTest::testUploadFileDeletesPreviousFileIfItExists(). */ -#[Group('test')] class ReplaceFileWithDifferentExtensionTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; diff --git a/tests/FeatureTests/Endpoint/File/WildcardFileEtagTest.php b/tests/FeatureTests/Endpoint/File/WildcardFileEtagTest.php new file mode 100644 index 00000000..ace9f620 --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/WildcardFileEtagTest.php @@ -0,0 +1,102 @@ + $additionalHeaders + */ + private function sendFile(string $method, string $elementId, string $content, array $additionalHeaders = []): mixed + { + return $this->runUploadRequest( + $method, + sprintf('/%s/file', $elementId), + $content, + self::TOKEN, + array_merge(['Content-Type' => 'application/octet-stream'], $additionalHeaders) + ); + } + + private function createNode(string $name): string + { + return $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => $name], + ])); + } + + private function assertWildcardBehaviour(string $elementId): void + { + // no file yet: If-Match can not be satisfied, If-None-Match is + $this->assertIsProblemResponse($this->sendFile('POST', $elementId, 'content', ['If-Match' => '*']), 412); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + $this->assertIsCreatedResponse($this->sendFile('POST', $elementId, 'content', ['If-None-Match' => '*']), false); + + // file exists now: If-None-Match: * fails, for reading it means 'not modified' + $this->assertIsProblemResponse($this->sendFile('POST', $elementId, 'other', ['If-None-Match' => '*']), 412); + $this->assertIsProblemResponse($this->sendFile('PUT', $elementId, 'other', ['If-None-Match' => '*']), 412); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-None-Match' => '*']), 412); + $this->assertNotModifiedResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-None-Match' => '*'])); + $this->assertSame('content', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + + // file exists now: If-Match: * is satisfied + $this->assertIsCreatedResponse($this->sendFile('PUT', $elementId, 'replaced', ['If-Match' => '*']), false); + $this->assertSame('replaced', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-Match' => '*'])->getBody()); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN, ['If-Match' => '*'])); + + // deleted again: no representation + $this->assertIsProblemResponse($this->sendFile('PUT', $elementId, 'again', ['If-Match' => '*']), 412); + } + + public function testWildcardOnNodeFile(): void + { + $nodeId = $this->createNode('wildcard-file-etag-node'); + + $this->assertWildcardBehaviour($nodeId); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $nodeId), self::TOKEN)); + } + + public function testWildcardOnRelationFile(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'wildcard-file-etag-relation'); + + $this->assertWildcardBehaviour($relationId); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } + + public function testDeleteFileOfElementWithoutFileReturns404(): void + { + $nodeId = $this->createNode('delete-file-without-file-node'); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/%s/file', $nodeId), self::TOKEN), 404); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $nodeId), self::TOKEN)); + + $relationId = $this->createEphemeralRelation(self::TOKEN, 'delete-file-without-file-relation'); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/%s/file', $relationId), self::TOKEN), 404); + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } + + public function testDeleteFileTwiceReturns404TheSecondTime(): void + { + $nodeId = $this->createNode('delete-file-twice'); + $this->assertIsCreatedResponse($this->sendFile('POST', $nodeId, 'content'), false); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s/file', $nodeId), self::TOKEN)); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/%s/file', $nodeId), self::TOKEN), 404); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $nodeId), self::TOKEN)); + } +} diff --git a/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadOnRelationTest.php b/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadOnRelationTest.php new file mode 100644 index 00000000..1ae41c5b --- /dev/null +++ b/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadOnRelationTest.php @@ -0,0 +1,41 @@ +createEphemeralRelation(self::TOKEN, 'zero-byte-direct-upload-relation'); + + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + '', + self::TOKEN, + ['Content-Type' => 'application/octet-stream'] + ); + $this->assertIsCreatedResponse($response, false); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertSame(200, $downloadResponse->getStatusCode()); + $this->assertSame(['0'], $downloadResponse->getHeader('Content-Length')); + $this->assertSame('', (string) $downloadResponse->getBody()); + $this->assertSame(['application/x-empty'], $downloadResponse->getHeader('Content-Type')); + + $body = $this->getBody($this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN)); + $this->assertSame(0, $body['file']['contentLength']); + $this->assertSame(hash('sha256', ''), $body['file']['hash']['sha256']); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } +} diff --git a/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadTest.php b/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadTest.php index 2d48b83b..7c2654da 100644 --- a/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadTest.php +++ b/tests/FeatureTests/Endpoint/File/ZeroByteFileUploadTest.php @@ -5,16 +5,11 @@ namespace App\Tests\FeatureTests\Endpoint\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * A zero-length file is a legitimate file (e.g. a placeholder, a "touch"ed marker, an intentionally emptied - * document) - nothing in the upload/download path requires non-empty content, so this must be supported, not - * rejected. In particular, MIME type sniffing (`MimeTypeService::getMimeTypeFromResource()`) must not crash on - * empty content: `finfo::buffer('')` returns the well-defined 'application/x-empty' rather than failing, so no - * special-cased "null mime type" is needed for this. + * Verifies that zero-length files can be uploaded and downloaded directly (non-resumable). MIME type sniffing + * returns 'application/x-empty' for empty content. */ -#[Group('test')] class ZeroByteFileUploadTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; diff --git a/tests/FeatureTests/Endpoint/System/MethodNotAllowedTest.php b/tests/FeatureTests/Endpoint/System/MethodNotAllowedTest.php new file mode 100644 index 00000000..e2fdd79c --- /dev/null +++ b/tests/FeatureTests/Endpoint/System/MethodNotAllowedTest.php @@ -0,0 +1,56 @@ +runDeleteRequest('/register', null); + + $this->assertIsProblemResponse($response, 405); + $body = \Safe\json_decode((string) $response->getBody(), true); + $this->assertStringEndsWith('/error/405/method-not-allowed', $body['type']); + $this->assertSame('Method not allowed', $body['title']); + $this->assertSame(405, $body['status']); + + // exactly one Allow header, listing only the methods of this route (not the global list of all methods) + $this->assertSame(['POST'], $response->getHeader('Allow')); + } + + public function testUnknownRouteStillReturnsNotFound(): void + { + $response = $this->runDeleteRequest('/this/route/does/not/exist', null); + + $this->assertIsProblemResponse($response, 404); + $this->assertNotSame(['POST'], $response->getHeader('Allow')); + } + + public function testResponseOfMatchedRouteListsOnlyMethodsOfThisRoute(): void + { + $response = $this->runGetRequest('/.well-known/security.txt', null); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['GET, HEAD, OPTIONS'], $response->getHeader('Allow')); + } + + public function testResponseOfElementRouteListsAllMethodsOfTheElementEndpoints(): void + { + // the element does not exist, the error response of a matched route carries the header as well + $response = $this->runGetRequest('/b4117ae0-1241-479f-b363-45f290ec7fc7', self::TOKEN); + + $this->assertIsProblemResponse($response, 404); + // all controllers for `/{id}`, including POST (create child) and the WebDAV methods + $this->assertSame( + ['COPY, DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PATCH, POST, PROPFIND, PROPPATCH, PUT, UNLOCK'], + $response->getHeader('Allow') + ); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/CancelUploadOnAccessLossTest.php b/tests/FeatureTests/Endpoint/Upload/CancelUploadOnAccessLossTest.php new file mode 100644 index 00000000..54fbd1cc --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/CancelUploadOnAccessLossTest.php @@ -0,0 +1,160 @@ +withDriver('bolt', $_ENV['CYPHER_AUTH'])->build(); + } + + /** + * @return array{0: string, 1: string} [targetId, uploadId], the upload has one stored chunk + */ + private function createTargetWithUpload(bool $onRelation): array + { + $targetId = $onRelation + ? $this->createEphemeralRelation(self::TOKEN, 'cancel-upload-on-access-loss') + : $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, ['type' => 'Data', 'data' => ['name' => 'cancel-upload-on-access-loss']])); + + $response = $this->runUploadRequest('POST', sprintf('/%s/file', $targetId), str_repeat('a', self::CHUNK_SIZE), self::TOKEN, [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ]); + $this->assertNoContentResponse($response, true); + $uploadId = $this->getUuidFromLocation($response); + $this->assertUploadExists($uploadId, true); + + return [$targetId, $uploadId]; + } + + /** + * @return string[] ids of the OWNS relations which grant the owner access to the nodes the target consists of + */ + private function getOwnsRelationIds(string $targetId, bool $onRelation): array + { + $nodeIds = [$targetId]; + if ($onRelation) { + $relation = $this->getBody($this->runGetRequest(sprintf('/%s', $targetId), self::TOKEN)); + $nodeIds = [$relation['start'], $relation['end']]; + } + $relationIds = []; + foreach ($nodeIds as $nodeId) { + $body = $this->getBody($this->runGetRequest(sprintf('/%s/parents', $nodeId), self::TOKEN)); + $relationIds[] = $body['relations'][0]['id']; + } + + return $relationIds; + } + + private function assertUploadExists(string $uploadId, bool $exists): void + { + $result = $this->getCypherClient()->run('MATCH (u:Upload {id: $id}) RETURN count(u) AS count', ['id' => $uploadId]); + $this->assertSame($exists ? 1 : 0, $result->first()->get('count')); + $this->assertSame($exists ? 1 : 0, $this->countUploadChunksInUploadBucket($uploadId)); + } + + /** + * @return array + */ + public static function targetProvider(): array + { + return ['node' => [false], 'relation' => [true]]; + } + + #[DataProvider('targetProvider')] + public function testRemovingAccessThroughApiCancelsUploadImmediately(bool $onRelation): void + { + [$targetId, $uploadId] = $this->createTargetWithUpload($onRelation); + $ownsRelationIds = $this->getOwnsRelationIds($targetId, $onRelation); + + // the upload must not be touched by any request, the cancellation is triggered by the removal alone + $this->assertUploadExists($uploadId, true); + foreach ($ownsRelationIds as $ownsRelationId) { + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $ownsRelationId), self::TOKEN)); + } + + $this->assertUploadExists($uploadId, false); + $this->assertSame(404, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN), 404); + } + + #[DataProvider('targetProvider')] + public function testUnrelatedRelationChangesKeepUpload(bool $onRelation): void + { + [$targetId, $uploadId] = $this->createTargetWithUpload($onRelation); + $otherId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, ['type' => 'Data', 'data' => ['name' => 'unrelated']])); + $otherOwns = $this->getOwnsRelationIds($otherId, false)[0]; + + // access is lost to another element only + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $otherOwns), self::TOKEN)); + + $this->assertUploadExists($uploadId, true); + $this->assertSame(204, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)); + if ($onRelation) { + $this->deleteEphemeralRelation(self::TOKEN, $targetId); + } else { + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $targetId), self::TOKEN)); + } + } + + /** + * @return array + */ + public static function lazyCancellationProvider(): array + { + $cases = []; + foreach (self::targetProvider() as $targetName => [$onRelation]) { + foreach (['HEAD', 'PATCH'] as $method) { + $cases[sprintf('%s via %s', $targetName, $method)] = [$onRelation, $method]; + } + } + + return $cases; + } + + #[DataProvider('lazyCancellationProvider')] + public function testUploadIsCancelledWhenAccessLossIsDetectedByRequest(bool $onRelation, string $method): void + { + [$targetId, $uploadId] = $this->createTargetWithUpload($onRelation); + $ownsRelationIds = $this->getOwnsRelationIds($targetId, $onRelation); + + // no events are fired for this, so only the request can notice it + $this->getCypherClient()->run('MATCH ()-[r:OWNS]->() WHERE r.id IN $ids DELETE r', ['ids' => $ownsRelationIds]); + $this->assertUploadExists($uploadId, true); + + if ('HEAD' === $method) { + $response = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(404, $response->getStatusCode()); + } else { + $response = $this->runUploadRequest('PATCH', sprintf('/upload/%s', $uploadId), 'data', self::TOKEN, [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => self::CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + ]); + $this->assertIsProblemResponse($response, 404); + } + + $this->assertUploadExists($uploadId, false); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/DeleteUploadTest.php b/tests/FeatureTests/Endpoint/Upload/DeleteUploadTest.php index 95f03025..9501812d 100644 --- a/tests/FeatureTests/Endpoint/Upload/DeleteUploadTest.php +++ b/tests/FeatureTests/Endpoint/Upload/DeleteUploadTest.php @@ -5,13 +5,11 @@ namespace App\Tests\FeatureTests\Endpoint\Upload; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** * Verifies that deleting an in-progress resumable upload removes it, so that further HEAD or PATCH requests * against it fail. */ -#[Group('test')] class DeleteUploadTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; diff --git a/tests/FeatureTests/Endpoint/Upload/DeletingElementCleansUpInProgressUploadTest.php b/tests/FeatureTests/Endpoint/Upload/DeletingElementCleansUpInProgressUploadTest.php index 0d890d24..bdd98b0f 100644 --- a/tests/FeatureTests/Endpoint/Upload/DeletingElementCleansUpInProgressUploadTest.php +++ b/tests/FeatureTests/Endpoint/Upload/DeletingElementCleansUpInProgressUploadTest.php @@ -5,17 +5,12 @@ namespace App\Tests\FeatureTests\Endpoint\Upload; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * `Upload.uploadTarget` (see UploadService) is a plain property, not a graph edge, so deleting an element does - * not automatically take any in-progress resumable upload(s) still targeting it along with it - without explicit - * cleanup, they would linger, still HEAD-able, until the `cron:delete-expired-uploads` grace period eventually - * catches up with them. `DeleteElementController` cleans these up immediately instead (see - * UploadService::deleteUploadsTargeting()); this covers that end-to-end, for both an upload with no chunks - * uploaded yet and one with an already-uploaded chunk. + * `Upload.uploadTarget` is a plain property, not a graph edge, so deleting an element does not remove uploads + * targeting it. Verifies that DeleteElementController cleans them up immediately (see + * UploadService::deleteUploadsTargeting()), both with and without already uploaded chunks. */ -#[Group('test')] class DeletingElementCleansUpInProgressUploadTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -90,8 +85,7 @@ public function testDeletingElementRemovesUploadWithAnAlreadyUploadedChunk(): vo $headResponseAfterDelete = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); $this->assertSame(404, $headResponseAfterDelete->getStatusCode()); - // the upload resource is really gone, not just inaccessible - a further attempt to continue it must - // report the same "not found" a client would get for any other unknown upload id, not a crash + // continuing the removed upload must answer 404 like any unknown upload id $patchResponse = $this->runUploadRequest( 'PATCH', sprintf('/upload/%s', $uploadId), @@ -105,4 +99,53 @@ public function testDeletingElementRemovesUploadWithAnAlreadyUploadedChunk(): vo ); $this->assertIsProblemResponse($patchResponse, 404); } + + /** + * The relation is removed together with the deleted node, uploads targeting it have to follow. + * + * @return array + */ + public static function deletedRelationEndpointProvider(): array + { + return [ + 'start node deleted' => ['start'], + 'end node deleted' => ['end'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('deletedRelationEndpointProvider')] + public function testDeletingNodeRemovesUploadTargetingAttachedRelation(string $deletedEndpoint): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, sprintf('delete-node-cleans-up-relation-upload-%s', $deletedEndpoint)); + $relation = $this->getBody($this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN)); + $deletedNodeId = $relation[$deletedEndpoint]; + $remainingNodeId = $relation['start' === $deletedEndpoint ? 'end' : 'start']; + + $filePath = __DIR__.'/../../Asset/delete-node-cleans-up-relation-upload.bin'; + $this->generateDeterministicFile(65432199, 5 * 1024 * 1024, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + unlink($filePath); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $this->assertSame(204, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); + $this->assertGreaterThan(0, $this->countUploadChunksInUploadBucket($uploadId)); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $deletedNodeId), self::TOKEN)); + + $this->assertSame(404, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); + $this->assertSame(0, $this->countUploadChunksInUploadBucket($uploadId)); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $remainingNodeId), self::TOKEN)); + } } diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadErrorsOnRelationTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadErrorsOnRelationTest.php new file mode 100644 index 00000000..8ef9c14d --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadErrorsOnRelationTest.php @@ -0,0 +1,153 @@ +generateDeterministicFile(crc32($assetName), self::CHUNK_SIZE, $filePath); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + unlink($filePath); + $this->assertNoContentResponse($response, true); + + return [ + $this->getUuidFromLocation($response), + (int) $response->getHeader('Upload-Offset')[0], + $response->getHeader('Upload-Limit')[0], + ]; + } + + private function assertUploadOffset(string $uploadId, int $expectedOffset): void + { + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) $expectedOffset, $headResponse->getHeader('Upload-Offset')[0]); + } + + public function testPatchUploadWithIncorrectOffsetReturnsConflictOnRelation(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'patch-upload-incorrect-offset-relation'); + [$uploadId, $offset] = $this->createResumableUploadWithOneChunk($relationId, 'patch-upload-incorrect-offset-relation.bin'); + + $incorrectOffset = $offset + 1; + $chunkPath = __DIR__.'/../../Asset/patch-upload-incorrect-offset-relation-chunk.bin'; + $this->generateDeterministicFile(35353535, 1024, $chunkPath); + $chunk = \Safe\fopen($chunkPath, 'r'); + + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $chunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => $incorrectOffset, + 'Content-Type' => 'application/partial-upload', + ] + ); + unlink($chunkPath); + $this->assertIsProblemResponse($response, 409); + $body = $this->getBody($response); + $this->assertSame($offset, $body['expectedOffset']); + $this->assertSame($incorrectOffset, $body['providedOffset']); + + $this->assertUploadOffset($uploadId, $offset); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN), 404); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } + + public function testOversizedFinalChunkIsRejectedOnRelation(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'patch-upload-oversized-chunk-relation'); + [$uploadId, $offset, $uploadLimit] = $this->createResumableUploadWithOneChunk($relationId, 'patch-upload-oversized-chunk-relation.bin'); + + $this->assertMatchesRegularExpression('/max-append-size=(\d+)/', $uploadLimit); + preg_match('/max-append-size=(\d+)/', $uploadLimit, $matches); + $maxAppendSizeInBytes = (int) $matches[1]; + + $chunkPath = __DIR__.'/../../Asset/patch-upload-oversized-chunk-relation-second.bin'; + $this->generateDeterministicFile(65746352, $maxAppendSizeInBytes + 1, $chunkPath); + $chunk = \Safe\fopen($chunkPath, 'r'); + + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $chunk, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => $offset, + 'Content-Type' => 'application/partial-upload', + ] + ); + unlink($chunkPath); + $this->assertIsProblemResponse($response, 400); + $this->assertStringContainsString((string) $maxAppendSizeInBytes, $this->getBody($response)['detail']); + + $this->assertUploadOffset($uploadId, $offset); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN), 404); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } + + public function testUndersizedIntermediateChunkIsRejectedOnRelation(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'patch-upload-undersized-relation'); + [$uploadId, $offset] = $this->createResumableUploadWithOneChunk($relationId, 'patch-upload-undersized-relation.bin'); + + $chunkPath = __DIR__.'/../../Asset/patch-upload-undersized-relation-chunk.bin'; + $this->generateDeterministicFile(44332211, 1024, $chunkPath); + $chunk = \Safe\fopen($chunkPath, 'r'); + + $response = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $chunk, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Upload-Offset' => $offset, + 'Content-Type' => 'application/partial-upload', + ] + ); + unlink($chunkPath); + $this->assertIsProblemResponse($response, 400); + + $this->assertUploadOffset($uploadId, $offset); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadIncorrectOffsetTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadIncorrectOffsetTest.php index cdc5378a..83357ebb 100644 --- a/tests/FeatureTests/Endpoint/Upload/PatchUploadIncorrectOffsetTest.php +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadIncorrectOffsetTest.php @@ -5,13 +5,11 @@ namespace App\Tests\FeatureTests\Endpoint\Upload; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** * Verifies that continuing a resumable upload with an Upload-Offset which does not match the upload's actual * offset results in the correct conflict error. */ -#[Group('test')] class PatchUploadIncorrectOffsetTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -70,8 +68,8 @@ public function testPatchUploadWithIncorrectOffsetReturnsConflict(): void ); $this->assertIsProblemResponse($patchResponse, 409); $body = $this->getBody($patchResponse); - $this->assertSame($actualOffset, $body['expected-offset']); - $this->assertSame($incorrectOffset, $body['provided-offset']); + $this->assertSame($actualOffset, $body['expectedOffset']); + $this->assertSame($incorrectOffset, $body['providedOffset']); $this->cleanupChunks($chunks); unlink(self::FILE_PATH); diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadMissingUploadCompleteTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadMissingUploadCompleteTest.php new file mode 100644 index 00000000..a1039738 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadMissingUploadCompleteTest.php @@ -0,0 +1,85 @@ + + */ + public static function targetProvider(): array + { + return ['node' => [false], 'relation' => [true]]; + } + + #[DataProvider('targetProvider')] + public function testPatchWithoutUploadCompleteHeaderIsRejected(bool $onRelation): void + { + if ($onRelation) { + $elementId = $this->createEphemeralRelation(self::TOKEN, 'patch-missing-upload-complete'); + } else { + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'patch-missing-upload-complete'], + ])); + } + + $this->generateDeterministicFile(80020001, self::CHUNK_SIZE + 1024, self::FILE_PATH); + $chunks = $this->splitFileToChunks(self::FILE_PATH, self::CHUNK_SIZE); + + $createResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + \Safe\fopen($chunks[0], 'r'), + self::TOKEN, + ['Upload-Complete' => '?0', 'Content-Type' => 'application/octet-stream'] + ); + $this->assertNoContentResponse($createResponse, true); + $uploadId = $this->getUuidFromLocation($createResponse); + + $patchResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + \Safe\fopen($chunks[1], 'r'), + self::TOKEN, + ['Upload-Offset' => self::CHUNK_SIZE, 'Content-Type' => 'application/partial-upload'] + ); + $this->assertIsProblemResponse($patchResponse, 400); + + // the upload is unchanged and can still be completed properly + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame((string) self::CHUNK_SIZE, $headResponse->getHeader('Upload-Offset')[0]); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + + $finishResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + \Safe\fopen($chunks[1], 'r'), + self::TOKEN, + ['Upload-Complete' => '?1', 'Upload-Offset' => self::CHUNK_SIZE, 'Content-Type' => 'application/partial-upload'] + ); + $this->assertNoContentResponse($finishResponse); + + $this->cleanupChunks($chunks); + unlink(self::FILE_PATH); + if ($onRelation) { + $this->deleteEphemeralRelation(self::TOKEN, $elementId); + } else { + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadOversizedChunkTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadOversizedChunkTest.php index 0e65ca38..cc91475f 100644 --- a/tests/FeatureTests/Endpoint/Upload/PatchUploadOversizedChunkTest.php +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadOversizedChunkTest.php @@ -5,16 +5,11 @@ namespace App\Tests\FeatureTests\Endpoint\Upload; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * Complements PatchUploadUndersizedIntermediateChunkTest: unlike the minimum chunk size, which is waived for the - * chunk that completes the upload, the maximum chunk size is not waived for anything - a single PATCH request is - * still a single S3 multipart part, which has a hard upper size limit regardless of whether it happens to be the - * last one. The configured limit is read from the `Upload-Limit` response header (`max-append-size`) rather than - * hardcoded, so this stays correct if the configuration changes. + * Verifies that PATCH chunks above the maximum chunk size are rejected, including the completing chunk, since each + * chunk becomes one S3 multipart part. The limit is read from the `Upload-Limit` header (`max-append-size`). */ -#[Group('test')] class PatchUploadOversizedChunkTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadStateErrorsTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadStateErrorsTest.php new file mode 100644 index 00000000..83efb064 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadStateErrorsTest.php @@ -0,0 +1,253 @@ +withDriver('bolt', $_ENV['CYPHER_AUTH']) + ->build(); + } + + private function setUploadProperty(string $uploadId, string $propertyExpression): void + { + $result = $this->getCypherClient()->run( + sprintf('MATCH (u:Upload {id: $id}) SET %s RETURN count(u) AS count', $propertyExpression), + ['id' => $uploadId] + ); + $this->assertSame(1, $result->first()->get('count')); + } + + private function createNode(string $name): string + { + return $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => $name], + ])); + } + + /** + * @param array $additionalHeaders + */ + private function createUpload(string $elementId, string $body, array $additionalHeaders = []): string + { + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $body, + self::TOKEN, + array_merge([ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ], $additionalHeaders) + ); + $this->assertNoContentResponse($response, true); + + return $this->getUuidFromLocation($response); + } + + private function patchUpload(string $uploadId, int $offset, string $body, bool $complete = true): mixed + { + return $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $body, + self::TOKEN, + [ + 'Upload-Complete' => $complete ? '?1' : '?0', + 'Upload-Offset' => $offset, + 'Content-Type' => 'application/partial-upload', + ] + ); + } + + private function assertUploadStillAtOffset(string $uploadId, int $offset): void + { + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame((string) $offset, $headResponse->getHeader('Upload-Offset')[0]); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + } + + private function cleanUp(string $uploadId, string $elementId): void + { + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + public function testPatchOfExpiredUploadReturns410(): void + { + $elementId = $this->createNode('patch-upload-expired'); + $uploadId = $this->createUpload($elementId, ''); + $this->assertUploadStillAtOffset($uploadId, 0); + + $this->setUploadProperty($uploadId, "u.expires = datetime() - duration('PT1H')"); + + $response = $this->patchUpload($uploadId, 0, 'some data'); + $this->assertIsProblemResponse($response, 410); + + // no file was created + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + + $this->cleanUp($uploadId, $elementId); + } + + public function testHeadOfExpiredUploadReturns410(): void + { + $elementId = $this->createNode('head-upload-expired'); + $uploadId = $this->createUpload($elementId, ''); + $this->assertUploadStillAtOffset($uploadId, 0); + + // expired, but not yet removed by the cron job: same answer as PATCH + $this->setUploadProperty($uploadId, "u.expires = datetime() - duration('PT1H')"); + + $this->assertSame(410, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); // HEAD responses have no body + + $this->cleanUp($uploadId, $elementId); + } + + public function testPatchOfNotYetExpiredUploadSucceeds(): void + { + $elementId = $this->createNode('patch-upload-not-expired'); + $uploadId = $this->createUpload($elementId, ''); + + // counterpart of the 410 test: shifting the expiration date into the future keeps the upload usable + $this->setUploadProperty($uploadId, "u.expires = datetime() + duration('PT1H')"); + + $this->assertNoContentResponse($this->patchUpload($uploadId, 0, 'some data')); + $this->assertSame('some data', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + public function testPatchExceedingUploadLengthReturns409(): void + { + $elementId = $this->createNode('patch-upload-exceeds-length'); + $uploadId = $this->createUpload($elementId, '', ['Upload-Length' => 10]); + $this->assertUploadStillAtOffset($uploadId, 0); + + $response = $this->patchUpload($uploadId, 0, str_repeat('a', 11)); + $this->assertIsProblemResponse($response, 409); + $this->assertStringContainsString('upload length', $this->getBody($response)['detail']); + + // the request does not match the declared length, so only the request is rejected: the upload stays as it was + // and can be completed by a corrected request + $this->assertUploadStillAtOffset($uploadId, 0); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + $this->assertNoContentResponse($this->patchUpload($uploadId, 0, str_repeat('a', 10))); + $this->assertSame(str_repeat('a', 10), (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + public function testPatchWithinUploadLengthSucceeds(): void + { + $elementId = $this->createNode('patch-upload-within-length'); + $uploadId = $this->createUpload($elementId, '', ['Upload-Length' => 10]); + + $this->assertNoContentResponse($this->patchUpload($uploadId, 0, str_repeat('a', 10))); + $this->assertSame(str_repeat('a', 10), (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN)->getBody()); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + /** + * @return array + */ + public static function unusableHashStateProvider(): array + { + return [ + 'not base64' => ['%%% not base64 %%%'], + 'base64 of no serialized data' => [base64_encode('this is not serialized data')], + 'serialized object of wrong class' => [base64_encode(serialize(new stdClass()))], + 'serialized scalar' => [base64_encode(serialize('string'))], + // crafted states: unserialize() is restricted to HashContext (allowed_classes), so other classes are never + // instantiated, not even as harmless objects; they end up as __PHP_Incomplete_Class and are rejected + 'serialized object of other class' => [base64_encode(serialize(new ArrayObject(['a' => 'b'])))], + 'serialized object of unknown class' => [base64_encode('O:22:"Evil\\NotExistingClass":1:{s:1:"a";i:1;}')], + 'serialized nested object of wrong class' => [base64_encode(serialize([new stdClass()]))], + ]; + } + + #[DataProvider('unusableHashStateProvider')] + public function testPatchWithUnusableStoredHashStateReturns409(string $hashState): void + { + $elementId = $this->createNode('patch-upload-broken-hash-state'); + // the first chunk stores the running hash state of the upload + $uploadId = $this->createUpload($elementId, str_repeat('a', self::CHUNK_SIZE)); + $this->assertUploadStillAtOffset($uploadId, self::CHUNK_SIZE); + + $this->setUploadProperty($uploadId, sprintf("u.hashState = '%s'", $hashState)); + + $response = $this->patchUpload($uploadId, self::CHUNK_SIZE, 'final chunk'); + $this->assertIsProblemResponse($response, 409); + $this->assertStringContainsString('restart the upload', $this->getBody($response)['detail']); + + // the upload was not advanced and no file was created + $this->assertUploadStillAtOffset($uploadId, self::CHUNK_SIZE); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + + $this->cleanUp($uploadId, $elementId); + } + + /** + * Simulates a crash between the compare-and-set in the graph and the write of the chunk list to MongoDB: the graph + * knows a chunk which the list does not contain. + */ + public function testPatchOfInconsistentUploadReturns409AndDeletesUpload(): void + { + $elementId = $this->createNode('patch-upload-inconsistent'); + $uploadId = $this->createUpload($elementId, str_repeat('a', self::CHUNK_SIZE)); + $this->assertUploadStillAtOffset($uploadId, self::CHUNK_SIZE); + $this->assertSame(1, $this->countUploadChunksInUploadBucket($uploadId)); + + $this->setUploadProperty($uploadId, "u.lastChunkId = 'ffffffffffffffff'"); + + $response = $this->patchUpload($uploadId, self::CHUNK_SIZE, 'final chunk'); + $this->assertIsProblemResponse($response, 409); + $this->assertStringContainsString('restart the upload', $this->getBody($response)['detail']); + + // the upload can never produce the file, so it and its chunks are gone + $this->assertSame(404, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); + $this->assertSame(0, $this->countUploadChunksInUploadBucket($uploadId)); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + public function testHeadOfInconsistentUploadReturns409AndDeletesUpload(): void + { + $elementId = $this->createNode('head-upload-inconsistent'); + $uploadId = $this->createUpload($elementId, str_repeat('a', self::CHUNK_SIZE)); + + $this->setUploadProperty($uploadId, "u.lastChunkId = 'ffffffffffffffff'"); + + $this->assertSame(409, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); // HEAD responses have no body + $this->assertSame(404, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); + $this->assertSame(0, $this->countUploadChunksInUploadBucket($uploadId)); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadUndersizedIntermediateChunkTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadUndersizedIntermediateChunkTest.php index 993bb5e4..e65633c7 100644 --- a/tests/FeatureTests/Endpoint/Upload/PatchUploadUndersizedIntermediateChunkTest.php +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadUndersizedIntermediateChunkTest.php @@ -5,21 +5,12 @@ namespace App\Tests\FeatureTests\Endpoint\Upload; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * The resumable upload draft standard does not itself mandate a minimum chunk size - that is a storage-backend - * concern (S3 requires every part of a multipart upload except the last to be at least 5 MiB). This API already - * enforces its configured minimum on an upload's very first chunk (see UploadCreationService); the same has to - * hold for every later chunk PATCHed onto an already-started upload, or a client could pad out an upload - * indefinitely with tiny (including zero-length) chunks that never make progress, without limit. - * - * The one exception, both here and for the very first chunk, is the chunk which completes the upload - * (`Upload-Complete: ?1`): S3's minimum part size rule is explicitly waived for the last part, and a client - * legitimately needs to be able to close out an upload with no additional data (see ZeroByteResumableUploadTest, - * ResumableUploadCreationTest) - so only intermediate (still-incomplete) chunks are size-checked here. + * Verifies that intermediate PATCH chunks below the minimum chunk size are rejected, as S3 requires every + * multipart part except the last to be at least 5 MiB. The completing chunk (`Upload-Complete: ?1`) is exempt, and so + * is a chunk of exactly 0 bytes, which is a no-op (see PatchUploadZeroLengthChunkTest). */ -#[Group('test')] class PatchUploadUndersizedIntermediateChunkTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -66,7 +57,7 @@ private function createResumableUploadWithOneChunk(string $elementId, string $as return [$this->getUuidFromLocation($response), (int) $response->getHeader('Upload-Offset')[0]]; } - public function testZeroLengthIntermediateChunkIsRejected(): void + public function testZeroLengthIntermediateChunkIsAcceptedAsNoOp(): void { $elementId = $this->createElement('patch-upload-undersized-zero'); [$uploadId, $offset] = $this->createResumableUploadWithOneChunk($elementId, 'patch-upload-undersized-zero.bin'); @@ -82,9 +73,10 @@ public function testZeroLengthIntermediateChunkIsRejected(): void 'Content-Type' => 'application/partial-upload', ] ); - $this->assertIsProblemResponse($response, 400); + $this->assertSame(204, $response->getStatusCode()); + $this->assertSame((string) $offset, $response->getHeader('Upload-Offset')[0]); - // the rejected chunk must not have advanced the upload's offset + // the empty chunk must not have advanced the upload's offset $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); $this->assertSame(204, $headResponse->getStatusCode()); $this->assertSame((string) $offset, $headResponse->getHeader('Upload-Offset')[0]); @@ -119,9 +111,7 @@ public function testUndersizedNonZeroIntermediateChunkIsRejected(): void } /** - * Sanity check for the exemption itself: a zero-length chunk which *completes* the upload must still be - * accepted (already covered end-to-end by ZeroByteResumableUploadTest and ResumableUploadCreationTest, but - * checked directly against this validation here too). + * A zero-length chunk which completes the upload must still be accepted. */ public function testZeroLengthFinalChunkIsStillAccepted(): void { diff --git a/tests/FeatureTests/Endpoint/Upload/PatchUploadZeroLengthChunkTest.php b/tests/FeatureTests/Endpoint/Upload/PatchUploadZeroLengthChunkTest.php new file mode 100644 index 00000000..375eba0f --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/PatchUploadZeroLengthChunkTest.php @@ -0,0 +1,174 @@ + + */ + public static function targetProvider(): array + { + return ['node' => [false], 'relation' => [true]]; + } + + /** + * @return array onRelation, chunk size, accepted + */ + public static function chunkSizeProvider(): array + { + $cases = []; + foreach (self::targetProvider() as $targetName => [$onRelation]) { + $cases[$targetName.', 0 bytes'] = [$onRelation, 0, true]; + $cases[$targetName.', 1 byte'] = [$onRelation, 1, false]; + $cases[$targetName.', minimum - 1 bytes'] = [$onRelation, self::MIN_CHUNK_SIZE - 1, false]; + $cases[$targetName.', minimum bytes'] = [$onRelation, self::MIN_CHUNK_SIZE, true]; + } + + return $cases; + } + + private function createTarget(bool $onRelation, string $name): string + { + if ($onRelation) { + return $this->createEphemeralRelation(self::TOKEN, $name); + } + + return $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => $name], + ])); + } + + private function deleteTarget(bool $onRelation, string $elementId): void + { + if ($onRelation) { + $this->deleteEphemeralRelation(self::TOKEN, $elementId); + } else { + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + } + + private function createUpload(string $elementId, string $body): ResponseInterface + { + return $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $body, + self::TOKEN, + ['Upload-Complete' => '?0', 'Content-Type' => 'application/octet-stream'] + ); + } + + private function patchUpload(string $uploadId, int $offset, string $body, bool $complete = false, ?string $digest = null): ResponseInterface + { + $headers = [ + 'Upload-Complete' => $complete ? '?1' : '?0', + 'Upload-Offset' => $offset, + 'Content-Type' => 'application/partial-upload', + ]; + if (null !== $digest) { + $headers['Repr-Digest'] = sprintf('sha-256=:%s:', base64_encode(\Safe\hex2bin($digest))); + } + + return $this->runUploadRequest('PATCH', sprintf('/upload/%s', $uploadId), $body, self::TOKEN, $headers); + } + + private function assertOffset(ResponseInterface $response, int $offset): void + { + $this->assertSame(204, $response->getStatusCode()); + $this->assertSame((string) $offset, $response->getHeader('Upload-Offset')[0]); + $this->assertSame('?0', $response->getHeader('Upload-Complete')[0]); + } + + #[DataProvider('chunkSizeProvider')] + public function testChunkSizesOnCreation(bool $onRelation, int $size, bool $accepted): void + { + $elementId = $this->createTarget($onRelation, 'zero-length-chunk-creation'); + + $response = $this->createUpload($elementId, str_repeat('a', $size)); + if (!$accepted) { + $this->assertIsProblemResponse($response, 400); + $this->deleteTarget($onRelation, $elementId); + + return; + } + $this->assertNoContentResponse($response, true); + $uploadId = $this->getUuidFromLocation($response); + $this->assertOffset($response, $size); + $this->assertOffset($this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN), $size); + // an empty first chunk creates the upload only + $this->assertSame(0 === $size ? 0 : 1, $this->countUploadChunksInUploadBucket($uploadId)); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)); + $this->deleteTarget($onRelation, $elementId); + } + + #[DataProvider('chunkSizeProvider')] + public function testChunkSizesOnPatch(bool $onRelation, int $size, bool $accepted): void + { + $elementId = $this->createTarget($onRelation, 'zero-length-chunk-patch'); + $uploadId = $this->getUuidFromLocation($this->createUpload($elementId, '')); + + $response = $this->patchUpload($uploadId, 0, str_repeat('b', $size)); + if ($accepted) { + $this->assertOffset($response, $size); + } else { + $this->assertIsProblemResponse($response, 400); + } + $expectedOffset = $accepted ? $size : 0; + $this->assertOffset($this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN), $expectedOffset); + $this->assertSame($accepted && $size > 0 ? 1 : 0, $this->countUploadChunksInUploadBucket($uploadId)); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)); + $this->deleteTarget($onRelation, $elementId); + } + + #[DataProvider('targetProvider')] + public function testEmptyChunksDoNotAlterOffsetHashStateOrChunksAndUploadStaysCompletable(bool $onRelation): void + { + $elementId = $this->createTarget($onRelation, 'zero-length-chunk-lifecycle'); + $firstChunk = str_repeat('c', self::MIN_CHUNK_SIZE); + $finalChunk = 'the final chunk'; + + $uploadId = $this->getUuidFromLocation($this->createUpload($elementId, $firstChunk)); + + // empty chunks as status check, repeatedly and between real chunks + $this->assertOffset($this->patchUpload($uploadId, self::MIN_CHUNK_SIZE, ''), self::MIN_CHUNK_SIZE); + $this->assertOffset($this->patchUpload($uploadId, self::MIN_CHUNK_SIZE, ''), self::MIN_CHUNK_SIZE); + $this->assertSame(1, $this->countUploadChunksInUploadBucket($uploadId)); + + // the offset of an empty chunk must still match + $this->assertIsProblemResponse($this->patchUpload($uploadId, 0, ''), 409); + + $secondChunk = str_repeat('d', self::MIN_CHUNK_SIZE); + $expectedDigest = hash('sha256', $firstChunk.$secondChunk.$finalChunk); + $this->assertOffset($this->patchUpload($uploadId, self::MIN_CHUNK_SIZE, $secondChunk), 2 * self::MIN_CHUNK_SIZE); + $this->assertOffset($this->patchUpload($uploadId, 2 * self::MIN_CHUNK_SIZE, ''), 2 * self::MIN_CHUNK_SIZE); + $this->assertSame(2, $this->countUploadChunksInUploadBucket($uploadId)); + + // the hash state was not altered by the empty chunks, so the digest of the whole file still verifies + $this->assertNoContentResponse($this->patchUpload($uploadId, 2 * self::MIN_CHUNK_SIZE, $finalChunk, true, $expectedDigest)); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame(200, $fileResponse->getStatusCode()); + $this->assertSame($expectedDigest, hash('sha256', (string) $fileResponse->getBody())); + $this->assertSame(0, $this->countUploadChunksInUploadBucket($uploadId)); + + $this->deleteTarget($onRelation, $elementId); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/ResumableUploadCreationTest.php b/tests/FeatureTests/Endpoint/Upload/ResumableUploadCreationTest.php index 078f0ace..07d74fa2 100644 --- a/tests/FeatureTests/Endpoint/Upload/ResumableUploadCreationTest.php +++ b/tests/FeatureTests/Endpoint/Upload/ResumableUploadCreationTest.php @@ -5,15 +5,11 @@ namespace App\Tests\FeatureTests\Endpoint\Upload; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * The resumable upload draft standard (the basis for this API's `POST`/`PATCH` upload endpoints) allows a - * resumable upload to be created either with an initial chunk of data, or with none at all - an empty body - * alongside `Upload-Complete: ?0`, which merely reserves the upload without transferring any bytes yet. This is - * explicitly permitted unless a server opts out of it; this API does not, so both forms are covered here. + * Verifies that a resumable upload can be created both with an initial chunk and with an empty body + * (`Upload-Complete: ?0`), as permitted by the resumable upload draft standard. */ -#[Group('test')] class ResumableUploadCreationTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -95,8 +91,7 @@ public function testResumableUploadCreationWithoutInitialData(): void $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); $this->assertSame('0', $headResponse->getHeader('Upload-Offset')[0]); - // an empty-data creation is a genuinely usable upload session, not just an accepted-but-broken stub: the - // whole file can still be appended and completed from offset 0 via PATCH + // the whole file can still be appended from offset 0 and completed via PATCH $filePath = __DIR__.'/../../Asset/resumable-upload-creation-without-data.bin'; $this->generateDeterministicFile(56473829, self::CHUNK_SIZE, $filePath); $file = \Safe\fopen($filePath, 'r'); diff --git a/tests/FeatureTests/Endpoint/Upload/ResumableUploadFinalizeRaceTest.php b/tests/FeatureTests/Endpoint/Upload/ResumableUploadFinalizeRaceTest.php new file mode 100644 index 00000000..9a7ead51 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/ResumableUploadFinalizeRaceTest.php @@ -0,0 +1,81 @@ +/file` (`Upload-Complete: ?0`) can be overtaken by a concurrent `PUT`, + * which is allowed to race and creates the file directly. Once that happens, the still-open upload can no longer + * complete safely: appending another chunk to it, or finishing it, has to be rejected instead of silently + * overwriting (or losing) the file the `PUT` just created. + */ +class ResumableUploadFinalizeRaceTest extends BaseRequestTestCase +{ + private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; + + private function createElement(): string + { + return $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'resumable-upload-finalize-race'], + ])); + } + + private function createOpenUpload(string $elementId): string + { + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($response, true); + + return $this->getUuidFromLocation($response); + } + + public function testChunkIsRejectedAfterAConcurrentPutCreatedTheFile(): void + { + $elementId = $this->createElement(); + $uploadId = $this->createOpenUpload($elementId); + + // a concurrent PUT is allowed to race the still-open upload and creates the file directly + $putResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + 'winner of the race', + self::TOKEN, + ['Content-Type' => 'text/plain'] + ); + $this->assertContains($putResponse->getStatusCode(), [200, 201, 204]); + + // the open upload can no longer complete safely: appending to it now has to be rejected + $patchResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($patchResponse, 409); + $body = $this->getBody($patchResponse); + $this->assertStringContainsString('already has an associated file', $body['detail']); + + // the file created by PUT is untouched by the rejected upload + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame('winner of the race', (string) $downloadResponse->getBody()); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleOnRelationTest.php b/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleOnRelationTest.php new file mode 100644 index 00000000..afb53d45 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleOnRelationTest.php @@ -0,0 +1,118 @@ +generateDeterministicFile(self::FILE_SEED, self::FILE_SIZE, self::FILE_PATH); + $chunks = $this->splitFileToChunks(self::FILE_PATH, self::CHUNK_SIZE); + + $startNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'resumable-upload-lifecycle-relation-start', + ], + ] + ); + $startNodeId = $this->getUuidFromLocation($startNode); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'resumable-upload-lifecycle-relation-end', + ], + ] + ); + $endNodeId = $this->getUuidFromLocation($endNode); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'resumable-upload-lifecycle-relation', + ], + ] + ); + $relationId = $this->getUuidFromLocation($relation); + + // create the upload with the first chunk + $chunk1 = \Safe\fopen($chunks[0], 'r'); + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $chunk1, + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Disposition' => 'inline; filename=resumable-upload-lifecycle-relation.bin', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $this->assertSame('?0', $createUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::CHUNK_SIZE, $createUploadResponse->getHeader('Upload-Offset')[0]); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + // check upload status through HEAD + $headResponse1 = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse1->getStatusCode()); + $this->assertSame('?0', $headResponse1->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::CHUNK_SIZE, $headResponse1->getHeader('Upload-Offset')[0]); + + // upload the second, final chunk + $chunk2 = \Safe\fopen($chunks[1], 'r'); + $finishUploadResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $chunk2, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => self::CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($finishUploadResponse); + $this->assertSame('?1', $finishUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) self::FILE_SIZE, $finishUploadResponse->getHeader('Upload-Offset')[0]); + + // upload resource is gone once finished + $headResponse2 = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(404, $headResponse2->getStatusCode()); + + // verify the merged file is downloadable and matches what was uploaded + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $this->assertSame(self::FILE_SIZE, strlen((string) $downloadResponse->getBody())); + + $this->cleanupChunks($chunks); + unlink(self::FILE_PATH); + + $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleTest.php b/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleTest.php index b7cd9cc3..15f4c345 100644 --- a/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleTest.php +++ b/tests/FeatureTests/Endpoint/Upload/ResumableUploadLifecycleTest.php @@ -5,13 +5,11 @@ namespace App\Tests\FeatureTests\Endpoint\Upload; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** * Covers the full resumable upload lifecycle: create an element, create an upload, upload chunks while * checking the upload status through HEAD in between, and finish the upload. */ -#[Group('test')] class ResumableUploadLifecycleTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; diff --git a/tests/FeatureTests/Endpoint/Upload/ResumableUploadUnfinalizedStateTest.php b/tests/FeatureTests/Endpoint/Upload/ResumableUploadUnfinalizedStateTest.php new file mode 100644 index 00000000..5606e743 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/ResumableUploadUnfinalizedStateTest.php @@ -0,0 +1,146 @@ +withDriver('bolt', $_ENV['CYPHER_AUTH']) + ->build(); + } + + // the decoy node claims the only allowed `true` value of the constrained property first, so it is the + // target's own later write of `hasFile = true` which introduces the duplicate and fails + private function forceNextFinalizationToFailInTheDatabase(): void + { + $client = $this->getCypherClient(); + $client->run(sprintf('CREATE CONSTRAINT %s IF NOT EXISTS FOR (n:%s) REQUIRE n.hasFile IS UNIQUE', self::CONSTRAINT_NAME, self::LABEL)); + $client->run(sprintf('CREATE (:%s:%s {hasFile: true})', self::LABEL, self::DECOY_LABEL)); + } + + private function removeDatabaseFailureSimulation(): void + { + $client = $this->getCypherClient(); + $client->run(sprintf('MATCH (n:%s) DETACH DELETE n', self::DECOY_LABEL)); + $client->run(sprintf('DROP CONSTRAINT %s IF EXISTS', self::CONSTRAINT_NAME)); + } + + public function testFailedFinalizationLeavesUploadUnfinalizedAndRetrySucceeds(): void + { + $this->forceNextFinalizationToFailInTheDatabase(); + + try { + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => self::LABEL, + 'data' => ['name' => 'resumable-upload-unfinalized-state'], + ])); + + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $content = str_repeat('unfinalized-upload-content-', 64); + + $failingFinishResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + $content, + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($failingFinishResponse, 500); + + // the upload is still there, resumable, not deleted, and reports the offset of the data already received + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) strlen($content), $headResponse->getHeader('Upload-Offset')[0]); + + // the failed graph write never committed, so the element still has no file + $elementAfterFailure = $this->getBody($this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN)); + $this->assertArrayNotHasKey('file', $elementAfterFailure); + $this->assertFalse($elementAfterFailure['data']['hasFile'] ?? false); + + $this->removeDatabaseFailureSimulation(); + + // the client retries completion with an empty PATCH at the offset reported by HEAD; the preserved hash + // state is what allows the retry to produce the correct hash of the whole file without re-sending it + $retryResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => strlen($content), + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($retryResponse); + $this->assertSame('?1', $retryResponse->getHeader('Upload-Complete')[0]); + $this->assertSame((string) strlen($content), $retryResponse->getHeader('Upload-Offset')[0]); + + // the upload resource is gone once finished + $headAfterCompletion = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(404, $headAfterCompletion->getStatusCode()); + + $elementAfterRetry = $this->getBody($this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN)); + $this->assertTrue($elementAfterRetry['data']['hasFile']); + $this->assertSame(strlen($content), $elementAfterRetry['file']['contentLength']); + $this->assertSame(hash('sha256', $content), $elementAfterRetry['file']['hash']['sha256']); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame($content, (string) $downloadResponse->getBody()); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } finally { + $this->removeDatabaseFailureSimulation(); + } + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/UploadElementCanNotBeModifiedThroughGenericEndpointsTest.php b/tests/FeatureTests/Endpoint/Upload/UploadElementCanNotBeModifiedThroughGenericEndpointsTest.php new file mode 100644 index 00000000..5e4683b5 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/UploadElementCanNotBeModifiedThroughGenericEndpointsTest.php @@ -0,0 +1,101 @@ +}> + */ + public static function internalPropertiesProvider(): array + { + return [ + 'hashState' => [['hashState' => 'abc']], + 'uploadOffset' => [['uploadOffset' => 0]], + 'uploadOwner' => [['uploadOwner' => '00000000-0000-4000-8000-000000000000']], + 'chunkIds' => [['chunkIds' => ['abc']]], + 'uploadComplete' => [['uploadComplete' => true]], + 'expires' => [['expires' => '2099-01-01T00:00:00+00:00']], + 'arbitrary property' => [['name' => 'changed']], + ]; + } + + private function createNode(): string + { + return $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'upload-generic-endpoints'], + ])); + } + + private function createUpload(string $elementId): string + { + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + str_repeat('a', self::CHUNK_SIZE), + self::TOKEN, + ['Upload-Complete' => '?0', 'Content-Type' => 'application/octet-stream'] + ); + $this->assertNoContentResponse($response, true); + + return $this->getUuidFromLocation($response); + } + + private function assertUploadUntouched(string $uploadId): void + { + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame((string) self::CHUNK_SIZE, $headResponse->getHeader('Upload-Offset')[0]); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + } + + /** + * @param array $properties + */ + #[DataProvider('internalPropertiesProvider')] + public function testPutAndPatchOfUploadElementAnswerNotFound(array $properties): void + { + $elementId = $this->createNode(); + $uploadId = $this->createUpload($elementId); + + $this->assertIsProblemResponse($this->runPutRequest(sprintf('/%s', $uploadId), self::TOKEN, $properties), 404); + $this->assertIsProblemResponse($this->runPatchRequest(sprintf('/%s', $uploadId), self::TOKEN, $properties), 404); + $this->assertUploadUntouched($uploadId); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + + public function testGetAndDeleteOfUploadElementAnswerNotFound(): void + { + $elementId = $this->createNode(); + $uploadId = $this->createUpload($elementId); + + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s', $uploadId), self::TOKEN), 404); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/%s', $uploadId), self::TOKEN), 404); + $this->assertUploadUntouched($uploadId); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/UploadLengthEnforcementTest.php b/tests/FeatureTests/Endpoint/Upload/UploadLengthEnforcementTest.php new file mode 100644 index 00000000..c113366c --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/UploadLengthEnforcementTest.php @@ -0,0 +1,163 @@ + + */ + public static function targetProvider(): array + { + return ['node' => [false], 'relation' => [true]]; + } + + private function createTarget(bool $onRelation): string + { + if ($onRelation) { + return $this->createEphemeralRelation(self::TOKEN, 'upload-length-enforcement'); + } + + return $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'upload-length-enforcement'], + ])); + } + + private function deleteTarget(bool $onRelation, string $elementId): void + { + if ($onRelation) { + $this->deleteEphemeralRelation(self::TOKEN, $elementId); + } else { + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + } + + private function createUpload(string $elementId, string $body, int $uploadLength): ResponseInterface + { + return $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + $body, + self::TOKEN, + ['Upload-Complete' => '?0', 'Upload-Length' => $uploadLength, 'Content-Type' => 'application/octet-stream'] + ); + } + + private function patchUpload(string $uploadId, int $offset, string $body, bool $complete): ResponseInterface + { + return $this->runUploadRequest('PATCH', sprintf('/upload/%s', $uploadId), $body, self::TOKEN, [ + 'Upload-Complete' => $complete ? '?1' : '?0', + 'Upload-Offset' => $offset, + 'Content-Type' => 'application/partial-upload', + ]); + } + + private function assertUploadKept(string $uploadId, string $elementId): void + { + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame((string) self::MIN_CHUNK_SIZE, $headResponse->getHeader('Upload-Offset')[0]); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + // the rejected chunk was removed, only the first chunk remains + $this->assertSame(1, $this->countUploadChunksInUploadBucket($uploadId)); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN), 404); + } + + private function assertUploadCanBeCompleted(string $uploadId, string $elementId, int $missingBytes): void + { + $this->assertNoContentResponse($this->patchUpload($uploadId, self::MIN_CHUNK_SIZE, str_repeat('b', $missingBytes), true)); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame(200, $fileResponse->getStatusCode()); + $this->assertSame(self::MIN_CHUNK_SIZE + $missingBytes, strlen((string) $fileResponse->getBody())); + } + + #[DataProvider('targetProvider')] + public function testCompletingWithFewerBytesThanDeclaredIsRejectedButKeepsUpload(bool $onRelation): void + { + $elementId = $this->createTarget($onRelation); + $uploadId = $this->getUuidFromLocation($this->createUpload($elementId, str_repeat('a', self::MIN_CHUNK_SIZE), self::MIN_CHUNK_SIZE + 100)); + $this->assertSame(1, $this->countUploadChunksInUploadBucket($uploadId)); + + $response = $this->patchUpload($uploadId, self::MIN_CHUNK_SIZE, str_repeat('b', 99), true); + $this->assertIsProblemResponse($response, 409); + $this->assertStringContainsString('upload length', $this->getBody($response)['detail']); + + $this->assertUploadKept($uploadId, $elementId); + // the upload was declared with 100 more bytes, so the corrected request completes it + $this->assertUploadCanBeCompleted($uploadId, $elementId, 100); + $this->deleteTarget($onRelation, $elementId); + } + + #[DataProvider('targetProvider')] + public function testCompletingWithMoreBytesThanDeclaredIsRejectedButKeepsUpload(bool $onRelation): void + { + $elementId = $this->createTarget($onRelation); + $uploadId = $this->getUuidFromLocation($this->createUpload($elementId, str_repeat('a', self::MIN_CHUNK_SIZE), self::MIN_CHUNK_SIZE + 100)); + + $response = $this->patchUpload($uploadId, self::MIN_CHUNK_SIZE, str_repeat('b', 101), true); + $this->assertIsProblemResponse($response, 409); + $this->assertStringContainsString('exceeds', $this->getBody($response)['detail']); + + $this->assertUploadKept($uploadId, $elementId); + $this->assertUploadCanBeCompleted($uploadId, $elementId, 100); + $this->deleteTarget($onRelation, $elementId); + } + + #[DataProvider('targetProvider')] + public function testCompletingWithExactlyTheDeclaredLengthSucceeds(bool $onRelation): void + { + $elementId = $this->createTarget($onRelation); + $uploadId = $this->getUuidFromLocation($this->createUpload($elementId, str_repeat('a', self::MIN_CHUNK_SIZE), self::MIN_CHUNK_SIZE + 100)); + + $this->assertNoContentResponse($this->patchUpload($uploadId, self::MIN_CHUNK_SIZE, str_repeat('b', 100), true)); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertSame(200, $fileResponse->getStatusCode()); + $this->assertSame(self::MIN_CHUNK_SIZE + 100, strlen((string) $fileResponse->getBody())); + $this->deleteTarget($onRelation, $elementId); + } + + #[DataProvider('targetProvider')] + public function testNonFinalChunkExceedingDeclaredLengthIsRejectedButKeepsUpload(bool $onRelation): void + { + $elementId = $this->createTarget($onRelation); + $uploadId = $this->getUuidFromLocation($this->createUpload($elementId, str_repeat('a', self::MIN_CHUNK_SIZE), self::MIN_CHUNK_SIZE + 100)); + + $this->assertIsProblemResponse($this->patchUpload($uploadId, self::MIN_CHUNK_SIZE, str_repeat('b', self::MIN_CHUNK_SIZE), false), 409); + + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame((string) self::MIN_CHUNK_SIZE, $headResponse->getHeader('Upload-Offset')[0]); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)); + $this->deleteTarget($onRelation, $elementId); + } + + #[DataProvider('targetProvider')] + public function testFirstChunkExceedingDeclaredLengthIsRejectedOnCreation(bool $onRelation): void + { + $elementId = $this->createTarget($onRelation); + + $response = $this->createUpload($elementId, str_repeat('a', self::MIN_CHUNK_SIZE), self::MIN_CHUNK_SIZE - 1); + $this->assertIsProblemResponse($response, 409); + + $this->deleteTarget($onRelation, $elementId); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/UploadLimitHeaderTest.php b/tests/FeatureTests/Endpoint/Upload/UploadLimitHeaderTest.php new file mode 100644 index 00000000..8c7aeebf --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/UploadLimitHeaderTest.php @@ -0,0 +1,98 @@ + + */ + public static function targetProvider(): array + { + return ['node' => [false], 'relation' => [true]]; + } + + private function setExpiresInSeconds(string $uploadId, int $seconds): void + { + $client = ClientBuilder::create()->withDriver('bolt', $_ENV['CYPHER_AUTH'])->build(); + $result = $client->run( + 'MATCH (u:Upload {id: $id}) SET u.expires = datetime() + duration({seconds: $seconds}) RETURN count(u) AS count', + ['id' => $uploadId, 'seconds' => $seconds] + ); + $this->assertSame(1, $result->first()->get('count')); + } + + private function getMaxAge(ResponseInterface $response): int + { + $this->assertSame(1, preg_match('/max-age=(-?\d+),/', $response->getHeader('Upload-Limit')[0], $matches)); + + return (int) $matches[1]; + } + + private function getSecondsUntilExpiresHeader(ResponseInterface $response): int + { + return (new DateTimeImmutable($response->getHeader('Expires')[0]))->getTimestamp() - time(); + } + + #[DataProvider('targetProvider')] + public function testMaxAgeIsRemainingLifetimeAndConsistentWithExpires(bool $onRelation): void + { + $elementId = $onRelation + ? $this->createEphemeralRelation(self::TOKEN, 'upload-limit-header') + : $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, ['type' => 'Data', 'data' => ['name' => 'upload-limit-header']])); + + $createResponse = $this->runUploadRequest('POST', sprintf('/%s/file', $elementId), '', self::TOKEN, [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ]); + $this->assertNoContentResponse($createResponse, true); + $uploadId = $this->getUuidFromLocation($createResponse); + + // fresh upload: the remaining lifetime is (almost) the configured window + $fullWindow = $this->getMaxAge($createResponse); + $this->assertGreaterThan(0, $fullWindow); + $this->assertEqualsWithDelta($fullWindow, $this->getSecondsUntilExpiresHeader($createResponse), 5); + + $this->setExpiresInSeconds($uploadId, 300); + foreach ([ + $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN), + $this->runUploadRequest('PATCH', sprintf('/upload/%s', $uploadId), '', self::TOKEN, [ + 'Upload-Complete' => '?0', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ]), + ] as $response) { + $this->assertSame(204, $response->getStatusCode()); + $maxAge = $this->getMaxAge($response); + $this->assertLessThanOrEqual(300, $maxAge); + $this->assertGreaterThanOrEqual(290, $maxAge); + $this->assertLessThan($fullWindow, $maxAge); + $this->assertEqualsWithDelta($maxAge, $this->getSecondsUntilExpiresHeader($response), 5); + } + + // expired, but not yet removed by the cron job: gone, no limits are reported anymore + $this->setExpiresInSeconds($uploadId, -3600); + $this->assertSame(410, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); // HEAD responses have no body + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)); + if ($onRelation) { + $this->deleteEphemeralRelation(self::TOKEN, $elementId); + } else { + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN)); + } + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadOnRelationTest.php b/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadOnRelationTest.php new file mode 100644 index 00000000..6d5b43f1 --- /dev/null +++ b/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadOnRelationTest.php @@ -0,0 +1,61 @@ +createEphemeralRelation(self::TOKEN, 'zero-byte-resumable-upload-relation'); + + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $this->assertSame('0', $createUploadResponse->getHeader('Upload-Offset')[0]); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $finishUploadResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($finishUploadResponse); + $this->assertSame('?1', $finishUploadResponse->getHeader('Upload-Complete')[0]); + $this->assertSame('0', $finishUploadResponse->getHeader('Upload-Offset')[0]); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertSame(200, $downloadResponse->getStatusCode()); + $this->assertSame(['0'], $downloadResponse->getHeader('Content-Length')); + $this->assertSame('', (string) $downloadResponse->getBody()); + $this->assertSame(['application/x-empty'], $downloadResponse->getHeader('Content-Type')); + + $body = $this->getBody($this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN)); + $this->assertSame(0, $body['file']['contentLength']); + $this->assertSame(hash('sha256', ''), $body['file']['hash']['sha256']); + + $this->deleteEphemeralRelation(self::TOKEN, $relationId); + } +} diff --git a/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadTest.php b/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadTest.php index 5d42731e..f55cf45d 100644 --- a/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadTest.php +++ b/tests/FeatureTests/Endpoint/Upload/ZeroByteResumableUploadTest.php @@ -5,18 +5,12 @@ namespace App\Tests\FeatureTests\Endpoint\Upload; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * A zero-length file can also be produced through the resumable upload flow, across two requests which both carry - * no data at all: the creation request (`Upload-Complete: ?0`, empty body - see ResumableUploadCreationTest) is - * immediately followed by a completing PATCH (`Upload-Complete: ?1`, `Upload-Offset: 0`, still an empty body). - * This exercises `PatchUploadController::createFile()`'s merge path with a single zero-byte chunk, which is a - * meaningfully different code path from the direct upload covered by ZeroByteFileUploadTest (real S3 multipart - * upload machinery: `CreateMultipartUpload`/`UploadPartCopy`/`CompleteMultipartUpload`, rather than a single - * `PutObject`). + * Verifies that a zero-length file can be created through the resumable upload flow: an empty creation request + * followed by an empty completing PATCH. Unlike ZeroByteFileUploadTest, this goes through the S3 multipart merge + * path of `PatchUploadController::createFile()`. */ -#[Group('test')] class ZeroByteResumableUploadTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; diff --git a/tests/FeatureTests/Endpoint/User/PostChangePasswordOldTest.php b/tests/FeatureTests/Endpoint/User/PostChangePasswordOldTest.php deleted file mode 100644 index 0adf13d6..00000000 --- a/tests/FeatureTests/Endpoint/User/PostChangePasswordOldTest.php +++ /dev/null @@ -1,184 +0,0 @@ -runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'currentPassword' => self::PASSWORD, - 'newPassword' => self::NEW_PASSWORD, - 'data' => [ - 'email' => self::EMAIL, - ], - ] - ); - $this->assertNoContentResponse($changePasswordResponse); - - $createTokenWithOldPasswordResponse = $this->runPostRequest( - '/token', - null, - [ - 'type' => 'Token', - 'user' => self::EMAIL, - 'password' => self::PASSWORD, - ] - ); - $this->assertIsProblemResponse($createTokenWithOldPasswordResponse, 401); - - $createTokenWithNewPasswordResponse = $this->runPostRequest( - '/token', - null, - [ - 'type' => 'Token', - 'user' => self::EMAIL, - 'password' => self::NEW_PASSWORD, - ] - ); - $this->assertSame(201, $createTokenWithNewPasswordResponse->getStatusCode()); - - $changePasswordWithOldCredentialsFails = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'currentPassword' => self::PASSWORD, - 'newPassword' => self::NEW_PASSWORD, - 'data' => [ - 'email' => self::EMAIL, - ], - ] - ); - $this->assertIsProblemResponse($changePasswordWithOldCredentialsFails, 401); - } - - public function testChangePasswordFailsForMissingUser(): void - { - $changePasswordWithOldCredentialsFails = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'currentPassword' => '1234', - 'newPassword' => 'abcd', - 'data' => [ - 'email' => 'this-email-does-not-exist@localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($changePasswordWithOldCredentialsFails, 401); - } - - public function testChangePasswordFailsIfTypeIsMissing(): void - { - $changePasswordWithOldCredentialsFails = $this->runPostRequest( - '/change-password', - null, - [ - 'currentPassword' => '1234', - 'newPassword' => 'abcd', - 'data' => [ - 'email' => 'this-email-does-not-exist@localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($changePasswordWithOldCredentialsFails, 400); - } - - public function testChangePasswordFailsIfTypeIsWrong(): void - { - $changePasswordWithOldCredentialsFails = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'NotActionChangePassword', - 'currentPassword' => '1234', - 'newPassword' => 'abcd', - 'data' => [ - 'email' => 'this-email-does-not-exist@localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($changePasswordWithOldCredentialsFails, 400); - } - - public function testChangePasswordFailsIfCurrentPasswordIsMissing(): void - { - $changePasswordWithOldCredentialsFails = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'newPassword' => 'abcd', - 'data' => [ - 'email' => 'this-email-does-not-exist@localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($changePasswordWithOldCredentialsFails, 400); - } - - public function testChangePasswordFailsIfNewPasswordIsMissing(): void - { - $changePasswordWithOldCredentialsFails = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'currentPassword' => '1234', - 'data' => [ - 'email' => 'this-email-does-not-exist@localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($changePasswordWithOldCredentialsFails, 400); - } - - public function testChangePasswordFailsIfUniqueIdentifyingPropertyIsMissing(): void - { - $changePasswordWithOldCredentialsFails = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'currentPassword' => '1234', - 'newPassword' => 'abcd', - 'data' => [ - ], - ] - ); - $this->assertIsProblemResponse($changePasswordWithOldCredentialsFails, 400); - } - - public function testChangePasswordFailsIfNewPasswordIsIdenticalToCurrentPassword(): void - { - $changePasswordWithOldCredentialsFails = $this->runPostRequest( - '/change-password', - null, - [ - 'type' => 'ActionChangePassword', - 'currentPassword' => '1234', - 'newPassword' => '1234', - 'data' => [ - 'email' => 'this-email-does-not-exist@localhost.dev', - ], - ] - ); - $this->assertIsProblemResponse($changePasswordWithOldCredentialsFails, 400); - } -} diff --git a/tests/FeatureTests/Endpoint/User/PostRegisterOldTest.php b/tests/FeatureTests/Endpoint/User/PostRegisterOldTest.php deleted file mode 100644 index af1d74aa..00000000 --- a/tests/FeatureTests/Endpoint/User/PostRegisterOldTest.php +++ /dev/null @@ -1,125 +0,0 @@ -runPostRequest( - '/register', - null, - [ - 'type' => 'User', - 'password' => '1234', - 'data' => [ - 'email' => 'user1@register.user.endpoint.localhost.dev', - ], - ] - ); - - $this->assertIsCreatedResponse($response); - } - - public function testPostRegisterWithoutType(): void - { - $response = $this->runPostRequest( - '/register', - null, - [ - 'password' => '1234', - 'data' => [ - 'email' => 'user2@register.user.endpoint.localhost.dev', - ], - ] - ); - - $this->assertIsProblemResponse($response, 400); - } - - public function testPostRegisterWithWrongType(): void - { - $response = $this->runPostRequest( - '/register', - null, - [ - 'type' => 'NotAUser', - 'password' => '1234', - 'data' => [ - 'email' => 'user3@register.user.endpoint.localhost.dev', - ], - ] - ); - - $this->assertIsProblemResponse($response, 400); - } - - public function testPostRegisterWithNoPassword(): void - { - $response = $this->runPostRequest( - '/register', - null, - [ - 'type' => 'User', - 'data' => [ - 'email' => 'user4@register.user.endpoint.localhost.dev', - ], - ] - ); - - $this->assertIsProblemResponse($response, 400); - } - - public function testPostRegisterWithNoEmail(): void - { - $response = $this->runPostRequest( - '/register', - null, - [ - 'type' => 'User', - 'password' => '1234', - 'data' => [ - ], - ] - ); - - $this->assertIsProblemResponse($response, 400); - } - - public function testPostRegisterFailsForDuplicateEmail(): void - { - $response1 = $this->runPostRequest( - '/register', - null, - [ - 'type' => 'User', - 'password' => '1234', - 'data' => [ - 'email' => 'user5@register.user.endpoint.localhost.dev', - ], - ] - ); - - $this->assertIsCreatedResponse($response1); - - $response2 = $this->runPostRequest( - '/register', - null, - [ - 'type' => 'User', - 'password' => '1234', - 'data' => [ - 'email' => 'user5@register.user.endpoint.localhost.dev', - ], - ] - ); - - $this->assertIsProblemResponse($response2, 400); - } -} diff --git a/tests/FeatureTests/Endpoint/User/PostTokenOldTest.php b/tests/FeatureTests/Endpoint/User/PostTokenOldTest.php deleted file mode 100644 index 0b28dc7a..00000000 --- a/tests/FeatureTests/Endpoint/User/PostTokenOldTest.php +++ /dev/null @@ -1,135 +0,0 @@ -runGetRequest('/', self::TOKEN); - $getIndexResponseCount = json_decode((string) $getIndexResponse->getBody(), true)['totalNodes']; - - $response = $this->runPostRequest( - '/token', - null, - [ - 'type' => 'Token', - 'user' => self::EMAIL, - 'password' => self::PASSWORD, - ] - ); - - $this->assertSame(201, $response->getStatusCode()); - $this->assertSame('application/json; charset=utf-8', $response->getHeader('content-type')[0]); - $body = \Safe\json_decode((string) $response->getBody(), true); - $this->assertSame('_TokenResponse', $body['type']); - $this->assertArrayHasKey('token', $body); - - // test that new token works - $getIndexResponseFromNewToken = $this->runGetRequest('/', $body['token']); - $getIndexResponseFromNewTokenCount = json_decode((string) $getIndexResponseFromNewToken->getBody(), true)['totalNodes']; - - // test that new token is counted as new element - $this->assertSame($getIndexResponseFromNewTokenCount, $getIndexResponseCount + 1); - } - - public function testPostTokenWithLifetime(): void - { - $getIndexResponse = $this->runGetRequest('/', self::TOKEN); - $getIndexResponseCount = json_decode((string) $getIndexResponse->getBody(), true)['totalNodes']; - - $response = $this->runPostRequest( - '/token', - null, - [ - 'type' => 'Token', - 'user' => self::EMAIL, - 'password' => self::PASSWORD, - 'lifetime' => 3600, - ] - ); - - $this->assertSame(201, $response->getStatusCode()); - $this->assertSame('application/json; charset=utf-8', $response->getHeader('content-type')[0]); - $body = \Safe\json_decode((string) $response->getBody(), true); - $this->assertSame('_TokenResponse', $body['type']); - $this->assertArrayHasKey('token', $body); - - // test that new token works - $getIndexResponseFromNewToken = $this->runGetRequest('/', $body['token']); - $getIndexResponseFromNewTokenCount = json_decode((string) $getIndexResponseFromNewToken->getBody(), true)['totalNodes']; - - // test that new token is counted as new element - $this->assertSame($getIndexResponseFromNewTokenCount, $getIndexResponseCount + 1); - } - - public function testPostTokenWithLifetimeUnderMinimumGetsSetToMinimum(): void - { - $getIndexResponse = $this->runGetRequest('/', self::TOKEN); - $getIndexResponseCount = json_decode((string) $getIndexResponse->getBody(), true)['totalNodes']; - - $response = $this->runPostRequest( - '/token', - null, - [ - 'type' => 'Token', - 'user' => self::EMAIL, - 'password' => self::PASSWORD, - 'lifetime' => 0, - ] - ); - - $this->assertSame(201, $response->getStatusCode()); - $this->assertSame('application/json; charset=utf-8', $response->getHeader('content-type')[0]); - $body = \Safe\json_decode((string) $response->getBody(), true); - $this->assertSame('_TokenResponse', $body['type']); - $this->assertArrayHasKey('token', $body); - - // test that new token works - $getIndexResponseFromNewToken = $this->runGetRequest('/', $body['token']); - $getIndexResponseFromNewTokenCount = json_decode((string) $getIndexResponseFromNewToken->getBody(), true)['totalNodes']; - - // test that new token is counted as new element - $this->assertSame($getIndexResponseFromNewTokenCount, $getIndexResponseCount + 1); - } - - public function testPostTokenWithLifetimeAboveMaximumGetsSetToMaximum(): void - { - $getIndexResponse = $this->runGetRequest('/', self::TOKEN); - $getIndexResponseCount = json_decode((string) $getIndexResponse->getBody(), true)['totalNodes']; - - $response = $this->runPostRequest( - '/token', - null, - [ - 'type' => 'Token', - 'user' => self::EMAIL, - 'password' => self::PASSWORD, - 'lifetime' => 100 * 365 * 24 * 3600, // 100 years - ] - ); - - $this->assertSame(201, $response->getStatusCode()); - $this->assertSame('application/json; charset=utf-8', $response->getHeader('content-type')[0]); - $body = \Safe\json_decode((string) $response->getBody(), true); - $this->assertSame('_TokenResponse', $body['type']); - $this->assertArrayHasKey('token', $body); - - // test that new token works - $getIndexResponseFromNewToken = $this->runGetRequest('/', $body['token']); - $getIndexResponseFromNewTokenCount = json_decode((string) $getIndexResponseFromNewToken->getBody(), true)['totalNodes']; - - // test that new token is counted as new element - $this->assertSame($getIndexResponseFromNewTokenCount, $getIndexResponseCount + 1); - } -} diff --git a/tests/FeatureTests/General/EtagAccessControlTest.php b/tests/FeatureTests/General/EtagAccessControlTest.php new file mode 100644 index 00000000..34dd5ad7 --- /dev/null +++ b/tests/FeatureTests/General/EtagAccessControlTest.php @@ -0,0 +1,100 @@ + + */ + public static function preconditionHeaderProvider(): array + { + return [ + 'If-None-Match wildcard' => ['If-None-Match: *'], + 'If-Match wildcard' => ['If-Match: *'], + 'If-Match etag' => ['If-Match: "someEtag"'], + 'If-None-Match etag' => ['If-None-Match: "someEtag"'], + ]; + } + + /** + * @return array + */ + private function parseHeader(string $header): array + { + [$name, $value] = explode(': ', $header, 2); + + return [$name => $value]; + } + + #[DataProvider('preconditionHeaderProvider')] + public function testReadEndpointsWithoutAccessAnswerLikeForMissingElements(string $header): void + { + $headers = $this->parseHeader($header); + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::OWNER_TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'etag-access-control-read'], + ])); + + foreach (['', '/children', '/parents', '/related', '/file'] as $suffix) { + $this->assertIsProblemResponse( + $this->runGetRequest(sprintf('/%s%s', $elementId, $suffix), self::OTHER_TOKEN, $headers), + 404 + ); + // same answer as for an element which does not exist + $this->assertIsProblemResponse( + $this->runGetRequest(sprintf('/%s%s', self::UNKNOWN_ID, $suffix), self::OTHER_TOKEN, $headers), + 404 + ); + } + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::OWNER_TOKEN)); + } + + #[DataProvider('preconditionHeaderProvider')] + public function testModifyingEndpointsWithoutAccessAnswerLikeForMissingElements(string $header): void + { + $headers = $this->parseHeader($header); + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::OWNER_TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'etag-access-control-modify'], + ])); + $this->assertIsCreatedResponse( + $this->runUploadRequest('POST', sprintf('/%s/file', $elementId), 'content', self::OWNER_TOKEN), + false + ); + + foreach ([$elementId, self::UNKNOWN_ID] as $id) { + $this->assertIsProblemResponse($this->runPatchRequest(sprintf('/%s', $id), self::OTHER_TOKEN, ['name' => 'x'], $headers), 404); + $this->assertIsProblemResponse($this->runPutRequest(sprintf('/%s', $id), self::OTHER_TOKEN, ['name' => 'x'], $headers), 404); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/%s', $id), self::OTHER_TOKEN, $headers), 404); + $this->assertIsProblemResponse($this->runDeleteRequest(sprintf('/%s/file', $id), self::OTHER_TOKEN, $headers), 404); + foreach (['POST', 'PUT'] as $method) { + $this->assertIsProblemResponse( + $this->runUploadRequest($method, sprintf('/%s/file', $id), 'other', self::OTHER_TOKEN, $headers), + 404 + ); + } + } + + // nothing was modified + $this->assertSame('content', (string) $this->runGetRequest(sprintf('/%s/file', $elementId), self::OWNER_TOKEN)->getBody()); + $body = $this->getBody($this->runGetRequest(sprintf('/%s', $elementId), self::OWNER_TOKEN)); + $this->assertSame('etag-access-control-modify', $body['data']['name']); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::OWNER_TOKEN)); + } +} diff --git a/tests/FeatureTests/General/Exception/ProblemJsonInstanceTest.php b/tests/FeatureTests/General/Exception/ProblemJsonInstanceTest.php new file mode 100644 index 00000000..3a4a49b3 --- /dev/null +++ b/tests/FeatureTests/General/Exception/ProblemJsonInstanceTest.php @@ -0,0 +1,53 @@ +`), which is also part of the logs. + */ +class ProblemJsonInstanceTest extends BaseRequestTestCase +{ + private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; + private const string INSTANCE_PATTERN = '/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/'; + + public function testNotFoundResponseContainsRequestIdAsInstance(): void + { + $response = $this->runGetRequest(sprintf('/%s', Uuid::uuid4()->toString()), self::TOKEN); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringStartsWith('application/problem+json', $response->getHeaderLine('Content-Type')); + $body = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + $this->assertSame(['type', 'title', 'status', 'instance', 'detail'], array_slice(array_keys($body), 0, 5)); + $this->assertMatchesRegularExpression(self::INSTANCE_PATTERN, $body['instance']); + // the id is only part of the body and of the logs + $this->assertFalse($response->hasHeader('X-Request-Id')); + } + + public function testEveryRequestGetsItsOwnInstance(): void + { + $instances = []; + for ($i = 0; $i < 2; ++$i) { + $response = $this->runGetRequest(sprintf('/%s', Uuid::uuid4()->toString()), self::TOKEN); + $instances[] = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)['instance']; + } + + $this->assertNotSame($instances[0], $instances[1]); + } + + public function testResponsesWhichAreNoProblemsHaveNoInstance(): void + { + // runRequest(), not runGetRequest(): the latter also compares HEAD against GET headers (including ETag), + // which differ if another parallel test creates/deletes a root element under the same shared token in + // between the two requests; this test only cares about the body, see WildcardEtagTest's docblock for the + // same reasoning + $response = $this->runRequest('GET', '/', self::TOKEN); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertArrayNotHasKey('instance', json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/FeatureTests/General/File/AnonymousFileAccessTest.php b/tests/FeatureTests/General/File/AnonymousFileAccessTest.php index 980a68bb..88d2f6ce 100644 --- a/tests/FeatureTests/General/File/AnonymousFileAccessTest.php +++ b/tests/FeatureTests/General/File/AnonymousFileAccessTest.php @@ -5,14 +5,11 @@ namespace App\Tests\FeatureTests\General\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * Unauthenticated requests are treated as the fixed anonymous user (ANONYMOUS_USER_UUID). The - * "general.anonymousUser" reference dataset scenario seeds a Data element owned by that user, with a file - * attached, so that anonymous file access can be verified without any Authorization header. + * Verifies that unauthenticated requests (treated as the anonymous user) can access a file owned by the + * anonymous user, seeded by the "general.anonymousUser" reference dataset scenario. */ -#[Group('test')] class AnonymousFileAccessTest extends BaseRequestTestCase { private const string ANONYMOUS_DATA_ID = '9edb178e-1b4a-4518-a9d3-0fa97e6d1007'; diff --git a/tests/FeatureTests/General/File/BotanicalFileTest.php b/tests/FeatureTests/General/File/BotanicalFileTest.php index ad1fe2eb..1c92d23f 100644 --- a/tests/FeatureTests/General/File/BotanicalFileTest.php +++ b/tests/FeatureTests/General/File/BotanicalFileTest.php @@ -5,15 +5,11 @@ namespace App\Tests\FeatureTests\General\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * Exercises file download, replace and delete behaviour against images seeded by the botanical reference - * dataset scenario ("general.botanicExample"). Nodes touched by mutating tests (replace/delete) are picked - * from the plants which are not referenced by any other test or documentation example, so this test can run - * safely alongside the rest of the suite. + * Tests file download, replace and delete against images of the "general.botanicExample" reference dataset + * scenario. Mutating tests only use plants which are not referenced by any other test or documentation example. */ -#[Group('test')] class BotanicalFileTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -36,8 +32,7 @@ public function testDownloadedRoseImageHasCorrectHashAndLength(): void $response = $this->runGetRequest(sprintf('/%s/file', self::ROSE_ID), self::TOKEN); $this->assertIsBinaryStreamResponse($response, 'image/jpeg'); - // the element's 'name' property ("Rose") is used as the download's filename, with the stored file - // extension ("jpg") appended; see ElementService::getFileName(). + // filename is built from the element's 'name' property and the stored extension $this->assertStringContainsString('filename=Rose.jpg', $response->getHeader('Content-Disposition')[0]); $body = (string) $response->getBody(); diff --git a/tests/FeatureTests/General/File/FileDigestOnRelationTest.php b/tests/FeatureTests/General/File/FileDigestOnRelationTest.php new file mode 100644 index 00000000..ac7d4ba6 --- /dev/null +++ b/tests/FeatureTests/General/File/FileDigestOnRelationTest.php @@ -0,0 +1,178 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name.'-start', + ], + ] + ); + $startNodeId = $this->getUuidFromLocation($startNode); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name.'-end', + ], + ] + ); + $endNodeId = $this->getUuidFromLocation($endNode); + + $relation = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => $name, + ], + ] + ); + + return $this->getUuidFromLocation($relation); + } + + public function testDownloadResponseIncludesReprDigestHeaderOnRelation(): void + { + $relationId = $this->createRelation('digest-response-headers-relation'); + + $filePath = __DIR__.'/../../Asset/file-digest-response-relation.bin'; + $this->generateDeterministicFile(11223355, 4096, $filePath); + $expectedHash = hash_file('sha256', $filePath); + $expectedHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($expectedHash))); + + $file = \Safe\fopen($filePath, 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + unlink($filePath); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $this->assertSame([$expectedHeaderValue], $downloadResponse->getHeader('Repr-Digest')); + $this->assertSame([], $downloadResponse->getHeader('Content-Digest')); + + $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + } + + public function testRangedResponseIncludesReprDigestButNotContentDigestOnRelation(): void + { + $relationId = $this->createRelation('digest-ranged-response-relation'); + + $filePath = __DIR__.'/../../Asset/file-digest-ranged-relation.bin'; + $this->generateDeterministicFile(55667799, 4096, $filePath); + $expectedHash = hash_file('sha256', $filePath); + $expectedHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($expectedHash))); + + $file = \Safe\fopen($filePath, 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + unlink($filePath); + + $downloadResponse = $this->runGetRequest( + sprintf('/%s/file', $relationId), + self::TOKEN, + ['Range' => 'bytes=0-99'] + ); + $this->assertSame(206, $downloadResponse->getStatusCode()); + $this->assertSame([$expectedHeaderValue], $downloadResponse->getHeader('Repr-Digest')); + $this->assertSame([], $downloadResponse->getHeader('Content-Digest')); + + $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + } + + public function testUploadWithCorrectDigestSucceedsOnRelation(): void + { + $relationId = $this->createRelation('digest-upload-correct-relation'); + + $filePath = __DIR__.'/../../Asset/file-digest-correct-relation.bin'; + $this->generateDeterministicFile(99887799, 2048, $filePath); + $hash = hash_file('sha256', $filePath); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($hash))); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertIsCreatedResponse($response, false); + unlink($filePath); + + $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + } + + public function testUploadWithMismatchedDigestIsRejectedOnRelation(): void + { + $relationId = $this->createRelation('digest-upload-mismatch-relation'); + + $filePath = __DIR__.'/../../Asset/file-digest-mismatch-relation.bin'; + $this->generateDeterministicFile(13245769, 2048, $filePath); + + $bogusHash = str_repeat('00', 32); + $digestHeaderValue = sprintf('sha-256=:%s:', base64_encode(hex2bin($bogusHash))); + + $file = \Safe\fopen($filePath, 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + 'Repr-Digest' => $digestHeaderValue, + ] + ); + $this->assertIsProblemResponse($response, 400); + unlink($filePath); + + // the relation must not end up with a file property, since the upload was rejected + $getResponse = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsProblemResponse($getResponse, 404); + + $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/General/File/FileDigestTest.php b/tests/FeatureTests/General/File/FileDigestTest.php index ef3d42b4..477ad372 100644 --- a/tests/FeatureTests/General/File/FileDigestTest.php +++ b/tests/FeatureTests/General/File/FileDigestTest.php @@ -5,13 +5,11 @@ namespace App\Tests\FeatureTests\General\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * Covers RFC 9530 ("Digest Fields") support: `Repr-Digest`/`Content-Digest` response headers on file downloads, - * and client-supplied `Repr-Digest`/`Content-Digest` request headers being verified against the uploaded file. + * Covers RFC 9530 ("Digest Fields") support: the `Repr-Digest` response header on file downloads, and + * client-supplied `Repr-Digest` request headers being verified against the uploaded file. */ -#[Group('test')] class FileDigestTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -57,9 +55,7 @@ public function testDownloadResponseIncludesReprDigestHeader(): void $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); $this->assertSame([$expectedHeaderValue], $downloadResponse->getHeader('Repr-Digest')); - // Content-Digest is deliberately not set at all: for a full response it would always equal Repr-Digest - // (no extra information), and it is never set on a partial response either (see below), so it never - // carries any information Repr-Digest doesn't already provide. + // Content-Digest is never set, as it would carry no information beyond Repr-Digest $this->assertSame([], $downloadResponse->getHeader('Content-Digest')); $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); diff --git a/tests/FeatureTests/General/File/FileDigestVerificationTest.php b/tests/FeatureTests/General/File/FileDigestVerificationTest.php new file mode 100644 index 00000000..5ea4782a --- /dev/null +++ b/tests/FeatureTests/General/File/FileDigestVerificationTest.php @@ -0,0 +1,350 @@ + + */ + public static function targetProvider(): array + { + return ['node' => [false], 'relation' => [true]]; + } + + /** + * @return array + */ + public static function singleRequestProvider(): array + { + $cases = []; + foreach (['node' => false, 'relation' => true] as $targetName => $onRelation) { + foreach (['POST', 'PUT'] as $method) { + foreach (['Repr-Digest', 'Content-Digest'] as $header) { + $cases[sprintf('%s %s %s', $targetName, $method, $header)] = [$onRelation, $method, $header]; + } + } + } + + return $cases; + } + + /** + * @return array{0: string, 1: callable} + */ + private function createTarget(bool $onRelation, string $name): array + { + if ($onRelation) { + return [ + $this->createEphemeralRelation(self::TOKEN, $name), + fn (string $id) => $this->deleteEphemeralRelation(self::TOKEN, $id), + ]; + } + $id = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => $name], + ])); + + return [$id, fn (string $id) => $this->runDeleteRequest(sprintf('/%s', $id), self::TOKEN)]; + } + + private function digestHeaderValue(string $hexHash): string + { + return sprintf('sha-256=:%s:', base64_encode(\Safe\hex2bin($hexHash))); + } + + /** + * @param array $headers + */ + private function upload(string $method, string $id, string $path, array $headers = []): \Psr\Http\Message\ResponseInterface + { + return $this->runUploadRequest( + $method, + sprintf('/%s/file', $id), + \Safe\fopen($path, 'r'), + self::TOKEN, + array_merge(['Content-Type' => 'application/octet-stream'], $headers) + ); + } + + private function assertStoredFile(string $id, string $path): void + { + // runRequest(), not runGetRequest(): resumable finalize writes the element in more than one step, so a HEAD + // immediately followed by a GET can occasionally observe different ETags for the same id under load; this + // only cares about the stored content/hash, not HEAD/GET header parity + $download = $this->runRequest('GET', sprintf('/%s/file', $id), self::TOKEN); + $this->assertSame(200, $download->getStatusCode()); + $this->assertSame(hash_file('sha256', $path), hash('sha256', (string) $download->getBody())); + + $element = $this->getBody($this->runRequest('GET', sprintf('/%s', $id), self::TOKEN)); + $this->assertSame(hash_file('sha256', $path), $element['file']['hash']['sha256']); + $this->assertSame(filesize($path), $element['file']['contentLength']); + } + + #[DataProvider('singleRequestProvider')] + public function testSingleRequestWithCorrectDigestStoresFile(bool $onRelation, string $method, string $header): void + { + [$id, $cleanup] = $this->createTarget($onRelation, 'digest-verification-correct'); + $this->generateDeterministicFile(70010001, 4096, self::ASSET_PATH); + + $response = $this->upload($method, $id, self::ASSET_PATH, [ + $header => $this->digestHeaderValue(hash_file('sha256', self::ASSET_PATH)), + ]); + $this->assertIsCreatedResponse($response, false); + $this->assertStoredFile($id, self::ASSET_PATH); + + unlink(self::ASSET_PATH); + $cleanup($id); + } + + #[DataProvider('singleRequestProvider')] + public function testSingleRequestWithWrongDigestLeavesElementWithoutFile(bool $onRelation, string $method, string $header): void + { + [$id, $cleanup] = $this->createTarget($onRelation, 'digest-verification-wrong'); + $this->generateDeterministicFile(70010002, 4096, self::ASSET_PATH); + + $response = $this->upload($method, $id, self::ASSET_PATH, [ + $header => $this->digestHeaderValue(str_repeat('ab', 32)), + ]); + $this->assertIsProblemResponse($response, 400); + unlink(self::ASSET_PATH); + + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $id), self::TOKEN), 404); + $element = $this->getBody($this->runGetRequest(sprintf('/%s', $id), self::TOKEN)); + $this->assertArrayNotHasKey('file', $element); + $this->assertFalse($element['data']['hasFile'] ?? false); + + $cleanup($id); + } + + /** + * @return array + */ + public static function singleRequestMethodProvider(): array + { + $cases = []; + foreach (['node' => false, 'relation' => true] as $targetName => $onRelation) { + foreach (['POST', 'PUT'] as $method) { + $cases[sprintf('%s %s', $targetName, $method)] = [$onRelation, $method]; + } + } + + return $cases; + } + + /** + * The body of a single request is the whole file, so `Repr-Digest` and `Content-Digest` are the same hash and + * both are accepted together. + */ + #[DataProvider('singleRequestMethodProvider')] + public function testSingleRequestWithCorrectReprAndContentDigestStoresFile(bool $onRelation, string $method): void + { + [$id, $cleanup] = $this->createTarget($onRelation, 'digest-verification-both-correct'); + $this->generateDeterministicFile(70010009, 4096, self::ASSET_PATH); + $digest = $this->digestHeaderValue(hash_file('sha256', self::ASSET_PATH)); + + $response = $this->upload($method, $id, self::ASSET_PATH, [ + 'Repr-Digest' => $digest, + 'Content-Digest' => $digest, + ]); + $this->assertIsCreatedResponse($response, false); + $this->assertStoredFile($id, self::ASSET_PATH); + + unlink(self::ASSET_PATH); + $cleanup($id); + } + + /** + * @return array + */ + public static function singleRequestWrongHeaderProvider(): array + { + $cases = []; + foreach (['node' => false, 'relation' => true] as $targetName => $onRelation) { + foreach (['POST', 'PUT'] as $method) { + foreach (['Repr-Digest', 'Content-Digest'] as $wrongHeader) { + $cases[sprintf('%s %s wrong %s', $targetName, $method, $wrongHeader)] = [$onRelation, $method, $wrongHeader]; + } + } + } + + return $cases; + } + + /** + * Every supplied digest has to match, a correct one does not excuse a wrong one. + */ + #[DataProvider('singleRequestWrongHeaderProvider')] + public function testSingleRequestWithOneCorrectAndOneWrongDigestLeavesElementWithoutFile(bool $onRelation, string $method, string $wrongHeader): void + { + [$id, $cleanup] = $this->createTarget($onRelation, 'digest-verification-one-wrong'); + $this->generateDeterministicFile(70010010, 4096, self::ASSET_PATH); + $correctHeader = 'Repr-Digest' === $wrongHeader ? 'Content-Digest' : 'Repr-Digest'; + + $response = $this->upload($method, $id, self::ASSET_PATH, [ + $correctHeader => $this->digestHeaderValue(hash_file('sha256', self::ASSET_PATH)), + $wrongHeader => $this->digestHeaderValue(str_repeat('ab', 32)), + ]); + $this->assertIsProblemResponse($response, 400); + $this->assertStringContainsString($wrongHeader, $this->getBody($response)['detail']); + unlink(self::ASSET_PATH); + + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $id), self::TOKEN), 404); + $this->assertArrayNotHasKey('file', $this->getBody($this->runGetRequest(sprintf('/%s', $id), self::TOKEN))); + + $cleanup($id); + } + + /** + * A replacement with one wrong digest keeps the old file, whichever header is wrong. + */ + #[DataProvider('targetProvider')] + public function testPutWithOneWrongOfBothDigestsKeepsOldFile(bool $onRelation): void + { + [$id, $cleanup] = $this->createTarget($onRelation, 'digest-verification-replace-one-wrong'); + $this->generateDeterministicFile(70010011, 4096, self::ASSET_PATH); + $this->assertIsCreatedResponse($this->upload('POST', $id, self::ASSET_PATH), false); + $oldEtag = $this->runGetRequest(sprintf('/%s/file', $id), self::TOKEN)->getHeader('ETag'); + + $replacementPath = __DIR__.'/../../Asset/file-digest-verification-replacement.bin'; + $this->generateDeterministicFile(70010012, 8192, $replacementPath); + $correct = $this->digestHeaderValue(hash_file('sha256', $replacementPath)); + $wrong = $this->digestHeaderValue(str_repeat('ab', 32)); + foreach ([['Repr-Digest' => $correct, 'Content-Digest' => $wrong], ['Repr-Digest' => $wrong, 'Content-Digest' => $correct]] as $headers) { + $this->assertIsProblemResponse($this->upload('PUT', $id, $replacementPath, $headers), 400); + $this->assertStoredFile($id, self::ASSET_PATH); + $this->assertSame($oldEtag, $this->runGetRequest(sprintf('/%s/file', $id), self::TOKEN)->getHeader('ETag')); + } + unlink($replacementPath); + + unlink(self::ASSET_PATH); + $cleanup($id); + } + + #[DataProvider('targetProvider')] + public function testPutWithWrongDigestKeepsOldFileAndEtag(bool $onRelation): void + { + [$id, $cleanup] = $this->createTarget($onRelation, 'digest-verification-replace'); + $this->generateDeterministicFile(70010003, 4096, self::ASSET_PATH); + $this->assertIsCreatedResponse($this->upload('POST', $id, self::ASSET_PATH), false); + $oldEtag = $this->runGetRequest(sprintf('/%s/file', $id), self::TOKEN)->getHeader('ETag'); + $this->assertCount(1, $oldEtag); + + $replacementPath = __DIR__.'/../../Asset/file-digest-verification-replacement.bin'; + $this->generateDeterministicFile(70010004, 8192, $replacementPath); + $response = $this->upload('PUT', $id, $replacementPath, [ + 'Repr-Digest' => $this->digestHeaderValue(hash_file('sha256', self::ASSET_PATH)), + ]); + $this->assertIsProblemResponse($response, 400); + unlink($replacementPath); + + $this->assertStoredFile($id, self::ASSET_PATH); + $this->assertSame($oldEtag, $this->runGetRequest(sprintf('/%s/file', $id), self::TOKEN)->getHeader('ETag')); + + unlink(self::ASSET_PATH); + $cleanup($id); + } + + /** + * @return array{0: string, 1: array} upload id and chunk paths + */ + private function startResumableUpload(string $method, string $id, string $path): array + { + $chunks = $this->splitFileToChunks($path, self::CHUNK_SIZE); + $response = $this->upload($method, $id, $chunks[0], ['Upload-Complete' => '?0']); + $this->assertNoContentResponse($response, true); + + return [$this->getUuidFromLocation($response), $chunks]; + } + + private function finishResumableUpload(string $uploadId, string $lastChunk, ?string $digest): \Psr\Http\Message\ResponseInterface + { + $headers = [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => self::CHUNK_SIZE, + 'Content-Type' => 'application/partial-upload', + ]; + if (null !== $digest) { + $headers['Repr-Digest'] = $digest; + } + + return $this->runUploadRequest('PATCH', sprintf('/upload/%s', $uploadId), \Safe\fopen($lastChunk, 'r'), self::TOKEN, $headers); + } + + #[DataProvider('targetProvider')] + public function testResumableUploadWithCorrectDigestStoresFile(bool $onRelation): void + { + [$id, $cleanup] = $this->createTarget($onRelation, 'digest-verification-resumable-correct'); + $this->generateDeterministicFile(70010005, self::RESUMABLE_FILE_SIZE, self::ASSET_PATH); + [$uploadId, $chunks] = $this->startResumableUpload('POST', $id, self::ASSET_PATH); + + $response = $this->finishResumableUpload($uploadId, $chunks[1], $this->digestHeaderValue(hash_file('sha256', self::ASSET_PATH))); + $this->assertNoContentResponse($response); + $this->assertStoredFile($id, self::ASSET_PATH); + + $this->cleanupChunks($chunks); + unlink(self::ASSET_PATH); + $cleanup($id); + } + + #[DataProvider('targetProvider')] + public function testResumableUploadWithWrongDigestDiscardsUploadAndKeepsElementWithoutFile(bool $onRelation): void + { + [$id, $cleanup] = $this->createTarget($onRelation, 'digest-verification-resumable-wrong'); + $this->generateDeterministicFile(70010006, self::RESUMABLE_FILE_SIZE, self::ASSET_PATH); + [$uploadId, $chunks] = $this->startResumableUpload('POST', $id, self::ASSET_PATH); + + $response = $this->finishResumableUpload($uploadId, $chunks[1], $this->digestHeaderValue(str_repeat('cd', 32))); + $this->assertIsProblemResponse($response, 400); + + $this->assertSame(404, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $id), self::TOKEN), 404); + $this->assertArrayNotHasKey('file', $this->getBody($this->runGetRequest(sprintf('/%s', $id), self::TOKEN))); + + $this->cleanupChunks($chunks); + unlink(self::ASSET_PATH); + $cleanup($id); + } + + #[DataProvider('targetProvider')] + public function testResumableReplaceWithWrongDigestDiscardsUploadAndKeepsOldFile(bool $onRelation): void + { + [$id, $cleanup] = $this->createTarget($onRelation, 'digest-verification-resumable-replace'); + $oldPath = __DIR__.'/../../Asset/file-digest-verification-old.bin'; + $this->generateDeterministicFile(70010007, 4096, $oldPath); + $this->assertIsCreatedResponse($this->upload('POST', $id, $oldPath), false); + $oldEtag = $this->runGetRequest(sprintf('/%s/file', $id), self::TOKEN)->getHeader('ETag'); + + $this->generateDeterministicFile(70010008, self::RESUMABLE_FILE_SIZE, self::ASSET_PATH); + [$uploadId, $chunks] = $this->startResumableUpload('PUT', $id, self::ASSET_PATH); + + $response = $this->finishResumableUpload($uploadId, $chunks[1], $this->digestHeaderValue(str_repeat('ef', 32))); + $this->assertIsProblemResponse($response, 400); + + $this->assertSame(404, $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN)->getStatusCode()); + $this->assertStoredFile($id, $oldPath); + $this->assertSame($oldEtag, $this->runGetRequest(sprintf('/%s/file', $id), self::TOKEN)->getHeader('ETag')); + + $this->cleanupChunks($chunks); + unlink(self::ASSET_PATH); + unlink($oldPath); + $cleanup($id); + } +} diff --git a/tests/FeatureTests/General/File/FileNameFallbackTest.php b/tests/FeatureTests/General/File/FileNameFallbackTest.php index b2416816..41932886 100644 --- a/tests/FeatureTests/General/File/FileNameFallbackTest.php +++ b/tests/FeatureTests/General/File/FileNameFallbackTest.php @@ -5,18 +5,13 @@ namespace App\Tests\FeatureTests\General\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * The filename used in a file download's `Content-Disposition` header is decided by priority: the element's - * 'name' property, if it exists and is a string, otherwise the element's id. In both cases, the extension is - * taken from the file's stored extension, falling back to "bin" if none was determined during upload (e.g. the - * client did not supply a `Content-Disposition` header with a filename during upload). - * - * Explicitly setting the filename to the element's id is likely to be disabled in a future feature upgrade - * (not yet decided); until then, both cases must keep working. + * Verifies the download filename in `Content-Disposition`: the element's string 'name' property if set, otherwise + * the element's id, with the stored extension. A missing extension property falls back to "bin", while an empty extension (uploaded filename + * without any extension, e.g. 'Makefile') means that the file has no extension at all: no suffix and no trailing dot + * (see ElementService::getFileName()). */ -#[Group('test')] class FileNameFallbackTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -100,4 +95,155 @@ public function testDownloadFilenameUsesExplicitNamePropertyWhenSet(): void $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); } + + private function assertDownloadFilename(string $expectedFileName, string $contentDisposition): void + { + $this->assertSame(1, preg_match('/filename=([^;]*)/', $contentDisposition, $matches), $contentDisposition); + $this->assertSame($expectedFileName, $matches[1]); + } + + public function testFileWithoutExtensionIsDownloadedWithoutExtension(): void + { + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'Makefile', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + 'just some plain text', + self::TOKEN, + [ + 'Content-Type' => 'text/plain', + 'Content-Disposition' => 'attachment; filename=Makefile', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + + $downloadResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertIsBinaryStreamResponse($downloadResponse, 'text/plain'); + $this->assertDownloadFilename('Makefile', $downloadResponse->getHeader('Content-Disposition')[0]); + $this->assertSame('just some plain text', (string) $downloadResponse->getBody()); + + // an empty extension is stored as it is, it must not be replaced by the default extension + $elementAfterUpload = $this->getBody($this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN)); + $this->assertSame('', $elementAfterUpload['file']['extension']); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testReplacingFileBetweenExtensionAndNoExtensionServesOnlyTheNewFile(): void + { + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'Dockerfile', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + 'FROM alpine', + self::TOKEN, + [ + 'Content-Type' => 'text/plain', + 'Content-Disposition' => 'attachment; filename=Dockerfile', + ] + ); + $this->assertIsCreatedResponse($postFileResponse, false); + + $withExtensionResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + 'plain text', + self::TOKEN, + [ + 'Content-Type' => 'text/plain', + 'Content-Disposition' => 'attachment; filename=notes.txt', + ] + ); + $this->assertIsCreatedResponse($withExtensionResponse, false); + $download = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertDownloadFilename('Dockerfile.txt', $download->getHeader('Content-Disposition')[0]); + $this->assertSame('plain text', (string) $download->getBody()); + + $withoutExtensionResponse = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', $elementId), + 'FROM scratch', + self::TOKEN, + [ + 'Content-Type' => 'text/plain', + 'Content-Disposition' => 'attachment; filename=Dockerfile', + ] + ); + $this->assertIsCreatedResponse($withoutExtensionResponse, false); + $download = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertDownloadFilename('Dockerfile', $download->getHeader('Content-Disposition')[0]); + $this->assertSame('FROM scratch', (string) $download->getBody()); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testResumableUploadWithoutExtensionIsDownloadedWithoutExtension(): void + { + $elementResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'LICENSE', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + self::TOKEN, + [ + 'Content-Type' => 'text/plain', + 'Content-Disposition' => 'attachment; filename=LICENSE', + 'Upload-Complete' => '?0', + ] + ); + $this->assertSame(204, $createUploadResponse->getStatusCode()); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $finishUploadResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + 'MIT', + self::TOKEN, + [ + 'Content-Type' => 'application/partial-upload', + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + ] + ); + $this->assertSame(204, $finishUploadResponse->getStatusCode()); + + $download = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); + $this->assertDownloadFilename('LICENSE', $download->getHeader('Content-Disposition')[0]); + $this->assertSame('MIT', (string) $download->getBody()); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } } diff --git a/tests/FeatureTests/General/File/FileRemovedWhenElementRemovedTest.php b/tests/FeatureTests/General/File/FileRemovedWhenElementRemovedTest.php index aa957e34..31c2d087 100644 --- a/tests/FeatureTests/General/File/FileRemovedWhenElementRemovedTest.php +++ b/tests/FeatureTests/General/File/FileRemovedWhenElementRemovedTest.php @@ -5,13 +5,11 @@ namespace App\Tests\FeatureTests\General\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * Verifies that a file is no longer accessible once the element it was attached to is deleted, even though - * the file itself was never explicitly deleted through the file endpoint. + * Verifies that a file is removed from storage once the element it was attached to is deleted, even though the + * file itself was never explicitly deleted through the file endpoint. */ -#[Group('test')] class FileRemovedWhenElementRemovedTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -42,6 +40,8 @@ public function testFileIsGoneAfterElementIsDeleted(): void $getFileResponse1 = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); $this->assertIsBinaryStreamResponse($getFileResponse1, 'image/jpeg'); + $this->assertFileExistsInStorage($elementId); + $deleteElementResponse = $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); $this->assertIsDeletedResponse($deleteElementResponse); @@ -50,5 +50,25 @@ public function testFileIsGoneAfterElementIsDeleted(): void $getFileResponse2 = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); $this->assertIsProblemResponse($getFileResponse2, 404); + + $this->assertFileDoesNotExistInStorage($elementId); + } + + public function testFileOfAttachedRelationIsRemovedWhenNodeIsDeleted(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN, 'file-removed-when-node-removed'); + $relation = $this->getBody($this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN)); + + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $postFileResponse = $this->runUploadRequest('POST', sprintf('/%s/file', $relationId), $file, self::TOKEN); + $this->assertIsCreatedResponse($postFileResponse, false); + $this->assertFileExistsInStorage($relationId); + + // deleting the start node also deletes the attached relation, and therefore its file + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $relation['start']), self::TOKEN)); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN), 404); + $this->assertFileDoesNotExistInStorage($relationId); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $relation['end']), self::TOKEN)); } } diff --git a/tests/FeatureTests/General/File/FileRemovedWhenRelationRemovedTest.php b/tests/FeatureTests/General/File/FileRemovedWhenRelationRemovedTest.php new file mode 100644 index 00000000..93695b76 --- /dev/null +++ b/tests/FeatureTests/General/File/FileRemovedWhenRelationRemovedTest.php @@ -0,0 +1,81 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'file-removed-when-relation-removed-start', + ], + ] + ); + $startNodeId = $this->getUuidFromLocation($startNode); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'file-removed-when-relation-removed-end', + ], + ] + ); + $endNodeId = $this->getUuidFromLocation($endNode); + + $relationResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => 'file-removed-when-relation-removed', + ], + ] + ); + $relationId = $this->getUuidFromLocation($relationResponse); + + $file = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); + $postFileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN + ); + $this->assertIsCreatedResponse($postFileResponse, false); + + $getFileResponse1 = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsBinaryStreamResponse($getFileResponse1, 'image/jpeg'); + + $this->assertFileExistsInStorage($relationId); + + $deleteRelationResponse = $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + $this->assertIsDeletedResponse($deleteRelationResponse); + + $getRelationResponse = $this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN); + $this->assertIsProblemResponse($getRelationResponse, 404); + + $getFileResponse2 = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsProblemResponse($getFileResponse2, 404); + + $this->assertFileDoesNotExistInStorage($relationId); + } +} diff --git a/tests/FeatureTests/General/File/FileTopLevelPropertyOnRelationTest.php b/tests/FeatureTests/General/File/FileTopLevelPropertyOnRelationTest.php new file mode 100644 index 00000000..6878896b --- /dev/null +++ b/tests/FeatureTests/General/File/FileTopLevelPropertyOnRelationTest.php @@ -0,0 +1,319 @@ +runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name.'-start', + ], + ] + ); + $startNodeId = $this->getUuidFromLocation($startNode); + + $endNode = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => $name.'-end', + ], + ] + ); + $endNodeId = $this->getUuidFromLocation($endNode); + + $relationResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'start' => $startNodeId, + 'end' => $endNodeId, + 'data' => [ + 'name' => $name, + ], + ] + ); + $relationId = $this->getUuidFromLocation($relationResponse); + + $filePath = __DIR__.'/../../Asset/'.$name.'.bin'; + $this->generateDeterministicFile(45362718, 128, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $fileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($fileResponse, false); + unlink($filePath); + + return [$startNodeId, $endNodeId, $relationId]; + } + + private function assertFileShape(array $file): void + { + $this->assertArrayHasKey('contentLength', $file); + $this->assertSame(128, $file['contentLength']); + $this->assertArrayHasKey('extension', $file); + $this->assertArrayHasKey('mimeType', $file); + $this->assertArrayHasKey('hash', $file); + $this->assertArrayHasKey('sha256', $file['hash']); + $this->assertSame(64, strlen($file['hash']['sha256'])); + } + + public function testFileIsTopLevelAndHasFileIsInDataOnRelation(): void + { + [$startNodeId, $endNodeId, $relationId] = $this->createRelationWithFile('top-level-file-property-relation'); + + $response = $this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN); + $body = \Safe\json_decode((string) $response->getBody(), true); + + $this->assertArrayHasKey('file', $body); + $this->assertArrayNotHasKey('file', $body['data']); + $this->assertFileShape($body['file']); + $this->assertTrue($body['data']['hasFile']); + + $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $startNodeId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $endNodeId), self::TOKEN); + } + + public function testRelatedCollectionIncludesFileAndHasFileForRelation(): void + { + [$startNodeId, $endNodeId, $relationId] = $this->createRelationWithFile('related-includes-file-relation'); + + $relatedResponse = $this->runGetRequest(sprintf('/%s/related', $startNodeId), self::TOKEN); + $this->assertIsCollectionResponse($relatedResponse); + $relatedBody = \Safe\json_decode((string) $relatedResponse->getBody(), true); + + $found = null; + foreach ($relatedBody['relations'] as $relation) { + if ($relation['id'] === $relationId) { + $found = $relation; + break; + } + } + $this->assertNotNull($found, 'Relation not found in /related collection response.'); + $this->assertArrayHasKey('file', $found); + $this->assertFileShape($found['file']); + $this->assertTrue($found['data']['hasFile']); + $this->assertArrayNotHasKey('file', $found['data']); + + $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $startNodeId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $endNodeId), self::TOKEN); + } + + public function testChildrenCollectionIncludesFileAndHasFileForOwnsRelation(): void + { + $parentResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'children-includes-file-parent', + ], + ] + ); + $parentId = $this->getUuidFromLocation($parentResponse); + + $childResponse = $this->runPostRequest( + sprintf('/%s', $parentId), + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'children-includes-file-child', + ], + ] + ); + $childId = $this->getUuidFromLocation($childResponse); + + // find the implicit OWNS relation created between parent and child + $relatedResponse = $this->runGetRequest(sprintf('/%s/related', $childId), self::TOKEN); + $relatedBody = \Safe\json_decode((string) $relatedResponse->getBody(), true); + $ownsRelationId = null; + foreach ($relatedBody['relations'] as $relation) { + if ('OWNS' === $relation['type'] && $relation['start'] === $parentId) { + $ownsRelationId = $relation['id']; + break; + } + } + $this->assertNotNull($ownsRelationId, 'Could not find implicit OWNS relation between parent and child.'); + + $filePath = __DIR__.'/../../Asset/children-includes-file-relation.bin'; + $this->generateDeterministicFile(87162534, 128, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $fileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $ownsRelationId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($fileResponse, false); + unlink($filePath); + + $childrenResponse = $this->runGetRequest(sprintf('/%s/children', $parentId), self::TOKEN); + $this->assertIsCollectionResponse($childrenResponse); + $childrenBody = \Safe\json_decode((string) $childrenResponse->getBody(), true); + + $found = null; + foreach ($childrenBody['relations'] as $relation) { + if ($relation['id'] === $ownsRelationId) { + $found = $relation; + break; + } + } + $this->assertNotNull($found, 'OWNS relation not found in /children collection response.'); + $this->assertArrayHasKey('file', $found); + $this->assertFileShape($found['file']); + $this->assertTrue($found['data']['hasFile']); + + $this->runDeleteRequest(sprintf('/%s', $childId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $parentId), self::TOKEN); + } + + public function testParentsCollectionIncludesFileAndHasFileForOwnsRelation(): void + { + $parentResponse = $this->runPostRequest( + '/', + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'parents-includes-file-parent', + ], + ] + ); + $parentId = $this->getUuidFromLocation($parentResponse); + + $childResponse = $this->runPostRequest( + sprintf('/%s', $parentId), + self::TOKEN, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'parents-includes-file-child', + ], + ] + ); + $childId = $this->getUuidFromLocation($childResponse); + + $relatedResponse = $this->runGetRequest(sprintf('/%s/related', $childId), self::TOKEN); + $relatedBody = \Safe\json_decode((string) $relatedResponse->getBody(), true); + $ownsRelationId = null; + foreach ($relatedBody['relations'] as $relation) { + if ('OWNS' === $relation['type'] && $relation['start'] === $parentId) { + $ownsRelationId = $relation['id']; + break; + } + } + $this->assertNotNull($ownsRelationId, 'Could not find implicit OWNS relation between parent and child.'); + + $filePath = __DIR__.'/../../Asset/parents-includes-file-relation.bin'; + $this->generateDeterministicFile(19283765, 128, $filePath); + $file = \Safe\fopen($filePath, 'r'); + $fileResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $ownsRelationId), + $file, + self::TOKEN, + [ + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsCreatedResponse($fileResponse, false); + unlink($filePath); + + $parentsResponse = $this->runGetRequest(sprintf('/%s/parents', $childId), self::TOKEN); + $this->assertIsCollectionResponse($parentsResponse); + $parentsBody = \Safe\json_decode((string) $parentsResponse->getBody(), true); + + $found = null; + foreach ($parentsBody['relations'] as $relation) { + if ($relation['id'] === $ownsRelationId) { + $found = $relation; + break; + } + } + $this->assertNotNull($found, 'OWNS relation not found in /parents collection response.'); + $this->assertArrayHasKey('file', $found); + $this->assertFileShape($found['file']); + $this->assertTrue($found['data']['hasFile']); + + $this->runDeleteRequest(sprintf('/%s', $childId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $parentId), self::TOKEN); + } + + public function testHasFileIsFalseAfterFileDeletionOnRelation(): void + { + [$startNodeId, $endNodeId, $relationId] = $this->createRelationWithFile('has-file-false-after-delete-relation'); + + $this->runDeleteRequest(sprintf('/%s/file', $relationId), self::TOKEN); + + $response = $this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN); + $body = \Safe\json_decode((string) $response->getBody(), true); + + $this->assertArrayNotHasKey('file', $body); + $this->assertFalse($body['data']['hasFile']); + + $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $startNodeId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $endNodeId), self::TOKEN); + } + + public function testPutFullReplaceDoesNotWipeFileOnRelation(): void + { + [$startNodeId, $endNodeId, $relationId] = $this->createRelationWithFile('put-preserves-file-relation'); + + $putResponse = $this->runPutRequest( + sprintf('/%s', $relationId), + self::TOKEN, + ['name' => 'replaced-via-put-relation'] + ); + $this->assertNoContentResponse($putResponse); + + $getResponse = $this->runGetRequest(sprintf('/%s', $relationId), self::TOKEN); + $body = \Safe\json_decode((string) $getResponse->getBody(), true); + + $this->assertArrayHasKey('file', $body); + $this->assertTrue($body['data']['hasFile']); + $this->assertSame('replaced-via-put-relation', $body['data']['name']); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN); + $this->assertIsBinaryStreamResponse($fileResponse, 'text/plain'); + + $this->runDeleteRequest(sprintf('/%s', $relationId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $startNodeId), self::TOKEN); + $this->runDeleteRequest(sprintf('/%s', $endNodeId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/General/File/FileTopLevelPropertyTest.php b/tests/FeatureTests/General/File/FileTopLevelPropertyTest.php index 089f66e0..591c61ff 100644 --- a/tests/FeatureTests/General/File/FileTopLevelPropertyTest.php +++ b/tests/FeatureTests/General/File/FileTopLevelPropertyTest.php @@ -5,18 +5,11 @@ namespace App\Tests\FeatureTests\General\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** - * Covers: - * - 'file' is exposed as a top-level property (sibling of id/type/data), 'hasFile' is a plain boolean inside - * 'data', kept in sync automatically. - * - 'file' is present, and 'hasFile' is omitted from top-level collection/parent listings ('hasFile' remains - * visible in 'data', 'file' is not). - * - both 'file' and 'hasFile' are reserved: a client can not set them directly via POST/PUT/PATCH. - * - a PUT (full data replace) does not wipe an element's file. + * Verifies that 'file' is a top-level property and 'hasFile' a boolean inside 'data', both for direct fetches and + * collection responses; that clients can not write either property via POST/PATCH; and that PUT keeps the file. */ -#[Group('test')] class FileTopLevelPropertyTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; @@ -70,9 +63,7 @@ public function testFileIsTopLevelAndHasFileIsInData(): void public function testCollectionListingIncludesFileAndHasFile(): void { - // a dedicated, freshly created parent is used (instead of e.g. the shared botanicExample user's - // children) so the collection queried here is not shared with, and can not race against, any other - // (parallel) test. + // dedicated parent, so the collection can not race against other (parallel) tests $parentResponse = $this->runPostRequest( '/', self::TOKEN, diff --git a/tests/FeatureTests/General/File/FileUploadUpdatesElementTest.php b/tests/FeatureTests/General/File/FileUploadUpdatesElementTest.php index 1af33606..d40c31ae 100644 --- a/tests/FeatureTests/General/File/FileUploadUpdatesElementTest.php +++ b/tests/FeatureTests/General/File/FileUploadUpdatesElementTest.php @@ -6,14 +6,10 @@ use App\Tests\FeatureTests\BaseRequestTestCase; use DateTime; -use PHPUnit\Framework\Attributes\Group; /** - * Attaching, replacing or deleting a file on an element must count as a change to the element itself: its - * 'updated' timestamp (and therefore its ETag) must move forward, even though 'updated' is not part of the - * request body sent to the file endpoints at all. + * Verifies that attaching, replacing or deleting a file moves the element's 'updated' timestamp and ETag forward. */ -#[Group('test')] class FileUploadUpdatesElementTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; diff --git a/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php b/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php index a5af4aea..ef891c10 100644 --- a/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php +++ b/tests/FeatureTests/General/File/MultiPartUploadLifecycleTest.php @@ -5,9 +5,7 @@ namespace App\Tests\FeatureTests\General\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; -#[Group('test')] class MultiPartUploadLifecycleTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; diff --git a/tests/FeatureTests/General/File/SimpleFileInteractivityTest.php b/tests/FeatureTests/General/File/SimpleFileInteractivityTest.php index 2a5f3227..0e7d81c5 100644 --- a/tests/FeatureTests/General/File/SimpleFileInteractivityTest.php +++ b/tests/FeatureTests/General/File/SimpleFileInteractivityTest.php @@ -5,20 +5,17 @@ namespace App\Tests\FeatureTests\General\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** * Covers the simplest possible file lifecycle: create an element, attach a file directly with POST, replace it * with PUT, delete it, then verify it is gone. */ -#[Group('test')] class SimpleFileInteractivityTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; public function testCreateElementPostFilePutReplaceDeleteThenGetIsNotFound(): void { - // create element $elementResponse = $this->runPostRequest( '/', self::TOKEN, @@ -31,7 +28,6 @@ public function testCreateElementPostFilePutReplaceDeleteThenGetIsNotFound(): vo ); $elementId = $this->getUuidFromLocation($elementResponse); - // post file directly $firstFile = \Safe\fopen(__DIR__.'/../../Asset/cherry-blossoms.jpg', 'r'); $postFileResponse = $this->runUploadRequest( 'POST', @@ -49,7 +45,6 @@ public function testCreateElementPostFilePutReplaceDeleteThenGetIsNotFound(): vo $this->assertIsBinaryStreamResponse($getFileResponse1, 'image/jpeg'); $this->assertSame(63933, strlen((string) $getFileResponse1->getBody())); - // put replace file $secondFilePath = __DIR__.'/../../Asset/simple-file-interactivity-replacement.bin'; $this->generateDeterministicFile(24681012, 16 * 1024, $secondFilePath); $secondFile = \Safe\fopen($secondFilePath, 'r'); @@ -74,11 +69,9 @@ public function testCreateElementPostFilePutReplaceDeleteThenGetIsNotFound(): vo unlink($secondFilePath); - // delete file $deleteFileResponse = $this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN); $this->assertIsDeletedResponse($deleteFileResponse); - // get file -> 404 $getFileResponse3 = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN); $this->assertIsProblemResponse($getFileResponse3, 404); } diff --git a/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php b/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php index b12ea332..8365feab 100644 --- a/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php +++ b/tests/FeatureTests/General/File/SinglePartUploadLifecycleTest.php @@ -5,9 +5,7 @@ namespace App\Tests\FeatureTests\General\File; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; -#[Group('test')] class SinglePartUploadLifecycleTest extends BaseRequestTestCase { private const string TOKEN = 'secret-token:1nc1pFdBO2QLYRMMvULgtQ'; diff --git a/tests/FeatureTests/General/IfMatchAndIfNoneMatchCombinedTest.php b/tests/FeatureTests/General/IfMatchAndIfNoneMatchCombinedTest.php new file mode 100644 index 00000000..f5c6a75c --- /dev/null +++ b/tests/FeatureTests/General/IfMatchAndIfNoneMatchCombinedTest.php @@ -0,0 +1,161 @@ +getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => $name], + ])); + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN); + $etag = $response->getHeader('ETag')[0]; + + return [$elementId, $etag]; + } + + public function testBothMatchGivesNotModified(): void + { + [$elementId, $etag] = $this->createElementAndGetEtag('if-match-and-if-none-match-both-match'); + + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN, [ + 'If-Match' => $etag, + 'If-None-Match' => $etag, + ]); + $this->assertNotModifiedResponse($response); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testIfMatchFailingWinsOverMatchingIfNoneMatch(): void + { + [$elementId, $etag] = $this->createElementAndGetEtag('if-match-fails-if-none-match-matches'); + + // If-Match is evaluated first and fails: the request must get 412, not the 304 that If-None-Match alone + // would give + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN, [ + 'If-Match' => '"wrongEtag"', + 'If-None-Match' => $etag, + ]); + $this->assertIsProblemResponse($response, 412); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testBothFailingGives412FromIfMatch(): void + { + [$elementId] = $this->createElementAndGetEtag('if-match-and-if-none-match-both-fail'); + + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN, [ + 'If-Match' => '"wrongEtag"', + 'If-None-Match' => '"alsoWrongEtag"', + ]); + $this->assertIsProblemResponse($response, 412); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testIfMatchSatisfiedAndIfNoneMatchNotMatchingContinuesNormally(): void + { + [$elementId, $etag] = $this->createElementAndGetEtag('if-match-matches-if-none-match-does-not'); + + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN, [ + 'If-Match' => $etag, + 'If-None-Match' => '"wrongEtag"', + ]); + $this->assertIsNodeResponse($response, 'Data'); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testWildcardIfMatchWithMatchingIfNoneMatchGivesNotModified(): void + { + [$elementId, $etag] = $this->createElementAndGetEtag('if-match-wildcard-if-none-match-matches'); + + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN, [ + 'If-Match' => '*', + 'If-None-Match' => $etag, + ]); + $this->assertNotModifiedResponse($response); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testMatchingIfMatchWithWildcardIfNoneMatchGivesNotModified(): void + { + [$elementId, $etag] = $this->createElementAndGetEtag('if-match-matches-if-none-match-wildcard'); + + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN, [ + 'If-Match' => $etag, + 'If-None-Match' => '*', + ]); + $this->assertNotModifiedResponse($response); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testFailingIfMatchWithWildcardIfNoneMatchStillGives412(): void + { + [$elementId] = $this->createElementAndGetEtag('if-match-fails-if-none-match-wildcard'); + + // the wildcard If-None-Match would be satisfied by any current representation, but If-Match is evaluated + // first and fails, so the request must never reach the point where the wildcard could turn it into a 304 + $response = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN, [ + 'If-Match' => '"wrongEtag"', + 'If-None-Match' => '*', + ]); + $this->assertIsProblemResponse($response, 412); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testBothMatchingOnModifyingRequestGives412NotNotModified(): void + { + [$elementId, $etag] = $this->createElementAndGetEtag('if-match-and-if-none-match-both-match-patch'); + + // 304 only applies to GET/HEAD; a modifying request with a matching If-None-Match gives 412 instead, even + // though If-Match (evaluated first) is satisfied + $response = $this->runPatchRequest(sprintf('/%s', $elementId), self::TOKEN, ['name' => 'changed'], [ + 'If-Match' => $etag, + 'If-None-Match' => $etag, + ]); + $this->assertIsProblemResponse($response, 412); + + // nothing was changed by the rejected request + $body = $this->getBody($this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN)); + $this->assertSame('if-match-and-if-none-match-both-match-patch', $body['data']['name']); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } + + public function testIfMatchFailingOnModifyingRequestWinsOverMatchingIfNoneMatch(): void + { + [$elementId, $etag] = $this->createElementAndGetEtag('if-match-fails-if-none-match-matches-patch'); + + $response = $this->runPatchRequest(sprintf('/%s', $elementId), self::TOKEN, ['name' => 'changed'], [ + 'If-Match' => '"wrongEtag"', + 'If-None-Match' => $etag, + ]); + $this->assertIsProblemResponse($response, 412); + + $body = $this->getBody($this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN)); + $this->assertSame('if-match-fails-if-none-match-matches-patch', $body['data']['name']); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN); + } +} diff --git a/tests/FeatureTests/General/IfMatchTest.php b/tests/FeatureTests/General/IfMatchTest.php index e5870d89..0dd40032 100644 --- a/tests/FeatureTests/General/IfMatchTest.php +++ b/tests/FeatureTests/General/IfMatchTest.php @@ -296,22 +296,17 @@ public function testIfMatchRelated(): void public function testEtagIfMatchWithPatchElement(): void { - $response = $this->runGetRequest( - sprintf( - '%s', - self::ID_DATA - ), - self::TOKEN - ); + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'if-match-patch-element'], + ])); + + $response = $this->runGetRequest($elementId, self::TOKEN); $this->assertIsNodeResponse($response, 'Data'); $etag = $response->getHeader('ETag')[0]; - $this->assertSame('"6JM8JahrCeu"', $etag); $response = $this->runPatchRequest( - sprintf( - '%s', - self::ID_DATA - ), + $elementId, self::TOKEN, [ 'new' => 'data', @@ -323,56 +318,47 @@ public function testEtagIfMatchWithPatchElement(): void $this->assertIsProblemResponse($response, 412); $response = $this->runPatchRequest( - sprintf( - '%s', - self::ID_DATA - ), + $elementId, self::TOKEN, [ 'new' => 'data', ], [ - 'If-Match' => '"6JM8JahrCeu"', + 'If-Match' => $etag, ] ); $this->assertNoContentResponse($response); $response = $this->runPatchRequest( - sprintf( - '%s', - self::ID_DATA - ), + $elementId, self::TOKEN, [ 'new' => 'data 2', ], ); $this->assertNoContentResponse($response); + + $this->assertIsDeletedResponse($this->runDeleteRequest($elementId, self::TOKEN)); } public function testEtagIfMatchWithPutElement(): void { - $response = $this->runGetRequest( - sprintf( - '%s', - self::ID_CHILD - ), - self::TOKEN - ); + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'if-match-put-element'], + ])); + + $response = $this->runGetRequest($elementId, self::TOKEN); $this->assertIsNodeResponse($response, 'Data'); $etag = $response->getHeader('ETag')[0]; - $this->assertSame('"aKq8GOPALf"', $etag); $response = $this->runPutRequest( - sprintf( - '%s', - self::ID_CHILD - ), + $elementId, self::TOKEN, [ 'new' => 'data', 'scenario' => 'general.if-match', - 'name' => 'Child', + 'name' => 'if-match-put-element', ], [ 'If-Match' => '"wrongEtag"', @@ -381,55 +367,46 @@ public function testEtagIfMatchWithPutElement(): void $this->assertIsProblemResponse($response, 412); $response = $this->runPutRequest( - sprintf( - '%s', - self::ID_CHILD - ), + $elementId, self::TOKEN, [ 'new' => 'data', 'scenario' => 'general.if-match', - 'name' => 'Child', + 'name' => 'if-match-put-element', ], [ - 'If-Match' => '"aKq8GOPALf"', + 'If-Match' => $etag, ] ); $this->assertNoContentResponse($response); $response = $this->runPutRequest( - sprintf( - '%s', - self::ID_CHILD - ), + $elementId, self::TOKEN, [ 'new' => 'data', 'scenario' => 'general.if-match', - 'name' => 'Child', + 'name' => 'if-match-put-element', ], ); $this->assertNoContentResponse($response); + + $this->assertIsDeletedResponse($this->runDeleteRequest($elementId, self::TOKEN)); } public function testEtagIfMatchWithDeleteElement(): void { - $response = $this->runGetRequest( - sprintf( - '%s', - self::ID_PARENT - ), - self::TOKEN - ); + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'if-match-delete-element'], + ])); + + $response = $this->runGetRequest($elementId, self::TOKEN); $this->assertIsNodeResponse($response, 'Data'); $etag = $response->getHeader('ETag')[0]; - $this->assertSame('"VaJ7FG60f3R"', $etag); $response = $this->runDeleteRequest( - sprintf( - '%s', - self::ID_PARENT - ), + $elementId, self::TOKEN, [ 'If-Match' => '"wrongEtag"', @@ -438,13 +415,10 @@ public function testEtagIfMatchWithDeleteElement(): void $this->assertIsProblemResponse($response, 412); $response = $this->runDeleteRequest( - sprintf( - '%s', - self::ID_PARENT - ), + $elementId, self::TOKEN, [ - 'If-Match' => '"VaJ7FG60f3R"', + 'If-Match' => $etag, ] ); $this->assertNoContentResponse($response); diff --git a/tests/FeatureTests/General/IfNoneMatchTest.php b/tests/FeatureTests/General/IfNoneMatchTest.php index 5dcfb720..c3dcc704 100644 --- a/tests/FeatureTests/General/IfNoneMatchTest.php +++ b/tests/FeatureTests/General/IfNoneMatchTest.php @@ -296,37 +296,29 @@ public function testIfMatchRelated(): void public function testEtagIfMatchWithPatchElement(): void { - $response = $this->runGetRequest( - sprintf( - '%s', - self::ID_DATA - ), - self::TOKEN - ); + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'if-none-match-patch-element'], + ])); + + $response = $this->runGetRequest($elementId, self::TOKEN); $this->assertIsNodeResponse($response, 'Data'); $etag = $response->getHeader('ETag')[0]; - $this->assertSame('"ROiR1100cKu"', $etag); $response = $this->runPatchRequest( - sprintf( - '%s', - self::ID_DATA - ), + $elementId, self::TOKEN, [ 'new' => 'data', ], [ - 'If-None-Match' => '"ROiR1100cKu"', + 'If-None-Match' => $etag, ] ); $this->assertIsProblemResponse($response, 412); $response = $this->runPatchRequest( - sprintf( - '%s', - self::ID_DATA - ), + $elementId, self::TOKEN, [ 'new' => 'data', @@ -338,58 +330,49 @@ public function testEtagIfMatchWithPatchElement(): void $this->assertNoContentResponse($response); $response = $this->runPatchRequest( - sprintf( - '%s', - self::ID_DATA - ), + $elementId, self::TOKEN, [ 'new' => 'data 2', ], ); $this->assertNoContentResponse($response); + + $this->assertIsDeletedResponse($this->runDeleteRequest($elementId, self::TOKEN)); } public function testEtagIfMatchWithPutElement(): void { - $response = $this->runGetRequest( - sprintf( - '%s', - self::ID_CHILD - ), - self::TOKEN - ); + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'if-none-match-put-element'], + ])); + + $response = $this->runGetRequest($elementId, self::TOKEN); $this->assertIsNodeResponse($response, 'Data'); $etag = $response->getHeader('ETag')[0]; - $this->assertSame('"QIDRBlpmjG4"', $etag); $response = $this->runPutRequest( - sprintf( - '%s', - self::ID_CHILD - ), + $elementId, self::TOKEN, [ 'new' => 'data', - 'scenario' => 'general.if-None-match', - 'name' => 'Child', + 'scenario' => 'general.if-none-match', + 'name' => 'if-none-match-put-element', ], [ - 'If-None-Match' => '"QIDRBlpmjG4"', + 'If-None-Match' => $etag, ] ); $this->assertIsProblemResponse($response, 412); $response = $this->runPutRequest( - sprintf( - '%s', - self::ID_CHILD - ), + $elementId, self::TOKEN, [ 'new' => 'data', 'scenario' => 'general.if-none-match', - 'name' => 'Child', + 'name' => 'if-none-match-put-element', ], [ 'If-None-Match' => '"wrongEtag"', @@ -398,50 +381,41 @@ public function testEtagIfMatchWithPutElement(): void $this->assertNoContentResponse($response); $response = $this->runPutRequest( - sprintf( - '%s', - self::ID_CHILD - ), + $elementId, self::TOKEN, [ 'new' => 'data', 'scenario' => 'general.if-none-match', - 'name' => 'Child', + 'name' => 'if-none-match-put-element', ], ); $this->assertNoContentResponse($response); + + $this->assertIsDeletedResponse($this->runDeleteRequest($elementId, self::TOKEN)); } public function testEtagIfMatchWithDeleteElement(): void { - $response = $this->runGetRequest( - sprintf( - '%s', - self::ID_PARENT - ), - self::TOKEN - ); + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'if-none-match-delete-element'], + ])); + + $response = $this->runGetRequest($elementId, self::TOKEN); $this->assertIsNodeResponse($response, 'Data'); $etag = $response->getHeader('ETag')[0]; - $this->assertSame('"EGZLT62PpEk"', $etag); $response = $this->runDeleteRequest( - sprintf( - '%s', - self::ID_PARENT - ), + $elementId, self::TOKEN, [ - 'If-None-Match' => '"EGZLT62PpEk"', + 'If-None-Match' => $etag, ] ); $this->assertIsProblemResponse($response, 412); $response = $this->runDeleteRequest( - sprintf( - '%s', - self::ID_PARENT - ), + $elementId, self::TOKEN, [ 'If-None-Match' => '"wrongEtag"', diff --git a/tests/FeatureTests/General/WildcardEtagTest.php b/tests/FeatureTests/General/WildcardEtagTest.php new file mode 100644 index 00000000..079309d8 --- /dev/null +++ b/tests/FeatureTests/General/WildcardEtagTest.php @@ -0,0 +1,105 @@ + + */ + public static function readEndpointProvider(): array + { + return [ + 'node' => [sprintf('/%s', self::ID_DATA)], + 'relation' => [sprintf('/%s', self::ID_RELATION)], + 'index collection' => ['/'], + 'children collection' => [sprintf('/%s/children', self::ID_PARENT)], + 'parents collection' => [sprintf('/%s/parents', self::ID_CHILD)], + 'related collection' => [sprintf('/%s/related', self::ID_PARENT)], + ]; + } + + #[DataProvider('readEndpointProvider')] + public function testIfNoneMatchWildcardReturns304(string $uri): void + { + $this->assertNotModifiedResponse($this->runRequest('GET', $uri, self::TOKEN, headers: ['If-None-Match' => '*'])); + } + + #[DataProvider('readEndpointProvider')] + public function testIfNoneMatchWildcardInListReturns304(string $uri): void + { + $this->assertNotModifiedResponse($this->runRequest('GET', $uri, self::TOKEN, headers: ['If-None-Match' => '"etagDoesNotExist", *'])); + } + + #[DataProvider('readEndpointProvider')] + public function testIfMatchWildcardIsSatisfied(string $uri): void + { + $response = $this->runRequest('GET', $uri, self::TOKEN, headers: ['If-Match' => '*']); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testIfNoneMatchWildcardReturns412OnModifyingRequests(): void + { + $elementId = $this->getUuidFromLocation($this->runPostRequest('/', self::WRITE_TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'wildcard-etag-modify-node'], + ])); + + $this->assertIsProblemResponse( + $this->runPatchRequest(sprintf('/%s', $elementId), self::WRITE_TOKEN, ['name' => 'changed'], ['If-None-Match' => '*']), + 412 + ); + $this->assertIsProblemResponse( + $this->runPutRequest(sprintf('/%s', $elementId), self::WRITE_TOKEN, ['name' => 'changed'], ['If-None-Match' => '*']), + 412 + ); + $this->assertIsProblemResponse( + $this->runDeleteRequest(sprintf('/%s', $elementId), self::WRITE_TOKEN, ['If-None-Match' => '*']), + 412 + ); + + // nothing was changed by the rejected requests + $body = $this->getBody($this->runGetRequest(sprintf('/%s', $elementId), self::WRITE_TOKEN)); + $this->assertSame('wildcard-etag-modify-node', $body['data']['name']); + + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $elementId), self::WRITE_TOKEN)); + } + + public function testIfMatchWildcardAllowsModifyingRequestsOnNodeAndRelation(): void + { + $nodeId = $this->getUuidFromLocation($this->runPostRequest('/', self::WRITE_TOKEN, [ + 'type' => 'Data', + 'data' => ['name' => 'wildcard-etag-if-match-node'], + ])); + $this->assertNoContentResponse( + $this->runPatchRequest(sprintf('/%s', $nodeId), self::WRITE_TOKEN, ['name' => 'changed'], ['If-Match' => '*']) + ); + $this->assertIsDeletedResponse($this->runDeleteRequest(sprintf('/%s', $nodeId), self::WRITE_TOKEN, ['If-Match' => '*'])); + + $relationId = $this->createEphemeralRelation(self::WRITE_TOKEN, 'wildcard-etag-if-match-relation'); + $this->assertNoContentResponse( + $this->runPatchRequest(sprintf('/%s', $relationId), self::WRITE_TOKEN, ['name' => 'changed'], ['If-Match' => '*']) + ); + $this->deleteEphemeralRelation(self::WRITE_TOKEN, $relationId); + } +} diff --git a/tests/FeatureTests/Security/FileAndUploadWriteAccessOnRelationTest.php b/tests/FeatureTests/Security/FileAndUploadWriteAccessOnRelationTest.php new file mode 100644 index 00000000..1329f3e4 --- /dev/null +++ b/tests/FeatureTests/Security/FileAndUploadWriteAccessOnRelationTest.php @@ -0,0 +1,127 @@ +createEphemeralRelation(self::TOKEN_OWNER, 'relation-file-write-access'); + $fileUrl = sprintf('/%s/file', $relationId); + + // no file yet + $this->assertIsProblemResponse($this->runUploadRequest('POST', $fileUrl, 'stranger', self::TOKEN_STRANGER), 404); + $this->assertIsProblemResponse($this->runUploadRequest('PUT', $fileUrl, 'stranger', self::TOKEN_STRANGER), 404); + $this->assertIsProblemResponse($this->runDeleteRequest($fileUrl, self::TOKEN_STRANGER), 404); + $this->assertIsProblemResponse($this->runGetRequest($fileUrl, self::TOKEN_OWNER), 404); + + // with a file + $this->assertIsCreatedResponse($this->runUploadRequest('POST', $fileUrl, 'owner content', self::TOKEN_OWNER), false); + + $this->assertIsProblemResponse($this->runUploadRequest('POST', $fileUrl, 'stranger', self::TOKEN_STRANGER), 404); + $this->assertIsProblemResponse($this->runUploadRequest('PUT', $fileUrl, 'stranger', self::TOKEN_STRANGER), 404); + $this->assertIsProblemResponse($this->runDeleteRequest($fileUrl, self::TOKEN_STRANGER), 404); + $this->assertIsProblemResponse($this->runGetRequest($fileUrl, self::TOKEN_STRANGER), 404); + + // nothing was changed + $this->assertSame('owner content', (string) $this->runGetRequest($fileUrl, self::TOKEN_OWNER)->getBody()); + + // the owner can still replace and delete it + $this->assertIsCreatedResponse($this->runUploadRequest('PUT', $fileUrl, 'replaced', self::TOKEN_OWNER), false); + $this->assertSame('replaced', (string) $this->runGetRequest($fileUrl, self::TOKEN_OWNER)->getBody()); + $this->assertIsDeletedResponse($this->runDeleteRequest($fileUrl, self::TOKEN_OWNER)); + + $this->deleteEphemeralRelation(self::TOKEN_OWNER, $relationId); + } + + public function testStrangerCannotCreateUploadForRelation(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN_OWNER, 'relation-upload-creation-access'); + + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + '', + self::TOKEN_STRANGER, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertIsProblemResponse($response, 404); + + $this->deleteEphemeralRelation(self::TOKEN_OWNER, $relationId); + } + + public function testStrangerCannotUseUploadOfRelation(): void + { + $relationId = $this->createEphemeralRelation(self::TOKEN_OWNER, 'relation-upload-access'); + + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $relationId), + '', + self::TOKEN_OWNER, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + $uploadUrl = sprintf('/upload/%s', $uploadId); + + $this->assertSame(404, $this->runHeadRequest($uploadUrl, self::TOKEN_STRANGER)->getStatusCode()); + $patchResponse = $this->runUploadRequest( + 'PATCH', + $uploadUrl, + 'stranger', + self::TOKEN_STRANGER, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($patchResponse, 404); + $this->assertIsProblemResponse($this->runDeleteRequest($uploadUrl, self::TOKEN_STRANGER), 404); + + // the denied attempts changed nothing + $headResponse = $this->runHeadRequest($uploadUrl, self::TOKEN_OWNER); + $this->assertSame(204, $headResponse->getStatusCode()); + $this->assertSame('0', $headResponse->getHeader('Upload-Offset')[0]); + $this->assertSame('?0', $headResponse->getHeader('Upload-Complete')[0]); + $this->assertIsProblemResponse($this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN_OWNER), 404); + + // the owner can finish it + $finishResponse = $this->runUploadRequest( + 'PATCH', + $uploadUrl, + 'owner data', + self::TOKEN_OWNER, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($finishResponse); + $this->assertSame('owner data', (string) $this->runGetRequest(sprintf('/%s/file', $relationId), self::TOKEN_OWNER)->getBody()); + + $this->deleteEphemeralRelation(self::TOKEN_OWNER, $relationId); + } +} diff --git a/tests/FeatureTests/Security/FileEndpointAccessControlTest.php b/tests/FeatureTests/Security/FileEndpointAccessControlTest.php new file mode 100644 index 00000000..655b2374 --- /dev/null +++ b/tests/FeatureTests/Security/FileEndpointAccessControlTest.php @@ -0,0 +1,136 @@ +createElement(self::TOKEN_OWNER); + + $file = \Safe\fopen(__DIR__.'/../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest('POST', sprintf('/%s/file', $elementId), $file, self::TOKEN_OWNER); + $this->assertIsCreatedResponse($response, false); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN_OWNER); + } + + public function testUserWithoutAccessCannotPostFileToElement(): void + { + // the access check runs before the "already has a file" check, so 404 instead of 409 + $file = \Safe\fopen(__DIR__.'/../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', self::OWNED_DATA_WITH_FILE_ID), + $file, + self::TOKEN_STRANGER + ); + $this->assertIsProblemResponse($response, 404); + } + + public function testOwnerCanPutFileToReplaceExisting(): void + { + $elementId = $this->createElement(self::TOKEN_OWNER); + + $file = \Safe\fopen(__DIR__.'/../Asset/cherry-blossoms.jpg', 'r'); + $createResponse = $this->runUploadRequest('POST', sprintf('/%s/file', $elementId), $file, self::TOKEN_OWNER); + $this->assertIsCreatedResponse($createResponse, false); + + $replacementFile = \Safe\fopen(__DIR__.'/../Asset/cherry-blossoms.jpg', 'r'); + $putResponse = $this->runUploadRequest('PUT', sprintf('/%s/file', $elementId), $replacementFile, self::TOKEN_OWNER); + $this->assertIsCreatedResponse($putResponse, false); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN_OWNER); + } + + public function testUserWithoutAccessCannotPutFileToElement(): void + { + $file = \Safe\fopen(__DIR__.'/../Asset/cherry-blossoms.jpg', 'r'); + $response = $this->runUploadRequest( + 'PUT', + sprintf('/%s/file', self::OWNED_DATA_WITH_FILE_ID), + $file, + self::TOKEN_STRANGER + ); + $this->assertIsProblemResponse($response, 404); + + // the file must remain untouched: the owner can still retrieve it afterwards + $getFileResponse = $this->runGetRequest(sprintf('/%s/file', self::OWNED_DATA_WITH_FILE_ID), self::TOKEN_OWNER); + $this->assertIsBinaryStreamResponse($getFileResponse, 'image/jpeg'); + } + + public function testOwnerCanDeleteFile(): void + { + $elementId = $this->createElement(self::TOKEN_OWNER); + + $file = \Safe\fopen(__DIR__.'/../Asset/cherry-blossoms.jpg', 'r'); + $createResponse = $this->runUploadRequest('POST', sprintf('/%s/file', $elementId), $file, self::TOKEN_OWNER); + $this->assertIsCreatedResponse($createResponse, false); + + $deleteResponse = $this->runDeleteRequest(sprintf('/%s/file', $elementId), self::TOKEN_OWNER); + $this->assertIsDeletedResponse($deleteResponse); + + $getFileResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN_OWNER); + $this->assertIsProblemResponse($getFileResponse, 404); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN_OWNER); + } + + public function testUserWithoutAccessCannotDeleteFile(): void + { + $response = $this->runDeleteRequest(sprintf('/%s/file', self::OWNED_DATA_WITH_FILE_ID), self::TOKEN_STRANGER); + $this->assertIsProblemResponse($response, 404); + + // the file must remain untouched: the owner can still retrieve it afterwards + $getFileResponse = $this->runGetRequest(sprintf('/%s/file', self::OWNED_DATA_WITH_FILE_ID), self::TOKEN_OWNER); + $this->assertIsBinaryStreamResponse($getFileResponse, 'image/jpeg'); + } + + public function testUserWithReadOnlyAccessCanReadElementButNotWriteItsFile(): void + { + $elementResponse = $this->runGetRequest(sprintf('/%s', self::READ_ONLY_DATA_ID), self::TOKEN_READ_ONLY); + $this->assertIsNodeResponse($elementResponse, 'Data'); + + $file = \Safe\fopen(__DIR__.'/../Asset/cherry-blossoms.jpg', 'r'); + $postResponse = $this->runUploadRequest('POST', sprintf('/%s/file', self::READ_ONLY_DATA_ID), $file, self::TOKEN_READ_ONLY); + $this->assertIsProblemResponse($postResponse, 404); + + $file = \Safe\fopen(__DIR__.'/../Asset/cherry-blossoms.jpg', 'r'); + $putResponse = $this->runUploadRequest('PUT', sprintf('/%s/file', self::READ_ONLY_DATA_ID), $file, self::TOKEN_READ_ONLY); + $this->assertIsProblemResponse($putResponse, 404); + + $deleteResponse = $this->runDeleteRequest(sprintf('/%s/file', self::READ_ONLY_DATA_ID), self::TOKEN_READ_ONLY); + $this->assertIsProblemResponse($deleteResponse, 404); + } + + private function createElement(string $token): string + { + $response = $this->runPostRequest( + '/', + $token, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'file-access-control-test', + ], + ] + ); + + return $this->getUuidFromLocation($response); + } +} diff --git a/tests/FeatureTests/Security/FilePermissionOnRelationTest.php b/tests/FeatureTests/Security/FilePermissionOnRelationTest.php new file mode 100644 index 00000000..3c3127fa --- /dev/null +++ b/tests/FeatureTests/Security/FilePermissionOnRelationTest.php @@ -0,0 +1,43 @@ +runGetRequest(sprintf('/%s', self::RELATION_ID), self::TOKEN_USER_A); + $this->assertIsRelationResponse($relationResponse, 'RELATED'); + $body = $this->getBody($relationResponse); + $this->assertSame(strlen(self::FILE_CONTENT), $body['file']['contentLength']); + $this->assertSame('txt', $body['file']['extension']); + $this->assertSame(hash('sha256', self::FILE_CONTENT), $body['file']['hash']['sha256']); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', self::RELATION_ID), self::TOKEN_USER_A); + $this->assertIsBinaryStreamResponse($fileResponse, 'text/plain'); + $this->assertSame(self::FILE_CONTENT, (string) $fileResponse->getBody()); + } + + public function testUserWithoutAccessGetsNotFoundForRelationAndFile(): void + { + $relationResponse = $this->runGetRequest(sprintf('/%s', self::RELATION_ID), self::TOKEN_USER_B); + $this->assertIsProblemResponse($relationResponse, 404); + + $fileResponse = $this->runGetRequest(sprintf('/%s/file', self::RELATION_ID), self::TOKEN_USER_B); + $this->assertIsProblemResponse($fileResponse, 404); + } +} diff --git a/tests/FeatureTests/Security/FilePermissionTest.php b/tests/FeatureTests/Security/FilePermissionTest.php index 5c438f70..1a4a7cb5 100644 --- a/tests/FeatureTests/Security/FilePermissionTest.php +++ b/tests/FeatureTests/Security/FilePermissionTest.php @@ -5,13 +5,11 @@ namespace App\Tests\FeatureTests\Security; use App\Tests\FeatureTests\BaseRequestTestCase; -use PHPUnit\Framework\Attributes\Group; /** * Verifies the "security.filePermission" reference dataset scenario: User A created and owns a Data element * with a file, User B has no relation to it at all and must be unable to see the element or its file. */ -#[Group('test')] class FilePermissionTest extends BaseRequestTestCase { private const string TOKEN_USER_A = 'secret-token:V8m72O3ovtRU09JrdbJRnh'; diff --git a/tests/FeatureTests/Security/OwnerOnElementWithoutFileTest.php b/tests/FeatureTests/Security/OwnerOnElementWithoutFileTest.php new file mode 100644 index 00000000..e4d45fe9 --- /dev/null +++ b/tests/FeatureTests/Security/OwnerOnElementWithoutFileTest.php @@ -0,0 +1,82 @@ +runPostRequest( + '/', + self::TOKEN_OWNER, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'owner-on-element-without-file-test', + ], + ] + ); + $this->elementId = $this->getUuidFromLocation($response); + } + + protected function tearDown(): void + { + $this->runDeleteRequest(sprintf('/%s', $this->elementId), self::TOKEN_OWNER); + parent::tearDown(); + } + + public function testOwnerGetsNotFoundWhenRequestingFileOfElementWithoutFile(): void + { + $response = $this->runGetRequest(sprintf('/%s/file', $this->elementId), self::TOKEN_OWNER); + $this->assertIsProblemResponse($response, 404); + } + + public function testOwnerGetsNotFoundWhenDeletingFileOfElementWithoutFile(): void + { + $response = $this->runDeleteRequest(sprintf('/%s/file', $this->elementId), self::TOKEN_OWNER); + $this->assertIsProblemResponse($response, 404); + } + + public function testOwnerGetsMethodNotAllowedWhenPatchingFileEndpoint(): void + { + $response = $this->runPatchRequest(sprintf('/%s/file', $this->elementId), self::TOKEN_OWNER, []); + $this->assertIsProblemResponse($response, 405); + } + + #[DataProvider('webDavMethodProvider')] + public function testOwnerGetsNotImplementedForWebDavMethods(string $method): void + { + $response = $this->runRequest($method, sprintf('/%s', $this->elementId), self::TOKEN_OWNER); + $this->assertSame(501, $response->getStatusCode()); + } + + /** + * @return array + */ + public static function webDavMethodProvider(): array + { + return [ + 'COPY' => ['COPY'], + 'LOCK' => ['LOCK'], + 'MKCOL' => ['MKCOL'], + 'MOVE' => ['MOVE'], + 'PROPFIND' => ['PROPFIND'], + 'PROPPATCH' => ['PROPPATCH'], + 'UNLOCK' => ['UNLOCK'], + ]; + } +} diff --git a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_01_NoConnectionTest.php b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_01_NoConnectionTest.php index 9f536274..bf58824c 100644 --- a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_01_NoConnectionTest.php +++ b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_01_NoConnectionTest.php @@ -130,9 +130,8 @@ public function test1010208(): void */ public function test1010220(): void { - $this->markTestSkipped('WIP'); $response = $this->runGetRequest(sprintf('/%s/file', self::DATA), self::TOKEN); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -142,7 +141,6 @@ public function test1010220(): void */ public function test1010221(): void { - $this->markTestSkipped('WIP'); $response = $this->runPostRequest( sprintf('/%s/file', self::DATA), self::TOKEN, @@ -153,7 +151,7 @@ public function test1010221(): void ], ] ); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -163,9 +161,8 @@ public function test1010221(): void */ public function test1010222(): void { - $this->markTestSkipped('WIP'); $response = $this->runPutRequest(sprintf('/%s/file', self::DATA), self::TOKEN, []); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -175,9 +172,8 @@ public function test1010222(): void */ public function test1010223(): void { - $this->markTestSkipped('WIP'); $response = $this->runPatchRequest(sprintf('/%s/file', self::DATA), self::TOKEN, []); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 405); } /** @@ -187,9 +183,8 @@ public function test1010223(): void */ public function test1010224(): void { - $this->markTestSkipped('WIP'); $response = $this->runDeleteRequest(sprintf('/%s/file', self::DATA), self::TOKEN); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -199,7 +194,6 @@ public function test1010224(): void */ public function test1010230(): void { - $this->markTestSkipped('WIP'); $response = $this->runCopyRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -211,7 +205,6 @@ public function test1010230(): void */ public function test1010231(): void { - $this->markTestSkipped('WIP'); $response = $this->runLockRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -223,7 +216,6 @@ public function test1010231(): void */ public function test1010232(): void { - $this->markTestSkipped('WIP'); $response = $this->runUnlockRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -235,7 +227,6 @@ public function test1010232(): void */ public function test1010233(): void { - $this->markTestSkipped('WIP'); $response = $this->runMkcolRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -247,7 +238,6 @@ public function test1010233(): void */ public function test1010234(): void { - $this->markTestSkipped('WIP'); $response = $this->runMoveRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -259,7 +249,6 @@ public function test1010234(): void */ public function test1010235(): void { - $this->markTestSkipped('WIP'); $response = $this->runPropfindRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -271,7 +260,6 @@ public function test1010235(): void */ public function test1010236(): void { - $this->markTestSkipped('WIP'); $response = $this->runProppatchRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsProblemResponse($response, 501); } diff --git a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php index c32d1338..f54c8da8 100644 --- a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php +++ b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_02_NoRelevantConnectionTest.php @@ -131,9 +131,8 @@ public function test1020208(): void */ public function test1020220(): void { - $this->markTestSkipped('WIP'); $response = $this->runGetRequest(sprintf('/%s/file', self::RELATION), self::TOKEN); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -143,7 +142,6 @@ public function test1020220(): void */ public function test1020221(): void { - $this->markTestSkipped('WIP'); $response = $this->runPostRequest( sprintf('/%s/file', self::RELATION), self::TOKEN, @@ -154,7 +152,7 @@ public function test1020221(): void ], ] ); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -164,9 +162,8 @@ public function test1020221(): void */ public function test1020222(): void { - $this->markTestSkipped('WIP'); $response = $this->runPutRequest(sprintf('/%s/file', self::RELATION), self::TOKEN, []); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -176,9 +173,8 @@ public function test1020222(): void */ public function test1020223(): void { - $this->markTestSkipped('WIP'); $response = $this->runPatchRequest(sprintf('/%s/file', self::RELATION), self::TOKEN, []); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 405); } /** @@ -188,9 +184,8 @@ public function test1020223(): void */ public function test1020224(): void { - $this->markTestSkipped('WIP'); $response = $this->runDeleteRequest(sprintf('/%s/file', self::RELATION), self::TOKEN); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -200,7 +195,6 @@ public function test1020224(): void */ public function test1020230(): void { - $this->markTestSkipped('WIP'); $response = $this->runCopyRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -212,7 +206,6 @@ public function test1020230(): void */ public function test1020231(): void { - $this->markTestSkipped('WIP'); $response = $this->runLockRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -224,7 +217,6 @@ public function test1020231(): void */ public function test1020232(): void { - $this->markTestSkipped('WIP'); $response = $this->runUnlockRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -236,7 +228,6 @@ public function test1020232(): void */ public function test1020233(): void { - $this->markTestSkipped('WIP'); $response = $this->runMkcolRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -248,7 +239,6 @@ public function test1020233(): void */ public function test1020234(): void { - $this->markTestSkipped('WIP'); $response = $this->runMoveRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -260,7 +250,6 @@ public function test1020234(): void */ public function test1020235(): void { - $this->markTestSkipped('WIP'); $response = $this->runPropfindRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -272,7 +261,6 @@ public function test1020235(): void */ public function test1020236(): void { - $this->markTestSkipped('WIP'); $response = $this->runProppatchRequest(sprintf('/%s', self::RELATION), self::TOKEN); $this->assertIsProblemResponse($response, 501); } diff --git a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php index 736570e2..b5c95164 100644 --- a/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php +++ b/tests/FeatureTests/Security/Scenario01BasicNegativeTests/_01_03_MissingAccessToEitherStartOrEndNodeOfRelationshipsTest.php @@ -153,9 +153,8 @@ public function test1030408(): void */ public function test1030420(): void { - $this->markTestSkipped('WIP'); $response = $this->runGetRequest(sprintf('/%s/file', self::RELATION_1), self::TOKEN); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -165,7 +164,6 @@ public function test1030420(): void */ public function test1030421(): void { - $this->markTestSkipped('WIP'); $response = $this->runPostRequest( sprintf('/%s/file', self::RELATION_1), self::TOKEN, @@ -176,7 +174,7 @@ public function test1030421(): void ], ] ); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -186,9 +184,8 @@ public function test1030421(): void */ public function test1030422(): void { - $this->markTestSkipped('WIP'); $response = $this->runPutRequest(sprintf('/%s/file', self::RELATION_1), self::TOKEN, []); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -198,9 +195,8 @@ public function test1030422(): void */ public function test1030423(): void { - $this->markTestSkipped('WIP'); $response = $this->runPatchRequest(sprintf('/%s/file', self::RELATION_1), self::TOKEN, []); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 405); } /** @@ -210,9 +206,8 @@ public function test1030423(): void */ public function test1030424(): void { - $this->markTestSkipped('WIP'); $response = $this->runDeleteRequest(sprintf('/%s/file', self::RELATION_1), self::TOKEN); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -222,7 +217,6 @@ public function test1030424(): void */ public function test1030430(): void { - $this->markTestSkipped('WIP'); $response = $this->runCopyRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -234,7 +228,6 @@ public function test1030430(): void */ public function test1030431(): void { - $this->markTestSkipped('WIP'); $response = $this->runLockRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -246,7 +239,6 @@ public function test1030431(): void */ public function test1030432(): void { - $this->markTestSkipped('WIP'); $response = $this->runUnlockRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -258,7 +250,6 @@ public function test1030432(): void */ public function test1030433(): void { - $this->markTestSkipped('WIP'); $response = $this->runMkcolRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -270,7 +261,6 @@ public function test1030433(): void */ public function test1030434(): void { - $this->markTestSkipped('WIP'); $response = $this->runMoveRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -282,7 +272,6 @@ public function test1030434(): void */ public function test1030435(): void { - $this->markTestSkipped('WIP'); $response = $this->runPropfindRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -294,7 +283,6 @@ public function test1030435(): void */ public function test1030436(): void { - $this->markTestSkipped('WIP'); $response = $this->runProppatchRequest(sprintf('/%s', self::RELATION_1), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -408,9 +396,8 @@ public function test1030608(): void */ public function test1030620(): void { - $this->markTestSkipped('WIP'); $response = $this->runGetRequest(sprintf('/%s/file', self::RELATION_2), self::TOKEN); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -420,7 +407,6 @@ public function test1030620(): void */ public function test1030621(): void { - $this->markTestSkipped('WIP'); $response = $this->runPostRequest( sprintf('/%s/file', self::RELATION_2), self::TOKEN, @@ -431,7 +417,7 @@ public function test1030621(): void ], ] ); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -441,9 +427,8 @@ public function test1030621(): void */ public function test1030622(): void { - $this->markTestSkipped('WIP'); $response = $this->runPutRequest(sprintf('/%s/file', self::RELATION_2), self::TOKEN, []); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -453,9 +438,8 @@ public function test1030622(): void */ public function test1030623(): void { - $this->markTestSkipped('WIP'); $response = $this->runPatchRequest(sprintf('/%s/file', self::RELATION_2), self::TOKEN, []); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 405); } /** @@ -465,9 +449,8 @@ public function test1030623(): void */ public function test1030624(): void { - $this->markTestSkipped('WIP'); $response = $this->runDeleteRequest(sprintf('/%s/file', self::RELATION_2), self::TOKEN); - $this->assertIsProblemResponse($response, 501); + $this->assertIsProblemResponse($response, 404); } /** @@ -477,7 +460,6 @@ public function test1030624(): void */ public function test1030630(): void { - $this->markTestSkipped('WIP'); $response = $this->runCopyRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -489,7 +471,6 @@ public function test1030630(): void */ public function test1030631(): void { - $this->markTestSkipped('WIP'); $response = $this->runLockRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -501,7 +482,6 @@ public function test1030631(): void */ public function test1030632(): void { - $this->markTestSkipped('WIP'); $response = $this->runUnlockRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -513,7 +493,6 @@ public function test1030632(): void */ public function test1030633(): void { - $this->markTestSkipped('WIP'); $response = $this->runMkcolRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -525,7 +504,6 @@ public function test1030633(): void */ public function test1030634(): void { - $this->markTestSkipped('WIP'); $response = $this->runMoveRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -537,7 +515,6 @@ public function test1030634(): void */ public function test1030635(): void { - $this->markTestSkipped('WIP'); $response = $this->runPropfindRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } @@ -549,7 +526,6 @@ public function test1030635(): void */ public function test1030636(): void { - $this->markTestSkipped('WIP'); $response = $this->runProppatchRequest(sprintf('/%s', self::RELATION_2), self::TOKEN); $this->assertIsProblemResponse($response, 501); } diff --git a/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php b/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php index 9ff40d39..7ed155e1 100644 --- a/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php @@ -143,147 +143,4 @@ public function test2010308(): void $response = $this->runDeleteRequest(sprintf('/%s', self::DATA), self::TOKEN); $this->assertIsDeletedResponse($response); } - - /** - * @description test 2-01-03-20 - * - * @todo refactor for v0.2.0 - */ - public function test2010320(): void - { - $this->markTestSkipped(); - $response = $this->runGetRequest(sprintf('/%s/file', self::DATA), self::TOKEN); - $this->assertIsProblemResponse($response, 404); - } - - /** - * @description test 2-01-03-21 - * - * @todo refactor in v0.2.0 with actual body content required - */ - public function test2010321(): void - { - $this->markTestSkipped(); - $response = $this->runPostRequest( - sprintf('/%s/file', self::DATA), - self::TOKEN, - [ - 'type' => 'Data', - 'data' => [ - 'name' => 'I shall not exist.', - ], - ] - ); - $this->assertIsProblemResponse($response, 404); - } - - /** - * @description test 2-01-03-22 - * - * @todo refactor in v0.2.0 with actual body content required - */ - public function test2010322(): void - { - $this->markTestSkipped(); - $response = $this->runPutRequest(sprintf('/%s/file', self::DATA), self::TOKEN, []); - } - - /** - * @description test 2-01-03-23 - * - * @todo refactor in v0.2.0 with actual body content required - */ - public function test2010323(): void - { - $this->markTestSkipped(); - $response = $this->runPatchRequest(sprintf('/%s/file', self::DATA), self::TOKEN, []); - } - - /** - * @description test 2-01-03-24 - * - * @todo refactor in v0.2.0 with actual request required - */ - public function test2010324(): void - { - $this->markTestSkipped(); - $response = $this->runDeleteRequest(sprintf('/%s/file', self::DATA), self::TOKEN); - } - - /** - * @description test 2-01-03-30 - * - * @todo refactor in v0.2.0 with actual request required - */ - public function test2010330(): void - { - $this->markTestSkipped(); - $response = $this->runCopyRequest(sprintf('/%s', self::DATA), self::TOKEN); - } - - /** - * @description test 2-01-03-31 - * - * @todo refactor in v0.2.0 with actual request required - */ - public function test2010331(): void - { - $this->markTestSkipped(); - $response = $this->runLockRequest(sprintf('/%s', self::DATA), self::TOKEN); - } - - /** - * @description test 2-01-03-32 - * - * @todo refactor in v0.2.0 with actual request required - */ - public function test2010332(): void - { - $this->markTestSkipped(); - $response = $this->runUnlockRequest(sprintf('/%s', self::DATA), self::TOKEN); - } - - /** - * @description test 2-01-03-33 - * - * @todo refactor in v0.2.0 with actual request required - */ - public function test2010333(): void - { - $this->markTestSkipped(); - $response = $this->runMkcolRequest(sprintf('/%s', self::DATA), self::TOKEN); - } - - /** - * @description test 2-01-03-34 - * - * @todo refactor in v0.2.0 with actual request required - */ - public function test2010334(): void - { - $this->markTestSkipped(); - $response = $this->runMoveRequest(sprintf('/%s', self::DATA), self::TOKEN); - } - - /** - * @description test 2-01-03-35 - * - * @todo refactor in v0.2.0 with actual request required - */ - public function test2010335(): void - { - $this->markTestSkipped(); - $response = $this->runPropfindRequest(sprintf('/%s', self::DATA), self::TOKEN); - } - - /** - * @description test 2-01-03-36 - * - * @todo refactor in v0.2.0 with actual request required - */ - public function test2010336(): void - { - $this->markTestSkipped(); - $response = $this->runProppatchRequest(sprintf('/%s', self::DATA), self::TOKEN); - } } diff --git a/tests/FeatureTests/Security/UploadEndpointAccessControlTest.php b/tests/FeatureTests/Security/UploadEndpointAccessControlTest.php new file mode 100644 index 00000000..dba6f290 --- /dev/null +++ b/tests/FeatureTests/Security/UploadEndpointAccessControlTest.php @@ -0,0 +1,151 @@ +createElementWithInProgressUpload(self::TOKEN_OWNER); + + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN_OWNER); + $this->assertSame(204, $headResponse->getStatusCode()); + + $finishResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN_OWNER, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertNoContentResponse($finishResponse); + + $getFileResponse = $this->runGetRequest(sprintf('/%s/file', $elementId), self::TOKEN_OWNER); + $this->assertSame(200, $getFileResponse->getStatusCode()); + + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN_OWNER); + } + + public function testDifferentUserCannotPatchHeadOrDeleteUpload(): void + { + [$elementId, $uploadId] = $this->createElementWithInProgressUpload(self::TOKEN_OWNER); + + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN_STRANGER); + $this->assertSame(404, $headResponse->getStatusCode()); + + $patchResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN_STRANGER, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($patchResponse, 404); + + $deleteResponse = $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN_STRANGER); + $this->assertIsProblemResponse($deleteResponse, 404); + + // the denied attempts must not have changed the upload + $headResponseAfter = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN_OWNER); + $this->assertSame(204, $headResponseAfter->getStatusCode()); + $this->assertSame('?0', $headResponseAfter->getHeader('Upload-Complete')[0]); + $this->assertSame('0', $headResponseAfter->getHeader('Upload-Offset')[0]); + + $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN_OWNER); + $this->runDeleteRequest(sprintf('/%s', $elementId), self::TOKEN_OWNER); + } + + /** + * PATCH and HEAD re-check UPDATE access on the upload's target on every call; once the owner loses access to the + * target, the upload is cancelled (see CancelUploadOnAccessLossTest), so there is nothing left to delete either. + */ + public function testUploadCanNoLongerBeInspectedOrCompletedOnceOwnerLosesAccessToTarget(): void + { + [$elementId, $uploadId, $ownsRelationId] = $this->createElementWithInProgressUpload(self::TOKEN_OWNER, true); + + // revoke the owner's own access to the target element by removing the only relation which grants it + $revokeResponse = $this->runDeleteRequest(sprintf('/%s', $ownsRelationId), self::TOKEN_OWNER); + $this->assertIsDeletedResponse($revokeResponse); + $elementResponseAfterRevoke = $this->runGetRequest(sprintf('/%s', $elementId), self::TOKEN_OWNER); + $this->assertIsProblemResponse($elementResponseAfterRevoke, 404); + + $headResponse = $this->runHeadRequest(sprintf('/upload/%s', $uploadId), self::TOKEN_OWNER); + $this->assertSame(404, $headResponse->getStatusCode()); + + $patchResponse = $this->runUploadRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + '', + self::TOKEN_OWNER, + [ + 'Upload-Complete' => '?1', + 'Upload-Offset' => 0, + 'Content-Type' => 'application/partial-upload', + ] + ); + $this->assertIsProblemResponse($patchResponse, 404); + + // the upload was cancelled when the access was revoked + $deleteResponse = $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), self::TOKEN_OWNER); + $this->assertIsProblemResponse($deleteResponse, 404); + } + + /** + * @return array{0: string, 1: string, 2: string} [elementId, uploadId, ownsRelationId] + */ + private function createElementWithInProgressUpload(string $token, bool $includeOwnsRelationId = false): array + { + $elementResponse = $this->runPostRequest( + '/', + $token, + [ + 'type' => 'Data', + 'data' => [ + 'name' => 'upload-access-control-test', + ], + ] + ); + $elementId = $this->getUuidFromLocation($elementResponse); + + $createUploadResponse = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + '', + $token, + [ + 'Upload-Complete' => '?0', + 'Content-Type' => 'application/octet-stream', + ] + ); + $this->assertNoContentResponse($createUploadResponse, true); + $uploadId = $this->getUuidFromLocation($createUploadResponse); + + $ownsRelationId = ''; + if ($includeOwnsRelationId) { + $parentsResponse = $this->runGetRequest(sprintf('/%s/parents', $elementId), $token); + $body = $this->getBody($parentsResponse); + $ownsRelationId = $body['relations'][0]['id']; + } + + return [$elementId, $uploadId, $ownsRelationId]; + } +} diff --git a/tests/FeatureTests/docker-compose-neo4j-5.20.yml b/tests/FeatureTests/docker-compose-neo4j-5.20.yml index 2481b4e6..0fef1121 100644 --- a/tests/FeatureTests/docker-compose-neo4j-5.20.yml +++ b/tests/FeatureTests/docker-compose-neo4j-5.20.yml @@ -1,10 +1,34 @@ +# The production image only contains the application, the connections to the other services are configured through its +# environment (the repository is not mounted, so no `.env` file provides them). +x-api-environment: &api-environment + CYPHER_AUTH: bolt://neo4j:password@neo4j + MONGO_AUTH: mongodb://mongodb:password@mongodb:27017 + ELASTIC_AUTH: elasticsearch:9200 + REDIS_AUTH: tcp://redis?password=redis-password + RABBITMQ_AUTH: amqp://user:password@rabbitmq:5672 + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY_ID: accessKey + S3_SECRET_ACCESS_KEY: secretKey + # cron commands would otherwise run whenever the scheduler triggers them during the test run; the tty-bypass + # rule still lets a manually-`docker exec -t`'d cron command run despite this + DISABLE_CRON: 'true' + services: api-dev: container_name: api-dev image: api:dev-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio expose: - "80" + environment: + DISABLE_CRON: 'true' volumes: - ../../:/var/www/html - ./well-known-security.txt:/well-known-security.txt @@ -12,10 +36,18 @@ services: api-prod: container_name: api-prod image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio expose: - "80" + environment: *api-environment volumes: - - ../../:/var/www/html + # the repository is deliberately not mounted: the production image is tested as it is shipped - ./well-known-security.txt:/well-known-security.txt neo4j: @@ -35,6 +67,7 @@ services: environment: - MONGO_INITDB_ROOT_USERNAME=mongodb - MONGO_INITDB_ROOT_PASSWORD=password + - GLIBC_TUNABLES=glibc.pthread.rseq=1 elasticsearch: container_name: elasticsearch @@ -65,7 +98,7 @@ services: minio: container_name: minio - image: quay.io/minio/minio:latest + image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z expose: - "9000" - "9001" @@ -75,7 +108,7 @@ services: command: server /data --console-address ":9001" minio-init: - image: quay.io/minio/mc:latest + image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z depends_on: - minio entrypoint: > diff --git a/tests/FeatureTests/docker-compose-neo4j-5.7.yml b/tests/FeatureTests/docker-compose-neo4j-5.7.yml index 0e8f669e..e12af63e 100644 --- a/tests/FeatureTests/docker-compose-neo4j-5.7.yml +++ b/tests/FeatureTests/docker-compose-neo4j-5.7.yml @@ -1,10 +1,34 @@ +# The production image only contains the application, the connections to the other services are configured through its +# environment (the repository is not mounted, so no `.env` file provides them). +x-api-environment: &api-environment + CYPHER_AUTH: bolt://neo4j:password@neo4j + MONGO_AUTH: mongodb://mongodb:password@mongodb:27017 + ELASTIC_AUTH: elasticsearch:9200 + REDIS_AUTH: tcp://redis?password=redis-password + RABBITMQ_AUTH: amqp://user:password@rabbitmq:5672 + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY_ID: accessKey + S3_SECRET_ACCESS_KEY: secretKey + # cron commands would otherwise run whenever the scheduler triggers them during the test run; the tty-bypass + # rule still lets a manually-`docker exec -t`'d cron command run despite this + DISABLE_CRON: 'true' + services: api-dev: container_name: api-dev image: api:dev-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio expose: - "80" + environment: + DISABLE_CRON: 'true' volumes: - ../../:/var/www/html - ./well-known-security.txt:/well-known-security.txt @@ -12,10 +36,18 @@ services: api-prod: container_name: api-prod image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio expose: - "80" + environment: *api-environment volumes: - - ../../:/var/www/html + # the repository is deliberately not mounted: the production image is tested as it is shipped - ./well-known-security.txt:/well-known-security.txt neo4j: @@ -35,6 +67,7 @@ services: environment: - MONGO_INITDB_ROOT_USERNAME=mongodb - MONGO_INITDB_ROOT_PASSWORD=password + - GLIBC_TUNABLES=glibc.pthread.rseq=1 elasticsearch: container_name: elasticsearch @@ -65,7 +98,7 @@ services: minio: container_name: minio - image: quay.io/minio/minio:latest + image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z expose: - "9000" - "9001" @@ -75,7 +108,7 @@ services: command: server /data --console-address ":9001" minio-init: - image: quay.io/minio/mc:latest + image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z depends_on: - minio entrypoint: > diff --git a/tests/FeatureTests/docker-compose-neo4j-latest.yml b/tests/FeatureTests/docker-compose-neo4j-latest.yml index f0a60502..80dd5d94 100644 --- a/tests/FeatureTests/docker-compose-neo4j-latest.yml +++ b/tests/FeatureTests/docker-compose-neo4j-latest.yml @@ -1,10 +1,34 @@ +# The production image only contains the application, the connections to the other services are configured through its +# environment (the repository is not mounted, so no `.env` file provides them). +x-api-environment: &api-environment + CYPHER_AUTH: bolt://neo4j:password@neo4j + MONGO_AUTH: mongodb://mongodb:password@mongodb:27017 + ELASTIC_AUTH: elasticsearch:9200 + REDIS_AUTH: tcp://redis?password=redis-password + RABBITMQ_AUTH: amqp://user:password@rabbitmq:5672 + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY_ID: accessKey + S3_SECRET_ACCESS_KEY: secretKey + # cron commands would otherwise run whenever the scheduler triggers them during the test run; the tty-bypass + # rule still lets a manually-`docker exec -t`'d cron command run despite this + DISABLE_CRON: 'true' + services: api-dev: container_name: api-dev image: api:dev-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio expose: - "80" + environment: + DISABLE_CRON: 'true' volumes: - ../../:/var/www/html - ./well-known-security.txt:/well-known-security.txt @@ -12,10 +36,18 @@ services: api-prod: container_name: api-prod image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio expose: - "80" + environment: *api-environment volumes: - - ../../:/var/www/html + # the repository is deliberately not mounted: the production image is tested as it is shipped - ./well-known-security.txt:/well-known-security.txt neo4j: @@ -35,6 +67,7 @@ services: environment: - MONGO_INITDB_ROOT_USERNAME=mongodb - MONGO_INITDB_ROOT_PASSWORD=password + - GLIBC_TUNABLES=glibc.pthread.rseq=1 elasticsearch: container_name: elasticsearch @@ -65,7 +98,7 @@ services: minio: container_name: minio - image: quay.io/minio/minio:latest + image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z expose: - "9000" - "9001" @@ -75,7 +108,7 @@ services: command: server /data --console-address ":9001" minio-init: - image: quay.io/minio/mc:latest + image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z depends_on: - minio entrypoint: > diff --git a/tests/ServerTests/BaseServerTestCase.php b/tests/ServerTests/BaseServerTestCase.php new file mode 100644 index 00000000..db0ee5c2 --- /dev/null +++ b/tests/ServerTests/BaseServerTestCase.php @@ -0,0 +1,161 @@ +elementIdsToDelete as $elementId) { + $this->runDeleteRequest(sprintf('/%s', $elementId), $this->getToken()); + } + $this->elementIdsToDelete = []; + parent::tearDown(); + } + + protected function trackElementFromLocation(ResponseInterface $response): void + { + $this->elementIdsToDelete[] = $this->getUuidFromLocation($response); + } + + protected function getToken(): string + { + return $_ENV['SERVER_TEST_TOKEN']; + } + + protected function createElement(): string + { + $response = $this->runPostRequest('/', $this->getToken(), ['type' => 'Data', 'data' => ['name' => 'server test']]); + $this->assertSame(201, $response->getStatusCode()); + $elementId = $this->getUuidFromLocation($response); + $this->elementIdsToDelete[] = $elementId; + + return $elementId; + } + + /** + * Creates an element with a resumable upload, which already contains its first chunk of the minimum size. + * + * @return array{string, string} element id and upload id + */ + protected function createElementWithResumableUpload(): array + { + $elementId = $this->createElement(); + $response = $this->runUploadRequest( + 'POST', + sprintf('/%s/file', $elementId), + str_repeat('a', self::FIRST_CHUNK_SIZE_IN_BYTES), + $this->getToken(), + ['Upload-Complete' => '?0', 'Content-Type' => 'application/octet-stream'] + ); + $this->assertSame(204, $response->getStatusCode()); + + return [$elementId, $this->getUuidFromLocation($response)]; + } + + /** + * Starts the final chunk of a resumable upload with the given body length, the body has to be sent by the caller. + */ + protected function startFinalChunkRequest(string $uploadId, int $bodyLength): RawHttpRequest + { + return new RawHttpRequest( + 'PATCH', + sprintf('/upload/%s', $uploadId), + [ + 'Authorization' => sprintf('Bearer %s', $this->getToken()), + 'Upload-Complete' => '?1', + 'Upload-Offset' => (string) self::FIRST_CHUNK_SIZE_IN_BYTES, + 'Content-Type' => 'application/partial-upload', + ], + $bodyLength + ); + } + + protected function startCreateElementRequest(string $body): RawHttpRequest + { + return new RawHttpRequest( + 'POST', + '/', + [ + 'Authorization' => sprintf('Bearer %s', $this->getToken()), + 'Content-Type' => 'application/json; charset=utf-8', + ], + strlen($body) + ); + } + + protected function createElementBody(): string + { + return json_encode(['type' => 'Data', 'data' => ['name' => 'slow server test']], JSON_THROW_ON_ERROR); + } + + /** + * Every problem json response, also the ones which are created by the web server, identifies its request as + * `instance` (`urn:uuid:`); the id is also part of the logs (`requestId` of the application, `request_id` of + * the web server). + */ + protected function getInstanceOfProblemResponse(ResponseInterface $response): string + { + $this->assertGreaterThanOrEqual(400, $response->getStatusCode()); + $this->assertStringStartsWith('application/problem+json', $response->getHeaderLine('Content-Type')); + $body = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + $this->assertIsArray($body); + $this->assertArrayHasKey('instance', $body); + $this->assertIsString($body['instance']); + $this->assertMatchesRegularExpression('/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $body['instance']); + // the id is not part of the response headers + $this->assertFalse($response->hasHeader('X-Request-Id')); + + return $body['instance']; + } + + /** + * A cheap problem json response created by the application. + */ + protected function runNotFoundRequest(): ResponseInterface + { + $response = $this->runGetRequest(sprintf('/%s', Uuid::uuid4()->toString()), $this->getToken()); + $this->assertSame(404, $response->getStatusCode()); + + return $response; + } + + protected function assertResponseMatchesDocumentation(string $directory, ResponseInterface $response, bool $ignoreTypeLine = false, bool $ignoreDetailLine = false): void + { + $this->getInstanceOfProblemResponse($response); + $prefix = sprintf('docs/server/%s/%d-response-', $directory, $response->getStatusCode()); + $this->assertHeadersInDocumentationAreIdenticalToHeadersFromRequest( + self::PATH_TO_ROOT, + $prefix.'header.txt', + $response + ); + $this->assertBodyInDocumentationIsIdenticalToBodyFromRequest( + self::PATH_TO_ROOT, + $prefix.'body.json', + $response, + true, + // the type of application errors contains the host name, the details of errors caused by truncated content + // depend on the PHP version + [...($ignoreTypeLine ? ['"type"'] : []), ...($ignoreDetailLine ? ['"detail"'] : [])] + ); + } +} diff --git a/tests/ServerTests/PhpBootFailureTest.php b/tests/ServerTests/PhpBootFailureTest.php new file mode 100644 index 00000000..579194ab --- /dev/null +++ b/tests/ServerTests/PhpBootFailureTest.php @@ -0,0 +1,29 @@ +runGetRequest('/', $this->getToken()); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertResponseMatchesDocumentation('php-boot-failure', $response); + $this->assertStringNotContainsStringIgnoringCase('memory', (string) $response->getBody()); + // the response created by the web server identifies its request as well, see docker/Caddyfile + $this->assertNotSame( + $this->getInstanceOfProblemResponse($response), + $this->getInstanceOfProblemResponse($this->runGetRequest('/', $this->getToken())) + ); + } +} diff --git a/tests/ServerTests/PhpOutOfMemoryTest.php b/tests/ServerTests/PhpOutOfMemoryTest.php new file mode 100644 index 00000000..4728f8c9 --- /dev/null +++ b/tests/ServerTests/PhpOutOfMemoryTest.php @@ -0,0 +1,48 @@ +runUploadRequest( + 'POST', + '/', + $body, + $this->getToken(), + ['Content-Type' => 'application/json; charset=utf-8'] + ); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertResponseMatchesDocumentation('php-out-of-memory', $response, true); + $instanceOfErrorResponse = $this->getInstanceOfProblemResponse($response); + $responseBody = (string) $response->getBody(); + $this->assertStringNotContainsStringIgnoringCase('memory', $responseBody); + $this->assertStringNotContainsStringIgnoringCase('fatal', $responseBody); + + // the server keeps working afterwards + $followUpResponse = $this->runGetRequest('/', $this->getToken()); + $this->assertSame(200, $followUpResponse->getStatusCode()); + $this->assertNotSame($instanceOfErrorResponse, $this->getInstanceOfProblemResponse($this->runNotFoundRequest())); + } +} diff --git a/tests/ServerTests/PhpThreadExhaustionTest.php b/tests/ServerTests/PhpThreadExhaustionTest.php new file mode 100644 index 00000000..6a2fcb2b --- /dev/null +++ b/tests/ServerTests/PhpThreadExhaustionTest.php @@ -0,0 +1,46 @@ +createElementWithResumableUpload(); + + // the slow upload request occupies the only PHP thread while it waits for the rest of its body + $body = 'final chunk'; + $slowRequest = $this->startFinalChunkRequest($uploadId, strlen($body)); + $slowRequest->sendBody(substr($body, 0, 3)); + sleep(1); + + $start = microtime(true); + $response = $this->runGetRequest('/', $this->getToken()); + $duration = microtime(true) - $start; + + $this->assertSame(503, $response->getStatusCode()); + $this->assertGreaterThan(1.5, $duration, 'The request should have waited for a free thread.'); + $this->assertLessThan(10, $duration); + $this->assertResponseMatchesDocumentation('no-free-php-thread', $response); + $instanceOfServerResponse = $this->getInstanceOfProblemResponse($response); + + // the server recovers as soon as the thread is free again + $slowRequest->sendBody(substr($body, 3)); + $slowResponse = $slowRequest->readResponse(); + $this->assertNotNull($slowResponse); + $this->assertSame(204, $slowResponse->getStatusCode()); + $recoveredResponse = $this->runGetRequest('/', $this->getToken()); + $this->assertSame(200, $recoveredResponse->getStatusCode()); + // the id belongs to the request, also if the answer is created by the web server, and differs between requests + $this->assertNotSame($instanceOfServerResponse, $this->getInstanceOfProblemResponse($this->runNotFoundRequest())); + } +} diff --git a/tests/ServerTests/RawHttpRequest.php b/tests/ServerTests/RawHttpRequest.php new file mode 100644 index 00000000..aa3ec000 --- /dev/null +++ b/tests/ServerTests/RawHttpRequest.php @@ -0,0 +1,121 @@ + $headers + */ + public function __construct(string $method, string $uri, array $headers, private int $contentLength) + { + $parsedUrl = \Safe\parse_url($_ENV['API_DOMAIN']); + $host = $parsedUrl['host'] ?? 'localhost'; + $port = $parsedUrl['port'] ?? 80; + $this->socket = \Safe\stream_socket_client(sprintf('tcp://%s:%d', $host, $port), $errorCode, $errorMessage, 10); + + $headers = array_merge( + ['Host' => $host, 'Connection' => 'close', 'Content-Length' => (string) $contentLength], + $headers + ); + $head = sprintf("%s %s HTTP/1.1\r\n", $method, $uri); + foreach ($headers as $name => $value) { + $head .= sprintf("%s: %s\r\n", $name, $value); + } + $this->write($head."\r\n"); + } + + /** + * @return bool false if the server already closed the connection + */ + public function sendBody(string $bytes): bool + { + $this->sentBodyBytes += strlen($bytes); + + return $this->write($bytes); + } + + /** + * Sends the remaining body bytes, i.e. the ones which were not sent yet, in $parts parts with a pause in between. + */ + public function sendBodySlowly(string $body, int $parts, float $pauseInSeconds): bool + { + $partLength = (int) ceil(strlen($body) / $parts); + $result = true; + foreach (str_split($body, $partLength) as $index => $part) { + if ($index > 0) { + usleep((int) ($pauseInSeconds * 1_000_000)); + } + $result = $this->sendBody($part) && $result; + } + + return $result; + } + + /** + * Returns null if the server closed the connection without answering. + */ + public function readResponse(int $timeoutInSeconds = 60): ?ResponseInterface + { + stream_set_timeout($this->socket, $timeoutInSeconds); + $raw = ''; + while (!feof($this->socket)) { + $chunk = fread($this->socket, 65536); + if (false === $chunk || '' === $chunk) { + if (stream_get_meta_data($this->socket)['timed_out']) { + break; + } + continue; + } + $raw .= $chunk; + } + @fclose($this->socket); + + if ('' === $raw) { + return null; + } + + return Message::parseResponse($raw); + } + + public function close(): void + { + @fclose($this->socket); + } + + public function getSentBodyBytes(): int + { + return $this->sentBodyBytes; + } + + private function write(string $bytes): bool + { + $total = strlen($bytes); + $written = 0; + while ($written < $total) { + $result = @fwrite($this->socket, substr($bytes, $written)); + if (false === $result || 0 === $result) { + return false; + } + $written += $result; + } + + return true; + } +} diff --git a/tests/ServerTests/RequestBodyTimeoutTest.php b/tests/ServerTests/RequestBodyTimeoutTest.php new file mode 100644 index 00000000..4faf89e5 --- /dev/null +++ b/tests/ServerTests/RequestBodyTimeoutTest.php @@ -0,0 +1,75 @@ +/file) and chunks (PATCH /upload/) may take longer + * than the default to send their request body. + */ +#[Group('server-timeouts')] +class RequestBodyTimeoutTest extends BaseServerTestCase +{ + public function testSlowBodyWithinDefaultTimeoutIsAccepted(): void + { + $body = $this->createElementBody(); + $request = $this->startCreateElementRequest($body); + $request->sendBodySlowly($body, 3, 2.0); + $response = $request->readResponse(); + + $this->assertNotNull($response); + $this->assertSame(201, $response->getStatusCode()); + $this->trackElementFromLocation($response); + } + + public function testStalledBodyIsCutOffAfterDefaultTimeout(): void + { + $body = $this->createElementBody(); + $request = $this->startCreateElementRequest($body); + $request->sendBody(substr($body, 0, 10)); + sleep(13); + $request->sendBody(substr($body, 10)); + $response = $request->readResponse(); + + $this->assertNotNull($response); + $this->assertSame(408, $response->getStatusCode()); + $this->assertResponseMatchesDocumentation('request-body-timeout', $response, true); + } + + public function testSlowUploadChunkBeyondDefaultButWithinUploadTimeoutIsAccepted(): void + { + [, $uploadId] = $this->createElementWithResumableUpload(); + + $body = 'final chunk'; + $request = $this->startFinalChunkRequest($uploadId, strlen($body)); + // 15s, longer than the default timeout of 10s, but shorter than the one of upload endpoints of 20s + $request->sendBodySlowly($body, 6, 3.0); + $response = $request->readResponse(); + + $this->assertNotNull($response); + $this->assertSame(204, $response->getStatusCode()); + } + + public function testStalledUploadChunkIsCutOffAfterUploadTimeout(): void + { + [, $uploadId] = $this->createElementWithResumableUpload(); + + $body = 'final chunk'; + $request = $this->startFinalChunkRequest($uploadId, strlen($body)); + $request->sendBody(substr($body, 0, 5)); + sleep(23); + $request->sendBody(substr($body, 5)); + $response = $request->readResponse(); + + $this->assertNotNull($response); + $this->assertSame(408, $response->getStatusCode()); + $this->assertResponseMatchesDocumentation('upload-request-body-timeout', $response, true); + + // the upload was not modified and can be cleaned up + $this->assertSame(204, $this->runDeleteRequest(sprintf('/upload/%s', $uploadId), $this->getToken())->getStatusCode()); + } +} diff --git a/tests/ServerTests/docker-compose.yml b/tests/ServerTests/docker-compose.yml new file mode 100644 index 00000000..b7f74f56 --- /dev/null +++ b/tests/ServerTests/docker-compose.yml @@ -0,0 +1,124 @@ +# Stack for the server tests, see bin/test-server. Service names are used as host names, the containers have no fixed +# names so that the stack can run next to the development stack. +services: + + api-dev: + image: api:dev-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + volumes: + - ../../:/var/www/html + - ../FeatureTests/well-known-security.txt:/well-known-security.txt + + # production image without mounted source code, i.e. as it is delivered; the limits are lowered by bin/test-server + api-prod: + image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + volumes: + - ../FeatureTests/well-known-security.txt:/well-known-security.txt + - ./ini:/server-test-ini:ro + environment: + # additional php.ini directory, only used by scenarios which need a different PHP configuration + - PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d:${SERVER_TEST_INI_DIR:-/server-test-ini/default} + - CYPHER_AUTH=bolt://neo4j:password@neo4j + - MONGO_AUTH=mongodb://mongodb:password@mongodb:27017 + - ELASTIC_AUTH=elasticsearch:9200 + - REDIS_AUTH=tcp://redis?password=redis-password + - RABBITMQ_AUTH=amqp://user:password@rabbitmq:5672 + - S3_ENDPOINT=http://minio:9000 + - S3_ACCESS_KEY_ID=accessKey + - S3_SECRET_ACCESS_KEY=secretKey + - API_REQUEST_BODY_TIMEOUT=${API_REQUEST_BODY_TIMEOUT:-30s} + - API_UPLOAD_REQUEST_BODY_TIMEOUT=${API_UPLOAD_REQUEST_BODY_TIMEOUT:-15m} + - FRANKENPHP_MAX_WAIT_TIME=${FRANKENPHP_MAX_WAIT_TIME:-30s} + - FRANKENPHP_NUM_THREADS=${FRANKENPHP_NUM_THREADS:-16} + - FRANKENPHP_MAX_THREADS=${FRANKENPHP_MAX_THREADS:-64} + + neo4j: + image: neo4j:5.25 + expose: + - "7474" + - "7687" + environment: + - NEO4J_AUTH=neo4j/password + + mongodb: + image: mongo:8 + expose: + - "27017" + environment: + - MONGO_INITDB_ROOT_USERNAME=mongodb + - MONGO_INITDB_ROOT_PASSWORD=password + - GLIBC_TUNABLES=glibc.pthread.rseq=1 + + elasticsearch: + image: elasticsearch:8.17.2 + expose: + - "9200" + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.security.enrollment.enabled=true + + redis: + image: redis:7-alpine + expose: + - "6379" + command: redis-server --save 20 1 --loglevel warning --requirepass redis-password + + rabbitmq: + image: rabbitmq:3-management + expose: + - "5672" + - "15672" + environment: + - RABBITMQ_DEFAULT_USER=user + - RABBITMQ_DEFAULT_PASS=password + + minio: + image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z + expose: + - "9000" + - "9001" + environment: + - MINIO_ROOT_USER=ROOTNAME + - MINIO_ROOT_PASSWORD=CHANGEME123 + command: server /data --console-address ":9001" + + minio-init: + image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set minio http://minio:9000 ROOTNAME CHANGEME123; do + echo 'Waiting for MinIO...'; + sleep 1; + done; + + mc mb --ignore-existing minio/api-storage; + mc mb --ignore-existing minio/api-upload; + + mc anonymous set none minio/api-storage; + mc anonymous set none minio/api-upload; + + mc admin user add minio accessKey secretKey || true; + mc admin policy attach minio readwrite --user accessKey; + + echo 'MinIO buckets and user configured'; + " diff --git a/tests/ServerTests/ini/low-memory/low-memory.ini b/tests/ServerTests/ini/low-memory/low-memory.ini new file mode 100644 index 00000000..b81c4b35 --- /dev/null +++ b/tests/ServerTests/ini/low-memory/low-memory.ini @@ -0,0 +1,2 @@ +; too little memory for PHP to boot the application, see PhpBootFailureTest +memory_limit = 3M diff --git a/tests/UnitTests/Command/BackupCreateCommandTest.php b/tests/UnitTests/Command/BackupCreateCommandTest.php new file mode 100644 index 00000000..473f37fa --- /dev/null +++ b/tests/UnitTests/Command/BackupCreateCommandTest.php @@ -0,0 +1,220 @@ +storageRoot = sys_get_temp_dir().'/backup-create-command-test-'.bin2hex(random_bytes(6)); + $this->backupStorage = new Filesystem(new LocalFilesystemAdapter($this->storageRoot)); + } + + protected function tearDown(): void + { + $this->backupStorage->deleteDirectory(''); + @rmdir($this->storageRoot); + } + + /** + * @param array $fileContents element id => content of its file in S3; ids missing from the + * element manager can be added to $missingElementIds + * @param string[] $missingElementIds + * @param int $uploadCount number of unfinished `Upload` nodes reported by the database + */ + private function runCommand(array $fileContents, array $input = [], array $missingElementIds = [], int $uploadCount = 0): CommandTester + { + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any())->will(function (array $args) use ($missingElementIds) { + if (in_array($args[0]->toString(), $missingElementIds, true)) { + return null; + } + $element = new NodeElement(); + $element->setId($args[0]); + + return $element; + }); + + $summaryReference = null; + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->will(function (array $args) use (&$summaryReference, $fileContents, $uploadCount) { + $text = $args[0]->getText(); + if (str_contains($text, ':Upload')) { + return new SummarizedResult($summaryReference, [new CypherMap(['count' => $uploadCount])]); + } + if (str_contains($text, 'count(')) { + return new SummarizedResult($summaryReference, [new CypherMap(['count' => 0])]); + } + if (str_contains($text, 'element.id')) { + return new SummarizedResult($summaryReference, array_map( + fn (string $id) => new CypherMap(['element.id' => $id]), + array_keys($fileContents) + )); + } + + return new SummarizedResult($summaryReference, []); + }); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getFileNameExtension(Argument::any())->willReturn('bin'); + + $fileOperationFactory = $this->prophesize(S3OperationFactory::class); + $fileOperationFactory->createFileOperationFromElement(Argument::any())->will(function (array $args) { + return new FileOperation('storage', $args[0]->getId()->toString()); + }); + + $s3Service = $this->prophesize(S3Service::class); + $s3Service->getFileAsResource(Argument::any())->will(function (array $args) use ($fileContents) { + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $fileContents[$args[0]->getKey()]); + rewind($resource); + + return $resource; + }); + + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('version')->willReturn('0.0.0-test'); + + $command = new BackupCreateCommand( + $elementManager->reveal(), + $cypherEntityManager->reveal(), + $this->backupStorage, + $this->prophesize(ElementToRawService::class)->reveal(), + $elementService->reveal(), + $bag->reveal(), + new FileService( + $this->prophesize(EmberNexusConfiguration::class)->reveal(), + new StringService($this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal()), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ), + $s3Service->reveal(), + $fileOperationFactory->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + + $tester = new CommandTester($command); + $this->assertSame(Command::SUCCESS, $tester->execute(['name' => self::BACKUP_NAME, ...$input])); + + return $tester; + } + + /** + * @return array + */ + private function readSummary(): array + { + return json_decode($this->backupStorage->read(self::BACKUP_NAME.'/summary.json'), true); + } + + public function testFilesAreWrittenToBackupAndCountedInSummary(): void + { + $idA = Uuid::uuid4()->toString(); + $idB = Uuid::uuid4()->toString(); + + $tester = $this->runCommand([$idA => 'content a', $idB => '']); + + $this->assertSame('content a', $this->backupStorage->read(sprintf('%s/file/%s.bin', self::BACKUP_NAME, $idA))); + $this->assertSame('', $this->backupStorage->read(sprintf('%s/file/%s.bin', self::BACKUP_NAME, $idB))); + $this->assertSame(2, $this->readSummary()['fileCount']); + $this->assertStringContainsString('Successfully backed up 2 files.', $tester->getDisplay()); + } + + public function testNoFilesOptionSkipsFileExport(): void + { + $id = Uuid::uuid4()->toString(); + + $tester = $this->runCommand([$id => 'content'], ['--no-files' => true]); + + $this->assertSame([], $this->backupStorage->listContents(self::BACKUP_NAME.'/file', true)->toArray()); + $this->assertSame(0, $this->readSummary()['fileCount']); + $this->assertStringContainsString('File backup skipped.', $tester->getDisplay()); + } + + public function testBackupWithoutFilesReportsSkippedFileBackup(): void + { + $tester = $this->runCommand([]); + + $this->assertSame(0, $this->readSummary()['fileCount']); + $this->assertStringContainsString('Found 0 files.', $tester->getDisplay()); + $this->assertStringContainsString('File backup skipped.', $tester->getDisplay()); + } + + public function testFileOfVanishedElementIsSkipped(): void + { + $present = Uuid::uuid4()->toString(); + $vanished = Uuid::uuid4()->toString(); + + $this->runCommand([$present => 'a', $vanished => 'b'], [], [$vanished]); + + $this->assertTrue($this->backupStorage->fileExists(sprintf('%s/file/%s.bin', self::BACKUP_NAME, $present))); + $this->assertFalse($this->backupStorage->fileExists(sprintf('%s/file/%s.bin', self::BACKUP_NAME, $vanished))); + } + + public function testUnfinishedUploadsTriggerWarning(): void + { + $tester = $this->runCommand([], uploadCount: 3); + + $this->assertStringContainsString('Found 3 unfinished upload(s)', $tester->getDisplay()); + $this->assertStringContainsString('abandoned', $tester->getDisplay()); + } + + public function testUnfinishedUploadsWarningIsShownWithoutFileExportToo(): void + { + $tester = $this->runCommand([], ['--no-files' => true], uploadCount: 1); + + $this->assertStringContainsString('Found 1 unfinished upload(s)', $tester->getDisplay()); + $this->assertStringContainsString('File backup skipped.', $tester->getDisplay()); + } + + public function testNoWarningWithoutUnfinishedUploads(): void + { + $tester = $this->runCommand([]); + + $this->assertStringNotContainsString('unfinished upload', $tester->getDisplay()); + } +} diff --git a/tests/UnitTests/Command/BackupLoadCommandTest.php b/tests/UnitTests/Command/BackupLoadCommandTest.php new file mode 100644 index 00000000..8279e645 --- /dev/null +++ b/tests/UnitTests/Command/BackupLoadCommandTest.php @@ -0,0 +1,319 @@ + */ + private array $elements = []; + + /** @var string[] ids of elements which were uploaded to S3 */ + private array $uploadedIds = []; + + /** @var string[] ids of elements for which the upload throws */ + private array $failingIds = []; + + protected function setUp(): void + { + $this->storageRoot = sys_get_temp_dir().'/backup-load-command-test-'.bin2hex(random_bytes(6)); + $this->backupStorage = new Filesystem(new LocalFilesystemAdapter($this->storageRoot)); + $this->backupStorage->createDirectory(self::BACKUP_NAME.'/file'); + $this->backupStorage->write(self::BACKUP_NAME.'/summary.json', '{"nodeCount":0,"relationCount":0,"fileCount":0}'); + $this->elements = []; + $this->uploadedIds = []; + $this->failingIds = []; + } + + protected function tearDown(): void + { + $this->backupStorage->deleteDirectory(''); + @rmdir($this->storageRoot); + } + + /** + * @param array|null $fileProperty + */ + private function addBackupFile(string $content, ?array $fileProperty): string + { + $id = Uuid::uuid4(); + $this->backupStorage->write(sprintf('%s/file/%s.bin', self::BACKUP_NAME, $id->toString()), $content); + $element = new NodeElement(); + $element->setId($id); + if (null !== $fileProperty) { + $element->addProperty('file', $fileProperty); + } + $this->elements[$id->toString()] = $element; + + return $id->toString(); + } + + /** + * @return array + */ + private function validFileProperty(string $content = self::CONTENT): array + { + return ['hash' => ['sha256' => hash('sha256', $content)], 'extension' => 'bin']; + } + + /** + * @param array $input + */ + private function runCommand(array $input = [], int $maxFileSizeInBytes = 1024): CommandTester + { + $test = $this; + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->flush()->willReturn($elementManager->reveal()); + $elementManager->getElement(Argument::any())->will( + fn (array $args) => $test->elements[$args[0]->toString()] ?? null + ); + + $summaryReference = null; + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->will(function (array $args) use (&$summaryReference) { + $isCount = str_contains($args[0]->getText(), 'count('); + + return new SummarizedResult($summaryReference, $isCount ? [new CypherMap(['count' => 0])] : []); + }); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $elasticEntityManager = $this->prophesize(ElasticEntityManager::class); + $elasticEntityManager->flush()->willReturn($elasticEntityManager->reveal()); + + $redis = new class extends Client { + public function __call($commandID, $arguments) + { + return null; + } + }; + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileMaxFileSizeInBytes()->willReturn($maxFileSizeInBytes); + + $uploadFileOperationFactory = $this->prophesize(S3OperationFactory::class); + $uploadFileOperationFactory->createUploadFileOperationFromElementAndResource( + Argument::any(), + Argument::any(), + Argument::any() + )->will(fn (array $args) => new UploadFileOperation( + 'upload', + 'upload-key', + 'storage', + null, + $args[0]->getId()->toString(), + $args[1], + $args[2], + 'text/plain' + )); + + $s3Service = $this->prophesize(S3Service::class); + $s3Service->uploadFile(Argument::any())->will(function (array $args) use ($test) { + $id = $args[0]->getStorageKey(); + if (in_array($id, $test->failingIds, true)) { + throw new Exception('S3 is down'); + } + $test->uploadedIds[] = $id; + + return 0; + }); + + $command = new BackupLoadCommand( + $elementManager->reveal(), + $cypherEntityManager->reveal(), + $redis, + $this->backupStorage, + $this->prophesize(RawToElementService::class)->reveal(), + $this->prophesize(EventDispatcherInterface::class)->reveal(), + new AppStateService(), + $elasticEntityManager->reveal(), + $s3Service->reveal(), + $uploadFileOperationFactory->reveal(), + new FileSizeLimitService( + $emberNexusConfiguration->reveal(), + $this->prophesize(Client400BadContentExceptionFactory::class)->reveal() + ), + new FileHashService(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + new \Psr\Log\NullLogger() + ); + + $tester = new CommandTester($command); + $statusCode = $tester->execute(['name' => self::BACKUP_NAME, ...$input]); + $this->assertSame(Command::SUCCESS, $statusCode); + + return $tester; + } + + public function testFileWithMatchingHashIsLoaded(): void + { + $id = $this->addBackupFile(self::CONTENT, $this->validFileProperty()); + + $tester = $this->runCommand(); + + $this->assertSame([$id], $this->uploadedIds); + $this->assertStringContainsString('Loaded 1 files.', $tester->getDisplay()); + } + + public function testFileWithMismatchingHashIsSkipped(): void + { + $this->addBackupFile('tampered content', $this->validFileProperty()); + + $tester = $this->runCommand(); + + $this->assertSame([], $this->uploadedIds); + $this->assertStringContainsString("does not match 'file.hash.sha256'", $tester->getDisplay()); + $this->assertStringContainsString('Loaded 0 files, 1 could not be loaded', $tester->getDisplay()); + } + + public function testFileWithoutHashIsSkipped(): void + { + $this->addBackupFile(self::CONTENT, ['extension' => 'bin']); + + $tester = $this->runCommand(); + + $this->assertSame([], $this->uploadedIds); + $this->assertStringContainsString("no verifiable 'file.hash' property", $tester->getDisplay()); + } + + public function testFileOfElementWithoutFilePropertyIsSkipped(): void + { + $this->addBackupFile(self::CONTENT, null); + + $tester = $this->runCommand(); + + $this->assertSame([], $this->uploadedIds); + $this->assertStringContainsString("no verifiable 'file.hash' property", $tester->getDisplay()); + } + + public function testFileWithOnlyUnsupportedHashAlgorithmIsSkipped(): void + { + $this->addBackupFile(self::CONTENT, ['hash' => ['blake3' => 'abc']]); + + $this->runCommand(); + + $this->assertSame([], $this->uploadedIds); + } + + public function testSkipVerifyLoadsFileWithMismatchingHash(): void + { + $id = $this->addBackupFile('tampered content', $this->validFileProperty()); + + $tester = $this->runCommand(['--skip-verify' => true]); + + $this->assertSame([$id], $this->uploadedIds); + $this->assertStringContainsString('--skip-verify', $tester->getDisplay()); + } + + public function testSkipVerifyLoadsFileWithoutHash(): void + { + $id = $this->addBackupFile(self::CONTENT, null); + + $this->runCommand(['--skip-verify' => true]); + + $this->assertSame([$id], $this->uploadedIds); + } + + public function testOversizedFileIsSkippedEvenWithSkipVerify(): void + { + $this->addBackupFile(self::CONTENT, $this->validFileProperty()); + + $tester = $this->runCommand(['--skip-verify' => true], 5); + + $this->assertSame([], $this->uploadedIds); + $this->assertStringContainsString('File is 12 bytes, which exceeds', $tester->getDisplay()); + } + + public function testFileExactlyAtSizeLimitIsLoaded(): void + { + $id = $this->addBackupFile(self::CONTENT, $this->validFileProperty()); + + $this->runCommand([], strlen(self::CONTENT)); + + $this->assertSame([$id], $this->uploadedIds); + } + + public function testFileWithoutCorrespondingElementIsSkipped(): void + { + $id = $this->addBackupFile(self::CONTENT, $this->validFileProperty()); + unset($this->elements[$id]); + + $tester = $this->runCommand(); + + $this->assertSame([], $this->uploadedIds); + $this->assertStringContainsString('without corresponding element', $tester->getDisplay()); + } + + public function testFileWithNonUuidNameIsIgnored(): void + { + $this->backupStorage->write(self::BACKUP_NAME.'/file/not-a-uuid.bin', self::CONTENT); + + $tester = $this->runCommand(); + + $this->assertSame([], $this->uploadedIds); + $this->assertStringContainsString('Loaded 0 files.', $tester->getDisplay()); + } + + public function testFailingFileDoesNotAbortRestoreOfOtherFiles(): void + { + $failing = $this->addBackupFile(self::CONTENT, $this->validFileProperty()); + $tampered = $this->addBackupFile('tampered content', $this->validFileProperty()); + $good = $this->addBackupFile(self::CONTENT, $this->validFileProperty()); + $this->failingIds = [$failing]; + + $tester = $this->runCommand(); + + $this->assertSame([$good], $this->uploadedIds); + $this->assertNotContains($tampered, $this->uploadedIds); + $this->assertStringContainsString('S3 is down', $tester->getDisplay()); + $this->assertStringContainsString('Loaded 1 files, 2 could not be loaded', $tester->getDisplay()); + } +} diff --git a/tests/UnitTests/Command/Cron/DeleteExpiredUploadsCommandTest.php b/tests/UnitTests/Command/Cron/DeleteExpiredUploadsCommandTest.php index 72d78f5f..2bc89de4 100644 --- a/tests/UnitTests/Command/Cron/DeleteExpiredUploadsCommandTest.php +++ b/tests/UnitTests/Command/Cron/DeleteExpiredUploadsCommandTest.php @@ -7,7 +7,9 @@ use App\Command\Cron\DeleteExpiredUploadsCommand; use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Factory\Type\UploadFactory; +use App\Service\CronExecutionGateService; use App\Service\ElementManager; +use App\Service\ExpiredUploadDeletionAttemptService; use App\Service\UploadService; use App\Type\NodeElement; use App\Type\Upload; @@ -22,11 +24,12 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Log\LoggerInterface; use Ramsey\Uuid\Uuid; +use RuntimeException; use Safe\DateTime; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; #[Small] @@ -46,9 +49,11 @@ private function buildCommand( ?UploadService $uploadService = null, int $expiredUploadCanBeDeletedAfterExpirationInSeconds = 3600, ?ClientInterface $client = null, + ?ExpiredUploadDeletionAttemptService $attemptService = null, + ?LoggerInterface $logger = null, ): DeleteExpiredUploadsCommand { - $bag = $this->prophesize(ParameterBagInterface::class); - $bag->get('isCronDisabled')->willReturn($isCronDisabled); + $cronExecutionGateService = $this->prophesize(CronExecutionGateService::class); + $cronExecutionGateService->shouldSkipExecution()->willReturn($isCronDisabled); $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); $emberNexusConfiguration->getFileExpiredUploadCanBeDeletedAfterExpirationInSeconds() @@ -67,14 +72,23 @@ private function buildCommand( $cypherEntityManager->getClient()->willReturn($clientProphecy->reveal()); } + if (null === $attemptService) { + $attemptServiceProphecy = $this->prophesize(ExpiredUploadDeletionAttemptService::class); + $attemptServiceProphecy->isDeferred(Argument::any())->willReturn(false); + $attemptServiceProphecy->clear(Argument::any()); + $attemptService = $attemptServiceProphecy->reveal(); + } + return new DeleteExpiredUploadsCommand( - $bag->reveal(), + $cronExecutionGateService->reveal(), $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $elementManager ?? $this->prophesize(ElementManager::class)->reveal(), $uploadFactory ?? $this->prophesize(UploadFactory::class)->reveal(), $uploadService ?? $this->prophesize(UploadService::class)->reveal(), - $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + $attemptService, + $logger ?? $this->prophesize(LoggerInterface::class)->reveal(), ); } @@ -94,17 +108,21 @@ public function testCommandStopsEarlyIfCronIsDisabled(): void public function testCommandThrowsIfCronDisabledParameterIsNotBoolean(): void { - $bag = $this->prophesize(ParameterBagInterface::class); - $bag->get('isCronDisabled')->willReturn('not-a-boolean'); + $cronExecutionGateService = $this->prophesize(CronExecutionGateService::class); + $cronExecutionGateService->shouldSkipExecution()->willThrow( + new LogicException('Expected "isCronDisabled" to be of type boolean, got string.') + ); $command = new DeleteExpiredUploadsCommand( - $bag->reveal(), + $cronExecutionGateService->reveal(), $this->prophesize(EmberNexusConfiguration::class)->reveal(), $this->prophesize(CypherEntityManager::class)->reveal(), $this->prophesize(ElementManager::class)->reveal(), $this->prophesize(UploadFactory::class)->reveal(), $this->prophesize(UploadService::class)->reveal(), - $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + $this->prophesize(ExpiredUploadDeletionAttemptService::class)->reveal(), + $this->prophesize(LoggerInterface::class)->reveal(), ); $this->expectException(LogicException::class); @@ -143,7 +161,7 @@ public function testCommandDeletesSingleExpiredUploadIncludingItsChunks(): void 5 * 1024 * 1024, false, $uploadTarget, - 2, + ['aaaaaaaaaaaaaaa1', 'aaaaaaaaaaaaaaa2'], $uploadOwner, 'bin', (new DateTime())->modify('-1 hour') @@ -174,7 +192,7 @@ public function testCommandDeletesSingleExpiredUploadIncludingItsChunks(): void $commandTester->execute([]); $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); - $this->assertStringContainsString('Deleted 1 expired upload(s).', $commandTester->getDisplay()); + $this->assertStringContainsString('Deleted 1 expired upload(s), 0 failed', $commandTester->getDisplay()); } public function testCommandSkipsUploadWhichWasAlreadyDeletedInTheMeantime(): void @@ -197,7 +215,7 @@ public function testCommandSkipsUploadWhichWasAlreadyDeletedInTheMeantime(): voi $commandTester->execute([]); $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); - $this->assertStringContainsString('Deleted 1 expired upload(s).', $commandTester->getDisplay()); + $this->assertStringContainsString('Deleted 1 expired upload(s), 0 failed', $commandTester->getDisplay()); } public function testCommandAppliesGracePeriodToDeletionThreshold(): void @@ -234,7 +252,7 @@ public function testCommandDeletesMultipleExpiredUploads(): void $uploadId1 = Uuid::fromString('2d2c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d1'); $uploadId2 = Uuid::fromString('3e3c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d2'); - $buildUpload = fn ($id) => new Upload($id, null, 0, false, $id, 0, $id, 'bin', (new DateTime())->modify('-1 hour')); + $buildUpload = fn ($id) => new Upload($id, null, 0, false, $id, [], $id, 'bin', (new DateTime())->modify('-1 hour')); $uploadElement1 = (new NodeElement())->setId($uploadId1)->setLabel('Upload'); $uploadElement2 = (new NodeElement())->setId($uploadId2)->setLabel('Upload'); @@ -264,6 +282,108 @@ public function testCommandDeletesMultipleExpiredUploads(): void $commandTester = new CommandTester($command); $commandTester->execute([]); - $this->assertStringContainsString('Deleted 2 expired upload(s).', $commandTester->getDisplay()); + $this->assertStringContainsString('Deleted 2 expired upload(s), 0 failed', $commandTester->getDisplay()); + } + + public function testFailingUploadIsLoggedAndDoesNotStopOtherUploads(): void + { + $badId = Uuid::fromString('2d2c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d1'); + $goodId = Uuid::fromString('3e3c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d2'); + $goodUpload = new Upload($goodId, null, 0, false, $goodId, [], $goodId, 'bin', (new DateTime())->modify('-1 hour')); + $goodElement = (new NodeElement())->setId($goodId)->setLabel('Upload'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $badId->toString()))->willThrow(new RuntimeException('broken node')); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $goodId->toString()))->willReturn($goodElement); + $elementManager->flush()->willReturn($elementManager->reveal()); + + $uploadFactory = $this->prophesize(UploadFactory::class); + $uploadFactory->createUploadFromElement(Argument::is($goodElement))->willReturn($goodUpload); + $uploadService = $this->prophesize(UploadService::class); + $uploadService->deleteUploadAndChunks(Argument::is($goodUpload))->shouldBeCalledOnce(); + + $attemptService = $this->prophesize(ExpiredUploadDeletionAttemptService::class); + $attemptService->isDeferred(Argument::any())->willReturn(false); + $attemptService->recordFailure($badId->toString())->shouldBeCalledOnce()->willReturn(1); + $attemptService->clear($goodId->toString())->shouldBeCalledOnce(); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->error(Argument::containingString('attempt 1 of 3'))->shouldBeCalledOnce(); + + $command = $this->buildCommand( + expiredUploadRows: [['u.id' => $badId->toString()], ['u.id' => $goodId->toString()]], + elementManager: $elementManager->reveal(), + uploadFactory: $uploadFactory->reveal(), + uploadService: $uploadService->reveal(), + attemptService: $attemptService->reveal(), + logger: $logger->reveal(), + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Deleted 1 expired upload(s), 1 failed', $commandTester->getDisplay()); + } + + public function testUploadIsGivenUpAfterThirdFailedAttempt(): void + { + $uploadId = Uuid::fromString('2d2c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d1'); + $uploadElement = (new NodeElement())->setId($uploadId)->setLabel('Upload'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any())->willReturn($uploadElement); + $elementManager->delete(Argument::is($uploadElement))->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + $elementManager->flush()->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + + $uploadFactory = $this->prophesize(UploadFactory::class); + $uploadFactory->createUploadFromElement(Argument::any())->willThrow(new RuntimeException('malformed')); + + $attemptService = $this->prophesize(ExpiredUploadDeletionAttemptService::class); + $attemptService->isDeferred(Argument::any())->willReturn(false); + $attemptService->recordFailure($uploadId->toString())->willReturn(3); + $attemptService->clear($uploadId->toString())->shouldBeCalledOnce(); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->error(Argument::containingString('Giving up'))->shouldBeCalledOnce(); + + $command = $this->buildCommand( + expiredUploadRows: [['u.id' => $uploadId->toString()]], + elementManager: $elementManager->reveal(), + uploadFactory: $uploadFactory->reveal(), + attemptService: $attemptService->reveal(), + logger: $logger->reveal(), + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertStringContainsString('1 given up', $commandTester->getDisplay()); + } + + public function testDeferredUploadIsSkippedSilently(): void + { + $uploadId = Uuid::fromString('2d2c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d1'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any())->shouldNotBeCalled(); + + $attemptService = $this->prophesize(ExpiredUploadDeletionAttemptService::class); + $attemptService->isDeferred($uploadId->toString())->willReturn(true); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->error(Argument::any())->shouldNotBeCalled(); + + $command = $this->buildCommand( + expiredUploadRows: [['u.id' => $uploadId->toString()]], + elementManager: $elementManager->reveal(), + attemptService: $attemptService->reveal(), + logger: $logger->reveal(), + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertStringContainsString('1 waiting for a retry', $commandTester->getDisplay()); } } diff --git a/tests/UnitTests/Command/Cron/ReindexFilesCommandTest.php b/tests/UnitTests/Command/Cron/ReindexFilesCommandTest.php index 5c06bbd5..71a7fd46 100644 --- a/tests/UnitTests/Command/Cron/ReindexFilesCommandTest.php +++ b/tests/UnitTests/Command/Cron/ReindexFilesCommandTest.php @@ -5,6 +5,7 @@ namespace App\Tests\UnitTests\Command\Cron; use App\Command\Cron\ReindexFilesCommand; +use App\Service\CronExecutionGateService; use App\Service\ElementManager; use App\Service\QueueService; use App\Type\NodeElement; @@ -18,7 +19,6 @@ use Ramsey\Uuid\Uuid; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; #[Small] #[CoversClass(ReindexFilesCommand::class)] @@ -31,11 +31,11 @@ private function buildCommand( ?QueueService $queueService = null, ?ElementManager $elementManager = null, ): ReindexFilesCommand { - $bag = $this->prophesize(ParameterBagInterface::class); - $bag->get('isCronDisabled')->willReturn($isCronDisabled); + $cronExecutionGateService = $this->prophesize(CronExecutionGateService::class); + $cronExecutionGateService->shouldSkipExecution()->willReturn($isCronDisabled); return new ReindexFilesCommand( - $bag->reveal(), + $cronExecutionGateService->reveal(), $queueService ?? $this->prophesize(QueueService::class)->reveal(), $elementManager ?? $this->prophesize(ElementManager::class)->reveal() ); @@ -57,11 +57,13 @@ public function testCommandStopsEarlyIfCronIsDisabled(): void public function testCommandThrowsIfCronDisabledParameterIsNotBoolean(): void { - $bag = $this->prophesize(ParameterBagInterface::class); - $bag->get('isCronDisabled')->willReturn('not-a-boolean'); + $cronExecutionGateService = $this->prophesize(CronExecutionGateService::class); + $cronExecutionGateService->shouldSkipExecution()->willThrow( + new LogicException('Expected "isCronDisabled" to be of type boolean, got string.') + ); $command = new ReindexFilesCommand( - $bag->reveal(), + $cronExecutionGateService->reveal(), $this->prophesize(QueueService::class)->reveal(), $this->prophesize(ElementManager::class)->reveal() ); diff --git a/tests/UnitTests/Command/Cron/UpdateOwnershipCommandTest.php b/tests/UnitTests/Command/Cron/UpdateOwnershipCommandTest.php new file mode 100644 index 00000000..9aa95f19 --- /dev/null +++ b/tests/UnitTests/Command/Cron/UpdateOwnershipCommandTest.php @@ -0,0 +1,456 @@ +prophesize(CronExecutionGateService::class); + $cronExecutionGateService->shouldSkipExecution()->willReturn($isCronDisabled); + + return new UpdateOwnershipCommand( + $cronExecutionGateService->reveal(), + $queueService ?? $this->prophesize(QueueService::class)->reveal(), + $elementManager ?? $this->prophesize(ElementManager::class)->reveal(), + $searchAccessCalculatorService ?? $this->prophesize(SearchAccessCalculatorService::class)->reveal(), + $elasticEntityManager ?? $this->prophesize(ElasticEntityManager::class)->reveal(), + $cypherEntityManager ?? $this->prophesize(CypherEntityManager::class)->reveal(), + ); + } + + private function noChildrenCypherEntityManager(): CypherEntityManager + { + $noSummary = null; + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->willReturn(new SummarizedResult($noSummary, [])); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + return $cypherEntityManager->reveal(); + } + + public function testCommandStopsEarlyIfCronIsDisabled(): void + { + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::cetera())->shouldNotBeCalled(); + + $command = $this->buildCommand(isCronDisabled: true, queueService: $queueService->reveal()); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Cron is disabled', $commandTester->getDisplay()); + } + + public function testCommandFinishesWhenCronIsEnabledAndQueueIsEmpty(): void + { + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE), Argument::type('callable')) + ->shouldBeCalledOnce() + ->willReturn(0); + + $command = $this->buildCommand(queueService: $queueService->reveal()); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('Processed 0 queue message(s), updated search access of 0 element(s).', $commandTester->getDisplay()); + } + + public function testCommandThrowsIfCronDisabledParameterIsNotBoolean(): void + { + $cronExecutionGateService = $this->prophesize(CronExecutionGateService::class); + $cronExecutionGateService->shouldSkipExecution()->willThrow( + new LogicException('Expected "isCronDisabled" to be of type boolean, got string.') + ); + + $command = new UpdateOwnershipCommand( + $cronExecutionGateService->reveal(), + $this->prophesize(QueueService::class)->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(SearchAccessCalculatorService::class)->reveal(), + $this->prophesize(ElasticEntityManager::class)->reveal(), + $this->prophesize(CypherEntityManager::class)->reveal(), + ); + + $this->expectException(LogicException::class); + (new CommandTester($command))->execute([]); + } + + public function testCommandSkipsMessageIfElementWasAlreadyDeleted(): void + { + $elementId = Uuid::fromString('5a5c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d5'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any())->willReturn(null); + + $searchAccessCalculatorService = $this->prophesize(SearchAccessCalculatorService::class); + $searchAccessCalculatorService->calculateSearchAccess(Argument::any())->shouldNotBeCalled(); + + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE), Argument::type('callable')) + ->will(function ($args) use ($elementId) { + /** @var callable $handler */ + $handler = $args[1]; + $handler(['relationId' => null, 'startId' => $elementId->toString(), 'endId' => null]); + + return 1; + }); + + $command = $this->buildCommand( + queueService: $queueService->reveal(), + elementManager: $elementManager->reveal(), + searchAccessCalculatorService: $searchAccessCalculatorService->reveal(), + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('updated search access of 0 element(s)', $commandTester->getDisplay()); + } + + public function testCommandDoesNotStoreOrEnqueueChildrenWhenDeltaIsZero(): void + { + $elementId = Uuid::fromString('4f4c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d4'); + $element = (new NodeElement())->setId($elementId)->setLabel('Data'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $elementId->toString())) + ->willReturn($element); + + $searchAccessCalculatorService = $this->prophesize(SearchAccessCalculatorService::class); + $searchAccessCalculatorService->getIndexForElement($element)->willReturn('node_data'); + $searchAccessCalculatorService->calculateSearchAccess($element)->willReturn([ + 'groups' => ['group-a'], + 'users' => ['user-a'], + ]); + + $existingDocument = (new Document()) + ->setIdentifier($elementId->toString()) + ->setIndex('node_data') + ->addProperties([ + // same sets, different order, to also prove the comparison is set based rather than order based + '_groupsWithSearchAccess' => ['group-a'], + '_usersWithSearchAccess' => ['user-a'], + ]); + + $elasticEntityManager = $this->prophesize(ElasticEntityManager::class); + $elasticEntityManager->getOneByIdentifier('node_data', $elementId->toString())->willReturn($existingDocument); + $elasticEntityManager->merge(Argument::any())->shouldNotBeCalled(); + $elasticEntityManager->flush()->shouldNotBeCalled(); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->shouldNotBeCalled(); + + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE), Argument::type('callable')) + ->will(function ($args) use ($elementId) { + /** @var callable $handler */ + $handler = $args[1]; + $handler(['relationId' => null, 'startId' => $elementId->toString(), 'endId' => null]); + + return 1; + }); + + $command = $this->buildCommand( + queueService: $queueService->reveal(), + elementManager: $elementManager->reveal(), + searchAccessCalculatorService: $searchAccessCalculatorService->reveal(), + elasticEntityManager: $elasticEntityManager->reveal(), + cypherEntityManager: $cypherEntityManager->reveal(), + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('updated search access of 0 element(s)', $commandTester->getDisplay()); + } + + public function testCommandStoresAndEnqueuesChildrenWhenDeltaIsNonZero(): void + { + $parentId = Uuid::fromString('4f4c6b60-1b3b-4e6d-9c0b-6c1b7bb2f9d4'); + $childId = Uuid::fromString('11111111-1111-1111-1111-111111111111'); + $parent = (new NodeElement())->setId($parentId)->setLabel('Data'); + $child = (new NodeElement())->setId($childId)->setLabel('Data'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $parentId->toString()))->willReturn($parent); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $childId->toString()))->willReturn($child); + + $searchAccessCalculatorService = $this->prophesize(SearchAccessCalculatorService::class); + $searchAccessCalculatorService->getIndexForElement($parent)->willReturn('node_data'); + $searchAccessCalculatorService->getIndexForElement($child)->willReturn('node_data'); + $searchAccessCalculatorService->calculateSearchAccess($parent)->willReturn([ + 'groups' => [], + 'users' => ['new-user'], + ]); + $searchAccessCalculatorService->calculateSearchAccess($child)->willReturn([ + 'groups' => [], + 'users' => [], + ]); + + $elasticEntityManager = $this->prophesize(ElasticEntityManager::class); + $elasticEntityManager->getOneByIdentifier('node_data', $parentId->toString())->willReturn(null); + $elasticEntityManager->getOneByIdentifier('node_data', $childId->toString())->willReturn(null); + $elasticEntityManager->merge(Argument::any())->shouldBeCalledTimes(1)->willReturn($elasticEntityManager->reveal()); + $elasticEntityManager->flush()->shouldBeCalledTimes(1)->willReturn($elasticEntityManager->reveal()); + + $noSummary = null; + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->willReturn(new SummarizedResult($noSummary, [ + new CypherMap(['id' => $childId->toString()]), + ])); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE), Argument::type('callable')) + ->will(function ($args) use ($parentId) { + /** @var callable $handler */ + $handler = $args[1]; + $handler(['relationId' => null, 'startId' => $parentId->toString(), 'endId' => null]); + + return 1; + }); + + $command = $this->buildCommand( + queueService: $queueService->reveal(), + elementManager: $elementManager->reveal(), + searchAccessCalculatorService: $searchAccessCalculatorService->reveal(), + elasticEntityManager: $elasticEntityManager->reveal(), + cypherEntityManager: $cypherEntityManager->reveal(), + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + // only the parent changed (its own delta was non-zero, but the child's delta was zero once recomputed) + $this->assertStringContainsString('updated search access of 1 element(s)', $commandTester->getDisplay()); + } + + public function testCommandRecalculatesGroupChildrenDirectlyForIsInGroupEvents(): void + { + // IS_IN_GROUP does not change the group's own direct accessors (it grants the member the group's rights, + // not access to the group node itself), so its own delta is always zero; the group's children must still be + // recalculated directly rather than only cascading on a non-zero delta at the group itself + $groupId = Uuid::fromString('44444444-4444-4444-4444-444444444444'); + $memberId = Uuid::fromString('55555555-5555-5555-5555-555555555555'); + $childId = Uuid::fromString('66666666-6666-6666-6666-666666666666'); + $group = (new NodeElement())->setId($groupId)->setLabel('Group'); + $member = (new NodeElement())->setId($memberId)->setLabel('User'); + $child = (new NodeElement())->setId($childId)->setLabel('Data'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $groupId->toString()))->willReturn($group); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $memberId->toString()))->willReturn($member); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $childId->toString()))->willReturn($child); + + $searchAccessCalculatorService = $this->prophesize(SearchAccessCalculatorService::class); + $searchAccessCalculatorService->getIndexForElement($group)->willReturn('node_group'); + $searchAccessCalculatorService->getIndexForElement($member)->willReturn('node_user'); + $searchAccessCalculatorService->getIndexForElement($child)->willReturn('node_data'); + // the group's own delta is zero (its own accessors are unaffected by who is a member)... + $searchAccessCalculatorService->calculateSearchAccess($group)->willReturn(['groups' => [], 'users' => []]); + $searchAccessCalculatorService->calculateSearchAccess($member)->willReturn(['groups' => [], 'users' => []]); + // ...but a child the group owns can still gain the new member as an accessor + $searchAccessCalculatorService->calculateSearchAccess($child)->willReturn(['groups' => [], 'users' => [$memberId->toString()]]); + + $elasticEntityManager = $this->prophesize(ElasticEntityManager::class); + $elasticEntityManager->getOneByIdentifier('node_group', $groupId->toString())->willReturn(null); + $elasticEntityManager->getOneByIdentifier('node_user', $memberId->toString())->willReturn(null); + $elasticEntityManager->getOneByIdentifier('node_data', $childId->toString())->willReturn(null); + $elasticEntityManager->merge(Argument::any())->shouldBeCalledTimes(1)->willReturn($elasticEntityManager->reveal()); + $elasticEntityManager->flush()->shouldBeCalledTimes(1)->willReturn($elasticEntityManager->reveal()); + + $noSummary = null; + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->willReturn(new SummarizedResult($noSummary, [ + new CypherMap(['id' => $childId->toString()]), + ])); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE), Argument::type('callable')) + ->will(function ($args) use ($groupId, $memberId) { + /** @var callable $handler */ + $handler = $args[1]; + $handler([ + 'relationId' => null, + 'relationType' => 'IS_IN_GROUP', + 'startId' => $memberId->toString(), + 'endId' => $groupId->toString(), + ]); + + return 1; + }); + + $command = $this->buildCommand( + queueService: $queueService->reveal(), + elementManager: $elementManager->reveal(), + searchAccessCalculatorService: $searchAccessCalculatorService->reveal(), + elasticEntityManager: $elasticEntityManager->reveal(), + cypherEntityManager: $cypherEntityManager->reveal(), + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $this->assertStringContainsString('updated search access of 1 element(s)', $commandTester->getDisplay()); + } + + public function testCommandDeduplicatesIdsViaVisitedSetToHandleCycles(): void + { + // relationId, startId and endId of the same message all point at the same element; the graph could also + // cycle back to an already visited element through its children - either way it must be recomputed once + $elementId = Uuid::fromString('22222222-2222-2222-2222-222222222222'); + $element = (new NodeElement())->setId($elementId)->setLabel('Data'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::that(fn ($id) => $id->toString() === $elementId->toString())) + ->shouldBeCalledTimes(1) + ->willReturn($element); + + $searchAccessCalculatorService = $this->prophesize(SearchAccessCalculatorService::class); + $searchAccessCalculatorService->getIndexForElement($element)->willReturn('node_data'); + $searchAccessCalculatorService->calculateSearchAccess($element) + ->shouldBeCalledTimes(1) + ->willReturn(['groups' => [], 'users' => []]); + + $elasticEntityManager = $this->prophesize(ElasticEntityManager::class); + $elasticEntityManager->getOneByIdentifier('node_data', $elementId->toString())->willReturn(null); + + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE), Argument::type('callable')) + ->will(function ($args) use ($elementId) { + /** @var callable $handler */ + $handler = $args[1]; + $handler([ + 'relationId' => $elementId->toString(), + 'startId' => $elementId->toString(), + 'endId' => $elementId->toString(), + ]); + + return 1; + }); + + $command = $this->buildCommand( + queueService: $queueService->reveal(), + elementManager: $elementManager->reveal(), + searchAccessCalculatorService: $searchAccessCalculatorService->reveal(), + elasticEntityManager: $elasticEntityManager->reveal(), + cypherEntityManager: $this->noChildrenCypherEntityManager(), + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + } + + public function testCommandThrowsOnceBatchCapIsReachedSoQueueServiceRequeuesRemainingWork(): void + { + $ids = []; + for ($i = 0; $i < 501; ++$i) { + $ids[] = Uuid::uuid4(); + } + $idStrings = array_map(fn ($id) => $id->toString(), $ids); + $elementsById = []; + foreach ($ids as $id) { + $elementsById[$id->toString()] = (new NodeElement())->setId($id)->setLabel('Data'); + } + + // a single Argument::any() matcher per method, dispatching via an id => value map, keeps this fast: giving + // each of the 501 ids its own Argument::that() matcher makes Prophecy's per-call matching quadratic + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElement(Argument::any()) + ->will(fn ($args) => $elementsById[$args[0]->toString()] ?? null); + + $searchAccessCalculatorService = $this->prophesize(SearchAccessCalculatorService::class); + $searchAccessCalculatorService->getIndexForElement(Argument::any())->willReturn('node_data'); + $searchAccessCalculatorService->calculateSearchAccess(Argument::any()) + ->will(fn ($args) => ['groups' => [], 'users' => [$args[0]->getId()->toString()]]); + + $elasticEntityManager = $this->prophesize(ElasticEntityManager::class); + $elasticEntityManager->getOneByIdentifier(Argument::any(), Argument::any())->willReturn(null); + $elasticEntityManager->merge(Argument::any())->willReturn($elasticEntityManager->reveal()); + $elasticEntityManager->flush()->willReturn($elasticEntityManager->reveal()); + + // one child each, chained: id[0] -> id[1] -> ... -> id[500], so the work-list keeps growing past the cap + $noSummary = null; + $childrenByParent = []; + foreach ($idStrings as $index => $idString) { + $childrenByParent[$idString] = isset($ids[$index + 1]) ? [new CypherMap(['id' => $idStrings[$index + 1]])] : []; + } + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any()) + ->will(fn ($args) => new SummarizedResult($noSummary, $childrenByParent[$args[0]->getParameters()['parentId']] ?? [])); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $queueService = $this->prophesize(QueueService::class); + $queueService->consumeQueue(Argument::is(RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE), Argument::type('callable')) + ->will(function ($args) use ($ids) { + /** @var callable $handler */ + $handler = $args[1]; + $handler(['relationId' => null, 'startId' => $ids[0]->toString(), 'endId' => null]); + + return 1; + }); + + $command = $this->buildCommand( + queueService: $queueService->reveal(), + elementManager: $elementManager->reveal(), + searchAccessCalculatorService: $searchAccessCalculatorService->reveal(), + elasticEntityManager: $elasticEntityManager->reveal(), + cypherEntityManager: $cypherEntityManager->reveal(), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/[Bb]atch cap/'); + (new CommandTester($command))->execute([]); + } +} diff --git a/tests/UnitTests/Command/CronCommandTest.php b/tests/UnitTests/Command/CronCommandTest.php index 5d96ed2f..cf6ffbd7 100644 --- a/tests/UnitTests/Command/CronCommandTest.php +++ b/tests/UnitTests/Command/CronCommandTest.php @@ -6,11 +6,15 @@ use App\Command\Cron\DeleteExpiredUploadsCommand; use App\Command\Cron\ReindexFilesCommand; +use App\Command\Cron\UpdateOwnershipCommand; use App\Command\CronCommand; use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Factory\Type\UploadFactory; +use App\Service\CronExecutionGateService; use App\Service\ElementManager; +use App\Service\ExpiredUploadDeletionAttemptService; use App\Service\QueueService; +use App\Service\SearchAccessCalculatorService; use App\Service\UploadService; use EmberNexusBundle\Service\EmberNexusConfiguration; use Laudis\Neo4j\Contracts\ClientInterface; @@ -21,10 +25,12 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Log\LoggerInterface; +use RuntimeException; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; +use Syndesi\ElasticEntityManager\Type\EntityManager as ElasticEntityManager; #[Small] #[CoversClass(CronCommand::class)] @@ -32,11 +38,16 @@ class CronCommandTest extends TestCase { use ProphecyTrait; - private function buildDeleteExpiredUploadsCommand(bool $isCronDisabled): DeleteExpiredUploadsCommand + private function buildCronExecutionGateService(bool $isCronDisabled): CronExecutionGateService { - $bag = $this->prophesize(ParameterBagInterface::class); - $bag->get('isCronDisabled')->willReturn($isCronDisabled); + $cronExecutionGateService = $this->prophesize(CronExecutionGateService::class); + $cronExecutionGateService->shouldSkipExecution()->willReturn($isCronDisabled); + + return $cronExecutionGateService->reveal(); + } + private function buildDeleteExpiredUploadsCommand(bool $isCronDisabled): DeleteExpiredUploadsCommand + { $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); $emberNexusConfiguration->getFileExpiredUploadCanBeDeletedAfterExpirationInSeconds()->willReturn(3600); @@ -49,42 +60,57 @@ private function buildDeleteExpiredUploadsCommand(bool $isCronDisabled): DeleteE } return new DeleteExpiredUploadsCommand( - $bag->reveal(), + $this->buildCronExecutionGateService($isCronDisabled), $emberNexusConfiguration->reveal(), $cypherEntityManager->reveal(), $this->prophesize(ElementManager::class)->reveal(), $this->prophesize(UploadFactory::class)->reveal(), $this->prophesize(UploadService::class)->reveal(), - $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + $this->prophesize(ExpiredUploadDeletionAttemptService::class)->reveal(), + $this->prophesize(LoggerInterface::class)->reveal(), ); } private function buildReindexFilesCommand(bool $isCronDisabled): ReindexFilesCommand { - $bag = $this->prophesize(ParameterBagInterface::class); - $bag->get('isCronDisabled')->willReturn($isCronDisabled); - $queueService = $this->prophesize(QueueService::class); if (!$isCronDisabled) { $queueService->consumeQueue(Argument::any(), Argument::any())->willReturn(0); } return new ReindexFilesCommand( - $bag->reveal(), + $this->buildCronExecutionGateService($isCronDisabled), $queueService->reveal(), $this->prophesize(ElementManager::class)->reveal() ); } - private function buildCommand(bool $isCronDisabled): CronCommand + private function buildUpdateOwnershipCommand(bool $isCronDisabled): UpdateOwnershipCommand { - $bag = $this->prophesize(ParameterBagInterface::class); - $bag->get('isCronDisabled')->willReturn($isCronDisabled); + $queueService = $this->prophesize(QueueService::class); + if (!$isCronDisabled) { + $queueService->consumeQueue(Argument::any(), Argument::any())->willReturn(0); + } + + return new UpdateOwnershipCommand( + $this->buildCronExecutionGateService($isCronDisabled), + $queueService->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(SearchAccessCalculatorService::class)->reveal(), + $this->prophesize(ElasticEntityManager::class)->reveal(), + $this->prophesize(CypherEntityManager::class)->reveal(), + ); + } + private function buildCommand(bool $isCronDisabled): CronCommand + { return new CronCommand( - $bag->reveal(), + $this->buildCronExecutionGateService($isCronDisabled), $this->buildDeleteExpiredUploadsCommand($isCronDisabled), - $this->buildReindexFilesCommand($isCronDisabled) + $this->buildReindexFilesCommand($isCronDisabled), + $this->buildUpdateOwnershipCommand($isCronDisabled), + $this->prophesize(LoggerInterface::class)->reveal() ); } @@ -101,13 +127,17 @@ public function testCommandStopsEarlyIfCronIsDisabled(): void public function testCommandThrowsIfCronDisabledParameterIsNotBoolean(): void { - $bag = $this->prophesize(ParameterBagInterface::class); - $bag->get('isCronDisabled')->willReturn('not-a-boolean'); + $cronExecutionGateService = $this->prophesize(CronExecutionGateService::class); + $cronExecutionGateService->shouldSkipExecution()->willThrow( + new LogicException('Expected "isCronDisabled" to be of type boolean, got string.') + ); $command = new CronCommand( - $bag->reveal(), + $cronExecutionGateService->reveal(), $this->buildDeleteExpiredUploadsCommand(true), - $this->buildReindexFilesCommand(true) + $this->buildReindexFilesCommand(true), + $this->buildUpdateOwnershipCommand(true), + $this->prophesize(LoggerInterface::class)->reveal() ); $this->expectException(LogicException::class); @@ -125,6 +155,43 @@ public function testCommandDispatchesBothSubCommandsWhenEnabled(): void $display = $commandTester->getDisplay(); $this->assertStringContainsString('No expired uploads found.', $display); $this->assertStringContainsString('Reindexed 0 element file(s).', $display); + $this->assertStringContainsString('Processed 0 queue message(s), updated search access of 0 element(s).', $display); $this->assertStringContainsString('Finished.', $display); } + + public function testFailingSubCommandDoesNotPreventFollowingOnesAndFailsCron(): void + { + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileExpiredUploadCanBeDeletedAfterExpirationInSeconds()->willReturn(3600); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willThrow(new RuntimeException('neo4j down')); + $deleteExpiredUploadsCommand = new DeleteExpiredUploadsCommand( + $this->buildCronExecutionGateService(false), + $emberNexusConfiguration->reveal(), + $cypherEntityManager->reveal(), + $this->prophesize(ElementManager::class)->reveal(), + $this->prophesize(UploadFactory::class)->reveal(), + $this->prophesize(UploadService::class)->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + $this->prophesize(ExpiredUploadDeletionAttemptService::class)->reveal(), + $this->prophesize(LoggerInterface::class)->reveal(), + ); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->error(Argument::containingString('neo4j down'), Argument::any())->shouldBeCalledOnce(); + + $command = new CronCommand( + $this->buildCronExecutionGateService(false), + $deleteExpiredUploadsCommand, + $this->buildReindexFilesCommand(false), + $this->buildUpdateOwnershipCommand(false), + $logger->reveal() + ); + + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(Command::FAILURE, $commandTester->getStatusCode()); + $this->assertStringContainsString('Reindexed 0 element file(s).', $commandTester->getDisplay()); + } } diff --git a/tests/UnitTests/Command/DatabaseDropCommandTest.php b/tests/UnitTests/Command/DatabaseDropCommandTest.php new file mode 100644 index 00000000..a95417eb --- /dev/null +++ b/tests/UnitTests/Command/DatabaseDropCommandTest.php @@ -0,0 +1,295 @@ +s3Client = $this->prophesize(S3Client::class); + $this->redisClient = $this->prophesize(RedisClient::class); + $this->amqpChannel = $this->prophesize(AMQPChannel::class); + } + + private function buildCommandTester(bool $isRunCompleted = true): CommandTester + { + $null = null; + $neo4jClient = $this->prophesize(ClientInterface::class); + $neo4jClient->runStatement(Argument::any())->shouldBeCalledOnce()->willReturn(new SummarizedResult($null, [])); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($neo4jClient->reveal()); + + $mongoEntityManager = $this->prophesize(MongoEntityManager::class); + $mongoEntityManager->getDatabase()->willReturn(null); + + // the client is final, so a real one answers the request for the index list with an empty list + $elasticClient = ClientBuilder::create() + ->setHttpClient(new GuzzleClient(['handler' => HandlerStack::create(new MockHandler([ + new Response(200, ['X-Elastic-Product' => 'Elasticsearch'], ''), + ]))])) + ->build(); + $elasticEntityManager = $this->prophesize(ElasticEntityManager::class); + $elasticEntityManager->getClient()->willReturn($elasticClient); + + // the databases after the object storage are only reached if the storage could be emptied + $expectedCalls = $isRunCompleted ? 1 : 0; + $this->redisClient->flushdb()->shouldBeCalledTimes($expectedCalls); + + foreach (RabbitMQQueueType::cases() as $queue) { + $this->amqpChannel->queue_delete($queue->value)->shouldBeCalledTimes($expectedCalls); + } + $this->amqpChannel->close()->shouldBeCalledTimes($expectedCalls); + $amqpConnection = $this->prophesize(AMQPStreamConnection::class); + $amqpConnection->channel()->willReturn($this->amqpChannel->reveal()); + + $configuration = $this->prophesize(EmberNexusConfiguration::class); + $configuration->getFileS3StorageBucket()->willReturn(self::STORAGE_BUCKET); + $configuration->getFileS3UploadBucket()->willReturn(self::UPLOAD_BUCKET); + + $command = new DatabaseDropCommand( + $cypherEntityManager->reveal(), + $mongoEntityManager->reveal(), + $elasticEntityManager->reveal(), + $this->redisClient->reveal(), + $amqpConnection->reveal(), + $this->s3Client->reveal(), + $configuration->reveal(), + ); + $application = new Application(); + $application->addCommand($command); + + return new CommandTester($command); + } + + /** + * @param string[] $keys + */ + private function listOutput(array $keys): ListObjectsV2Output + { + $output = $this->prophesize(ListObjectsV2Output::class); + $output->getContents()->willReturn(array_map(static fn (string $key) => new AwsObject(['Key' => $key]), $keys)); + + return $output->reveal(); + } + + /** + * @param array $uploads pairs of key and upload id + */ + private function multipartOutput(array $uploads): ListMultipartUploadsOutput + { + $output = $this->prophesize(ListMultipartUploadsOutput::class); + $output->getUploads()->willReturn(array_map(static fn (array $upload) => new MultipartUpload(['Key' => $upload[0], 'UploadId' => $upload[1]]), $uploads)); + + return $output->reveal(); + } + + /** + * @param Error[] $errors + */ + private function deleteOutput(array $errors = []): DeleteObjectsOutput + { + $output = $this->prophesize(DeleteObjectsOutput::class); + $output->getErrors()->willReturn($errors); + + return $output->reveal(); + } + + private function noMultipartUploads(string $bucket): void + { + $this->s3Client->listMultipartUploads(['Bucket' => $bucket])->willReturn($this->multipartOutput([])); + } + + public function testDropsAllDatabasesAndEmptiesBothBuckets(): void + { + $this->noMultipartUploads(self::STORAGE_BUCKET); + $this->noMultipartUploads(self::UPLOAD_BUCKET); + $this->s3Client->listObjectsV2(['Bucket' => self::STORAGE_BUCKET])->willReturn($this->listOutput(['a', 'b']), $this->listOutput([])); + $this->s3Client->listObjectsV2(['Bucket' => self::UPLOAD_BUCKET])->willReturn($this->listOutput(['c']), $this->listOutput([])); + $this->s3Client->deleteObjects([ + 'Bucket' => self::STORAGE_BUCKET, + 'Delete' => ['Objects' => [['Key' => 'a'], ['Key' => 'b']]], + ])->shouldBeCalledOnce()->willReturn($this->deleteOutput()); + $this->s3Client->deleteObjects([ + 'Bucket' => self::UPLOAD_BUCKET, + 'Delete' => ['Objects' => [['Key' => 'c']]], + ])->shouldBeCalledOnce()->willReturn($this->deleteOutput()); + + $tester = $this->buildCommandTester(); + $exitCode = $tester->execute(['--force' => true]); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Successfully deleted object storage.', $tester->getDisplay()); + $this->assertStringContainsString('Database successfully dropped.', $tester->getDisplay()); + } + + public function testNoFilesOptionLeavesObjectStorageUntouched(): void + { + $this->s3Client->listObjectsV2(Argument::cetera())->shouldNotBeCalled(); + $this->s3Client->listMultipartUploads(Argument::cetera())->shouldNotBeCalled(); + $this->s3Client->deleteObjects(Argument::cetera())->shouldNotBeCalled(); + + $tester = $this->buildCommandTester(); + $exitCode = $tester->execute(['--force' => true, '--no-files' => true]); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Skipping object storage deletion (--no-files).', $tester->getDisplay()); + } + + public function testAbortsUnfinishedMultipartUploadsBeforeDeletingObjects(): void + { + $this->s3Client->listMultipartUploads(['Bucket' => self::STORAGE_BUCKET])->willReturn( + $this->multipartOutput([['key-1', 'upload-1'], ['key-2', 'upload-2'], [null, 'no-key'], ['no-upload-id', null]]), + $this->multipartOutput([]) + ); + $this->noMultipartUploads(self::UPLOAD_BUCKET); + $this->s3Client->abortMultipartUpload(['Bucket' => self::STORAGE_BUCKET, 'Key' => 'key-1', 'UploadId' => 'upload-1'])->shouldBeCalledOnce(); + $this->s3Client->abortMultipartUpload(['Bucket' => self::STORAGE_BUCKET, 'Key' => 'key-2', 'UploadId' => 'upload-2'])->shouldBeCalledOnce(); + $this->s3Client->abortMultipartUpload(Argument::that(static fn ($input) => is_array($input) && (!isset($input['Key']) || in_array($input['Key'], ['no-key', 'no-upload-id'], true))))->shouldNotBeCalled(); + $this->s3Client->listObjectsV2(Argument::cetera())->willReturn($this->listOutput([])); + + $tester = $this->buildCommandTester(); + + $this->assertSame(Command::SUCCESS, $tester->execute(['--force' => true])); + } + + public function testDeletesObjectsInBatchesOfAtMost1000Keys(): void + { + $this->noMultipartUploads(self::STORAGE_BUCKET); + $this->noMultipartUploads(self::UPLOAD_BUCKET); + $keys = array_map(static fn (int $i) => sprintf('key-%d', $i), range(1, 2500)); + $this->s3Client->listObjectsV2(['Bucket' => self::STORAGE_BUCKET])->willReturn($this->listOutput($keys), $this->listOutput([])); + $this->s3Client->listObjectsV2(['Bucket' => self::UPLOAD_BUCKET])->willReturn($this->listOutput([])); + + $batchSizes = []; + // the batch sizes are checked below, the result object only has to report no errors + $deleteOutput = $this->deleteOutput(); + $this->s3Client->deleteObjects(Argument::any())->will(function (array $args) use (&$batchSizes, $deleteOutput) { + $batchSizes[] = count($args[0]['Delete']['Objects']); + + return $deleteOutput; + }); + + $tester = $this->buildCommandTester(); + $this->assertSame(Command::SUCCESS, $tester->execute(['--force' => true])); + + $this->assertSame([1000, 1000, 500], $batchSizes); + } + + public function testFailedObjectDeletionAbortsWithErrorMessage(): void + { + $this->noMultipartUploads(self::STORAGE_BUCKET); + $this->s3Client->listObjectsV2(['Bucket' => self::STORAGE_BUCKET])->willReturn($this->listOutput(['locked', 'other'])); + $this->s3Client->deleteObjects(Argument::any())->willReturn($this->deleteOutput([ + new Error(['Key' => 'locked', 'Message' => 'Access Denied']), + new Error(['Key' => 'other', 'Code' => 'InternalError']), + new Error([]), + ])); + + $tester = $this->buildCommandTester(false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Unable to delete objects from bucket 'storage-bucket': locked (Access Denied), other (InternalError), ? (unknown error)"); + $tester->execute(['--force' => true]); + } + + public function testBucketWhichNeverBecomesEmptyAbortsAfterIterationGuard(): void + { + $this->noMultipartUploads(self::STORAGE_BUCKET); + $this->s3Client->listObjectsV2(['Bucket' => self::STORAGE_BUCKET])->willReturn($this->listOutput(['stuck'])); + $this->s3Client->deleteObjects(Argument::any())->shouldBeCalledTimes(10000)->willReturn($this->deleteOutput()); + + $tester = $this->buildCommandTester(false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Bucket 'storage-bucket' is not empty after 10000 delete rounds, aborting."); + $tester->execute(['--force' => true]); + } + + public function testMultipartUploadsWhichCanNotBeAbortedAbortAfterIterationGuard(): void + { + $this->s3Client->listMultipartUploads(['Bucket' => self::STORAGE_BUCKET])->willReturn($this->multipartOutput([['key', 'upload']])); + $this->s3Client->abortMultipartUpload(Argument::any())->shouldBeCalledTimes(10000); + + $tester = $this->buildCommandTester(false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Unable to abort all unfinished multipart uploads of bucket 'storage-bucket'."); + $tester->execute(['--force' => true]); + } + + public function testAbortsWhenConfirmationIsDeclined(): void + { + $this->s3Client->listObjectsV2(Argument::cetera())->shouldNotBeCalled(); + $this->redisClient->flushdb()->shouldNotBeCalled(); + + $neo4jClient = $this->prophesize(ClientInterface::class); + $neo4jClient->runStatement(Argument::any())->shouldNotBeCalled(); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($neo4jClient->reveal()); + $command = new DatabaseDropCommand( + $cypherEntityManager->reveal(), + $this->prophesize(MongoEntityManager::class)->reveal(), + $this->prophesize(ElasticEntityManager::class)->reveal(), + $this->redisClient->reveal(), + $this->prophesize(AMQPStreamConnection::class)->reveal(), + $this->s3Client->reveal(), + $this->prophesize(EmberNexusConfiguration::class)->reveal(), + ); + $application = new Application(); + $application->addCommand($command); + $tester = new CommandTester($command); + $tester->setInputs(['no']); + + $exitCode = $tester->execute([]); + + $this->assertSame(Command::FAILURE, $exitCode); + $this->assertStringContainsString('Aborted dropping databases.', $tester->getDisplay()); + } +} diff --git a/tests/UnitTests/Command/HealthcheckCommandS3Test.php b/tests/UnitTests/Command/HealthcheckCommandS3Test.php new file mode 100644 index 00000000..0aa8c901 --- /dev/null +++ b/tests/UnitTests/Command/HealthcheckCommandS3Test.php @@ -0,0 +1,90 @@ +prophesize(S3Client::class); + $s3Client->bucketExists(['Bucket' => 'storage-bucket'])->willReturn($this->bucketWaiter($isStorageBucketOnline)); + if ($isStorageBucketOnline) { + $s3Client->bucketExists(['Bucket' => 'upload-bucket'])->willReturn($this->bucketWaiter($isUploadBucketOnline)); + } else { + $s3Client->bucketExists(['Bucket' => 'upload-bucket'])->shouldNotBeCalled(); + } + + $configuration = $this->prophesize(EmberNexusConfiguration::class); + $configuration->getFileS3StorageBucket()->willReturn('storage-bucket'); + $configuration->getFileS3UploadBucket()->willReturn('upload-bucket'); + + $command = new HealthcheckCommand( + $this->prophesize(CypherEntityManager::class)->reveal(), + $this->prophesize(MongoEntityManager::class)->reveal(), + $this->prophesize(ElasticEntityManager::class)->reveal(), + $this->prophesize(RedisClient::class)->reveal(), + $this->prophesize(AMQPStreamConnection::class)->reveal(), + $s3Client->reveal(), + $configuration->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + ); + + return (new ReflectionMethod($command, 'getS3Status'))->invoke($command); + } + + public function testBothBucketsOnline(): void + { + $this->assertSame('online', $this->getS3Status(true, true)); + } + + public function testStorageBucketOfflineFailsBeforeCheckingUploadBucket(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Unable to connect to bucket storage-bucket.'); + + $this->getS3Status(false, true); + } + + public function testUploadBucketOfflineFails(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Unable to connect to bucket upload-bucket.'); + + $this->getS3Status(true, false); + } +} diff --git a/tests/UnitTests/Command/HealthcheckCommandVersionsTest.php b/tests/UnitTests/Command/HealthcheckCommandVersionsTest.php new file mode 100644 index 00000000..0fd66e1c --- /dev/null +++ b/tests/UnitTests/Command/HealthcheckCommandVersionsTest.php @@ -0,0 +1,138 @@ +prophesize(CypherEntityManager::class)->reveal(), + $mongoEntityManager ?? $this->prophesize(MongoEntityManager::class)->reveal(), + $this->prophesize(ElasticEntityManager::class)->reveal(), + $redisClient ?? $this->prophesize(RedisClient::class)->reveal(), + $AMQPStreamConnection ?? $this->prophesize(AMQPStreamConnection::class)->reveal(), + $this->prophesize(S3Client::class)->reveal(), + $this->prophesize(EmberNexusConfiguration::class)->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + ); + } + + private function invoke(HealthcheckCommand $command, string $method): string + { + /** @var string */ + return (new ReflectionMethod($command, $method))->invoke($command); + } + + public function testGetCypherVersion(): void + { + $summary = null; + $result = new SummarizedResult($summary, [new CypherMap(['version' => '5.25.0', 'edition' => 'community'])]); + + $cypherClient = $this->prophesize(CypherClientInterface::class); + $cypherClient->runStatement(Statement::create( + 'CALL dbms.components() YIELD versions, edition UNWIND versions AS version RETURN version, edition;' + ))->willReturn($result); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($cypherClient->reveal()); + + $command = $this->buildCommand(cypherEntityManager: $cypherEntityManager->reveal()); + + $this->assertSame('5.25.0 (community)', $this->invoke($command, 'getCypherVersion')); + } + + public function testGetMongoVersion(): void + { + $cursor = $this->prophesize(CursorInterface::class); + $cursor->toArray()->willReturn([new BSONDocument(['version' => '8.0.1'])]); + + $mongoDatabase = $this->prophesize(MongoDatabase::class); + $mongoDatabase->command(['buildInfo' => 1])->willReturn($cursor->reveal()); + + $mongoClient = $this->prophesize(MongoClient::class); + $mongoClient->selectDatabase('ember-nexus')->willReturn($mongoDatabase->reveal()); + + $mongoEntityManager = $this->prophesize(MongoEntityManager::class); + $mongoEntityManager->getClient()->willReturn($mongoClient->reveal()); + $mongoEntityManager->getDatabase()->willReturn('ember-nexus'); + + $command = $this->buildCommand(mongoEntityManager: $mongoEntityManager->reveal()); + + $this->assertSame('8.0.1', $this->invoke($command, 'getMongoVersion')); + } + + public function testGetMongoVersionFailsWithoutDatabaseName(): void + { + $mongoEntityManager = $this->prophesize(MongoEntityManager::class); + $mongoEntityManager->getClient()->willReturn($this->prophesize(MongoClient::class)->reveal()); + $mongoEntityManager->getDatabase()->willReturn(null); + + $command = $this->buildCommand(mongoEntityManager: $mongoEntityManager->reveal()); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Mongo database name can not be null.'); + + $this->invoke($command, 'getMongoVersion'); + } + + public function testGetRedisVersion(): void + { + $redisClient = $this->prophesize(RedisClient::class); + $redisClient->info()->willReturn(['Server' => ['redis_version' => '8.0.2']]); + + $command = $this->buildCommand(redisClient: $redisClient->reveal()); + + $this->assertSame('8.0.2', $this->invoke($command, 'getRedisVersion')); + } + + public function testGetRabbitMqVersion(): void + { + $connection = $this->prophesize(AMQPStreamConnection::class); + $connection->getServerProperties()->willReturn(['version' => ['S', '4.1.5']]); + + $command = $this->buildCommand(AMQPStreamConnection: $connection->reveal()); + + $this->assertSame('4.1.5', $this->invoke($command, 'getRabbitMqVersion')); + } +} diff --git a/tests/UnitTests/Controller/File/PostElementFileControllerTest.php b/tests/UnitTests/Controller/File/PostElementFileControllerTest.php new file mode 100644 index 00000000..39a15d81 --- /dev/null +++ b/tests/UnitTests/Controller/File/PostElementFileControllerTest.php @@ -0,0 +1,143 @@ +prophesize(AuthProvider::class); + $authProvider->getUserId()->willReturn(\Ramsey\Uuid\Uuid::uuid4()); + $this->accessChecker = $this->prophesize(AccessChecker::class); + $this->accessChecker->hasAccessToElement(Argument::cetera())->willReturn(true); + $this->uploadCreationService = $this->prophesize(UploadCreationService::class); + $this->elementManager = $this->prophesize(ElementManager::class); + $this->elementManager->getElementOrFail(Argument::any())->willReturn((new NodeElement())->setId(\Ramsey\Uuid\Uuid::fromString(self::ID))); + $this->elementService = $this->prophesize(ElementService::class); + $this->elementService->hasFile(Argument::any())->willReturn(false); + $this->fileCreationLockService = $this->prophesize(FileCreationLockService::class); + $this->fileCreationLockService->acquire(Argument::any())->willReturn('token'); + $this->uploadService = $this->prophesize(UploadService::class); + $this->uploadService->hasUploadsTargeting(Argument::any())->willReturn(false); + + $client404 = $this->prophesize(Client404NotFoundExceptionFactory::class); + $client404->createFromTemplate()->willReturn(new Client404NotFoundException('type')); + $client409 = $this->prophesize(Client409ConflictExceptionFactory::class); + $client409->createFromDetail(Argument::cetera())->willReturn(new Client409ConflictException('type')); + + return new PostElementFileController( + $authProvider->reveal(), + $this->accessChecker->reveal(), + $this->uploadCreationService->reveal(), + $this->elementManager->reveal(), + $this->elementService->reveal(), + $client404->reveal(), + $client409->reveal(), + $this->fileCreationLockService->reveal(), + $this->uploadService->reveal(), + ); + } + + public function testCreatesFileAndReleasesLock(): void + { + $controller = $this->buildController(); + $request = new Request(); + $response = new NoContentResponse(); + $this->uploadCreationService->handleUploadCreationFromRequest(Argument::any(), $request)->shouldBeCalledOnce()->willReturn($response); + $this->fileCreationLockService->release(Argument::any(), 'token')->shouldBeCalledOnce(); + + $this->assertSame($response, $controller->postElementFile(self::ID, $request)); + } + + public function testWithoutUpdateAccessAnswers404AndDoesNotLock(): void + { + $controller = $this->buildController(); + $this->accessChecker->hasAccessToElement(Argument::any(), Argument::any(), AccessType::UPDATE)->willReturn(false); + $this->fileCreationLockService->acquire(Argument::any())->shouldNotBeCalled(); + + $this->expectException(Client404NotFoundException::class); + $controller->postElementFile(self::ID, new Request()); + } + + public function testLockedElementAnswers409(): void + { + $controller = $this->buildController(); + $this->fileCreationLockService->acquire(Argument::any())->willReturn(null); + $this->uploadCreationService->handleUploadCreationFromRequest(Argument::cetera())->shouldNotBeCalled(); + $this->fileCreationLockService->release(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client409ConflictException::class); + $controller->postElementFile(self::ID, new Request()); + } + + public function testElementWithFileAnswers409AndReleasesLock(): void + { + $controller = $this->buildController(); + $this->elementService->hasFile(Argument::any())->willReturn(true); + $this->uploadCreationService->handleUploadCreationFromRequest(Argument::cetera())->shouldNotBeCalled(); + $this->fileCreationLockService->release(Argument::any(), 'token')->shouldBeCalledOnce(); + + $this->expectException(Client409ConflictException::class); + $controller->postElementFile(self::ID, new Request()); + } + + public function testElementWithUploadAnswers409AndReleasesLock(): void + { + $controller = $this->buildController(); + $this->uploadService->hasUploadsTargeting(Argument::any())->willReturn(true); + $this->uploadCreationService->handleUploadCreationFromRequest(Argument::cetera())->shouldNotBeCalled(); + $this->fileCreationLockService->release(Argument::any(), 'token')->shouldBeCalledOnce(); + + $this->expectException(Client409ConflictException::class); + $controller->postElementFile(self::ID, new Request()); + } + + public function testFailingUploadReleasesLock(): void + { + $controller = $this->buildController(); + $this->uploadCreationService->handleUploadCreationFromRequest(Argument::cetera())->willThrow(new RuntimeException('boom')); + $this->fileCreationLockService->release(Argument::any(), 'token')->shouldBeCalledOnce(); + + $this->expectException(RuntimeException::class); + $controller->postElementFile(self::ID, new Request()); + } +} diff --git a/tests/UnitTests/Controller/File/PutElementFileControllerTest.php b/tests/UnitTests/Controller/File/PutElementFileControllerTest.php new file mode 100644 index 00000000..eef7c29c --- /dev/null +++ b/tests/UnitTests/Controller/File/PutElementFileControllerTest.php @@ -0,0 +1,92 @@ +prophesize(AuthProvider::class); + $authProvider->getUserId()->willReturn(Uuid::uuid4()); + $this->accessChecker = $this->prophesize(AccessChecker::class); + $this->accessChecker->hasAccessToElement(Argument::cetera())->willReturn(true); + $this->uploadCreationService = $this->prophesize(UploadCreationService::class); + $this->fileCreationLockService = $this->prophesize(FileCreationLockService::class); + $this->fileCreationLockService->isLocked(Argument::any())->willReturn(false); + + $client404 = $this->prophesize(Client404NotFoundExceptionFactory::class); + $client404->createFromTemplate()->willReturn(new Client404NotFoundException('type')); + $client409 = $this->prophesize(Client409ConflictExceptionFactory::class); + $client409->createFromDetail(Argument::cetera())->willReturn(new Client409ConflictException('type')); + + return new PutElementFileController( + $authProvider->reveal(), + $this->accessChecker->reveal(), + $this->uploadCreationService->reveal(), + $client404->reveal(), + $client409->reveal(), + $this->fileCreationLockService->reveal(), + ); + } + + public function testReplacesFile(): void + { + $controller = $this->buildController(); + $request = new Request(); + $response = new NoContentResponse(); + $this->uploadCreationService->handleUploadCreationFromRequest(Argument::any(), $request)->shouldBeCalledOnce()->willReturn($response); + $this->fileCreationLockService->acquire(Argument::any())->shouldNotBeCalled(); + + $this->assertSame($response, $controller->putElementFile(self::ID, $request)); + } + + public function testWithoutUpdateAccessAnswers404(): void + { + $controller = $this->buildController(); + $this->accessChecker->hasAccessToElement(Argument::cetera())->willReturn(false); + + $this->expectException(Client404NotFoundException::class); + $controller->putElementFile(self::ID, new Request()); + } + + public function testWhileFileIsBeingCreatedAnswers409(): void + { + $controller = $this->buildController(); + $this->fileCreationLockService->isLocked(Argument::any())->willReturn(true); + $this->uploadCreationService->handleUploadCreationFromRequest(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client409ConflictException::class); + $controller->putElementFile(self::ID, new Request()); + } +} diff --git a/tests/UnitTests/Controller/Upload/DeleteUploadControllerTest.php b/tests/UnitTests/Controller/Upload/DeleteUploadControllerTest.php new file mode 100644 index 00000000..a94dfa4d --- /dev/null +++ b/tests/UnitTests/Controller/Upload/DeleteUploadControllerTest.php @@ -0,0 +1,84 @@ +prophesize(UploadInterface::class); + $upload->getId()->willReturn(Uuid::fromString(self::UPLOAD_ID)); + $upload->getUploadOwner()->willReturn($userId); + $upload->getUploadTarget()->willReturn(Uuid::uuid4()); + $uploadFactory = $this->prophesize(UploadFactory::class); + $uploadFactory->createUploadFromElement(Argument::any())->willReturn($upload->reveal()); + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::any())->willReturn($this->prophesize(NodeElementInterface::class)->reveal()); + $elementManager->flush()->willReturn($elementManager->reveal()); + $authProvider = $this->prophesize(AuthProvider::class); + $authProvider->getUserId()->willReturn($userId); + $lockService->acquire(Argument::any())->willReturn($lockToken); + $conflictFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $conflictFactory->createFromDetail(Argument::cetera())->will(fn ($args) => new Client409ConflictException('type', detail: $args[0])); + + return new DeleteUploadController( + $authProvider->reveal(), + $elementManager->reveal(), + $this->prophesize(LoggerInterface::class)->reveal(), + $uploadFactory->reveal(), + $uploadService->reveal(), + $lockService->reveal(), + $this->prophesize(Client404NotFoundExceptionFactory::class)->reveal(), + $conflictFactory->reveal(), + ); + } + + public function testHeldLockIsAnsweredWithConflict(): void + { + $uploadService = $this->prophesize(UploadService::class); + $uploadService->deleteUploadAndChunks(Argument::any())->shouldNotBeCalled(); + $lockService = $this->prophesize(UploadLockService::class); + $lockService->release(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client409ConflictException::class); + $this->buildController(null, $uploadService, $lockService)->deleteUpload(self::UPLOAD_ID); + } + + public function testUploadIsDeletedUnderLockAndLockIsReleased(): void + { + $uploadService = $this->prophesize(UploadService::class); + $uploadService->deleteUploadAndChunks(Argument::any())->shouldBeCalledOnce(); + $lockService = $this->prophesize(UploadLockService::class); + $lockService->release(Argument::any(), 'token')->shouldBeCalledOnce(); + + $this->buildController('token', $uploadService, $lockService)->deleteUpload(self::UPLOAD_ID); + } +} diff --git a/tests/UnitTests/Controller/Upload/HeadUploadControllerTest.php b/tests/UnitTests/Controller/Upload/HeadUploadControllerTest.php new file mode 100644 index 00000000..bb410545 --- /dev/null +++ b/tests/UnitTests/Controller/Upload/HeadUploadControllerTest.php @@ -0,0 +1,39 @@ +prophesize(UploadInterface::class)->reveal(); + $response = new NoContentResponse(); + + $uploadAccessService = $this->prophesize(UploadAccessService::class); + $uploadAccessService->loadAuthorizedUpload(self::UPLOAD_ID)->willReturn($upload)->shouldBeCalledOnce(); + $noContentResponseFactory = $this->prophesize(NoContentResponseFactory::class); + $noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($upload)->willReturn($response); + + $controller = new HeadUploadController($uploadAccessService->reveal(), $noContentResponseFactory->reveal()); + + $this->assertSame($response, $controller->headUpload(self::UPLOAD_ID)); + } +} diff --git a/tests/UnitTests/Controller/Upload/PatchUploadControllerTest.php b/tests/UnitTests/Controller/Upload/PatchUploadControllerTest.php new file mode 100644 index 00000000..c4b41842 --- /dev/null +++ b/tests/UnitTests/Controller/Upload/PatchUploadControllerTest.php @@ -0,0 +1,131 @@ +prophesize(UploadInterface::class); + $upload->getId()->willReturn(Uuid::fromString(self::UPLOAD_ID)); + $this->upload = $upload->reveal(); + $this->partialUploadRequest = $this->prophesize(PartialUploadRequestInterface::class)->reveal(); + + $this->uploadAccessService = $this->prophesize(UploadAccessService::class); + $this->uploadAccessService->loadAuthorizedUpload(self::UPLOAD_ID)->willReturn($this->upload); + $this->uploadAppendService = $this->prophesize(UploadAppendService::class); + $this->uploadLockService = $this->prophesize(UploadLockService::class); + $this->uploadLockService->acquire(Argument::any())->willReturn($lockToken); + $partialUploadRequestFactory = $this->prophesize(PartialUploadRequestFactory::class); + $partialUploadRequestFactory->createPartialUploadRequestFromRequest(Argument::any())->willReturn($this->partialUploadRequest); + $this->noContentResponseFactory = $this->prophesize(NoContentResponseFactory::class); + $client409ConflictExceptionFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $client409ConflictExceptionFactory->createFromDetail(Argument::cetera())->will( + fn ($args) => new Client409ConflictException('type', detail: $args[0]) + ); + + return new PatchUploadController( + $this->uploadAccessService->reveal(), + $this->uploadAppendService->reveal(), + $this->uploadLockService->reveal(), + $partialUploadRequestFactory->reveal(), + $this->noContentResponseFactory->reveal(), + $client409ConflictExceptionFactory->reveal(), + ); + } + + public function testAppendsUnderLockAndReportsTheStateAfterwards(): void + { + $controller = $this->createController(); + $nextUpload = $this->prophesize(UploadInterface::class); + $nextUpload->getId()->willReturn(Uuid::fromString(self::UPLOAD_ID)); + $response = new NoContentResponse(); + + // the upload is loaded again once the lock is held, as the state may have changed while waiting + $this->uploadAccessService->loadAuthorizedUpload(self::UPLOAD_ID)->shouldBeCalledTimes(2); + $this->uploadAppendService->append($this->upload, $this->partialUploadRequest, 'sha-256=:abc=:')->willReturn($nextUpload->reveal())->shouldBeCalledOnce(); + $this->noContentResponseFactory->createNoContentResponseWithResumableUploadHeadersFromUpload($nextUpload->reveal())->willReturn($response); + $this->uploadLockService->release(Argument::any(), 'token')->shouldBeCalledOnce(); + + $request = new Request(); + $request->headers->set('Repr-Digest', 'sha-256=:abc=:'); + + $this->assertSame($response, $controller->patchUpload(self::UPLOAD_ID, $request)); + } + + public function testHeldLockIsAnsweredWithConflictBeforeAnythingIsAppended(): void + { + $controller = $this->createController(null); + $this->uploadAppendService->append(Argument::cetera())->shouldNotBeCalled(); + $this->uploadLockService->release(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client409ConflictException::class); + $controller->patchUpload(self::UPLOAD_ID, new Request()); + } + + public function testLockIsReleasedWhenAppendFails(): void + { + $controller = $this->createController(); + $this->uploadAppendService->append(Argument::cetera())->willThrow(new RuntimeException('failed')); + $this->uploadLockService->release(Argument::any(), 'token')->shouldBeCalledOnce(); + + $this->expectException(RuntimeException::class); + $controller->patchUpload(self::UPLOAD_ID, new Request()); + } + + public function testLockIsReleasedWhenTheUploadCanNotBeLoadedAnymore(): void + { + $controller = $this->createController(); + // the first load before the lock succeeds, the second one inside of it fails + $calls = 0; + $upload = $this->upload; + $this->uploadAccessService->loadAuthorizedUpload(self::UPLOAD_ID)->will(function () use (&$calls, $upload) { + if (1 === ++$calls) { + return $upload; + } + + throw new RuntimeException('second load failed'); + }); + $this->uploadLockService->release(Argument::any(), 'token')->shouldBeCalledOnce(); + $this->uploadAppendService->append(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(RuntimeException::class); + $controller->patchUpload(self::UPLOAD_ID, new Request()); + } +} diff --git a/tests/UnitTests/Controller/User/DeleteTokenControllerTest.php b/tests/UnitTests/Controller/User/DeleteTokenControllerTest.php new file mode 100644 index 00000000..b557c863 --- /dev/null +++ b/tests/UnitTests/Controller/User/DeleteTokenControllerTest.php @@ -0,0 +1,64 @@ +prophesize(NodeElementInterface::class)->reveal(); + $fileOperation = $this->prophesize(FileOperationInterface::class)->reveal(); + + $authProvider = $this->prophesize(AuthProvider::class); + $authProvider->isAnonymous()->willReturn(false); + $authProvider->getHashedToken()->willReturn('hash'); + $authProvider->getTokenId()->willReturn($tokenId); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail($tokenId)->willReturn($tokenElement); + + $fileDeletionService = $this->prophesize(ElementFileDeletionService::class); + $fileDeletionService->getFileOperationsForDeletionOfElement($tokenElement)->willReturn([$fileOperation]); + + $uploadService = $this->prophesize(UploadService::class); + + $uploadService->deleteUploadsTargeting($tokenId)->shouldBeCalledOnce(); + $elementManager->delete($tokenElement)->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + $elementManager->flush()->shouldBeCalledTimes(2)->willReturn($elementManager->reveal()); + // files are deleted after the token is deleted and flushed + $fileDeletionService->deleteFiles([$fileOperation])->shouldBeCalledOnce(); + + $controller = new DeleteTokenController( + $elementManager->reveal(), + $authProvider->reveal(), + $uploadService->reveal(), + $fileDeletionService->reveal(), + $this->prophesize(Client401UnauthorizedExceptionFactory::class)->reveal(), + ); + + $response = $controller->deleteToken(); + + $this->assertSame(204, $response->getStatusCode()); + } +} diff --git a/tests/UnitTests/Controller/User/PostChangePasswordControllerTest.php b/tests/UnitTests/Controller/User/PostChangePasswordControllerTest.php index 5895ecc1..bdab9c01 100644 --- a/tests/UnitTests/Controller/User/PostChangePasswordControllerTest.php +++ b/tests/UnitTests/Controller/User/PostChangePasswordControllerTest.php @@ -9,6 +9,8 @@ use App\Exception\Client401UnauthorizedException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; +use App\Factory\Exception\Client408RequestTimeoutExceptionFactory; +use App\Service\RequestContentService; use App\Service\RequestUtilService; use App\Service\SecurityUtilService; use App\Type\NodeElement; @@ -43,6 +45,7 @@ private function getPostChangePasswordController( $securityUtilService ?? $this->createMock(SecurityUtilService::class), new Client400BadContentExceptionFactory($urlGenerator), new Client401UnauthorizedExceptionFactory($urlGenerator), + new RequestContentService(new Client408RequestTimeoutExceptionFactory($urlGenerator)), ); } diff --git a/tests/UnitTests/Controller/User/PostRegisterControllerTest.php b/tests/UnitTests/Controller/User/PostRegisterControllerTest.php index 99d8bf55..07fd83ef 100644 --- a/tests/UnitTests/Controller/User/PostRegisterControllerTest.php +++ b/tests/UnitTests/Controller/User/PostRegisterControllerTest.php @@ -11,10 +11,12 @@ use App\Factory\Exception\Client400MissingPropertyExceptionFactory; use App\Factory\Exception\Client400ReservedIdentifierExceptionFactory; use App\Factory\Exception\Client403ForbiddenExceptionFactory; +use App\Factory\Exception\Client408RequestTimeoutExceptionFactory; use App\Factory\Exception\Server500LogicErrorExceptionFactory; use App\Security\UserPasswordHasher; use App\Service\CreateElementFromRawDataService; use App\Service\ElementManager; +use App\Service\RequestContentService; use App\Service\RequestUtilService; use App\Type\NodeElement; use App\Type\Response\CreatedResponse; @@ -68,7 +70,8 @@ private function getPostRegisterController( $createElementFromRawDataService ?? $this->createMock(CreateElementFromRawDataService::class), $client400ReservedIdentifierExceptionFactory ?? $this->createMock(Client400ReservedIdentifierExceptionFactory::class), $client403ForbiddenExceptionFactory ?? $this->createMock(Client403ForbiddenExceptionFactory::class), - $server500LogicExceptionFactory ?? $this->createMock(Server500LogicErrorExceptionFactory::class) + $server500LogicExceptionFactory ?? $this->createMock(Server500LogicErrorExceptionFactory::class), + new RequestContentService(new Client408RequestTimeoutExceptionFactory($this->createStub(UrlGeneratorInterface::class))) ); } @@ -133,7 +136,6 @@ public function testPostRegisterWithEnabledRegisterAndData(): void $emberNexusConfiguration->method('isRegisterEnabled')->willReturn(true); $requestUtilService = new RequestUtilService( - $emberNexusConfiguration, $this->createMock(Client400BadContentExceptionFactory::class), $this->createMock(Client400MissingPropertyExceptionFactory::class), ); diff --git a/tests/UnitTests/Controller/User/PostTokenControllerTest.php b/tests/UnitTests/Controller/User/PostTokenControllerTest.php index a5a04e5d..25b3749b 100644 --- a/tests/UnitTests/Controller/User/PostTokenControllerTest.php +++ b/tests/UnitTests/Controller/User/PostTokenControllerTest.php @@ -9,7 +9,9 @@ use App\Exception\Client401UnauthorizedException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client401UnauthorizedExceptionFactory; +use App\Factory\Exception\Client408RequestTimeoutExceptionFactory; use App\Security\TokenGenerator; +use App\Service\RequestContentService; use App\Service\RequestUtilService; use App\Service\SecurityUtilService; use App\Type\NodeElement; @@ -45,7 +47,8 @@ private function getPostTokenController( new Client400BadContentExceptionFactory($urlGenerator), new Client401UnauthorizedExceptionFactory($urlGenerator), $requestUtilService ?? $this->createMock(RequestUtilService::class), - $securityUtilService ?? $this->createMock(SecurityUtilService::class) + $securityUtilService ?? $this->createMock(SecurityUtilService::class), + new RequestContentService(new Client408RequestTimeoutExceptionFactory($urlGenerator)) ); } diff --git a/tests/UnitTests/EmberNexusBundle/DependencyInjection/ConfigurationTest.php b/tests/UnitTests/EmberNexusBundle/DependencyInjection/ConfigurationTest.php new file mode 100644 index 00000000..8a5f96dc --- /dev/null +++ b/tests/UnitTests/EmberNexusBundle/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,115 @@ + $config + * + * @return array + */ + private function process(array $config): array + { + return (new Processor())->processConfiguration(new Configuration(), [$config]); + } + + public function testFileDefaults(): void + { + $config = $this->process([]); + + $this->assertSame([ + EmberNexusConfiguration::FILE_MAX_FILE_SIZE_IN_BYTES => 10 * 1024 * 1024 * 1024, + EmberNexusConfiguration::FILE_UPLOAD_EXPIRES_IN_SECONDS_AFTER_FIRST_REQUEST => 3 * 3600, + EmberNexusConfiguration::FILE_UPLOAD_CHUNK_DIGITS_LENGTH => 4, + EmberNexusConfiguration::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES => 5 * 1024 * 1024, + EmberNexusConfiguration::FILE_UPLOAD_MAX_CHUNK_SIZE_IN_BYTES => 101 * 1024 * 1024, + EmberNexusConfiguration::FILE_EXPIRED_UPLOAD_CAN_BE_DELETED_AFTER_EXPIRATION_IN_SECONDS => 3600, + EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET => 'api-storage', + EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET => 'api-upload', + EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVELS => 3, + EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVEL_LENGTH => 2, + EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVELS => 2, + EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVEL_LENGTH => 2, + ], $config[EmberNexusConfiguration::FILE]); + } + + public function testFileValuesCanBeOverwritten(): void + { + $config = $this->process([ + EmberNexusConfiguration::FILE => [ + EmberNexusConfiguration::FILE_MAX_FILE_SIZE_IN_BYTES => 1024, + EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET => 'my-storage', + EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET => 'my-upload', + ], + ]); + + $this->assertSame(1024, $config[EmberNexusConfiguration::FILE][EmberNexusConfiguration::FILE_MAX_FILE_SIZE_IN_BYTES]); + $this->assertSame('my-storage', $config[EmberNexusConfiguration::FILE][EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET]); + $this->assertSame('my-upload', $config[EmberNexusConfiguration::FILE][EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET]); + // untouched options keep their default + $this->assertSame(4, $config[EmberNexusConfiguration::FILE][EmberNexusConfiguration::FILE_UPLOAD_CHUNK_DIGITS_LENGTH]); + } + + /** + * @return array + */ + public static function invalidFileValuesProvider(): array + { + return [ + 'max file size of 0' => [EmberNexusConfiguration::FILE_MAX_FILE_SIZE_IN_BYTES, 0], + 'negative upload expiration' => [EmberNexusConfiguration::FILE_UPLOAD_EXPIRES_IN_SECONDS_AFTER_FIRST_REQUEST, -1], + 'chunk digits length of 0' => [EmberNexusConfiguration::FILE_UPLOAD_CHUNK_DIGITS_LENGTH, 0], + 'negative minimum chunk size' => [EmberNexusConfiguration::FILE_UPLOAD_MIN_CHUNK_SIZE_IN_BYTES, -1], + 'maximum chunk size of 0' => [EmberNexusConfiguration::FILE_UPLOAD_MAX_CHUNK_SIZE_IN_BYTES, 0], + 'negative grace period of expired uploads' => [EmberNexusConfiguration::FILE_EXPIRED_UPLOAD_CAN_BE_DELETED_AFTER_EXPIRATION_IN_SECONDS, -1], + 'storage bucket without levels' => [EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVELS, 0], + 'storage bucket level length of 0' => [EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVEL_LENGTH, 0], + 'upload bucket without levels' => [EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVELS, 0], + 'upload bucket level length of 0' => [EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVEL_LENGTH, 0], + ]; + } + + #[DataProvider('invalidFileValuesProvider')] + public function testInvalidFileValuesAreRejected(string $option, int $value): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage(sprintf('ember_nexus.file.%s', $option)); + + $this->process([EmberNexusConfiguration::FILE => [$option => $value]]); + } + + public function testNonIntegerFileValueIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process([EmberNexusConfiguration::FILE => [EmberNexusConfiguration::FILE_MAX_FILE_SIZE_IN_BYTES => 'big']]); + } + + public function testUnknownFileOptionIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process([EmberNexusConfiguration::FILE => ['unknownOption' => 1]]); + } + + public function testPageSizeMinimumIsEnforced(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process([EmberNexusConfiguration::PAGE_SIZE => [EmberNexusConfiguration::PAGE_SIZE_MIN => 0]]); + } +} diff --git a/tests/UnitTests/EventSystem/Controller/EventListener/EtagControllerEventListenerTest.php b/tests/UnitTests/EventSystem/Controller/EventListener/EtagControllerEventListenerTest.php index 8c17b17a..fafdf541 100644 --- a/tests/UnitTests/EventSystem/Controller/EventListener/EtagControllerEventListenerTest.php +++ b/tests/UnitTests/EventSystem/Controller/EventListener/EtagControllerEventListenerTest.php @@ -30,7 +30,7 @@ public function testControllerWithoutEndpointSupportsEtagAttributeAreIgnored(): $event = new ControllerEvent( $this->prophesize(HttpKernelInterface::class)->reveal(), function () {}, - $this->prophesize(Request::class)->reveal(), + new Request(), HttpKernelInterface::MAIN_REQUEST ); @@ -45,7 +45,7 @@ public function testControllerWithEndpointSupportsEtagAttributeCallsEtagService( $closure = #[EndpointSupportsEtag(EtagType::ELEMENT)] fn () => true; - $request = $this->prophesize(Request::class)->reveal(); + $request = new Request(); $event = new ControllerEvent( $this->prophesize(HttpKernelInterface::class)->reveal(), diff --git a/tests/UnitTests/EventSystem/Controller/EventListener/IfMatchControllerEventListenerTest.php b/tests/UnitTests/EventSystem/Controller/EventListener/IfMatchControllerEventListenerTest.php index c5e155c0..3ab952fe 100644 --- a/tests/UnitTests/EventSystem/Controller/EventListener/IfMatchControllerEventListenerTest.php +++ b/tests/UnitTests/EventSystem/Controller/EventListener/IfMatchControllerEventListenerTest.php @@ -32,7 +32,7 @@ public function testControllerWithoutEndpointSupportsEtagAttributeAreIgnored(): $event = new ControllerEvent( $this->prophesize(HttpKernelInterface::class)->reveal(), function () {}, - $this->prophesize(Request::class)->reveal(), + new Request(), HttpKernelInterface::MAIN_REQUEST ); @@ -51,7 +51,7 @@ public function testControllerWithNoEtagIsSkipped(): void $event = new ControllerEvent( $this->prophesize(HttpKernelInterface::class)->reveal(), $closure, - $this->prophesize(Request::class)->reveal(), + new Request(), HttpKernelInterface::MAIN_REQUEST ); @@ -227,4 +227,94 @@ public function testControllerWithEtagAndMultipleIfMatchHeaderIsWorking(): void $eventListener->onKernelController($event); } + + public function testWildcardIfMatchPassesWithCurrentEtag(): void + { + $closure = #[EndpointSupportsEtag(EtagType::ELEMENT)] + fn () => true; + + $request = new Request(); + $request->headers->set('If-Match', '*'); + + $event = new ControllerEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $closure, + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $etagService = $this->prophesize(EtagService::class); + $etagService->getCurrentRequestEtag()->shouldBeCalledOnce()->willReturn(new Etag('someEtag')); + + $client412PreconditionFailedExceptionFactory = $this->prophesize(Client412PreconditionFailedExceptionFactory::class); + $client412PreconditionFailedExceptionFactory->createFromTemplate()->shouldNotBeCalled(); + + $eventListener = new IfMatchControllerEventListener( + $etagService->reveal(), + $client412PreconditionFailedExceptionFactory->reveal() + ); + $eventListener->onKernelController($event); + + $this->assertSame($closure, $event->getController()); + } + + public function testIfMatchWithoutCurrentFileEtagReturns412(): void + { + $closure = #[EndpointSupportsEtag(EtagType::FILE)] + fn () => true; + + $request = new Request(); + $request->headers->set('If-Match', '*'); + + $event = new ControllerEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $closure, + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $etagService = $this->prophesize(EtagService::class); + $etagService->getCurrentRequestEtag()->shouldBeCalledOnce()->willReturn(null); + + $client412PreconditionFailedExceptionFactory = $this->prophesize(Client412PreconditionFailedExceptionFactory::class); + $client412PreconditionFailedExceptionFactory->createFromTemplate()->willReturn(new Client412PreconditionFailedException('title')); + + $eventListener = new IfMatchControllerEventListener( + $etagService->reveal(), + $client412PreconditionFailedExceptionFactory->reveal() + ); + + $this->expectException(Client412PreconditionFailedException::class); + $eventListener->onKernelController($event); + } + + public function testIfMatchWithoutCurrentNonFileEtagIsIgnored(): void + { + $closure = #[EndpointSupportsEtag(EtagType::CHILDREN_COLLECTION)] + fn () => true; + + $request = new Request(); + $request->headers->set('If-Match', '"someEtag"'); + + $event = new ControllerEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $closure, + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $etagService = $this->prophesize(EtagService::class); + $etagService->getCurrentRequestEtag()->shouldBeCalledOnce()->willReturn(null); + + $client412PreconditionFailedExceptionFactory = $this->prophesize(Client412PreconditionFailedExceptionFactory::class); + $client412PreconditionFailedExceptionFactory->createFromTemplate()->shouldNotBeCalled(); + + $eventListener = new IfMatchControllerEventListener( + $etagService->reveal(), + $client412PreconditionFailedExceptionFactory->reveal() + ); + $eventListener->onKernelController($event); + + $this->assertSame($closure, $event->getController()); + } } diff --git a/tests/UnitTests/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListenerTest.php b/tests/UnitTests/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListenerTest.php index 914e6dda..aad7e5cf 100644 --- a/tests/UnitTests/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListenerTest.php +++ b/tests/UnitTests/EventSystem/Controller/EventListener/IfNoneMatchControllerEventListenerTest.php @@ -33,7 +33,7 @@ public function testControllerWithoutEndpointSupportsEtagAttributeAreIgnored(): $event = new ControllerEvent( $this->prophesize(HttpKernelInterface::class)->reveal(), $closure, - $this->prophesize(Request::class)->reveal(), + new Request(), HttpKernelInterface::MAIN_REQUEST ); @@ -54,7 +54,7 @@ public function testControllerWithNoEtagIsSkipped(): void $event = new ControllerEvent( $this->prophesize(HttpKernelInterface::class)->reveal(), $closure, - $this->prophesize(Request::class)->reveal(), + new Request(), HttpKernelInterface::MAIN_REQUEST ); @@ -423,4 +423,91 @@ public function testControllerWithEtagAndMultipleIfNoneMatchHeaderAndBadWhitespa $response = $event->getController()(); $this->assertInstanceOf(NotModifiedResponse::class, $response); } + + public function testWildcardIfNoneMatchReturns304ForGet(): void + { + $closure = #[EndpointSupportsEtag(EtagType::ELEMENT)] + fn () => true; + + $request = new Request(); + $request->headers->set('If-None-Match', '*'); + + $event = new ControllerEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $closure, + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $etagService = $this->prophesize(EtagService::class); + $etagService->getCurrentRequestEtag()->shouldBeCalledOnce()->willReturn(new Etag('someEtag')); + + $eventListener = new IfNoneMatchControllerEventListener( + $etagService->reveal(), + $this->prophesize(Client412PreconditionFailedExceptionFactory::class)->reveal() + ); + $eventListener->onKernelController($event); + + $this->assertNotSame($closure, $event->getController()); + $this->assertInstanceOf(NotModifiedResponse::class, ($event->getController())()); + } + + public function testWildcardIfNoneMatchReturns412ForPost(): void + { + $closure = #[EndpointSupportsEtag(EtagType::FILE)] + fn () => true; + + $request = new Request(); + $request->setMethod('POST'); + $request->headers->set('If-None-Match', '*'); + + $event = new ControllerEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $closure, + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $etagService = $this->prophesize(EtagService::class); + $etagService->getCurrentRequestEtag()->shouldBeCalledOnce()->willReturn(new Etag('someEtag')); + + $client412PreconditionFailedExceptionFactory = $this->prophesize(Client412PreconditionFailedExceptionFactory::class); + $client412PreconditionFailedExceptionFactory->createFromTemplate()->willReturn(new Client412PreconditionFailedException('title')); + + $eventListener = new IfNoneMatchControllerEventListener( + $etagService->reveal(), + $client412PreconditionFailedExceptionFactory->reveal() + ); + + $this->expectException(Client412PreconditionFailedException::class); + $eventListener->onKernelController($event); + } + + public function testWildcardIfNoneMatchIsIgnoredWithoutCurrentEtag(): void + { + $closure = #[EndpointSupportsEtag(EtagType::FILE)] + fn () => true; + + $request = new Request(); + $request->setMethod('POST'); + $request->headers->set('If-None-Match', '*'); + + $event = new ControllerEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $closure, + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $etagService = $this->prophesize(EtagService::class); + $etagService->getCurrentRequestEtag()->shouldBeCalledOnce()->willReturn(null); + + $eventListener = new IfNoneMatchControllerEventListener( + $etagService->reveal(), + $this->prophesize(Client412PreconditionFailedExceptionFactory::class)->reveal() + ); + $eventListener->onKernelController($event); + + $this->assertSame($closure, $event->getController()); + } } diff --git a/tests/UnitTests/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListenerTest.php b/tests/UnitTests/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListenerTest.php new file mode 100644 index 00000000..2d3bab7d --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementDefragmentize/EventListener/GenericPropertyElementDefragmentizeEventListenerTest.php @@ -0,0 +1,93 @@ +addProperty('list', true); + $cypher->addProperty('name', 'abc'); + $mongo = new Document(); + $mongo->addProperty('list', [1, 2]); + + $element = new NodeElement(); + (new GenericPropertyElementDefragmentizeEventListener()) + ->onNodeElementDefragmentizeEvent(new NodeElementDefragmentizeEvent($element, $cypher, $mongo)); + + $this->assertSame(['list' => [1, 2], 'name' => 'abc'], $element->getProperties()); + } + + public function testTrueWithoutMongoCounterpartIsKept(): void + { + $cypher = new Node(); + $cypher->addProperty('flag', true); + + $element = new NodeElement(); + (new GenericPropertyElementDefragmentizeEventListener()) + ->onNodeElementDefragmentizeEvent(new NodeElementDefragmentizeEvent($element, $cypher, null)); + + $this->assertSame(['flag' => true], $element->getProperties()); + } + + public function testFalseIsNotTreatedAsPlaceholder(): void + { + $cypher = new Node(); + $cypher->addProperty('list', false); + $mongo = new Document(); + $mongo->addProperty('list', [1]); + + $element = new NodeElement(); + (new GenericPropertyElementDefragmentizeEventListener()) + ->onNodeElementDefragmentizeEvent(new NodeElementDefragmentizeEvent($element, $cypher, $mongo)); + + $this->assertFalse($element->getProperties()['list']); + } + + public function testBsonValuesAreConvertedToArrays(): void + { + $cypher = new Relation(); + $mongo = new Document(); + $mongo->addProperty('list', new BSONArray([1, 2])); + $mongo->addProperty('map', new BSONDocument(['a' => 'b'])); + + $element = new RelationElement(); + (new GenericPropertyElementDefragmentizeEventListener()) + ->onRelationElementDefragmentizeEvent(new RelationElementDefragmentizeEvent($element, $cypher, $mongo)); + + $this->assertSame(['list' => [1, 2], 'map' => ['a' => 'b']], $element->getProperties()); + } + + public function testReservedPropertiesAndNullsAreRemoved(): void + { + $cypher = new Node(); + $cypher->addProperty('id', 'x'); + $cypher->addProperty('nothing', null); + $cypher->addProperty('kept', 1); + + $element = new NodeElement(); + (new GenericPropertyElementDefragmentizeEventListener()) + ->onNodeElementDefragmentizeEvent(new NodeElementDefragmentizeEvent($element, $cypher, null)); + + $this->assertSame(['kept' => 1], $element->getProperties()); + } +} diff --git a/tests/UnitTests/EventSystem/ElementFileDelete/EventListener/ElasticsearchFileDeleteEventListenerTest.php b/tests/UnitTests/EventSystem/ElementFileDelete/EventListener/ElasticsearchFileDeleteEventListenerTest.php new file mode 100644 index 00000000..831f6b24 --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementFileDelete/EventListener/ElasticsearchFileDeleteEventListenerTest.php @@ -0,0 +1,35 @@ +prophesize(QueueService::class); + $queueService->publishEvent( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + ['elementId' => $elementId->toString()] + )->shouldBeCalledOnce(); + + $listener = new ElasticsearchFileDeleteEventListener($queueService->reveal()); + $listener->onElementFileDeleteEvent(new ElementFileDeleteEvent($elementId)); + } +} diff --git a/tests/UnitTests/EventSystem/ElementFileReplace/EventListener/ElasticsearchFileReplaceEventListenerTest.php b/tests/UnitTests/EventSystem/ElementFileReplace/EventListener/ElasticsearchFileReplaceEventListenerTest.php new file mode 100644 index 00000000..f07c5063 --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementFileReplace/EventListener/ElasticsearchFileReplaceEventListenerTest.php @@ -0,0 +1,35 @@ +prophesize(QueueService::class); + $queueService->publishEvent( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + ['elementId' => $elementId->toString()] + )->shouldBeCalledOnce(); + + $listener = new ElasticsearchFileReplaceEventListener($queueService->reveal()); + $listener->onElementFileReplaceEvent(new ElementFileReplaceEvent($elementId)); + } +} diff --git a/tests/UnitTests/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListenerTest.php b/tests/UnitTests/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListenerTest.php new file mode 100644 index 00000000..192becbb --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListenerTest.php @@ -0,0 +1,92 @@ + $properties + */ + private function fragmentizeNode(array $properties): NodeElementFragmentizeEvent + { + $element = new NodeElement(); + $element->addProperties($properties); + $event = new NodeElementFragmentizeEvent($element, new Node(), new MongoDocument(), new ElasticDocument()); + (new GenericPropertyElementFragmentizeEventListener( + $this->prophesize(Server500InternalServerErrorExceptionFactory::class)->reveal() + ))->onNodeElementFragmentizeEvent($event); + + return $event; + } + + public function testArrayPropertyGetsTruePlaceholderInNeo4jAndValueInMongo(): void + { + $event = $this->fragmentizeNode(['list' => [1, 2], 'map' => ['a' => 'b']]); + + $this->assertTrue($event->getCypherFragment()->getProperties()['list']); + $this->assertTrue($event->getCypherFragment()->getProperties()['map']); + $this->assertSame([1, 2], $event->getMongoFragment()->getProperties()['list']); + $this->assertSame(['a' => 'b'], $event->getMongoFragment()->getProperties()['map']); + } + + public function testObjectPropertyGetsTruePlaceholderInNeo4jAndValueInMongo(): void + { + $object = new stdClass(); + $event = $this->fragmentizeNode(['object' => $object]); + + $this->assertTrue($event->getCypherFragment()->getProperties()['object']); + $this->assertSame($object, $event->getMongoFragment()->getProperties()['object']); + } + + public function testScalarPropertiesStayInNeo4jOnly(): void + { + $event = $this->fragmentizeNode(['int' => 5, 'flag' => false, 'text' => 'abc']); + + $this->assertSame(['int' => 5, 'flag' => false, 'text' => 'abc'], $event->getCypherFragment()->getProperties()); + $this->assertSame([], $event->getMongoFragment()->getProperties()); + } + + public function testLongStringIsMovedToMongo(): void + { + $long = str_repeat('a', 1025); + $event = $this->fragmentizeNode(['text' => $long]); + + $this->assertArrayNotHasKey('text', $event->getCypherFragment()->getProperties()); + $this->assertSame($long, $event->getMongoFragment()->getProperties()['text']); + } + + public function testRelationEventAlsoUsesPlaceholder(): void + { + $element = new RelationElement(); + $element->addProperties(['list' => [1]]); + $event = new RelationElementFragmentizeEvent($element, new Relation(), new MongoDocument(), new ElasticDocument()); + (new GenericPropertyElementFragmentizeEventListener( + $this->prophesize(Server500InternalServerErrorExceptionFactory::class)->reveal() + ))->onRelationElementFragmentizeEvent($event); + + $this->assertTrue($event->getCypherFragment()->getProperties()['list']); + $this->assertSame([1], $event->getMongoFragment()->getProperties()['list']); + } +} diff --git a/tests/UnitTests/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListenerTest.php b/tests/UnitTests/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListenerTest.php new file mode 100644 index 00000000..44b33a2f --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementFragmentize/EventListener/HasFilePropertyElementFragmentizeEventListenerTest.php @@ -0,0 +1,115 @@ +prophesize(NodeElementInterface::class); + $nodeElement->hasProperty('hasFile')->willReturn(true); + $nodeElement->getProperty('hasFile')->willReturn(true); + + $cypherFragment = $this->prophesize(NodeInterface::class); + $cypherFragment->addProperty('hasFile', true)->shouldBeCalledOnce(); + $mongoFragment = $this->prophesize(MongoDocumentInterface::class); + $mongoFragment->addProperty('hasFile', true)->shouldBeCalledOnce(); + $elasticFragment = $this->prophesize(ElasticDocumentInterface::class); + $elasticFragment->addProperty('hasFile', true)->shouldBeCalledOnce(); + + $event = new NodeElementFragmentizeEvent( + $nodeElement->reveal(), + $cypherFragment->reveal(), + $mongoFragment->reveal(), + $elasticFragment->reveal(), + ); + + (new HasFilePropertyElementFragmentizeEventListener())->onNodeElementFragmentizeEvent($event); + } + + public function testNodeEventDoesNothingWhenPropertyIsMissing(): void + { + $nodeElement = $this->prophesize(NodeElementInterface::class); + $nodeElement->hasProperty('hasFile')->willReturn(false); + + $cypherFragment = $this->prophesize(NodeInterface::class); + $cypherFragment->addProperty('hasFile', null)->shouldNotBeCalled(); + $mongoFragment = $this->prophesize(MongoDocumentInterface::class); + $mongoFragment->addProperty('hasFile', null)->shouldNotBeCalled(); + $elasticFragment = $this->prophesize(ElasticDocumentInterface::class); + $elasticFragment->addProperty('hasFile', null)->shouldNotBeCalled(); + + $event = new NodeElementFragmentizeEvent( + $nodeElement->reveal(), + $cypherFragment->reveal(), + $mongoFragment->reveal(), + $elasticFragment->reveal(), + ); + + (new HasFilePropertyElementFragmentizeEventListener())->onNodeElementFragmentizeEvent($event); + $this->addToAssertionCount(1); + } + + public function testRelationEventCopiesHasFilePropertyToAllFragments(): void + { + $relationElement = $this->prophesize(RelationElementInterface::class); + $relationElement->hasProperty('hasFile')->willReturn(true); + $relationElement->getProperty('hasFile')->willReturn(false); + + $cypherFragment = $this->prophesize(RelationInterface::class); + $cypherFragment->addProperty('hasFile', false)->shouldBeCalledOnce(); + $mongoFragment = $this->prophesize(MongoDocumentInterface::class); + $mongoFragment->addProperty('hasFile', false)->shouldBeCalledOnce(); + $elasticFragment = $this->prophesize(ElasticDocumentInterface::class); + $elasticFragment->addProperty('hasFile', false)->shouldBeCalledOnce(); + + $event = new RelationElementFragmentizeEvent( + $relationElement->reveal(), + $cypherFragment->reveal(), + $mongoFragment->reveal(), + $elasticFragment->reveal(), + ); + + (new HasFilePropertyElementFragmentizeEventListener())->onRelationElementFragmentizeEvent($event); + } + + public function testRelationEventDoesNothingWhenPropertyIsMissing(): void + { + $relationElement = $this->prophesize(RelationElementInterface::class); + $relationElement->hasProperty('hasFile')->willReturn(false); + + $cypherFragment = $this->prophesize(RelationInterface::class); + $mongoFragment = $this->prophesize(MongoDocumentInterface::class); + $elasticFragment = $this->prophesize(ElasticDocumentInterface::class); + + $event = new RelationElementFragmentizeEvent( + $relationElement->reveal(), + $cypherFragment->reveal(), + $mongoFragment->reveal(), + $elasticFragment->reveal(), + ); + + (new HasFilePropertyElementFragmentizeEventListener())->onRelationElementFragmentizeEvent($event); + $this->addToAssertionCount(1); + } +} diff --git a/tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/UploadElementPropertyChangeEventListenerTest.php b/tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/UploadElementPropertyChangeEventListenerTest.php new file mode 100644 index 00000000..08fffb3a --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementPropertyChange/EventListener/UploadElementPropertyChangeEventListenerTest.php @@ -0,0 +1,66 @@ +prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate(Argument::cetera()) + ->willReturn('http://example.com/url-to-error'); + + return new UploadElementPropertyChangeEventListener( + new Client400ForbiddenPropertyExceptionFactory($urlGenerator->reveal()) + ); + } + + public function testElementsWhichAreNotUploadsAreIgnored(): void + { + self::expectNotToPerformAssertions(); + $this->createEventListener()->onElementPropertyChangeEvent( + new ElementPropertyChangeEvent('Test', null, ['hashState' => 'abc']) + ); + } + + public function testUploadsWithoutChangedPropertiesAreIgnored(): void + { + self::expectNotToPerformAssertions(); + $this->createEventListener()->onElementPropertyChangeEvent( + new ElementPropertyChangeEvent('Upload', null, []) + ); + } + + public function testUploadWithInternalPropertyTriggersException(): void + { + $this->expectException(Exception::class); + $this->createEventListener()->onElementPropertyChangeEvent( + new ElementPropertyChangeEvent('Upload', null, ['hashState' => 'abc']) + ); + } + + public function testUploadWithArbitraryPropertyTriggersException(): void + { + $this->expectException(Exception::class); + $this->createEventListener()->onElementPropertyChangeEvent( + new ElementPropertyChangeEvent('Upload', null, ['name' => 'Test']) + ); + } +} diff --git a/tests/UnitTests/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListenerTest.php b/tests/UnitTests/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListenerTest.php new file mode 100644 index 00000000..51d83029 --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementPropertyReset/EventListener/FileElementPropertyResetEventListenerTest.php @@ -0,0 +1,30 @@ +prophesize(NodeElementInterface::class)->reveal(); + $event = new ElementPropertyResetEvent($element); + + (new FileElementPropertyResetEventListener())->onElementPropertyResetEvent($event); + + $this->assertSame(['file'], $event->getPropertyNamesWhichAreKept()); + } +} diff --git a/tests/UnitTests/EventSystem/ElementPropertyReset/EventListener/HasFileElementPropertyResetEventListenerTest.php b/tests/UnitTests/EventSystem/ElementPropertyReset/EventListener/HasFileElementPropertyResetEventListenerTest.php new file mode 100644 index 00000000..acf4fc02 --- /dev/null +++ b/tests/UnitTests/EventSystem/ElementPropertyReset/EventListener/HasFileElementPropertyResetEventListenerTest.php @@ -0,0 +1,30 @@ +prophesize(NodeElementInterface::class)->reveal(); + $event = new ElementPropertyResetEvent($element); + + (new HasFileElementPropertyResetEventListener())->onElementPropertyResetEvent($event); + + $this->assertSame(['hasFile'], $event->getPropertyNamesWhichAreKept()); + } +} diff --git a/tests/UnitTests/EventSystem/EntityManager/EventListener/CancelUploadsOnAccessLossEventListenerTest.php b/tests/UnitTests/EventSystem/EntityManager/EventListener/CancelUploadsOnAccessLossEventListenerTest.php new file mode 100644 index 00000000..d6e260c9 --- /dev/null +++ b/tests/UnitTests/EventSystem/EntityManager/EventListener/CancelUploadsOnAccessLossEventListenerTest.php @@ -0,0 +1,115 @@ +prophesize(HttpKernelInterface::class)->reveal(), + new Request(), + $requestType, + new Response() + ); + } + + /** + * @return array + */ + public static function relationTypeProvider(): array + { + return [ + 'OWNS' => ['OWNS', true], + 'IS_IN_GROUP' => ['IS_IN_GROUP', true], + 'HAS_UPDATE_ACCESS' => ['HAS_UPDATE_ACCESS', true], + 'unrelated relation' => ['LIKES', false], + ]; + } + + #[DataProvider('relationTypeProvider')] + public function testDeletedRelationTriggersReviewOnResponse(string $type, bool $expectsReview): void + { + $service = $this->prophesize(UploadCancellationService::class); + $service->cancelUploadsWithoutAccess()->shouldBeCalledTimes($expectsReview ? 1 : 0)->willReturn(0); + $listener = new CancelUploadsOnAccessLossEventListener($service->reveal()); + + $listener->onElementPostDelete(new ElementPostDeleteEvent((new RelationElement())->setType($type))); + $listener->onResponse($this->createResponseEvent()); + // the review happens only once per marking + $listener->onResponse($this->createResponseEvent()); + } + + #[DataProvider('relationTypeProvider')] + public function testMergedRelationTriggersReviewOnResponse(string $type, bool $expectsReview): void + { + $service = $this->prophesize(UploadCancellationService::class); + $service->cancelUploadsWithoutAccess()->shouldBeCalledTimes($expectsReview ? 1 : 0)->willReturn(0); + $listener = new CancelUploadsOnAccessLossEventListener($service->reveal()); + + $listener->onElementPostMerge(new ElementPostMergeEvent((new RelationElement())->setType($type))); + $listener->onResponse($this->createResponseEvent()); + } + + /** + * @return array + */ + public static function nodeLabelProvider(): array + { + return ['User' => ['User', true], 'Group' => ['Group', true], 'Data' => ['Data', false], 'Upload' => ['Upload', false]]; + } + + #[DataProvider('nodeLabelProvider')] + public function testDeletedNodeTriggersReviewOnlyForUsersAndGroups(string $label, bool $expectsReview): void + { + $service = $this->prophesize(UploadCancellationService::class); + $service->cancelUploadsWithoutAccess()->shouldBeCalledTimes($expectsReview ? 1 : 0)->willReturn(0); + $listener = new CancelUploadsOnAccessLossEventListener($service->reveal()); + + $listener->onElementPostDelete(new ElementPostDeleteEvent((new NodeElement())->setLabel($label))); + $listener->onResponse($this->createResponseEvent()); + } + + public function testMergedNodeNeverTriggersReview(): void + { + $service = $this->prophesize(UploadCancellationService::class); + $service->cancelUploadsWithoutAccess()->shouldNotBeCalled(); + $listener = new CancelUploadsOnAccessLossEventListener($service->reveal()); + + $listener->onElementPostMerge(new ElementPostMergeEvent((new NodeElement())->setLabel('User'))); + $listener->onResponse($this->createResponseEvent()); + } + + public function testSubRequestsDoNotConsumeTheReview(): void + { + $service = $this->prophesize(UploadCancellationService::class); + $service->cancelUploadsWithoutAccess()->shouldBeCalledOnce()->willReturn(0); + $listener = new CancelUploadsOnAccessLossEventListener($service->reveal()); + + $listener->onElementPostDelete(new ElementPostDeleteEvent((new RelationElement())->setType('OWNS'))); + $listener->onResponse($this->createResponseEvent(HttpKernelInterface::SUB_REQUEST)); + $listener->onResponse($this->createResponseEvent()); + } +} diff --git a/tests/UnitTests/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListenerTest.php b/tests/UnitTests/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListenerTest.php new file mode 100644 index 00000000..acc958b4 --- /dev/null +++ b/tests/UnitTests/EventSystem/EntityManager/EventListener/ExpireEtagOnChangeEventListenerTest.php @@ -0,0 +1,341 @@ +|null $neighbours result of the first query, null for no row at all + * @param array|null $relation result of the second query, null for no row at all + * @param string[] $expectedKeys + */ + private function runListener( + callable $call, + ?array $neighbours, + ?array $relation, + array $expectedKeys, + ?Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory = null, + ): void { + $null = null; + $neighbourResult = new SummarizedResult($null, null === $neighbours ? [] : [new CypherMap($neighbours)]); + $relationResult = new SummarizedResult($null, null === $relation ? [] : [new CypherMap($relation)]); + + $clientInterface = $this->prophesize(ClientInterface::class); + $clientInterface->runStatement(Argument::any())->willReturn($neighbourResult, $relationResult); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($clientInterface->reveal()); + + $expiredKeys = []; + $redisClient = $this->prophesize(Client::class); + $redisClient->expire(Argument::type('string'), 0)->will(function ($args) use (&$expiredKeys) { + $expiredKeys[] = $args[0]; + + return 1; + }); + + $listener = new ExpireEtagOnChangeEventListener( + $redisClient->reveal(), + $cypherEntityManager->reveal(), + new RedisKeyFactory(), + $server500LogicErrorExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + $call($listener); + + sort($expiredKeys); + sort($expectedKeys); + $this->assertSame($expectedKeys, $expiredKeys); + } + + private function createNode(): NodeElement + { + $node = new NodeElement(); + $node->setId(Uuid::fromString(self::ELEMENT_ID)); + + return $node; + } + + /** + * @return array> + */ + private function createNeighbours(): array + { + return [ + 'childrenList' => new CypherList([self::CHILD_ID]), + 'parentsList' => new CypherList([self::PARENT_ID]), + 'relatedList' => new CypherList([self::RELATED_ID]), + 'indexList' => new CypherList([self::USER_ID]), + ]; + } + + /** + * @return string[] + */ + private function getExpectedKeysOfNode(): array + { + return [ + 'etag:element:'.self::ELEMENT_ID, + // the file ETag depends on name, file property and element ETag, so it expires with the element + 'etag:file:'.self::ELEMENT_ID, + 'etag:parents:'.self::CHILD_ID, + 'etag:children:'.self::PARENT_ID, + 'etag:related:'.self::RELATED_ID, + 'etag:index:'.self::USER_ID, + ]; + } + + public function testPostCreateExpiresEtagsOfElementAndItsNeighbours(): void + { + $event = new ElementPostCreateEvent($this->createNode()); + + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPostCreateEvent($event), + $this->createNeighbours(), + null, + $this->getExpectedKeysOfNode() + ); + } + + public function testPostMergeExpiresEtagsOfElementAndItsNeighbours(): void + { + $event = new ElementPostMergeEvent($this->createNode()); + + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPostMergeEvent($event), + $this->createNeighbours(), + null, + $this->getExpectedKeysOfNode() + ); + } + + public function testPreDeleteOnlyCollectsKeysAndExpiresNothing(): void + { + $event = new ElementPreDeleteEvent($this->createNode()); + + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPreDeleteEvent($event), + $this->createNeighbours(), + null, + [] + ); + } + + public function testPostDeleteExpiresTheKeysCollectedByPreDeleteOnce(): void + { + $preEvent = new ElementPreDeleteEvent($this->createNode()); + $postEvent = new ElementPostDeleteEvent($this->createNode()); + + $this->runListener( + function (ExpireEtagOnChangeEventListener $listener) use ($preEvent, $postEvent): void { + $listener->onElementPreDeleteEvent($preEvent); + $listener->onElementPostDeleteEvent($postEvent); + }, + $this->createNeighbours(), + null, + $this->getExpectedKeysOfNode() + ); + } + + public function testPostDeleteWithoutPreDeleteExpiresElementAndFileEtag(): void + { + $event = new ElementPostDeleteEvent($this->createNode()); + + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPostDeleteEvent($event), + null, + null, + ['etag:element:'.self::ELEMENT_ID, 'etag:file:'.self::ELEMENT_ID] + ); + } + + public function testElementWithoutNeighboursOnlyExpiresItsOwnEtags(): void + { + $event = new ElementPostMergeEvent($this->createNode()); + + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPostMergeEvent($event), + null, + null, + [ + 'etag:element:'.self::ELEMENT_ID, + 'etag:file:'.self::ELEMENT_ID, + ] + ); + } + + public function testNormalRelationAlsoExpiresRelatedCollectionsOfStartAndEnd(): void + { + $relation = new RelationElement(); + $relation->setId(Uuid::fromString(self::ELEMENT_ID)); + $event = new ElementPostMergeEvent($relation); + + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPostMergeEvent($event), + null, + ['start.id' => self::START_ID, 'type' => 'RELATED', 'end.id' => self::END_ID], + [ + 'etag:element:'.self::ELEMENT_ID, + 'etag:file:'.self::ELEMENT_ID, + 'etag:related:'.self::START_ID, + 'etag:related:'.self::END_ID, + ] + ); + } + + public function testOwnsRelationAlsoExpiresChildrenOfStartAndParentsOfEnd(): void + { + $relation = new RelationElement(); + $relation->setId(Uuid::fromString(self::ELEMENT_ID)); + $event = new ElementPostCreateEvent($relation); + + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPostCreateEvent($event), + null, + ['start.id' => self::START_ID, 'type' => 'OWNS', 'end.id' => self::END_ID], + [ + 'etag:element:'.self::ELEMENT_ID, + 'etag:file:'.self::ELEMENT_ID, + 'etag:related:'.self::START_ID, + 'etag:related:'.self::END_ID, + 'etag:children:'.self::START_ID, + 'etag:parents:'.self::END_ID, + ] + ); + } + + public function testElementWithoutIdThrows(): void + { + $listener = new ExpireEtagOnChangeEventListener( + $this->prophesize(Client::class)->reveal(), + $this->prophesize(CypherEntityManager::class)->reveal(), + new RedisKeyFactory(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Unable to expire etag for element with no identifier.'); + $listener->onElementPostMergeEvent(new ElementPostMergeEvent(new NodeElement())); + } + + /** + * @return array + */ + public static function invalidNeighbourListProvider(): array + { + return [ + 'children list' => ['childrenList'], + 'parents list' => ['parentsList'], + 'related list' => ['relatedList'], + 'index list' => ['indexList'], + ]; + } + + #[DataProvider('invalidNeighbourListProvider')] + public function testInvalidNeighbourListThrowsLogicError(string $brokenList): void + { + $neighbours = $this->createNeighbours(); + $neighbours[$brokenList] = 'not a list'; + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory + ->createFromTemplate(Argument::any()) + ->shouldBeCalledOnce() + ->willReturn(new Server500LogicErrorException('type')); + + $event = new ElementPostMergeEvent($this->createNode()); + + $this->expectException(Server500LogicErrorException::class); + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPostMergeEvent($event), + $neighbours, + null, + [], + $server500LogicErrorExceptionFactory->reveal() + ); + } + + public function testNonStringNeighbourIdThrowsLogicError(): void + { + $neighbours = $this->createNeighbours(); + $neighbours['childrenList'] = new CypherList([123]); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory + ->createFromTemplate(Argument::any()) + ->shouldBeCalledOnce() + ->willReturn(new Server500LogicErrorException('type')); + + $event = new ElementPostMergeEvent($this->createNode()); + + $this->expectException(Server500LogicErrorException::class); + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPostMergeEvent($event), + $neighbours, + null, + [], + $server500LogicErrorExceptionFactory->reveal() + ); + } + + public function testNonStringRelationEndpointThrowsLogicError(): void + { + $relation = new RelationElement(); + $relation->setId(Uuid::fromString(self::ELEMENT_ID)); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory + ->createFromTemplate(Argument::any()) + ->shouldBeCalledOnce() + ->willReturn(new Server500LogicErrorException('type')); + + $event = new ElementPostMergeEvent($relation); + + $this->expectException(Server500LogicErrorException::class); + $this->runListener( + fn (ExpireEtagOnChangeEventListener $listener) => $listener->onElementPostMergeEvent($event), + null, + ['start.id' => null, 'type' => 'RELATED', 'end.id' => self::END_ID], + [], + $server500LogicErrorExceptionFactory->reveal() + ); + } +} diff --git a/tests/UnitTests/EventSystem/EntityManager/EventListener/OwnershipChangeEventListenerTest.php b/tests/UnitTests/EventSystem/EntityManager/EventListener/OwnershipChangeEventListenerTest.php new file mode 100644 index 00000000..90770595 --- /dev/null +++ b/tests/UnitTests/EventSystem/EntityManager/EventListener/OwnershipChangeEventListenerTest.php @@ -0,0 +1,194 @@ +prophesize(AppStateService::class); + $appStateService->getAppState()->willReturn($appState); + + return new OwnershipChangeEventListener( + $queueService ?? $this->prophesize(QueueService::class)->reveal(), + $appStateService->reveal() + ); + } + + private function buildRelation(string $type): RelationElement + { + return (new RelationElement()) + ->setId(Uuid::fromString('11111111-1111-1111-1111-111111111111')) + ->setType($type) + ->setStart(Uuid::fromString('22222222-2222-2222-2222-222222222222')) + ->setEnd(Uuid::fromString('33333333-3333-3333-3333-333333333333')); + } + + /** + * @return array + */ + public static function triggeringTypesProvider(): array + { + return [ + 'OWNS' => ['OWNS'], + 'HAS_SEARCH_ACCESS' => ['HAS_SEARCH_ACCESS'], + 'IS_IN_GROUP' => ['IS_IN_GROUP'], + ]; + } + + /** + * @return array + */ + public static function nonTriggeringTypesProvider(): array + { + return [ + 'CREATED' => ['CREATED'], + 'HAS_READ_ACCESS' => ['HAS_READ_ACCESS'], + 'HAS_CREATE_ACCESS' => ['HAS_CREATE_ACCESS'], + 'HAS_UPDATE_ACCESS' => ['HAS_UPDATE_ACCESS'], + 'HAS_DELETE_ACCESS' => ['HAS_DELETE_ACCESS'], + 'SOME_OTHER_TYPE' => ['SOME_OTHER_TYPE'], + ]; + } + + #[DataProvider('triggeringTypesProvider')] + public function testPostCreateOfTriggeringRelationTypePublishesEvent(string $type): void + { + $relation = $this->buildRelation($type); + + $queueService = $this->prophesize(QueueService::class); + $queueService->publishEvent( + RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE, + [ + 'relationId' => '11111111-1111-1111-1111-111111111111', + 'relationType' => $type, + 'startId' => '22222222-2222-2222-2222-222222222222', + 'endId' => '33333333-3333-3333-3333-333333333333', + 'eventKind' => 'create', + 'tries' => 0, + ] + )->shouldBeCalledOnce(); + + $listener = $this->buildListener($queueService->reveal()); + $listener->onElementPostCreate(new ElementPostCreateEvent($relation)); + } + + #[DataProvider('nonTriggeringTypesProvider')] + public function testPostCreateOfNonTriggeringRelationTypeDoesNotPublish(string $type): void + { + $relation = $this->buildRelation($type); + + $queueService = $this->prophesize(QueueService::class); + $queueService->publishEvent(Argument::cetera())->shouldNotBeCalled(); + + $listener = $this->buildListener($queueService->reveal()); + $listener->onElementPostCreate(new ElementPostCreateEvent($relation)); + } + + public function testPostMergePublishesWithMergeEventKind(): void + { + $relation = $this->buildRelation('OWNS'); + + $queueService = $this->prophesize(QueueService::class); + $queueService->publishEvent( + RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE, + Argument::that(fn ($payload) => 'merge' === $payload['eventKind']) + )->shouldBeCalledOnce(); + + $listener = $this->buildListener($queueService->reveal()); + $listener->onElementPostMerge(new ElementPostMergeEvent($relation)); + } + + public function testPostDeletePublishesWithDeleteEventKindAndStillCarriesFullRelationData(): void + { + // the relation is already gone from the graph by the time this fires, so its data must travel in the + // payload itself + $relation = $this->buildRelation('OWNS'); + + $queueService = $this->prophesize(QueueService::class); + $queueService->publishEvent( + RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE, + [ + 'relationId' => '11111111-1111-1111-1111-111111111111', + 'relationType' => 'OWNS', + 'startId' => '22222222-2222-2222-2222-222222222222', + 'endId' => '33333333-3333-3333-3333-333333333333', + 'eventKind' => 'delete', + 'tries' => 0, + ] + )->shouldBeCalledOnce(); + + $listener = $this->buildListener($queueService->reveal()); + $listener->onElementPostDelete(new ElementPostDeleteEvent($relation)); + } + + public function testNodeElementIsIgnored(): void + { + $node = (new NodeElement())->setId(Uuid::fromString('44444444-4444-4444-4444-444444444444'))->setLabel('Data'); + + $queueService = $this->prophesize(QueueService::class); + $queueService->publishEvent(Argument::cetera())->shouldNotBeCalled(); + + $listener = $this->buildListener($queueService->reveal()); + $listener->onElementPostCreate(new ElementPostCreateEvent($node)); + } + + public function testRelationWithoutTypeIsIgnored(): void + { + $relation = (new RelationElement())->setId(Uuid::fromString('11111111-1111-1111-1111-111111111111')); + + $queueService = $this->prophesize(QueueService::class); + $queueService->publishEvent(Argument::cetera())->shouldNotBeCalled(); + + $listener = $this->buildListener($queueService->reveal()); + $listener->onElementPostCreate(new ElementPostCreateEvent($relation)); + } + + public function testRelationWithoutIdIsIgnored(): void + { + $relation = (new RelationElement())->setType('OWNS'); + + $queueService = $this->prophesize(QueueService::class); + $queueService->publishEvent(Argument::cetera())->shouldNotBeCalled(); + + $listener = $this->buildListener($queueService->reveal()); + $listener->onElementPostCreate(new ElementPostCreateEvent($relation)); + } + + public function testEventDuringBackupLoadIsIgnored(): void + { + $relation = $this->buildRelation('OWNS'); + + $queueService = $this->prophesize(QueueService::class); + $queueService->publishEvent(Argument::cetera())->shouldNotBeCalled(); + + $listener = $this->buildListener($queueService->reveal(), AppStateType::LOADING_BACKUP); + $listener->onElementPostCreate(new ElementPostCreateEvent($relation)); + $listener->onElementPostMerge(new ElementPostMergeEvent($relation)); + $listener->onElementPostDelete(new ElementPostDeleteEvent($relation)); + } +} diff --git a/tests/UnitTests/EventSystem/Etag/EventListener/EtagListenerPriorityTest.php b/tests/UnitTests/EventSystem/Etag/EventListener/EtagListenerPriorityTest.php new file mode 100644 index 00000000..c235da3f --- /dev/null +++ b/tests/UnitTests/EventSystem/Etag/EventListener/EtagListenerPriorityTest.php @@ -0,0 +1,56 @@ + + */ + public static function typeProvider(): array + { + $types = ['Element', 'File', 'ChildrenCollection', 'ParentsCollection', 'RelatedCollection', 'IndexCollection']; + + return array_combine($types, array_map(fn (string $type) => [$type], $types)); + } + + private function getPriority(string $class): int + { + $priorities = []; + foreach ((new ReflectionClass($class))->getMethods() as $method) { + foreach ($method->getAttributes(AsEventListener::class) as $attribute) { + /** @var AsEventListener $listener */ + $listener = $attribute->newInstance(); + $priorities[] = $listener->priority; + } + } + $this->assertCount(1, $priorities, sprintf('%s should register exactly one listener.', $class)); + + return $priorities[0] ?? 0; + } + + #[DataProvider('typeProvider')] + public function testRedisListenerRunsBeforeLiveListener(string $type): void + { + $redisPriority = $this->getPriority(sprintf('%sRedis%sEtagEventListener', self::NAMESPACE, $type)); + $livePriority = $this->getPriority(sprintf('%sLive%sEtagEventListener', self::NAMESPACE, $type)); + + $this->assertGreaterThan($livePriority, $redisPriority); + } +} diff --git a/tests/UnitTests/EventSystem/Exception/EventListener/ExceptionEventListenerTest.php b/tests/UnitTests/EventSystem/Exception/EventListener/ExceptionEventListenerTest.php index 9b85dc7b..73bbe935 100644 --- a/tests/UnitTests/EventSystem/Exception/EventListener/ExceptionEventListenerTest.php +++ b/tests/UnitTests/EventSystem/Exception/EventListener/ExceptionEventListenerTest.php @@ -6,8 +6,10 @@ use App\EventSystem\Exception\EventListener\ExceptionEventListener; use App\Exception\Client403ForbiddenException; +use App\Exception\ProblemJsonException; use App\Exception\Server500InternalServerErrorException; use App\Factory\Exception\Server500InternalServerErrorExceptionFactory; +use App\Service\RequestIdService; use App\Tests\UnitTests\AssertLoggerTrait; use App\Type\Response\ProblemJsonResponse; use Beste\Psr\Log\TestLogger; @@ -18,6 +20,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelInterface; @@ -31,6 +34,18 @@ class ExceptionEventListenerTest extends TestCase use ProphecyTrait; use AssertLoggerTrait; + private const string REQUEST_ID = '3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f'; + + private function createRequestIdService(): RequestIdService + { + $request = new Request(); + $request->headers->set('X-Request-Id', self::REQUEST_ID); + $requestStack = new RequestStack(); + $requestStack->push($request); + + return new RequestIdService($requestStack); + } + public function testBuildResponseForNormalExceptionInProduction(): void { $throwable = null; @@ -68,7 +83,8 @@ public function testBuildResponseForNormalExceptionInProduction(): void $urlGenerator->reveal(), $kernel->reveal(), $logger, - $server500InternalServerErrorExceptionFactory->reveal() + $server500InternalServerErrorExceptionFactory->reveal(), + $this->createRequestIdService() ); $exceptionEvent = new ExceptionEvent( @@ -88,7 +104,7 @@ public function testBuildResponseForNormalExceptionInProduction(): void /** * @var ProblemJsonResponse $response */ - $this->assertSame('{"type":"someType","title":"Internal server error","status":500}', $response->getContent()); + $this->assertSame('{"type":"someType","title":"Internal server error","status":500,"instance":"urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f"}', $response->getContent()); $this->assertSame(500, $response->getStatusCode()); $this->assertTrue($exceptionEvent->isPropagationStopped()); } @@ -130,7 +146,8 @@ public function testBuildResponseForNormalExceptionInDevelopment(): void $urlGenerator->reveal(), $kernel->reveal(), $logger, - $server500InternalServerErrorExceptionFactory->reveal() + $server500InternalServerErrorExceptionFactory->reveal(), + $this->createRequestIdService() ); $exceptionEvent = new ExceptionEvent( @@ -185,7 +202,8 @@ public function testBuildResponseForExtendedExceptionInProduction(): void $urlGenerator->reveal(), $kernel->reveal(), $logger, - $server500InternalServerErrorExceptionFactory->reveal() + $server500InternalServerErrorExceptionFactory->reveal(), + $this->createRequestIdService() ); $exceptionEvent = new ExceptionEvent( @@ -205,7 +223,7 @@ public function testBuildResponseForExtendedExceptionInProduction(): void /** * @var ProblemJsonResponse $response */ - $this->assertSame('{"type":"Some extended exception","title":"Forbidden","status":403,"detail":"Requested endpoint, element or action is forbidden."}', $response->getContent()); + $this->assertSame('{"type":"Some extended exception","title":"Forbidden","status":403,"instance":"urn:uuid:3f2a9c1e-8b4d-4c2a-9e1f-6a7b8c9d0e1f","detail":"Requested endpoint, element or action is forbidden."}', $response->getContent()); $this->assertSame(403, $response->getStatusCode()); $this->assertTrue($exceptionEvent->isPropagationStopped()); } @@ -236,7 +254,8 @@ public function testBuildResponseForExtendedExceptionInDevelopment(): void $urlGenerator->reveal(), $kernel->reveal(), $logger, - $server500InternalServerErrorExceptionFactory->reveal() + $server500InternalServerErrorExceptionFactory->reveal(), + $this->createRequestIdService() ); $exceptionEvent = new ExceptionEvent( @@ -287,7 +306,8 @@ public function testInstanceLinkCanBeGenerated(): void $urlGenerator->reveal(), $kernel->reveal(), $logger, - $server500InternalServerErrorExceptionFactory->reveal() + $server500InternalServerErrorExceptionFactory->reveal(), + $this->createRequestIdService() ); $exceptionEvent = new ExceptionEvent( @@ -311,4 +331,135 @@ public function testInstanceLinkCanBeGenerated(): void $this->assertSame(403, $response->getStatusCode()); $this->assertTrue($exceptionEvent->isPropagationStopped()); } + + public function testInstanceIsGeneratedRequestIdWithoutRequestIdHeader(): void + { + $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate(Argument::is('problem-unknown'))->willThrow(new RouteNotFoundException()); + $kernel = $this->prophesize(KernelInterface::class); + $kernel->isDebug()->willReturn(false); + $requestIdService = new RequestIdService(new RequestStack()); + + $exceptionEventListener = new ExceptionEventListener( + $urlGenerator->reveal(), + $kernel->reveal(), + TestLogger::create(), + $this->prophesize(Server500InternalServerErrorExceptionFactory::class)->reveal(), + $requestIdService + ); + $exceptionEvent = new ExceptionEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $this->prophesize(Request::class)->reveal(), + 0, + new Client403ForbiddenException('Some extended exception') + ); + + $exceptionEventListener->onKernelException($exceptionEvent); + + $response = $exceptionEvent->getResponse(); + $this->assertInstanceOf(ProblemJsonResponse::class, $response); + $responseData = json_decode((string) $response->getContent(), true); + $this->assertSame(sprintf('urn:uuid:%s', $requestIdService->getRequestId()->toString()), $responseData['instance']); + $this->assertMatchesRegularExpression('/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $responseData['instance']); + $this->assertSame(['type', 'title', 'status', 'instance', 'detail'], array_keys($responseData)); + } + + /** + * @return array + */ + private function runListenerWithProblemJsonException(ProblemJsonException $exception, bool $debug = false): array + { + $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate(Argument::is('problem-unknown'))->willThrow(new RouteNotFoundException()); + $kernel = $this->prophesize(KernelInterface::class); + $kernel->isDebug()->willReturn($debug); + + $exceptionEventListener = new ExceptionEventListener( + $urlGenerator->reveal(), + $kernel->reveal(), + TestLogger::create(), + $this->prophesize(Server500InternalServerErrorExceptionFactory::class)->reveal(), + $this->createRequestIdService() + ); + $exceptionEvent = new ExceptionEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $this->prophesize(Request::class)->reveal(), + 0, + $exception + ); + + $exceptionEventListener->onKernelException($exceptionEvent); + + $response = $exceptionEvent->getResponse(); + $this->assertInstanceOf(ProblemJsonResponse::class, $response); + + return json_decode((string) $response->getContent(), true); + } + + public function testAdditionalPropertiesCanNotReplaceDefaultProperties(): void + { + $responseData = $this->runListenerWithProblemJsonException(new ProblemJsonException( + 'the type', + 'the title', + 418, + 'the detail', + null, + null, + [ + 'type' => 'other type', + 'title' => 'other title', + 'status' => 200, + 'instance' => 'other instance', + 'detail' => 'other detail', + 'exception' => 'other exception', + ] + )); + + $this->assertSame([ + 'type' => 'the type', + 'title' => 'the title', + 'status' => 418, + 'instance' => 'urn:uuid:'.self::REQUEST_ID, + 'detail' => 'the detail', + ], $responseData); + } + + public function testOtherAdditionalPropertiesAreKept(): void + { + $responseData = $this->runListenerWithProblemJsonException(new ProblemJsonException( + 'the type', + 'the title', + 409, + 'the detail', + null, + null, + ['expectedOffset' => 5, 'status' => 200, 'providedOffset' => 6] + )); + + $this->assertSame(['type', 'title', 'status', 'instance', 'detail', 'expectedOffset', 'providedOffset'], array_keys($responseData)); + $this->assertSame(409, $responseData['status']); + $this->assertSame(5, $responseData['expectedOffset']); + $this->assertSame(6, $responseData['providedOffset']); + } + + public function testDebugExceptionMemberIsNotReplacedByAdditionalProperty(): void + { + $responseData = $this->runListenerWithProblemJsonException( + new ProblemJsonException('the type', 'the title', 400, 'the detail', null, null, ['exception' => 'spoofed']), + true + ); + + $this->assertIsArray($responseData['exception']); + $this->assertArrayHasKey('message', $responseData['exception']); + $this->assertArrayHasKey('trace', $responseData['exception']); + } + + public function testEmptyDetailIsRemovedAndAdditionalDetailDoesNotBringItBack(): void + { + $responseData = $this->runListenerWithProblemJsonException( + new ProblemJsonException('the type', 'the title', 400, '', null, null, ['detail' => 'spoofed', 'extra' => true]) + ); + + $this->assertSame(['type', 'title', 'status', 'instance', 'extra'], array_keys($responseData)); + } } diff --git a/tests/UnitTests/EventSystem/Exception/EventListener/NoRouteFoundExceptionEventListenerTest.php b/tests/UnitTests/EventSystem/Exception/EventListener/NoRouteFoundExceptionEventListenerTest.php index f7c3dbf6..b3825c6a 100644 --- a/tests/UnitTests/EventSystem/Exception/EventListener/NoRouteFoundExceptionEventListenerTest.php +++ b/tests/UnitTests/EventSystem/Exception/EventListener/NoRouteFoundExceptionEventListenerTest.php @@ -6,7 +6,9 @@ use App\EventSystem\Exception\EventListener\NoRouteFoundExceptionEventListener; use App\Exception\Client404NotFoundException; +use App\Exception\Client405MethodNotAllowedException; use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Factory\Exception\Client405MethodNotAllowedExceptionFactory; use Exception; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; @@ -15,6 +17,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -44,7 +47,8 @@ public function testWithDifferentException(): void $this->expectNotToPerformAssertions(); $noRouteFoundExceptionEventListener = new NoRouteFoundExceptionEventListener( - $client404NotFoundExceptionFactory + $client404NotFoundExceptionFactory, + $this->prophesize(Client405MethodNotAllowedExceptionFactory::class)->reveal() ); $noRouteFoundExceptionEventListener->onKernelException($event); @@ -75,11 +79,44 @@ public function testWithNotFoundHttpException(): void ); $noRouteFoundExceptionEventListener = new NoRouteFoundExceptionEventListener( - $client404NotFoundExceptionFactory->reveal() + $client404NotFoundExceptionFactory->reveal(), + $this->prophesize(Client405MethodNotAllowedExceptionFactory::class)->reveal() ); $this->expectExceptionObject($client404NotFoundException); $noRouteFoundExceptionEventListener->onKernelException($event); } + + public function testWithMethodNotAllowedHttpException(): void + { + $throwable = new MethodNotAllowedHttpException(['GET', 'POST']); + $client405MethodNotAllowedException = new Client405MethodNotAllowedException('some type'); + + $client405MethodNotAllowedExceptionFactory = $this->prophesize(Client405MethodNotAllowedExceptionFactory::class); + $client405MethodNotAllowedExceptionFactory + ->createFromTemplate() + ->shouldBeCalledOnce() + ->willReturn($client405MethodNotAllowedException); + + $event = new ExceptionEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $this->prophesize(Request::class)->reveal(), + 0, + $throwable + ); + + $noRouteFoundExceptionEventListener = new NoRouteFoundExceptionEventListener( + $this->prophesize(Client404NotFoundExceptionFactory::class)->reveal(), + $client405MethodNotAllowedExceptionFactory->reveal() + ); + + try { + $noRouteFoundExceptionEventListener->onKernelException($event); + $this->fail('Expected exception was not thrown.'); + } catch (Client405MethodNotAllowedException $exception) { + $this->assertSame($client405MethodNotAllowedException, $exception); + $this->assertSame(['Allow' => 'GET, POST'], $exception->getHeaders()); + } + } } diff --git a/tests/UnitTests/EventSystem/NormalizedValueToRawValue/EventListener/MongoDBNormalizedValueToRawValueEventListenerTest.php b/tests/UnitTests/EventSystem/NormalizedValueToRawValue/EventListener/MongoDBNormalizedValueToRawValueEventListenerTest.php new file mode 100644 index 00000000..9155055e --- /dev/null +++ b/tests/UnitTests/EventSystem/NormalizedValueToRawValue/EventListener/MongoDBNormalizedValueToRawValueEventListenerTest.php @@ -0,0 +1,47 @@ + 1], new stdClass()] as $value) { + $event = new NormalizedValueToRawValueEvent($value); + $eventListener->onNormalizedValueToRawValueEvent($event); + $this->assertFalse($event->isPropagationStopped()); + } + } + + public function testBsonArrayIsConvertedToArray(): void + { + $event = new NormalizedValueToRawValueEvent(new BSONArray([1, 2, 3])); + (new MongoDBNormalizedValueToRawValueEventListener())->onNormalizedValueToRawValueEvent($event); + + $this->assertTrue($event->isPropagationStopped()); + $this->assertSame([1, 2, 3], $event->getRawValue()); + } + + public function testBsonDocumentIsConvertedToArray(): void + { + $event = new NormalizedValueToRawValueEvent(new BSONDocument(['a' => 1, 'b' => 'c'])); + (new MongoDBNormalizedValueToRawValueEventListener())->onNormalizedValueToRawValueEvent($event); + + $this->assertTrue($event->isPropagationStopped()); + $this->assertSame(['a' => 1, 'b' => 'c'], $event->getRawValue()); + } +} diff --git a/tests/UnitTests/EventSystem/Response/EventListener/AllowHeaderResponseEventListenerTest.php b/tests/UnitTests/EventSystem/Response/EventListener/AllowHeaderResponseEventListenerTest.php new file mode 100644 index 00000000..8cbfc94f --- /dev/null +++ b/tests/UnitTests/EventSystem/Response/EventListener/AllowHeaderResponseEventListenerTest.php @@ -0,0 +1,120 @@ +add('get-element', new Route('/{id}', methods: ['GET'])); + $routes->add('patch-element', new Route('/{id}', methods: ['PATCH'])); + $routes->add('delete-element', new Route('/{id}', methods: ['DELETE', 'put'])); + $routes->add('post-register', new Route('/register', methods: ['POST'])); + $routes->add('any-method', new Route('/anything')); + $routes->add('any-method-with-restriction', new Route('/anything', methods: ['GET'])); + + $router = $this->prophesize(RouterInterface::class); + $router->getRouteCollection()->willReturn($routes); + + return new AllowHeaderResponseEventListener($router->reveal()); + } + + private function createEvent(?string $routeName, Response $response, int $requestType = HttpKernelInterface::MAIN_REQUEST): ResponseEvent + { + $request = new Request(); + if (null !== $routeName) { + $request->attributes->set('_route', $routeName); + } + + return new ResponseEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + $request, + $requestType, + $response + ); + } + + public function testMethodsOfAllRoutesWithTheSamePathAreListed(): void + { + $response = new Response(); + $this->createListener()->onKernelResponse($this->createEvent('patch-element', $response)); + + $this->assertSame('DELETE, GET, HEAD, OPTIONS, PATCH, PUT', $response->headers->get('Allow')); + } + + public function testHeadIsOnlyAddedIfGetIsAllowed(): void + { + $response = new Response(); + $this->createListener()->onKernelResponse($this->createEvent('post-register', $response)); + + $this->assertSame('OPTIONS, POST', $response->headers->get('Allow')); + } + + public function testErrorResponseOfMatchedRouteGetsHeader(): void + { + $response = new Response('', 404); + $this->createListener()->onKernelResponse($this->createEvent('get-element', $response)); + + $this->assertSame('DELETE, GET, HEAD, OPTIONS, PATCH, PUT', $response->headers->get('Allow')); + } + + public function testRequestWithoutMatchedRouteGetsNoHeader(): void + { + $response = new Response(); + $this->createListener()->onKernelResponse($this->createEvent(null, $response)); + + $this->assertFalse($response->headers->has('Allow')); + } + + public function testUnknownRouteNameGetsNoHeader(): void + { + $response = new Response(); + $this->createListener()->onKernelResponse($this->createEvent('does-not-exist', $response)); + + $this->assertFalse($response->headers->has('Allow')); + } + + public function testSubRequestGetsNoHeader(): void + { + $response = new Response(); + $this->createListener()->onKernelResponse($this->createEvent('get-element', $response, HttpKernelInterface::SUB_REQUEST)); + + $this->assertFalse($response->headers->has('Allow')); + } + + public function testExistingHeaderIsKept(): void + { + $response = new Response('', 405, ['Allow' => 'POST']); + $this->createListener()->onKernelResponse($this->createEvent('get-element', $response)); + + $this->assertSame('POST', $response->headers->get('Allow')); + } + + public function testPathWithRouteWhichAcceptsAnyMethodGetsNoHeader(): void + { + $response = new Response(); + $this->createListener()->onKernelResponse($this->createEvent('any-method-with-restriction', $response)); + + $this->assertFalse($response->headers->has('Allow')); + } +} diff --git a/tests/UnitTests/Exception/Client416RangeNotSatisfiableExceptionTest.php b/tests/UnitTests/Exception/Client416RangeNotSatisfiableExceptionTest.php new file mode 100644 index 00000000..ccc48d18 --- /dev/null +++ b/tests/UnitTests/Exception/Client416RangeNotSatisfiableExceptionTest.php @@ -0,0 +1,68 @@ +assertSame('type', $exception->getType()); + $this->assertSame('Range Not Satisfiable', $exception->getTitle()); + $this->assertSame(416, $exception->getStatus()); + $this->assertSame('The requested range can not be satisfied for the target resource.', $exception->getDetail()); + $this->assertNull($exception->getInstance()); + $this->assertSame('', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + $this->assertSame(0, $exception->getCode()); + $this->assertSame([], $exception->getAdditionalProperties()); + } + + public function testGetter(): void + { + $exception = new Client416RangeNotSatisfiableException( + 'type', + 'title', + 123, + 'detail', + 'instance', + null, + ['foo' => 'bar'], + ); + + $this->assertSame('type', $exception->getType()); + $this->assertSame('title', $exception->getTitle()); + $this->assertSame(123, $exception->getStatus()); + $this->assertSame('detail', $exception->getDetail()); + $this->assertSame('instance', $exception->getInstance()); + $this->assertSame('', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + $this->assertSame(0, $exception->getCode()); + $this->assertSame(['foo' => 'bar'], $exception->getAdditionalProperties()); + } + + public function testWithPrevious(): void + { + try { + throw new Exception('Test'); + } catch (Exception $previous) { + $exception = new Client416RangeNotSatisfiableException( + 'type', + previous: $previous, + ); + + $this->assertSame($previous, $exception->getPrevious()); + } + } +} diff --git a/tests/UnitTests/Exception/ProblemJsonExceptionTest.php b/tests/UnitTests/Exception/ProblemJsonExceptionTest.php index 26dc775a..cf13e589 100644 --- a/tests/UnitTests/Exception/ProblemJsonExceptionTest.php +++ b/tests/UnitTests/Exception/ProblemJsonExceptionTest.php @@ -78,4 +78,13 @@ public function testWithPrevious(): void $this->assertSame(0, $problemJsonException->getCode()); } } + + public function testHeaders(): void + { + $problemJsonException = new ProblemJsonException('type', 'title', 123, 'detail'); + $this->assertSame([], $problemJsonException->getHeaders()); + + $this->assertSame($problemJsonException, $problemJsonException->setHeaders(['Allow' => 'GET'])); + $this->assertSame(['Allow' => 'GET'], $problemJsonException->getHeaders()); + } } diff --git a/tests/UnitTests/Factory/Exception/Client408RequestTimeoutExceptionFactoryTest.php b/tests/UnitTests/Factory/Exception/Client408RequestTimeoutExceptionFactoryTest.php new file mode 100644 index 00000000..17c76286 --- /dev/null +++ b/tests/UnitTests/Factory/Exception/Client408RequestTimeoutExceptionFactoryTest.php @@ -0,0 +1,45 @@ +createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->once()) + ->method('generate') + ->with('exception-detail', ['code' => '408', 'name' => 'request-timeout'], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn('http://example.com/error/408/request-timeout'); + + return new Client408RequestTimeoutExceptionFactory($urlGenerator); + } + + public function testCreateFromDetail(): void + { + $exception = $this->buildFactory()->createFromDetail('Some detail.'); + + $this->assertSame('http://example.com/error/408/request-timeout', $exception->getType()); + $this->assertSame('Request timeout', $exception->getTitle()); + $this->assertSame(408, $exception->getStatus()); + $this->assertSame('Some detail.', $exception->getDetail()); + } + + public function testCreateFromIncompleteBody(): void + { + $exception = $this->buildFactory()->createFromIncompleteBody(5, 10); + + $this->assertStringContainsString('received 5 of 10 bytes', $exception->getDetail()); + $this->assertStringContainsString('time limit of the server', $exception->getDetail()); + } +} diff --git a/tests/UnitTests/Factory/Exception/Client409ConflictExceptionFactoryTest.php b/tests/UnitTests/Factory/Exception/Client409ConflictExceptionFactoryTest.php index 29807994..a6fb8c68 100644 --- a/tests/UnitTests/Factory/Exception/Client409ConflictExceptionFactoryTest.php +++ b/tests/UnitTests/Factory/Exception/Client409ConflictExceptionFactoryTest.php @@ -31,10 +31,10 @@ public function testCreateFromTemplate(): void )->shouldBeCalledOnce()->willReturn('https://mock.dev/123'); $factory = new Client409ConflictExceptionFactory($urlGenerator->reveal()); - $additionalDetails = [ + $additionalProperties = [ 'a' => 'b', ]; - $exception = $factory->createFromDetail('some detail', $additionalDetails); + $exception = $factory->createFromDetail('some detail', $additionalProperties); $this->assertSame(409, $exception->getStatus()); $this->assertSame('Conflict', $exception->getTitle()); @@ -42,6 +42,6 @@ public function testCreateFromTemplate(): void $this->assertSame('some detail', $exception->getDetail()); $this->assertSame(null, $exception->getInstance()); $this->assertSame('', $exception->getMessage()); - $this->assertSame($additionalDetails, $exception->getAdditionalProperties()); + $this->assertSame($additionalProperties, $exception->getAdditionalProperties()); } } diff --git a/tests/UnitTests/Factory/Exception/Client416RangeNotSatisfiableExceptionFactoryTest.php b/tests/UnitTests/Factory/Exception/Client416RangeNotSatisfiableExceptionFactoryTest.php new file mode 100644 index 00000000..644dd44f --- /dev/null +++ b/tests/UnitTests/Factory/Exception/Client416RangeNotSatisfiableExceptionFactoryTest.php @@ -0,0 +1,75 @@ +prophesize(UrlGeneratorInterface::class); + $urlGenerator + ->generate( + Argument::is('exception-detail'), + Argument::is([ + 'code' => '416', + 'name' => 'range-not-satisfiable', + ]), + Argument::is(UrlGeneratorInterface::ABSOLUTE_URL) + ) + ->shouldBeCalledOnce() + ->willReturn('https://mock.dev/416'); + $factory = new Client416RangeNotSatisfiableExceptionFactory($urlGenerator->reveal()); + + $exception = $factory->createFromDetail('some exception detail'); + + $this->assertSame(416, $exception->getStatus()); + $this->assertSame('Range Not Satisfiable', $exception->getTitle()); + $this->assertSame('https://mock.dev/416', $exception->getType()); + $this->assertSame('some exception detail', $exception->getDetail()); + $this->assertNull($exception->getInstance()); + $this->assertSame('', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + $this->assertSame([], $exception->getAdditionalProperties()); + $this->assertSame([], $exception->getHeaders()); + } + + public function testCreateFromDetailWithAdditionalProperties(): void + { + $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); + $urlGenerator + ->generate(Argument::cetera()) + ->willReturn('https://mock.dev/416'); + $factory = new Client416RangeNotSatisfiableExceptionFactory($urlGenerator->reveal()); + + $exception = $factory->createFromDetail('some detail', ['range' => 'bytes=0-10']); + + $this->assertSame(['range' => 'bytes=0-10'], $exception->getAdditionalProperties()); + } + + public function testCreateFromDetailWithTotalLengthSetsContentRangeHeader(): void + { + $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); + $urlGenerator + ->generate(Argument::cetera()) + ->willReturn('https://mock.dev/416'); + $factory = new Client416RangeNotSatisfiableExceptionFactory($urlGenerator->reveal()); + + $exception = $factory->createFromDetail('some detail', [], 1234); + + $this->assertSame(['Content-Range' => 'bytes */1234'], $exception->getHeaders()); + } +} diff --git a/tests/UnitTests/Factory/S3ClientFactoryTest.php b/tests/UnitTests/Factory/S3ClientFactoryTest.php new file mode 100644 index 00000000..28223a7c --- /dev/null +++ b/tests/UnitTests/Factory/S3ClientFactoryTest.php @@ -0,0 +1,23 @@ +assertInstanceOf(S3Client::class, $factory->createS3Client()); + } +} diff --git a/tests/UnitTests/Factory/Type/RedisKeyFactoryTest.php b/tests/UnitTests/Factory/Type/RedisKeyFactoryTest.php index 95cf294d..db9d822d 100644 --- a/tests/UnitTests/Factory/Type/RedisKeyFactoryTest.php +++ b/tests/UnitTests/Factory/Type/RedisKeyFactoryTest.php @@ -79,4 +79,22 @@ public function testGetEtagFileRedisKey(): void $etagFileRedisKey = $redisKeyTypeFactory->getEtagFileRedisKey(Uuid::fromString('fde09c2c-932c-4d61-bbfd-52096d5205c3')); $this->assertSame('etag:file:fde09c2c-932c-4d61-bbfd-52096d5205c3', (string) $etagFileRedisKey); } + + public function testGetUploadLockRedisKey(): void + { + $redisKey = (new RedisKeyFactory())->getUploadLockRedisKey(Uuid::fromString('644ddc69-de1e-4636-b3b6-06c8b001fbc1')); + $this->assertSame('upload-lock:644ddc69-de1e-4636-b3b6-06c8b001fbc1', (string) $redisKey); + } + + public function testGetFileCreationLockRedisKey(): void + { + $redisKey = (new RedisKeyFactory())->getFileCreationLockRedisKey(Uuid::fromString('644ddc69-de1e-4636-b3b6-06c8b001fbc1')); + $this->assertSame('file:create:644ddc69-de1e-4636-b3b6-06c8b001fbc1', (string) $redisKey); + } + + public function testGetCronDeleteExpiredUploadRedisKey(): void + { + $redisKey = (new RedisKeyFactory())->getCronDeleteExpiredUploadRedisKey('644ddc69-de1e-4636-b3b6-06c8b001fbc1'); + $this->assertSame('cron:delete-expired-upload:644ddc69-de1e-4636-b3b6-06c8b001fbc1', (string) $redisKey); + } } diff --git a/tests/UnitTests/Factory/Type/Request/PartialUploadRequestFactoryTest.php b/tests/UnitTests/Factory/Type/Request/PartialUploadRequestFactoryTest.php index d9fedbf8..0bdc2c50 100644 --- a/tests/UnitTests/Factory/Type/Request/PartialUploadRequestFactoryTest.php +++ b/tests/UnitTests/Factory/Type/Request/PartialUploadRequestFactoryTest.php @@ -4,15 +4,22 @@ namespace App\Tests\UnitTests\Factory\Type\Request; +use App\Exception\Client400BadContentException; +use App\Factory\Exception\Client400BadContentExceptionFactory; +use App\Factory\Exception\Client408RequestTimeoutExceptionFactory; use App\Factory\Type\Request\PartialUploadRequestFactory; use App\Service\HeaderParseService; +use App\Service\UploadBodyLimitService; +use EmberNexusBundle\Service\EmberNexusConfiguration; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\HttpFoundation\HeaderBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; #[Small] #[CoversClass(PartialUploadRequestFactory::class)] @@ -20,12 +27,97 @@ class PartialUploadRequestFactoryTest extends TestCase { use ProphecyTrait; + private function buildLimitService(int $maxChunkSize = 100000): UploadBodyLimitService + { + $configuration = $this->prophesize(EmberNexusConfiguration::class); + $configuration->getFileUploadMaxChunkSizeInBytes()->willReturn($maxChunkSize); + + return new UploadBodyLimitService($configuration->reveal(), $this->buildBadContentFactory(), new Client408RequestTimeoutExceptionFactory($this->createStub(UrlGeneratorInterface::class))); + } + + private function buildBadContentFactory(): Client400BadContentExceptionFactory + { + $factory = $this->prophesize(Client400BadContentExceptionFactory::class); + $factory->createFromDetail(Argument::any())->will(fn ($args) => new Client400BadContentException('type', detail: $args[0])); + + return $factory->reveal(); + } + + public function testMissingUploadCompleteHeaderIsRejected(): void + { + $headers = $this->prophesize(HeaderBag::class)->reveal(); + $request = $this->prophesize(Request::class); + $request->getContent(Argument::is(true))->shouldNotBeCalled(); + $request = $request->reveal(); + $request->headers = $headers; + + $headerParseService = $this->prophesize(HeaderParseService::class); + $headerParseService->getContentTypeFromHeaders(Argument::cetera())->willReturn('application/partial-upload'); + $headerParseService->getUploadOffsetFromHeaders(Argument::is($headers))->willReturn(0); + $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->willReturn(null); + + $factory = new PartialUploadRequestFactory($headerParseService->reveal(), $this->buildLimitService(), $this->buildBadContentFactory()); + + try { + $factory->createPartialUploadRequestFromRequest($request); + } catch (Client400BadContentException $exception) { + $this->assertStringContainsString('Upload-Complete', $exception->getDetail()); + + return; + } + $this->fail('Expected rejection.'); + } + + /** + * @return array + */ + public static function oversizedBodyProvider(): array + { + return [ + 'declared length above cap' => [11, 'short'], + 'missing content length, actual body above cap' => [null, 'this body is longer than ten bytes'], + ]; + } + + #[DataProvider('oversizedBodyProvider')] + public function testBodyAboveChunkSizeCapIsRejected(?int $declaredLength, string $body): void + { + $headers = $this->prophesize(HeaderBag::class)->reveal(); + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $body); + rewind($resource); + $request = $this->prophesize(Request::class); + $request->getContent(Argument::is(true))->willReturn($resource); + $request = $request->reveal(); + $request->headers = $headers; + + $headerParseService = $this->prophesize(HeaderParseService::class); + $headerParseService->getContentTypeFromHeaders(Argument::cetera())->willReturn('application/partial-upload'); + $headerParseService->getUploadOffsetFromHeaders(Argument::is($headers))->willReturn(0); + $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->willReturn(true); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->willReturn($declaredLength); + + $factory = new PartialUploadRequestFactory($headerParseService->reveal(), $this->buildLimitService(10), $this->buildBadContentFactory()); + + try { + $factory->createPartialUploadRequestFromRequest($request); + } catch (Client400BadContentException $exception) { + $this->assertStringContainsString('at most 10 bytes', $exception->getDetail()); + + return; + } + $this->fail('Expected rejection.'); + } + public function testCreatePartialUploadRequestFromRequest(): void { $headers = $this->prophesize(HeaderBag::class)->reveal(); + $resource = fopen('php://memory', 'r+'); + fwrite($resource, 'some content'); + rewind($resource); $request = $this->prophesize(Request::class); - $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn($resource); $request = $request->reveal(); $request->headers = $headers; @@ -34,16 +126,16 @@ public function testCreatePartialUploadRequestFromRequest(): void $headerParseService->getContentTypeFromHeaders(Argument::is($headers), Argument::is('application/partial-upload'))->shouldBeCalledOnce()->willReturn('application/partial-upload'); $headerParseService->getUploadOffsetFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(1234); $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(false); - $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(4321); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(12); - $partialUploadRequestFactory = new PartialUploadRequestFactory($headerParseService->reveal()); + $partialUploadRequestFactory = new PartialUploadRequestFactory($headerParseService->reveal(), $this->buildLimitService(), $this->buildBadContentFactory()); $partialUploadRequest = $partialUploadRequestFactory->createPartialUploadRequestFromRequest($request); - $this->assertSame('some content', $partialUploadRequest->getContent()); + $this->assertSame('some content', stream_get_contents($partialUploadRequest->getContent())); $this->assertSame('application/partial-upload', $partialUploadRequest->getContentType()); $this->assertSame(1234, $partialUploadRequest->getUploadOffset()); $this->assertFalse($partialUploadRequest->isUploadComplete()); - $this->assertSame(4321, $partialUploadRequest->getContentLength()); + $this->assertSame(12, $partialUploadRequest->getContentLength()); } } diff --git a/tests/UnitTests/Factory/Type/Request/ResumableUploadRequestFactoryTest.php b/tests/UnitTests/Factory/Type/Request/ResumableUploadRequestFactoryTest.php index 0eb83dc5..90760e8a 100644 --- a/tests/UnitTests/Factory/Type/Request/ResumableUploadRequestFactoryTest.php +++ b/tests/UnitTests/Factory/Type/Request/ResumableUploadRequestFactoryTest.php @@ -6,8 +6,11 @@ use App\Exception\Client400BadContentException; use App\Factory\Exception\Client400BadContentExceptionFactory; +use App\Factory\Exception\Client408RequestTimeoutExceptionFactory; use App\Factory\Type\Request\ResumableUploadRequestFactory; use App\Service\HeaderParseService; +use App\Service\UploadBodyLimitService; +use EmberNexusBundle\Service\EmberNexusConfiguration; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -16,6 +19,7 @@ use Ramsey\Uuid\Uuid; use Symfony\Component\HttpFoundation\HeaderBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; #[Small] #[CoversClass(ResumableUploadRequestFactory::class)] @@ -23,12 +27,35 @@ class ResumableUploadRequestFactoryTest extends TestCase { use ProphecyTrait; + private function buildUploadBodyLimitService(int $maxChunkSize = 100000): UploadBodyLimitService + { + $configuration = $this->prophesize(EmberNexusConfiguration::class); + $configuration->getFileUploadMaxChunkSizeInBytes()->willReturn($maxChunkSize); + $badContentFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $badContentFactory->createFromDetail(Argument::any())->will(fn ($args) => new Client400BadContentException('type', detail: $args[0])); + + return new UploadBodyLimitService($configuration->reveal(), $badContentFactory->reveal(), new Client408RequestTimeoutExceptionFactory($this->createStub(UrlGeneratorInterface::class))); + } + + /** + * @return resource + */ + private function contentResource(string $content = 'some content') + { + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $content); + rewind($resource); + + return $resource; + } + public function buildResumableUploadRequestFactory( ?HeaderParseService $headerParseService = null, ?Client400BadContentExceptionFactory $client400BadContentExceptionFactory = null, ): ResumableUploadRequestFactory { return new ResumableUploadRequestFactory( $headerParseService ?? $this->prophesize(HeaderParseService::class)->reveal(), + $this->buildUploadBodyLimitService(), $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), ); } @@ -40,15 +67,15 @@ public function testCreateResumableUploadRequestFromRequest(): void $headers = $this->prophesize(HeaderBag::class)->reveal(); $request = $this->prophesize(Request::class); - $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn($this->contentResource()); $request = $request->reveal(); $request->headers = $headers; $headerParseService = $this->prophesize(HeaderParseService::class); $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(false); - $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(654321); - $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(4321); + $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(6512); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(12); $headerParseService->getExtensionFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn('png'); $resumableUploadRequestFactory = $this->buildResumableUploadRequestFactory( @@ -58,10 +85,10 @@ public function testCreateResumableUploadRequestFromRequest(): void $resumableUploadRequest = $resumableUploadRequestFactory->createResumableUploadRequestFromRequest($request, $elementId); $this->assertSame($elementId, $resumableUploadRequest->getElementId()); - $this->assertSame('some content', $resumableUploadRequest->getContent()); + $this->assertSame('some content', stream_get_contents($resumableUploadRequest->getContent())); $this->assertFalse($resumableUploadRequest->isUploadComplete()); - $this->assertSame(654321, $resumableUploadRequest->getUploadLength()); - $this->assertSame(4321, $resumableUploadRequest->getContentLength()); + $this->assertSame(6512, $resumableUploadRequest->getUploadLength()); + $this->assertSame(12, $resumableUploadRequest->getContentLength()); $this->assertSame('png', $resumableUploadRequest->getExtension()); } @@ -72,15 +99,15 @@ public function testCreateResumableUploadRequestFromRequestThrowsOnLengthMismatc $headers = $this->prophesize(HeaderBag::class)->reveal(); $request = $this->prophesize(Request::class); - $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn($this->contentResource()); $request = $request->reveal(); $request->headers = $headers; $headerParseService = $this->prophesize(HeaderParseService::class); $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(true); - $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(654321); - $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(4321); + $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(6512); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(12); $headerParseService->getExtensionFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn('png'); $exception = $this->prophesize(Client400BadContentException::class)->reveal(); @@ -105,7 +132,7 @@ public function testCreateResumableUploadRequestFromRequestDoesNotThrowWhenUploa $headers = $this->prophesize(HeaderBag::class)->reveal(); $request = $this->prophesize(Request::class); - $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn($this->contentResource()); $request = $request->reveal(); $request->headers = $headers; @@ -113,7 +140,7 @@ public function testCreateResumableUploadRequestFromRequestDoesNotThrowWhenUploa $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(true); $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(null); - $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(4321); + $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(12); $headerParseService->getExtensionFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn('png'); $resumableUploadRequestFactory = $this->buildResumableUploadRequestFactory( @@ -130,14 +157,14 @@ public function testCreateResumableUploadRequestFromRequestDoesNotThrowWhenConte $headers = $this->prophesize(HeaderBag::class)->reveal(); $request = $this->prophesize(Request::class); - $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn('some content'); + $request->getContent(Argument::is(true))->shouldBeCalledOnce()->willReturn($this->contentResource()); $request = $request->reveal(); $request->headers = $headers; $headerParseService = $this->prophesize(HeaderParseService::class); $headerParseService->isUploadCompleteFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(true); - $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(654321); + $headerParseService->getUploadLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(6512); $headerParseService->getContentLengthFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn(null); $headerParseService->getExtensionFromHeaders(Argument::is($headers))->shouldBeCalledOnce()->willReturn('png'); diff --git a/tests/UnitTests/Factory/Type/Response/NoContentResponseFactoryTest.php b/tests/UnitTests/Factory/Type/Response/NoContentResponseFactoryTest.php index 4014ec66..f511837c 100644 --- a/tests/UnitTests/Factory/Type/Response/NoContentResponseFactoryTest.php +++ b/tests/UnitTests/Factory/Type/Response/NoContentResponseFactoryTest.php @@ -30,7 +30,7 @@ public function testCreateNoContentResponseWithResumableUploadHeadersFromUploadW $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest()->shouldBeCalledOnce()->willReturn(666666); + $emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest()->shouldNotBeCalled(); $emberNexusConfiguration->getFileMaxFileSizeInBytes()->shouldBeCalledOnce()->willReturn(555555); $emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()->shouldBeCalledOnce()->willReturn(444444); $emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()->shouldBeCalledOnce()->willReturn(333333); @@ -47,7 +47,7 @@ public function testCreateNoContentResponseWithResumableUploadHeadersFromUploadW $this->assertSame('1234', $headers->get('Upload-Offset')); $this->assertNull($headers->get('Location')); $this->assertNull($headers->get('Upload-Length')); - $this->assertSame('max-age=666666, max-size=555555, min-append-size=444444, max-append-size=333333', $headers->get('Upload-Limit')); + $this->assertSame('max-age=0, max-size=555555, min-append-size=444444, max-append-size=333333', $headers->get('Upload-Limit')); $this->assertSame('Sun, 19 Apr 2026 13:34:00 GMT', $headers->get('Expires')); $this->assertSame('no-store, private', $headers->get('Cache-Control')); } @@ -63,7 +63,7 @@ public function testCreateNoContentResponseWithResumableUploadHeadersFromUploadW $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest()->shouldBeCalledOnce()->willReturn(666666); + $emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest()->shouldNotBeCalled(); $emberNexusConfiguration->getFileMaxFileSizeInBytes()->shouldBeCalledOnce()->willReturn(555555); $emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()->shouldBeCalledOnce()->willReturn(444444); $emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()->shouldBeCalledOnce()->willReturn(333333); @@ -80,8 +80,56 @@ public function testCreateNoContentResponseWithResumableUploadHeadersFromUploadW $this->assertSame('1234', $headers->get('Upload-Offset')); $this->assertSame('new-location', $headers->get('Location')); $this->assertSame('4567', $headers->get('Upload-Length')); - $this->assertSame('max-age=666666, max-size=555555, min-append-size=444444, max-append-size=333333', $headers->get('Upload-Limit')); + $this->assertSame('max-age=0, max-size=555555, min-append-size=444444, max-append-size=333333', $headers->get('Upload-Limit')); $this->assertSame('Mon, 20 Apr 2026 00:00:00 GMT', $headers->get('Expires')); $this->assertSame('no-store, private', $headers->get('Cache-Control')); } + + public function testUploadLimitMaxAgeIsRemainingLifetimeOfUpload(): void + { + $expires = new DateTime('+1000 seconds'); + + $upload = $this->prophesize(UploadInterface::class); + $upload->isUploadComplete()->willReturn(false); + $upload->getUploadOffset()->willReturn(0); + $upload->getUploadLength()->willReturn(null); + $upload->getExpires()->willReturn($expires); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + // the full window must not leak into the header + $emberNexusConfiguration->getFileUploadExpiresInSecondsAfterFirstRequest()->willReturn(666666); + $emberNexusConfiguration->getFileMaxFileSizeInBytes()->willReturn(555555); + $emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()->willReturn(444444); + $emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()->willReturn(333333); + + $response = (new NoContentResponseFactory($emberNexusConfiguration->reveal())) + ->createNoContentResponseWithResumableUploadHeadersFromUpload($upload->reveal()); + + $this->assertSame(1, preg_match('/^max-age=(\d+), /', $response->headers->get('Upload-Limit') ?? '', $matches)); + $this->assertGreaterThanOrEqual(998, (int) $matches[1]); + $this->assertLessThanOrEqual(1000, (int) $matches[1]); + $this->assertSame( + $expires->getTimestamp(), + (new DateTime($response->headers->get('Expires') ?? ''))->getTimestamp() + ); + } + + public function testUploadLimitMaxAgeIsNeverNegativeForExpiredUpload(): void + { + $upload = $this->prophesize(UploadInterface::class); + $upload->isUploadComplete()->willReturn(false); + $upload->getUploadOffset()->willReturn(0); + $upload->getUploadLength()->willReturn(null); + $upload->getExpires()->willReturn(new DateTime('-1000 seconds')); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileMaxFileSizeInBytes()->willReturn(1); + $emberNexusConfiguration->getFileUploadMinChunkSizeInBytes()->willReturn(1); + $emberNexusConfiguration->getFileUploadMaxChunkSizeInBytes()->willReturn(1); + + $response = (new NoContentResponseFactory($emberNexusConfiguration->reveal())) + ->createNoContentResponseWithResumableUploadHeadersFromUpload($upload->reveal()); + + $this->assertStringStartsWith('max-age=0, ', $response->headers->get('Upload-Limit') ?? ''); + } } diff --git a/tests/UnitTests/Factory/Type/S3/FileOperationFactoryTest.php b/tests/UnitTests/Factory/Type/S3/FileOperationFactoryTest.php deleted file mode 100644 index bb45432b..00000000 --- a/tests/UnitTests/Factory/Type/S3/FileOperationFactoryTest.php +++ /dev/null @@ -1,115 +0,0 @@ -prophesize(EmberNexusConfiguration::class)->reveal(), - $fileService ?? $this->prophesize(FileService::class)->reveal(), - $elementService ?? $this->prophesize(ElementService::class)->reveal(), - $server500LogicExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() - ); - } - - public function testCreateFileOperationFromUpload(): void - { - $id = Uuid::fromString('2e92baf4-850e-4a98-ab03-a5b2f717808f'); - - $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); - - $fileService = $this->prophesize(FileService::class); - $fileService->getUploadBucketKey(Argument::is($id), Argument::is(3))->shouldBeCalledOnce()->willReturn('upload-key'); - - $factory = $this->buildFileOperationFactory( - emberNexusConfiguration: $emberNexusConfiguration->reveal(), - fileService: $fileService->reveal() - ); - - $upload = $this->prophesize(UploadInterface::class); - $upload->getId()->shouldBeCalledOnce()->willReturn($id); - - $operation = $factory->createFileOperationFromUpload($upload->reveal(), 3); - - $this->assertInstanceOf(FileOperation::class, $operation); - $this->assertSame('upload-bucket', $operation->getBucket()); - $this->assertSame('upload-key', $operation->getKey()); - } - - public function testCreateFileOperationFromElement(): void - { - $id = Uuid::fromString('2e92baf4-850e-4a98-ab03-a5b2f717808f'); - - $element = $this->prophesize(NodeElement::class); - $element->getId()->shouldBeCalledOnce()->willReturn($id); - $element = $element->reveal(); - - $elementService = $this->prophesize(ElementService::class); - $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('test'); - - $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); - - $fileService = $this->prophesize(FileService::class); - $fileService->getStorageBucketKey(Argument::is($id), Argument::is('test'))->shouldBeCalledOnce()->willReturn('storage-key.test'); - - $factory = $this->buildFileOperationFactory( - emberNexusConfiguration: $emberNexusConfiguration->reveal(), - fileService: $fileService->reveal(), - elementService: $elementService->reveal() - ); - - $operation = $factory->createFileOperationFromElement($element); - - $this->assertInstanceOf(FileOperation::class, $operation); - $this->assertSame('storage-bucket', $operation->getBucket()); - $this->assertSame('storage-key.test', $operation->getKey()); - } - - public function testCreateFileOperationFromElementFailsOnMissingElementId(): void - { - $element = $this->prophesize(NodeElementInterface::class)->reveal(); - $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); - - $server500LogicExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); - $server500LogicExceptionFactory->createFromTemplate(Argument::is('Expected elementId to be not null.'))->shouldBeCalledOnce()->willReturn($exception); - - $factory = $this->buildFileOperationFactory( - server500LogicExceptionFactory: $server500LogicExceptionFactory->reveal() - ); - - $this->expectException(Server500LogicErrorException::class); - - $factory->createFileOperationFromElement($element); - } -} diff --git a/tests/UnitTests/Factory/Type/S3/MergeFileChunksOperationFactoryTest.php b/tests/UnitTests/Factory/Type/S3/MergeFileChunksOperationFactoryTest.php deleted file mode 100644 index c32ecc45..00000000 --- a/tests/UnitTests/Factory/Type/S3/MergeFileChunksOperationFactoryTest.php +++ /dev/null @@ -1,158 +0,0 @@ -prophesize(EmberNexusConfiguration::class)->reveal(), - $elementManager ?? $this->prophesize(ElementManager::class)->reveal(), - $elementService ?? $this->prophesize(ElementService::class)->reveal(), - $fileService ?? $this->prophesize(FileService::class)->reveal(), - $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), - ); - } - - public function testCreateMergeFileOperationFromUpload(): void - { - $uploadId = Uuid::fromString('8fb0017a-c2af-4140-96fd-c8e7bbe03f5e'); - $uploadTarget = Uuid::fromString('25825901-d004-4c13-a9d9-a0f813de49b4'); - - $upload = $this->prophesize(UploadInterface::class); - $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); - $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); - $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); - $upload->getUploadTarget()->shouldBeCalledTimes(2)->willReturn($uploadTarget); - $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); - - $element = $this->prophesize(NodeElementInterface::class); - $element->hasProperty(Argument::is('file'))->shouldBeCalledOnce()->willReturn(false); - $element = $element->reveal(); - - $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); - $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); - - $elementManager = $this->prophesize(ElementManager::class); - $elementManager->getElementOrFail(Argument::is($uploadTarget))->shouldBeCalledOnce()->willReturn($element); - - $fileService = $this->prophesize(FileService::class); - $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(1))->shouldBeCalledOnce()->willReturn('upload-key-0001'); - $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(2))->shouldBeCalledOnce()->willReturn('upload-key-0002'); - $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(3))->shouldBeCalledOnce()->willReturn('upload-key-0003'); - $fileService->getStorageBucketKey(Argument::is($uploadTarget), Argument::is('ext'))->shouldBeCalledOnce()->willReturn('target-key.ext'); - - $mergeFileChunksOperationFactory = $this->buildMergeFileChunksOperationFactory( - emberNexusConfiguration: $emberNexusConfiguration->reveal(), - elementManager: $elementManager->reveal(), - fileService: $fileService->reveal() - ); - - $mergeFileChunksOperation = $mergeFileChunksOperationFactory->createMergeFileOperationFromUpload($upload->reveal()); - - $this->assertInstanceOf(MergeFileChunksOperation::class, $mergeFileChunksOperation); - $this->assertSame('upload-bucket', $mergeFileChunksOperation->getUploadBucket()); - $this->assertSame(['upload-key-0001', 'upload-key-0002', 'upload-key-0003'], $mergeFileChunksOperation->getUploadKeys()); - $this->assertSame('storage-bucket', $mergeFileChunksOperation->getStorageBucket()); - $this->assertNull($mergeFileChunksOperation->getPreviousStorageKey()); - $this->assertSame('target-key.ext', $mergeFileChunksOperation->getStorageKey()); - } - - public function testCreateMergeFileOperationFromUploadSetsPreviousStorageKeyWhenPossible(): void - { - $uploadId = Uuid::fromString('8fb0017a-c2af-4140-96fd-c8e7bbe03f5e'); - $uploadTarget = Uuid::fromString('25825901-d004-4c13-a9d9-a0f813de49b4'); - - $upload = $this->prophesize(UploadInterface::class); - $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); - $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(2); - $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); - $upload->getUploadTarget()->shouldBeCalledTimes(3)->willReturn($uploadTarget); - $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); - - $element = $this->prophesize(NodeElementInterface::class); - $element->hasProperty(Argument::is('file'))->shouldBeCalledOnce()->willReturn(true); - $element = $element->reveal(); - - $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); - $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); - - $elementManager = $this->prophesize(ElementManager::class); - $elementManager->getElementOrFail(Argument::is($uploadTarget))->shouldBeCalledOnce()->willReturn($element); - - $fileService = $this->prophesize(FileService::class); - $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(1))->shouldBeCalledOnce()->willReturn('upload-key-0001'); - $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(2))->shouldBeCalledOnce()->willReturn('upload-key-0002'); - $fileService->getStorageBucketKey(Argument::is($uploadTarget), Argument::is('ext'))->shouldBeCalledOnce()->willReturn('target-key.ext'); - $fileService->getStorageBucketKey(Argument::is($uploadTarget), Argument::is('prev'))->shouldBeCalledOnce()->willReturn('target-key.prev'); - - $elementService = $this->prophesize(ElementService::class); - $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('prev'); - - $mergeFileChunksOperationFactory = $this->buildMergeFileChunksOperationFactory( - emberNexusConfiguration: $emberNexusConfiguration->reveal(), - elementManager: $elementManager->reveal(), - elementService: $elementService->reveal(), - fileService: $fileService->reveal() - ); - - $mergeFileChunksOperation = $mergeFileChunksOperationFactory->createMergeFileOperationFromUpload($upload->reveal()); - - $this->assertInstanceOf(MergeFileChunksOperation::class, $mergeFileChunksOperation); - $this->assertSame('upload-bucket', $mergeFileChunksOperation->getUploadBucket()); - $this->assertSame(['upload-key-0001', 'upload-key-0002'], $mergeFileChunksOperation->getUploadKeys()); - $this->assertSame('storage-bucket', $mergeFileChunksOperation->getStorageBucket()); - $this->assertSame('target-key.prev', $mergeFileChunksOperation->getPreviousStorageKey()); - $this->assertSame('target-key.ext', $mergeFileChunksOperation->getStorageKey()); - } - - public function testCreateMergeFileOperationFromUploadThrowsWhenUploadIsIncomplete(): void - { - $upload = $this->prophesize(UploadInterface::class); - $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); - - $exception = $this->prophesize(Client400BadContentException::class)->reveal(); - - $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); - $client400BadContentExceptionFactory->createFromDetail(Argument::is("'MergeFileChunksOperation' requires 'Upload' to be complete."))->shouldBeCalledOnce()->willReturn($exception); - - $mergeFileChunksOperationFactory = $this->buildMergeFileChunksOperationFactory( - client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() - ); - - $this->expectException(Client400BadContentException::class); - - $mergeFileChunksOperationFactory->createMergeFileOperationFromUpload($upload->reveal()); - } -} diff --git a/tests/UnitTests/Factory/Type/S3/S3OperationFactoryTest.php b/tests/UnitTests/Factory/Type/S3/S3OperationFactoryTest.php new file mode 100644 index 00000000..b06af612 --- /dev/null +++ b/tests/UnitTests/Factory/Type/S3/S3OperationFactoryTest.php @@ -0,0 +1,531 @@ +prophesize(EmberNexusConfiguration::class)->reveal(), + $elementManager ?? $this->prophesize(ElementManager::class)->reveal(), + $elementService ?? $this->prophesize(ElementService::class)->reveal(), + $fileService ?? $this->prophesize(FileService::class)->reveal(), + $mimeTypeService ?? $this->prophesize(MimeTypeService::class)->reveal(), + $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), + $server500LogicExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + ); + } + + public function testCreateFileOperationFromUpload(): void + { + $id = Uuid::fromString('2e92baf4-850e-4a98-ab03-a5b2f717808f'); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($id), Argument::is(3), Argument::is('0123456789abcdef'))->shouldBeCalledOnce()->willReturn('upload-key'); + + $factory = $this->buildS3OperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + fileService: $fileService->reveal() + ); + + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->shouldBeCalledOnce()->willReturn($id); + + $operation = $factory->createFileOperationFromUpload($upload->reveal(), 3, '0123456789abcdef'); + + $this->assertInstanceOf(FileOperation::class, $operation); + $this->assertSame('upload-bucket', $operation->getBucket()); + $this->assertSame('upload-key', $operation->getKey()); + } + + public function testCreateFileOperationFromElement(): void + { + $id = Uuid::fromString('2e92baf4-850e-4a98-ab03-a5b2f717808f'); + + $element = $this->prophesize(NodeElement::class); + $element->getId()->shouldBeCalledOnce()->willReturn($id); + $element = $element->reveal(); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('test'); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getStorageBucketKey(Argument::is($id), Argument::is('test'))->shouldBeCalledOnce()->willReturn('storage-key.test'); + + $factory = $this->buildS3OperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + fileService: $fileService->reveal(), + elementService: $elementService->reveal() + ); + + $operation = $factory->createFileOperationFromElement($element); + + $this->assertInstanceOf(FileOperation::class, $operation); + $this->assertSame('storage-bucket', $operation->getBucket()); + $this->assertSame('storage-key.test', $operation->getKey()); + } + + public function testCreateFileOperationFromElementFailsOnMissingElementId(): void + { + $element = $this->prophesize(NodeElementInterface::class)->reveal(); + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicExceptionFactory->createFromTemplate(Argument::is('Expected elementId to be not null.'))->shouldBeCalledOnce()->willReturn($exception); + + $factory = $this->buildS3OperationFactory( + server500LogicExceptionFactory: $server500LogicExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $factory->createFileOperationFromElement($element); + } + + public function testCreateUploadFileOperationFromResumableUploadRequest(): void + { + $elementId = Uuid::fromString('9c981309-8269-4469-a595-282eb62ec04f'); + + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $resumableUploadRequest->getElementId()->shouldBeCalledOnce()->willReturn($elementId); + $resumableUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $resumableUploadRequest->getExtension()->shouldBeCalledOnce()->willReturn('txt'); + $resumableUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $element = $this->prophesize(NodeElementInterface::class); + $element = $element->reveal(); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getStorageKeyOfFile(Argument::is($element))->shouldBeCalledOnce()->willReturn(null); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($elementId))->shouldBeCalledOnce()->willReturn($element); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($elementId), Argument::is(0))->shouldBeCalledOnce()->willReturn('upload-key'); + $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('txt'))->shouldBeCalledOnce()->willReturn('storage-key.txt'); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $factory = $this->buildS3OperationFactory( + elementService: $elementService->reveal(), + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementManager: $elementManager->reveal(), + fileService: $fileService->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $uploadFileOperation = $factory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest->reveal()); + + $this->assertInstanceOf(UploadFileOperation::class, $uploadFileOperation); + $this->assertSame('upload-bucket', $uploadFileOperation->getUploadBucket()); + $this->assertSame('upload-key', $uploadFileOperation->getUploadKey()); + $this->assertSame('storage-bucket', $uploadFileOperation->getStorageBucket()); + $this->assertNull($uploadFileOperation->getPreviousStorageKey()); + $this->assertSame('storage-key.txt', $uploadFileOperation->getStorageKey()); + $this->assertSame('some content', $uploadFileOperation->getContent()); + $this->assertSame(12, $uploadFileOperation->getContentLength()); + $this->assertSame('text/plain', $uploadFileOperation->getMimeType()); + } + + public function testCreateUploadFileOperationFromResumableUploadRequestSetsPreviousStorageKeyWhenPossible(): void + { + $elementId = Uuid::fromString('9c981309-8269-4469-a595-282eb62ec04f'); + + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $resumableUploadRequest->getElementId()->shouldBeCalledOnce()->willReturn($elementId); + $resumableUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $resumableUploadRequest->getExtension()->shouldBeCalledOnce()->willReturn('txt'); + $resumableUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $element = $this->prophesize(NodeElementInterface::class); + $element = $element->reveal(); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($elementId))->shouldBeCalledOnce()->willReturn($element); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($elementId), Argument::is(0))->shouldBeCalledOnce()->willReturn('upload-key'); + $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('txt'))->shouldBeCalledOnce()->willReturn('storage-key.txt'); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getStorageKeyOfFile(Argument::is($element))->shouldBeCalledOnce()->willReturn('storage-key.prev'); + + $factory = $this->buildS3OperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementManager: $elementManager->reveal(), + elementService: $elementService->reveal(), + fileService: $fileService->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $uploadFileOperation = $factory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest->reveal()); + + $this->assertInstanceOf(UploadFileOperation::class, $uploadFileOperation); + $this->assertSame('storage-key.prev', $uploadFileOperation->getPreviousStorageKey()); + } + + public function testCreateUploadFileOperationFromResumableUploadRequestFailsWhenUploadIsIncomplete(): void + { + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("'UploadFileOperation' requires 'ResumableUploadRequest' to contain the whole content, i.e. be a non-chunked upload request."))->shouldBeCalledOnce()->willReturn($exception); + + $factory = $this->buildS3OperationFactory( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $factory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest->reveal()); + } + + public function testCreateUploadFileOperationFromElementAndResource(): void + { + $elementId = Uuid::fromString('9c981309-8269-4469-a595-282eb62ec04f'); + + $element = $this->prophesize(NodeElementInterface::class); + $element->getId()->shouldBeCalledOnce()->willReturn($elementId); + $element = $element->reveal(); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($elementId), Argument::is(0))->shouldBeCalledOnce()->willReturn('upload-key'); + $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('ext'))->shouldBeCalledOnce()->willReturn('storage-key.ext'); + + $mimeTypeService = $this->prophesize(MimeTypeService::class); + $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('ext'); + + $factory = $this->buildS3OperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementService: $elementService->reveal(), + fileService: $fileService->reveal(), + mimeTypeService: $mimeTypeService->reveal() + ); + + $uploadFileOperation = $factory->createUploadFileOperationFromElementAndResource($element, 'some content'); + + $this->assertInstanceOf(UploadFileOperation::class, $uploadFileOperation); + $this->assertSame('upload-bucket', $uploadFileOperation->getUploadBucket()); + $this->assertSame('upload-key', $uploadFileOperation->getUploadKey()); + $this->assertSame('storage-bucket', $uploadFileOperation->getStorageBucket()); + $this->assertNull($uploadFileOperation->getPreviousStorageKey()); + $this->assertSame('storage-key.ext', $uploadFileOperation->getStorageKey()); + $this->assertSame('some content', $uploadFileOperation->getContent()); + $this->assertNull($uploadFileOperation->getContentLength()); + $this->assertSame('text/plain', $uploadFileOperation->getMimeType()); + } + + public function testCreateUploadFileOperationFromElementAndResourceFailsWhenElementIdIsMissing(): void + { + $element = $this->prophesize(NodeElementInterface::class); + $element->getId()->shouldBeCalledOnce()->willReturn(null); + $element = $element->reveal(); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicExceptionFactory->createFromTemplate(Argument::is('Expected element.id to not be null.'))->shouldBeCalledOnce()->willReturn($exception); + + $factory = $this->buildS3OperationFactory( + server500LogicExceptionFactory: $server500LogicExceptionFactory->reveal() + ); + + $this->expectException(Server500LogicErrorException::class); + + $factory->createUploadFileOperationFromElementAndResource($element, 'some content'); + } + + public function testCreateUploadFileChunkOperationFromResumableUploadRequest(): void + { + $uploadId = Uuid::fromString('8bcc36eb-0a16-4a2f-b5f9-a892a81c56c5'); + + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); + $resumableUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $resumableUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); + $resumableUploadRequest = $resumableUploadRequest->reveal(); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(1), Argument::is('0123456789abcdef'))->shouldBeCalledOnce()->willReturn('upload-key'); + + $factory = $this->buildS3OperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + fileService: $fileService->reveal() + ); + + $operation = $factory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId, '0123456789abcdef'); + + $this->assertInstanceOf(UploadFileChunkOperation::class, $operation); + $this->assertSame('upload-bucket', $operation->getUploadBucket()); + $this->assertSame('upload-key', $operation->getUploadKey()); + $this->assertSame('some content', $operation->getContent()); + $this->assertSame(12, $operation->getContentLength()); + $this->assertSame('application/octet-stream', $operation->getMimeType()); + } + + public function testCreateUploadFileChunkOperationFromResumableUploadRequestFailsOnCompleteUpload(): void + { + $uploadId = Uuid::fromString('8bcc36eb-0a16-4a2f-b5f9-a892a81c56c5'); + + $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $resumableUploadRequest = $resumableUploadRequest->reveal(); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("'UploadFileChunkOperation' requires 'ResumableUploadRequest' to contain partial content, i.e. be a chunked upload request."))->shouldBeCalledOnce()->willReturn($exception); + + $factory = $this->buildS3OperationFactory( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $factory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId, '0123456789abcdef'); + } + + public function testCreateUploadFileChunkOperationFromPartialUploadRequest(): void + { + $uploadId = Uuid::fromString('8bcc36eb-0a16-4a2f-b5f9-a892a81c56c5'); + + $partialUploadRequest = $this->prophesize(PartialUploadRequestInterface::class); + $partialUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $partialUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); + + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); + $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(4), Argument::is('0123456789abcdef'))->shouldBeCalledOnce()->willReturn('upload-key'); + + $factory = $this->buildS3OperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + fileService: $fileService->reveal() + ); + + $operation = $factory->createUploadFileChunkOperationFromPartialUploadRequest($partialUploadRequest->reveal(), $upload->reveal(), '0123456789abcdef'); + + $this->assertInstanceOf(UploadFileChunkOperation::class, $operation); + $this->assertSame('upload-bucket', $operation->getUploadBucket()); + $this->assertSame('upload-key', $operation->getUploadKey()); + $this->assertSame('some content', $operation->getContent()); + $this->assertSame(12, $operation->getContentLength()); + $this->assertSame('application/octet-stream', $operation->getMimeType()); + } + + public function testCreateUploadFileChunkOperationFromUploadFileOperation(): void + { + $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $uploadFileOperation->getUploadKey()->shouldBeCalledOnce()->willReturn('upload-key'); + $uploadFileOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); + $uploadFileOperation->getContentLength()->shouldBeCalledOnce()->willReturn(12); + $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); + + $factory = $this->buildS3OperationFactory(); + + $operation = $factory->createUploadFileChunkOperationFromUploadFileOperation($uploadFileOperation->reveal()); + + $this->assertInstanceOf(UploadFileChunkOperationInterface::class, $operation); + $this->assertSame('upload-bucket', $operation->getUploadBucket()); + $this->assertSame('upload-key', $operation->getUploadKey()); + $this->assertSame('some content', $operation->getContent()); + $this->assertSame(12, $operation->getContentLength()); + $this->assertSame('text/plain', $operation->getMimeType()); + } + + public function testCreateMergeFileOperationFromUpload(): void + { + $uploadId = Uuid::fromString('8fb0017a-c2af-4140-96fd-c8e7bbe03f5e'); + $uploadTarget = Uuid::fromString('25825901-d004-4c13-a9d9-a0f813de49b4'); + + $upload = $this->prophesize(UploadInterface::class); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $upload->getChunkIds()->shouldBeCalledOnce()->willReturn(['aaaaaaaaaaaaaaa1', 'aaaaaaaaaaaaaaa2', 'aaaaaaaaaaaaaaa3']); + $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); + $upload->getUploadTarget()->shouldBeCalledTimes(2)->willReturn($uploadTarget); + $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); + + $element = $this->prophesize(NodeElementInterface::class); + $element = $element->reveal(); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getStorageKeyOfFile(Argument::is($element))->shouldBeCalledOnce()->willReturn(null); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($uploadTarget))->shouldBeCalledOnce()->willReturn($element); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(1), Argument::is('aaaaaaaaaaaaaaa1'))->shouldBeCalledOnce()->willReturn('upload-key-0001'); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(2), Argument::is('aaaaaaaaaaaaaaa2'))->shouldBeCalledOnce()->willReturn('upload-key-0002'); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(3), Argument::is('aaaaaaaaaaaaaaa3'))->shouldBeCalledOnce()->willReturn('upload-key-0003'); + $fileService->getStorageBucketKey(Argument::is($uploadTarget), Argument::is('ext'))->shouldBeCalledOnce()->willReturn('target-key.ext'); + + $factory = $this->buildS3OperationFactory( + elementService: $elementService->reveal(), + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementManager: $elementManager->reveal(), + fileService: $fileService->reveal() + ); + + $mergeFileChunksOperation = $factory->createMergeFileOperationFromUpload($upload->reveal()); + + $this->assertInstanceOf(MergeFileChunksOperation::class, $mergeFileChunksOperation); + $this->assertSame('upload-bucket', $mergeFileChunksOperation->getUploadBucket()); + $this->assertSame(['upload-key-0001', 'upload-key-0002', 'upload-key-0003'], $mergeFileChunksOperation->getUploadKeys()); + $this->assertSame('storage-bucket', $mergeFileChunksOperation->getStorageBucket()); + $this->assertNull($mergeFileChunksOperation->getPreviousStorageKey()); + $this->assertSame('target-key.ext', $mergeFileChunksOperation->getStorageKey()); + } + + public function testCreateMergeFileOperationFromUploadSetsPreviousStorageKeyWhenPossible(): void + { + $uploadId = Uuid::fromString('8fb0017a-c2af-4140-96fd-c8e7bbe03f5e'); + $uploadTarget = Uuid::fromString('25825901-d004-4c13-a9d9-a0f813de49b4'); + + $upload = $this->prophesize(UploadInterface::class); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); + $upload->getChunkIds()->shouldBeCalledOnce()->willReturn(['aaaaaaaaaaaaaaa1', 'aaaaaaaaaaaaaaa2']); + $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); + $upload->getUploadTarget()->shouldBeCalledTimes(2)->willReturn($uploadTarget); + $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); + + $element = $this->prophesize(NodeElementInterface::class); + $element = $element->reveal(); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($uploadTarget))->shouldBeCalledOnce()->willReturn($element); + + $fileService = $this->prophesize(FileService::class); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(1), Argument::is('aaaaaaaaaaaaaaa1'))->shouldBeCalledOnce()->willReturn('upload-key-0001'); + $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(2), Argument::is('aaaaaaaaaaaaaaa2'))->shouldBeCalledOnce()->willReturn('upload-key-0002'); + $fileService->getStorageBucketKey(Argument::is($uploadTarget), Argument::is('ext'))->shouldBeCalledOnce()->willReturn('target-key.ext'); + + $elementService = $this->prophesize(ElementService::class); + $elementService->getStorageKeyOfFile(Argument::is($element))->shouldBeCalledOnce()->willReturn('target-key.prev'); + + $factory = $this->buildS3OperationFactory( + emberNexusConfiguration: $emberNexusConfiguration->reveal(), + elementManager: $elementManager->reveal(), + elementService: $elementService->reveal(), + fileService: $fileService->reveal() + ); + + $mergeFileChunksOperation = $factory->createMergeFileOperationFromUpload($upload->reveal()); + + $this->assertInstanceOf(MergeFileChunksOperation::class, $mergeFileChunksOperation); + $this->assertSame('upload-bucket', $mergeFileChunksOperation->getUploadBucket()); + $this->assertSame(['upload-key-0001', 'upload-key-0002'], $mergeFileChunksOperation->getUploadKeys()); + $this->assertSame('storage-bucket', $mergeFileChunksOperation->getStorageBucket()); + $this->assertSame('target-key.prev', $mergeFileChunksOperation->getPreviousStorageKey()); + $this->assertSame('target-key.ext', $mergeFileChunksOperation->getStorageKey()); + } + + public function testCreateMergeFileOperationFromUploadThrowsWhenUploadIsIncomplete(): void + { + $upload = $this->prophesize(UploadInterface::class); + $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); + + $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::is("'MergeFileChunksOperation' requires 'Upload' to be complete."))->shouldBeCalledOnce()->willReturn($exception); + + $factory = $this->buildS3OperationFactory( + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() + ); + + $this->expectException(Client400BadContentException::class); + + $factory->createMergeFileOperationFromUpload($upload->reveal()); + } +} diff --git a/tests/UnitTests/Factory/Type/S3/UploadFileChunkOperationFactoryTest.php b/tests/UnitTests/Factory/Type/S3/UploadFileChunkOperationFactoryTest.php deleted file mode 100644 index bbeceb80..00000000 --- a/tests/UnitTests/Factory/Type/S3/UploadFileChunkOperationFactoryTest.php +++ /dev/null @@ -1,149 +0,0 @@ -prophesize(EmberNexusConfiguration::class)->reveal(), - $fileService ?? $this->prophesize(FileService::class)->reveal(), - $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal() - ); - } - - public function testCreateUploadFileChunkOperationFromResumableUploadRequest(): void - { - $uploadId = Uuid::fromString('8bcc36eb-0a16-4a2f-b5f9-a892a81c56c5'); - - $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); - $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); - $resumableUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); - $resumableUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); - $resumableUploadRequest = $resumableUploadRequest->reveal(); - - $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); - - $fileService = $this->prophesize(FileService::class); - $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(1))->shouldBeCalledOnce()->willReturn('upload-key'); - - $factory = $this->buildUploadFileChunkOperationFactory( - emberNexusConfiguration: $emberNexusConfiguration->reveal(), - fileService: $fileService->reveal() - ); - - $operation = $factory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId); - - $this->assertInstanceOf(UploadFileChunkOperation::class, $operation); - $this->assertSame('upload-bucket', $operation->getUploadBucket()); - $this->assertSame('upload-key', $operation->getUploadKey()); - $this->assertSame('some content', $operation->getContent()); - $this->assertSame(12, $operation->getContentLength()); - $this->assertSame('application/octet-stream', $operation->getMimeType()); - } - - public function testCreateUploadFileChunkOperationFromResumableUploadRequestFailsOnCompleteUpload(): void - { - $uploadId = Uuid::fromString('8bcc36eb-0a16-4a2f-b5f9-a892a81c56c5'); - - $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); - $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); - $resumableUploadRequest = $resumableUploadRequest->reveal(); - - $exception = $this->prophesize(Client400BadContentException::class)->reveal(); - - $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); - $client400BadContentExceptionFactory->createFromDetail(Argument::is("'UploadFileChunkOperation' requires 'ResumableUploadRequest' to contain partial content, i.e. be a chunked upload request."))->shouldBeCalledOnce()->willReturn($exception); - - $factory = $this->buildUploadFileChunkOperationFactory( - client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() - ); - - $this->expectException(Client400BadContentException::class); - - $factory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId); - } - - public function testCreateUploadFileChunkOperationFromPartialUploadRequest(): void - { - $uploadId = Uuid::fromString('8bcc36eb-0a16-4a2f-b5f9-a892a81c56c5'); - - $partialUploadRequest = $this->prophesize(PartialUploadRequestInterface::class); - $partialUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); - $partialUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); - - $upload = $this->prophesize(UploadInterface::class); - $upload->getId()->shouldBeCalledOnce()->willReturn($uploadId); - $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); - - $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); - - $fileService = $this->prophesize(FileService::class); - $fileService->getUploadBucketKey(Argument::is($uploadId), Argument::is(4))->shouldBeCalledOnce()->willReturn('upload-key'); - - $factory = $this->buildUploadFileChunkOperationFactory( - emberNexusConfiguration: $emberNexusConfiguration->reveal(), - fileService: $fileService->reveal() - ); - - $operation = $factory->createUploadFileChunkOperationFromPartialUploadRequest($partialUploadRequest->reveal(), $upload->reveal()); - - $this->assertInstanceOf(UploadFileChunkOperation::class, $operation); - $this->assertSame('upload-bucket', $operation->getUploadBucket()); - $this->assertSame('upload-key', $operation->getUploadKey()); - $this->assertSame('some content', $operation->getContent()); - $this->assertSame(12, $operation->getContentLength()); - $this->assertSame('application/octet-stream', $operation->getMimeType()); - } - - public function testCreateUploadFileChunkOperationFromUploadFileOperation(): void - { - $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); - $uploadFileOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); - $uploadFileOperation->getUploadKey()->shouldBeCalledOnce()->willReturn('upload-key'); - $uploadFileOperation->getContent()->shouldBeCalledOnce()->willReturn('some content'); - $uploadFileOperation->getContentLength()->shouldBeCalledOnce()->willReturn(12); - $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); - - $factory = $this->buildUploadFileChunkOperationFactory(); - - $operation = $factory->createUploadFileChunkOperationFromUploadFileOperation($uploadFileOperation->reveal()); - - $this->assertInstanceOf(UploadFileChunkOperationInterface::class, $operation); - $this->assertSame('upload-bucket', $operation->getUploadBucket()); - $this->assertSame('upload-key', $operation->getUploadKey()); - $this->assertSame('some content', $operation->getContent()); - $this->assertSame(12, $operation->getContentLength()); - $this->assertSame('text/plain', $operation->getMimeType()); - } -} diff --git a/tests/UnitTests/Factory/Type/S3/UploadFileOperationFactoryTest.php b/tests/UnitTests/Factory/Type/S3/UploadFileOperationFactoryTest.php deleted file mode 100644 index 81d824f4..00000000 --- a/tests/UnitTests/Factory/Type/S3/UploadFileOperationFactoryTest.php +++ /dev/null @@ -1,229 +0,0 @@ -prophesize(EmberNexusConfiguration::class)->reveal(), - $elementManager ?? $this->prophesize(ElementManager::class)->reveal(), - $elementService ?? $this->prophesize(ElementService::class)->reveal(), - $fileService ?? $this->prophesize(FileService::class)->reveal(), - $mimeTypeService ?? $this->prophesize(MimeTypeService::class)->reveal(), - $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), - $server500LogicExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() - ); - } - - public function testCreateUploadFileOperationFromResumableUploadRequest(): void - { - $elementId = Uuid::fromString('9c981309-8269-4469-a595-282eb62ec04f'); - - $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); - $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); - $resumableUploadRequest->getElementId()->shouldBeCalledOnce()->willReturn($elementId); - $resumableUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); - $resumableUploadRequest->getExtension()->shouldBeCalledOnce()->willReturn('txt'); - $resumableUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); - - $element = $this->prophesize(NodeElementInterface::class); - $element->hasProperty(Argument::is('file'))->shouldBeCalledOnce()->willReturn(false); - $element = $element->reveal(); - - $elementManager = $this->prophesize(ElementManager::class); - $elementManager->getElementOrFail(Argument::is($elementId))->shouldBeCalledOnce()->willReturn($element); - - $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); - $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); - - $fileService = $this->prophesize(FileService::class); - $fileService->getUploadBucketKey(Argument::is($elementId), Argument::is(0))->shouldBeCalledOnce()->willReturn('upload-key'); - $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('txt'))->shouldBeCalledOnce()->willReturn('storage-key.txt'); - - $mimeTypeService = $this->prophesize(MimeTypeService::class); - $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); - - $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( - emberNexusConfiguration: $emberNexusConfiguration->reveal(), - elementManager: $elementManager->reveal(), - fileService: $fileService->reveal(), - mimeTypeService: $mimeTypeService->reveal() - ); - - $uploadFileOperation = $uploadFileOperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest->reveal()); - - $this->assertInstanceOf(UploadFileOperation::class, $uploadFileOperation); - $this->assertSame('upload-bucket', $uploadFileOperation->getUploadBucket()); - $this->assertSame('upload-key', $uploadFileOperation->getUploadKey()); - $this->assertSame('storage-bucket', $uploadFileOperation->getStorageBucket()); - $this->assertNull($uploadFileOperation->getPreviousStorageKey()); - $this->assertSame('storage-key.txt', $uploadFileOperation->getStorageKey()); - $this->assertSame('some content', $uploadFileOperation->getContent()); - $this->assertSame(12, $uploadFileOperation->getContentLength()); - $this->assertSame('text/plain', $uploadFileOperation->getMimeType()); - } - - public function testCreateUploadFileOperationFromResumableUploadRequestSetsPreviousStorageKeyWhenPossible(): void - { - $elementId = Uuid::fromString('9c981309-8269-4469-a595-282eb62ec04f'); - - $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); - $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(true); - $resumableUploadRequest->getElementId()->shouldBeCalledOnce()->willReturn($elementId); - $resumableUploadRequest->getContent()->shouldBeCalledOnce()->willReturn('some content'); - $resumableUploadRequest->getExtension()->shouldBeCalledOnce()->willReturn('txt'); - $resumableUploadRequest->getContentLength()->shouldBeCalledOnce()->willReturn(12); - - $element = $this->prophesize(NodeElementInterface::class); - $element->hasProperty(Argument::is('file'))->shouldBeCalledOnce()->willReturn(true); - $element = $element->reveal(); - - $elementManager = $this->prophesize(ElementManager::class); - $elementManager->getElementOrFail(Argument::is($elementId))->shouldBeCalledOnce()->willReturn($element); - - $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); - $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); - - $fileService = $this->prophesize(FileService::class); - $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('prev'))->shouldBeCalledOnce()->willReturn('storage-key.prev'); - $fileService->getUploadBucketKey(Argument::is($elementId), Argument::is(0))->shouldBeCalledOnce()->willReturn('upload-key'); - $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('txt'))->shouldBeCalledOnce()->willReturn('storage-key.txt'); - - $mimeTypeService = $this->prophesize(MimeTypeService::class); - $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); - - $elementService = $this->prophesize(ElementService::class); - $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('prev'); - - $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( - emberNexusConfiguration: $emberNexusConfiguration->reveal(), - elementManager: $elementManager->reveal(), - elementService: $elementService->reveal(), - fileService: $fileService->reveal(), - mimeTypeService: $mimeTypeService->reveal() - ); - - $uploadFileOperation = $uploadFileOperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest->reveal()); - - $this->assertInstanceOf(UploadFileOperation::class, $uploadFileOperation); - $this->assertSame('storage-key.prev', $uploadFileOperation->getPreviousStorageKey()); - } - - public function testCreateUploadFileOperationFromResumableUploadRequestFailsWhenUploadIsIncomplete(): void - { - $resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); - $resumableUploadRequest->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); - - $exception = $this->prophesize(Client400BadContentException::class)->reveal(); - - $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); - $client400BadContentExceptionFactory->createFromDetail(Argument::is("'UploadFileOperation' requires 'ResumableUploadRequest' to contain the whole content, i.e. be a non-chunked upload request."))->shouldBeCalledOnce()->willReturn($exception); - - $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( - client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() - ); - - $this->expectException(Client400BadContentException::class); - - $uploadFileOperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest->reveal()); - } - - public function testCreateUploadFileOperationFromElementAndResource(): void - { - $elementId = Uuid::fromString('9c981309-8269-4469-a595-282eb62ec04f'); - - $element = $this->prophesize(NodeElementInterface::class); - $element->getId()->shouldBeCalledOnce()->willReturn($elementId); - $element = $element->reveal(); - - $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getFileS3UploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); - $emberNexusConfiguration->getFileS3StorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); - - $fileService = $this->prophesize(FileService::class); - $fileService->getUploadBucketKey(Argument::is($elementId), Argument::is(0))->shouldBeCalledOnce()->willReturn('upload-key'); - $fileService->getStorageBucketKey(Argument::is($elementId), Argument::is('ext'))->shouldBeCalledOnce()->willReturn('storage-key.ext'); - - $mimeTypeService = $this->prophesize(MimeTypeService::class); - $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); - - $elementService = $this->prophesize(ElementService::class); - $elementService->getFileNameExtension(Argument::is($element))->shouldBeCalledOnce()->willReturn('ext'); - - $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( - emberNexusConfiguration: $emberNexusConfiguration->reveal(), - elementService: $elementService->reveal(), - fileService: $fileService->reveal(), - mimeTypeService: $mimeTypeService->reveal() - ); - - $uploadFileOperation = $uploadFileOperationFactory->createUploadFileOperationFromElementAndResource($element, 'some content'); - - $this->assertInstanceOf(UploadFileOperation::class, $uploadFileOperation); - $this->assertSame('upload-bucket', $uploadFileOperation->getUploadBucket()); - $this->assertSame('upload-key', $uploadFileOperation->getUploadKey()); - $this->assertSame('storage-bucket', $uploadFileOperation->getStorageBucket()); - $this->assertNull($uploadFileOperation->getPreviousStorageKey()); - $this->assertSame('storage-key.ext', $uploadFileOperation->getStorageKey()); - $this->assertSame('some content', $uploadFileOperation->getContent()); - $this->assertNull($uploadFileOperation->getContentLength()); - $this->assertSame('text/plain', $uploadFileOperation->getMimeType()); - } - - public function testCreateUploadFileOperationFromElementAndResourceFailsWhenElementIdIsMissing(): void - { - $element = $this->prophesize(NodeElementInterface::class); - $element->getId()->shouldBeCalledOnce()->willReturn(null); - $element = $element->reveal(); - - $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); - - $server500LogicExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); - $server500LogicExceptionFactory->createFromTemplate(Argument::is('Expected element.id to not be null.'))->shouldBeCalledOnce()->willReturn($exception); - - $uploadFileOperationFactory = $this->buildUploadFileOperationFactory( - server500LogicExceptionFactory: $server500LogicExceptionFactory->reveal() - ); - - $this->expectException(Server500LogicErrorException::class); - - $uploadFileOperationFactory->createUploadFileOperationFromElementAndResource($element, 'some content'); - } -} diff --git a/tests/UnitTests/Factory/Type/UploadFactoryTest.php b/tests/UnitTests/Factory/Type/UploadFactoryTest.php index 846b9aaf..2969c93a 100644 --- a/tests/UnitTests/Factory/Type/UploadFactoryTest.php +++ b/tests/UnitTests/Factory/Type/UploadFactoryTest.php @@ -54,11 +54,12 @@ public function testCreateUploadFromElement(): void $propertyParseService->getUploadOffsetFromProperties(Argument::is($properties))->willReturn(2222); $propertyParseService->getIsUploadCompleteFromProperties(Argument::is($properties))->willReturn(false); $propertyParseService->getUploadTargetFromProperties(Argument::is($properties))->willReturn($uploadTarget); - $propertyParseService->getAlreadyUploadedChunksFromProperties(Argument::is($properties))->willReturn(5); + $propertyParseService->getChunkIdsFromProperties(Argument::is($properties))->willReturn(['a', 'b', 'c', 'd', 'e']); $propertyParseService->getUploadOwnerFromProperties(Argument::is($properties))->willReturn($uploadOwner); $propertyParseService->getExtensionFromProperties(Argument::is($properties))->willReturn('some-ext'); $propertyParseService->getExpiresFromProperties(Argument::is($properties))->willReturn($expires); $propertyParseService->getHashStateFromProperties(Argument::is($properties))->willReturn('some-hash-state'); + $propertyParseService->getTargetHadFileAtCreationFromProperties(Argument::is($properties))->willReturn(true); $uploadFactory = $this->buildUploadFactory( propertyParseService: $propertyParseService->reveal() @@ -72,10 +73,12 @@ public function testCreateUploadFromElement(): void $this->assertSame(false, $upload->isUploadComplete()); $this->assertSame($uploadTarget, $upload->getUploadTarget()); $this->assertSame(5, $upload->getAlreadyUploadedChunks()); + $this->assertSame(['a', 'b', 'c', 'd', 'e'], $upload->getChunkIds()); $this->assertSame($uploadOwner, $upload->getUploadOwner()); $this->assertSame('some-ext', $upload->getExtension()); $this->assertSame($expires, $upload->getExpires()); $this->assertSame('some-hash-state', $upload->getHashState()); + $this->assertTrue($upload->targetHadFileAtCreation()); } public function testCreateUploadFromElementThrowsWhenElementIsNotANode(): void @@ -148,11 +151,12 @@ public function testMarkUploadAsComplete(): void $upload->getUploadOffset()->shouldBeCalledOnce()->willReturn(2222); $upload->isUploadComplete()->shouldNotBeCalled(); $upload->getUploadTarget()->shouldBeCalledOnce()->willReturn($uploadTarget); - $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); + $upload->getChunkIds()->shouldBeCalledOnce()->willReturn(['a', 'b', 'c']); $upload->getUploadOwner()->shouldBeCalledOnce()->willReturn($uploadOwner); $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); $upload->getHashState()->shouldBeCalledOnce()->willReturn('some-hash-state'); + $upload->targetHadFileAtCreation()->shouldBeCalledOnce()->willReturn(true); $uploadFactory = $this->buildUploadFactory(); @@ -168,6 +172,7 @@ public function testMarkUploadAsComplete(): void $this->assertSame('ext', $result->getExtension()); $this->assertSame($expires, $result->getExpires()); $this->assertSame('some-hash-state', $result->getHashState()); + $this->assertTrue($result->targetHadFileAtCreation()); } public function testAddNewChunkToUpload(): void @@ -183,15 +188,16 @@ public function testAddNewChunkToUpload(): void $upload->getUploadOffset()->shouldBeCalledOnce()->willReturn(10000); $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); $upload->getUploadTarget()->shouldBeCalledOnce()->willReturn($uploadTarget); - $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); + $upload->getChunkIds()->shouldBeCalledOnce()->willReturn(['a', 'b', 'c']); $upload->getUploadOwner()->shouldBeCalledOnce()->willReturn($uploadOwner); $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); $upload->getHashState()->shouldBeCalledOnce()->willReturn('some-hash-state'); + $upload->targetHadFileAtCreation()->shouldBeCalledOnce()->willReturn(true); $uploadFactory = $this->buildUploadFactory(); - $result = $uploadFactory->addNewChunkToUpload($upload->reveal(), 9999); + $result = $uploadFactory->addNewChunkToUpload($upload->reveal(), 9999, 'd'); $this->assertSame($uploadId, $result->getId()); $this->assertSame(20000, $result->getUploadLength()); @@ -199,10 +205,12 @@ public function testAddNewChunkToUpload(): void $this->assertSame(false, $result->isUploadComplete()); $this->assertSame($uploadTarget, $result->getUploadTarget()); $this->assertSame(4, $result->getAlreadyUploadedChunks()); + $this->assertSame(['a', 'b', 'c', 'd'], $result->getChunkIds()); $this->assertSame($uploadOwner, $result->getUploadOwner()); $this->assertSame('ext', $result->getExtension()); $this->assertSame($expires, $result->getExpires()); $this->assertSame('some-hash-state', $result->getHashState()); + $this->assertTrue($result->targetHadFileAtCreation()); } public function testAddNewChunkToUploadUsesProvidedHashStateInsteadOfExistingUploadHashState(): void @@ -218,15 +226,16 @@ public function testAddNewChunkToUploadUsesProvidedHashStateInsteadOfExistingUpl $upload->getUploadOffset()->shouldBeCalledOnce()->willReturn(10000); $upload->isUploadComplete()->shouldBeCalledOnce()->willReturn(false); $upload->getUploadTarget()->shouldBeCalledOnce()->willReturn($uploadTarget); - $upload->getAlreadyUploadedChunks()->shouldBeCalledOnce()->willReturn(3); + $upload->getChunkIds()->shouldBeCalledOnce()->willReturn(['a', 'b', 'c']); $upload->getUploadOwner()->shouldBeCalledOnce()->willReturn($uploadOwner); $upload->getExtension()->shouldBeCalledOnce()->willReturn('ext'); $upload->getExpires()->shouldBeCalledOnce()->willReturn($expires); $upload->getHashState()->shouldNotBeCalled(); + $upload->targetHadFileAtCreation()->shouldBeCalledOnce()->willReturn(false); $uploadFactory = $this->buildUploadFactory(); - $result = $uploadFactory->addNewChunkToUpload($upload->reveal(), 9999, 'new-hash-state'); + $result = $uploadFactory->addNewChunkToUpload($upload->reveal(), 9999, 'd', 'new-hash-state'); $this->assertSame('new-hash-state', $result->getHashState()); } diff --git a/tests/UnitTests/Logger/TerminalConsoleHandlerTest.php b/tests/UnitTests/Logger/TerminalConsoleHandlerTest.php new file mode 100644 index 00000000..8bac6716 --- /dev/null +++ b/tests/UnitTests/Logger/TerminalConsoleHandlerTest.php @@ -0,0 +1,138 @@ +onCommand(new ConsoleCommandEvent(new Command('some-command'), new ArrayInput([]), $output)); + } + + public function testHandlesNoticeAndAboveInTerminal(): void + { + $handler = new TerminalConsoleHandler(isTerminal: fn () => true); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL); + $this->startCommand($handler, $output); + + $this->assertFalse($handler->isHandling($this->createRecord(Level::Debug))); + $this->assertFalse($handler->isHandling($this->createRecord(Level::Info))); + $this->assertTrue($handler->isHandling($this->createRecord(Level::Notice))); + $this->assertTrue($handler->isHandling($this->createRecord(Level::Warning))); + $this->assertTrue($handler->isHandling($this->createRecord(Level::Error))); + } + + public function testHandledRecordsAreWrittenAndNotPassedOn(): void + { + $handler = new TerminalConsoleHandler(isTerminal: fn () => true); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL); + $this->startCommand($handler, $output); + + $this->assertTrue($handler->handle($this->createRecord(Level::Error))); + $this->assertStringContainsString('some message', $output->fetch()); + } + + public function testRecordsBelowTheLevelBubbleToOtherHandlers(): void + { + $handler = new TerminalConsoleHandler(isTerminal: fn () => true); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL); + $this->startCommand($handler, $output); + + $this->assertFalse($handler->handle($this->createRecord(Level::Info))); + $this->assertSame('', $output->fetch()); + } + + public function testVerbosityShowsMoreRecords(): void + { + $handler = new TerminalConsoleHandler(isTerminal: fn () => true); + $output = new BufferedOutput(OutputInterface::VERBOSITY_VERY_VERBOSE); + $this->startCommand($handler, $output); + + $this->assertTrue($handler->isHandling($this->createRecord(Level::Info))); + $this->assertFalse($handler->isHandling($this->createRecord(Level::Debug))); + } + + public function testQuietOnlyShowsErrors(): void + { + $handler = new TerminalConsoleHandler(isTerminal: fn () => true); + $output = new BufferedOutput(OutputInterface::VERBOSITY_QUIET); + $this->startCommand($handler, $output); + + $this->assertFalse($handler->isHandling($this->createRecord(Level::Warning))); + $this->assertTrue($handler->isHandling($this->createRecord(Level::Error))); + } + + public function testDoesNothingWithoutTerminal(): void + { + $handler = new TerminalConsoleHandler(isTerminal: fn () => false); + $output = new BufferedOutput(OutputInterface::VERBOSITY_DEBUG); + $this->startCommand($handler, $output); + + $this->assertFalse($handler->isHandling($this->createRecord(Level::Emergency))); + $this->assertFalse($handler->handle($this->createRecord(Level::Emergency))); + $this->assertSame('', $output->fetch()); + } + + public function testDoesNothingOutsideOfCommands(): void + { + // regular requests never dispatch console events, so no output is ever set + $handler = new TerminalConsoleHandler(isTerminal: fn () => true); + + $this->assertFalse($handler->isHandling($this->createRecord(Level::Emergency))); + $this->assertFalse($handler->handle($this->createRecord(Level::Emergency))); + } + + public function testStopsHandlingAfterTheCommandTerminated(): void + { + $handler = new TerminalConsoleHandler(isTerminal: fn () => true); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL); + $this->startCommand($handler, $output); + $this->assertTrue($handler->isHandling($this->createRecord(Level::Error))); + + $handler->onTerminate(new ConsoleTerminateEvent(new Command('some-command'), new ArrayInput([]), $output, 0)); + + $this->assertFalse($handler->isHandling($this->createRecord(Level::Error))); + } + + public function testSubscribesToConsoleEvents(): void + { + $events = TerminalConsoleHandler::getSubscribedEvents(); + + $this->assertArrayHasKey(ConsoleEvents::COMMAND, $events); + $this->assertArrayHasKey(ConsoleEvents::TERMINATE, $events); + } + + public function testDefaultTerminalDetectionDoesNotFail(): void + { + // the phpunit process usually has no terminal on STDERR; only checks that the default detection works + $handler = new TerminalConsoleHandler(); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL); + $this->startCommand($handler, $output); + + $this->assertIsBool($handler->isHandling($this->createRecord(Level::Error))); + } +} diff --git a/tests/UnitTests/Service/CronExecutionGateServiceTest.php b/tests/UnitTests/Service/CronExecutionGateServiceTest.php new file mode 100644 index 00000000..456097fa --- /dev/null +++ b/tests/UnitTests/Service/CronExecutionGateServiceTest.php @@ -0,0 +1,76 @@ +prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn(false); + + $ttyDetectorService = $this->prophesize(TtyDetectorService::class); + $ttyDetectorService->isInteractive()->shouldNotBeCalled(); + + $service = new CronExecutionGateService($bag->reveal(), $ttyDetectorService->reveal()); + + $this->assertFalse($service->shouldSkipExecution()); + } + + public function testExecutionIsSkippedWhenCronIsDisabledAndNotInteractive(): void + { + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn(true); + + $ttyDetectorService = $this->prophesize(TtyDetectorService::class); + $ttyDetectorService->isInteractive()->willReturn(false); + + $service = new CronExecutionGateService($bag->reveal(), $ttyDetectorService->reveal()); + + $this->assertTrue($service->shouldSkipExecution()); + } + + public function testExecutionIsNotSkippedWhenCronIsDisabledButInteractive(): void + { + // a developer running a cron command by hand in a real terminal always executes it, regardless of + // DISABLE_CRON + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn(true); + + $ttyDetectorService = $this->prophesize(TtyDetectorService::class); + $ttyDetectorService->isInteractive()->willReturn(true); + + $service = new CronExecutionGateService($bag->reveal(), $ttyDetectorService->reveal()); + + $this->assertFalse($service->shouldSkipExecution()); + } + + public function testThrowsIfCronDisabledParameterIsNotBoolean(): void + { + $bag = $this->prophesize(ParameterBagInterface::class); + $bag->get('isCronDisabled')->willReturn('not-a-boolean'); + + $ttyDetectorService = $this->prophesize(TtyDetectorService::class); + $ttyDetectorService->isInteractive()->shouldNotBeCalled(); + + $service = new CronExecutionGateService($bag->reveal(), $ttyDetectorService->reveal()); + + $this->expectException(LogicException::class); + $service->shouldSkipExecution(); + } +} diff --git a/tests/UnitTests/Service/ElementFileDeletionServiceTest.php b/tests/UnitTests/Service/ElementFileDeletionServiceTest.php new file mode 100644 index 00000000..2dd2ce44 --- /dev/null +++ b/tests/UnitTests/Service/ElementFileDeletionServiceTest.php @@ -0,0 +1,240 @@ + + */ + private ObjectProphecy $elementManager; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $elementService; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $fileOperationFactory; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $s3Service; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $client; + private TestLogger $logger; + private ElementFileDeletionService $service; + + protected function setUp(): void + { + $this->elementManager = $this->prophesize(ElementManager::class); + $this->elementService = $this->prophesize(ElementService::class); + $this->fileOperationFactory = $this->prophesize(S3OperationFactory::class); + $this->s3Service = $this->prophesize(S3Service::class); + $this->logger = TestLogger::create(); + $this->client = $this->prophesize(ClientInterface::class); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->willReturn($this->client->reveal()); + + $this->service = new ElementFileDeletionService( + $this->elementManager->reveal(), + $this->elementService->reveal(), + $this->fileOperationFactory->reveal(), + $this->s3Service->reveal(), + $cypherEntityManager->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + $this->logger, + ); + } + + private function buildNode(UuidInterface $id): NodeElement + { + $node = new NodeElement(); + $node->setId($id); + + return $node; + } + + private function buildAttachedRelationsResult(string ...$relationIds): SummarizedResult + { + $summary = null; + + return new SummarizedResult($summary, array_map(fn (string $id) => new CypherMap(['r.id' => $id]), $relationIds)); + } + + public function testNodeWithoutFileAndWithoutAttachedRelationsHasNoFileOperations(): void + { + $node = $this->buildNode(UuidV4::uuid4()); + $this->client->runStatement(Argument::any())->willReturn($this->buildAttachedRelationsResult()); + $this->elementService->hasFile($node)->willReturn(false); + $this->fileOperationFactory->createFileOperationFromElement(Argument::any())->shouldNotBeCalled(); + + $this->assertSame([], $this->service->getFileOperationsForDeletionOfElement($node)); + } + + public function testNodeWithFileHasFileOperation(): void + { + $node = $this->buildNode(UuidV4::uuid4()); + $fileOperation = $this->prophesize(FileOperationInterface::class)->reveal(); + $this->client->runStatement(Argument::any())->willReturn($this->buildAttachedRelationsResult()); + $this->elementService->hasFile($node)->willReturn(true); + $this->fileOperationFactory->createFileOperationFromElement($node)->willReturn($fileOperation); + + $this->assertSame([$fileOperation], $this->service->getFileOperationsForDeletionOfElement($node)); + } + + public function testFilesOfAttachedRelationsAreIncludedForNodes(): void + { + $node = $this->buildNode(UuidV4::uuid4()); + $relationId = UuidV4::uuid4(); + $relation = $this->prophesize(RelationElementInterface::class)->reveal(); + $relationFileOperation = $this->prophesize(FileOperationInterface::class)->reveal(); + + $this->client->runStatement(Argument::any())->willReturn($this->buildAttachedRelationsResult($relationId->toString())); + $this->elementManager->getElementOrFail(Argument::that(fn (UuidInterface $id) => $id->equals($relationId)))->willReturn($relation); + $this->elementService->hasFile($node)->willReturn(false); + $this->elementService->hasFile($relation)->willReturn(true); + $this->fileOperationFactory->createFileOperationFromElement($relation)->willReturn($relationFileOperation); + + $this->assertSame([$relationFileOperation], $this->service->getFileOperationsForDeletionOfElement($node)); + } + + public function testRelationDoesNotQueryForAttachedRelations(): void + { + $relation = $this->prophesize(RelationElementInterface::class)->reveal(); + $fileOperation = $this->prophesize(FileOperationInterface::class)->reveal(); + $this->client->runStatement(Argument::any())->shouldNotBeCalled(); + $this->elementService->hasFile($relation)->willReturn(true); + $this->fileOperationFactory->createFileOperationFromElement($relation)->willReturn($fileOperation); + + $this->assertSame([$fileOperation], $this->service->getFileOperationsForDeletionOfElement($relation)); + } + + public function testDeleteFilesDeletesEveryFile(): void + { + $first = $this->prophesize(FileOperationInterface::class)->reveal(); + $second = $this->prophesize(FileOperationInterface::class)->reveal(); + + $this->s3Service->deleteFile($first)->shouldBeCalledOnce(); + $this->s3Service->deleteFile($second)->shouldBeCalledOnce(); + + $this->service->deleteFiles([$first, $second]); + } + + public function testDeleteFilesLogsFailuresAndContinues(): void + { + $failing = $this->prophesize(FileOperationInterface::class); + $failing->getKey()->willReturn('some-key.txt'); + $failing->getBucket()->willReturn('some-bucket'); + $failingOperation = $failing->reveal(); + $second = $this->prophesize(FileOperationInterface::class)->reveal(); + + $this->s3Service->deleteFile($failingOperation)->willThrow(new RuntimeException('S3 is down')); + $this->s3Service->deleteFile($second)->shouldBeCalledOnce(); + + $this->service->deleteFiles([$failingOperation, $second]); + + $this->assertLogHappened( + $this->logger, + 'error', + "Unable to delete file 'some-key.txt' from bucket 'some-bucket'", + ['bucket' => 'some-bucket', 'key' => 'some-key.txt'] + ); + } + + public function testDeleteFileDoesNotThrow(): void + { + $failing = $this->prophesize(FileOperationInterface::class); + $failing->getKey()->willReturn('k'); + $failing->getBucket()->willReturn('b'); + $operation = $failing->reveal(); + $this->s3Service->deleteFile($operation)->willThrow(new RuntimeException('boom')); + + $this->service->deleteFile($operation); + + $this->assertCount(1, $this->logger->records->all()); + } + + public function testDeletePreviousFileAfterReplaceDeletesTheOldKey(): void + { + $operation = $this->prophesize(UploadFileOperationInterface::class); + $operation->getPreviousStorageKey()->willReturn('some-key.old'); + $operation->getStorageKey()->willReturn('some-key.new'); + $operation->getStorageBucket()->willReturn('some-bucket'); + + $this->s3Service->deleteFile(Argument::that( + fn (FileOperationInterface $fileOperation) => 'some-bucket' === $fileOperation->getBucket() && 'some-key.old' === $fileOperation->getKey() + ))->shouldBeCalledOnce(); + + $this->service->deletePreviousFileAfterReplace($operation->reveal()); + } + + public function testDeletePreviousFileAfterReplaceKeepsTheFileIfKeyIsUnchangedOrThereIsNoPreviousFile(): void + { + $sameKey = $this->prophesize(MergeFileChunksOperationInterface::class); + $sameKey->getPreviousStorageKey()->willReturn('some-key'); + $sameKey->getStorageKey()->willReturn('some-key'); + $sameKey->getStorageBucket()->willReturn('some-bucket'); + $noPrevious = $this->prophesize(MergeFileChunksOperationInterface::class); + $noPrevious->getPreviousStorageKey()->willReturn(null); + + $this->s3Service->deleteFile(Argument::any())->shouldNotBeCalled(); + + $this->service->deletePreviousFileAfterReplace($sameKey->reveal()); + $this->service->deletePreviousFileAfterReplace($noPrevious->reveal()); + } + + public function testDeletePreviousFileAfterReplaceLogsFailureAndDoesNotThrow(): void + { + $operation = $this->prophesize(MergeFileChunksOperationInterface::class); + $operation->getPreviousStorageKey()->willReturn('some-key.old'); + $operation->getStorageKey()->willReturn('some-key.new'); + $operation->getStorageBucket()->willReturn('some-bucket'); + $this->s3Service->deleteFile(Argument::any())->willThrow(new RuntimeException('S3 is down')); + + $this->service->deletePreviousFileAfterReplace($operation->reveal()); + + $this->assertLogHappened( + $this->logger, + 'error', + "Unable to delete file 'some-key.old' from bucket 'some-bucket'", + ['bucket' => 'some-bucket', 'key' => 'some-key.old'] + ); + } +} diff --git a/tests/UnitTests/Service/ElementManagerGetElementOrFailTest.php b/tests/UnitTests/Service/ElementManagerGetElementOrFailTest.php new file mode 100644 index 00000000..9011bf98 --- /dev/null +++ b/tests/UnitTests/Service/ElementManagerGetElementOrFailTest.php @@ -0,0 +1,111 @@ +notFoundException = new Client404NotFoundException('some type'); + } + + private function buildElementManager(): ElementManager + { + $notFoundFactory = $this->prophesize(Client404NotFoundExceptionFactory::class); + $notFoundFactory->createFromTemplate(Argument::cetera())->willReturn($this->notFoundException); + + $arguments = [ + $this->prophesize(CypherEntityManager::class)->reveal(), + $this->prophesize(MongoEntityManager::class)->reveal(), + $this->prophesize(ElasticEntityManager::class)->reveal(), + $this->prophesize(ElementFragmentizeService::class)->reveal(), + $this->prophesize(ElementDefragmentizeService::class)->reveal(), + $this->prophesize(Neo4jClientHelper::class)->reveal(), + $this->prophesize(EventDispatcherInterface::class)->reveal(), + $notFoundFactory->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + ]; + + return new class(...$arguments) extends ElementManager { + public ?NodeElementInterface $node = null; + public ?RelationElementInterface $relation = null; + + public function getNode(UuidInterface $id): ?NodeElementInterface + { + return $this->node; + } + + public function getRelation(UuidInterface $id): ?RelationElementInterface + { + return $this->relation; + } + }; + } + + public function testReturnsNodeIfItExists(): void + { + $node = new NodeElement(); + $elementManager = $this->buildElementManager(); + $elementManager->node = $node; + + $this->assertSame($node, $elementManager->getElementOrFail(Uuid::fromString(self::ID))); + } + + public function testReturnsRelationIfNoNodeExists(): void + { + $relation = new RelationElement(); + $elementManager = $this->buildElementManager(); + $elementManager->relation = $relation; + + $this->assertSame($relation, $elementManager->getElementOrFail(Uuid::fromString(self::ID))); + } + + public function testThrowsNotFoundIfNeitherNodeNorRelationExists(): void + { + $elementManager = $this->buildElementManager(); + + $this->expectExceptionObject($this->notFoundException); + + $elementManager->getElementOrFail(Uuid::fromString(self::ID)); + } + + public function testGetElementReturnsNullIfNothingExists(): void + { + $elementManager = $this->buildElementManager(); + + $this->assertNull($elementManager->getElement(Uuid::fromString(self::ID))); + } +} diff --git a/tests/UnitTests/Service/ElementServiceTest.php b/tests/UnitTests/Service/ElementServiceTest.php new file mode 100644 index 00000000..83f0baf8 --- /dev/null +++ b/tests/UnitTests/Service/ElementServiceTest.php @@ -0,0 +1,168 @@ +prophesize(Server500LogicErrorExceptionFactory::class); + $errorFactory + ->createFromTemplate(Argument::cetera()) + ->willReturn(new Server500LogicErrorException('logic-error')); + $errorFactory = $errorFactory->reveal(); + + return new ElementService( + new FileService( + $this->prophesize(EmberNexusConfiguration::class)->reveal(), + $this->prophesize(StringService::class)->reveal(), + $errorFactory, + ), + new FilePropertyService($errorFactory), + $errorFactory, + ); + } + + private function buildNode(?string $id = '9a1a5c5e-3f5c-4a0f-9d0e-2a3f3a3b8a11'): NodeElement + { + $node = new NodeElement(); + if (null !== $id) { + $node->setId(Uuid::fromString($id)); + } + + return $node; + } + + public function testGetElementIdReturnsId(): void + { + $node = $this->buildNode(); + $this->assertSame($node->getId(), $this->buildService()->getElementId($node)); + } + + public function testGetElementIdThrowsWithoutId(): void + { + $this->expectException(Server500LogicErrorException::class); + $this->buildService()->getElementId($this->buildNode(null)); + } + + public function testHasFileIsFalseWithoutFlag(): void + { + $this->assertFalse($this->buildService()->hasFile($this->buildNode())); + } + + public function testHasFileIsTrueOnlyForTrueFlag(): void + { + $node = $this->buildNode(); + $node->addProperty('hasFile', true); + $this->assertTrue($this->buildService()->hasFile($node)); + + $node->addProperty('hasFile', false); + $this->assertFalse($this->buildService()->hasFile($node)); + + $node->addProperty('hasFile', 'true'); + $this->assertFalse($this->buildService()->hasFile($node)); + } + + public function testHasFileWorksForRelations(): void + { + $relation = new RelationElement(); + $relation->addProperty('hasFile', true); + $this->assertTrue($this->buildService()->hasFile($relation)); + } + + public function testFileNameFallsBackToIdAndDefaultExtension(): void + { + $this->assertSame( + sprintf('9a1a5c5e-3f5c-4a0f-9d0e-2a3f3a3b8a11.%s', FileService::DEFAULT_EXTENSION), + $this->buildService()->getFileName($this->buildNode()) + ); + } + + public function testFileNameUsesNameAndFileExtension(): void + { + $node = $this->buildNode(); + $node->addProperty('name', 'My report'); + $node->addProperty('file', ['extension' => 'pdf']); + + $this->assertSame('My report.pdf', $this->buildService()->getFileName($node)); + } + + public function testFileNameRemovesReservedCharacters(): void + { + $node = $this->buildNode(); + $node->addProperty('name', 'a/b:c*"d'); + + $this->assertSame( + sprintf('abcd.%s', FileService::DEFAULT_EXTENSION), + $this->buildService()->getFileName($node) + ); + } + + public function testFileNameFallsBackToIdIfNameBecomesEmptyOrIsNotAString(): void + { + $node = $this->buildNode(); + $expected = sprintf('9a1a5c5e-3f5c-4a0f-9d0e-2a3f3a3b8a11.%s', FileService::DEFAULT_EXTENSION); + + $node->addProperty('name', '/:*'); + $this->assertSame($expected, $this->buildService()->getFileName($node)); + + $node->addProperty('name', 123); + $this->assertSame($expected, $this->buildService()->getFileName($node)); + } + + public function testGetFileNameExtensionDefaultsWithoutFileProperty(): void + { + $this->assertSame(FileService::DEFAULT_EXTENSION, $this->buildService()->getFileNameExtension($this->buildNode())); + } + + public function testGetFileNameExtensionReadsFileProperty(): void + { + $node = $this->buildNode(); + $node->addProperty('file', ['extension' => 'txt']); + $this->assertSame('txt', $this->buildService()->getFileNameExtension($node)); + } + + public function testGetStorageKeyOfFileIsNullWithoutFile(): void + { + $this->assertNull($this->buildService()->getStorageKeyOfFile($this->buildNode())); + } + + public function testGetStorageKeyOfFileUsesTheExtensionOfTheStoredFile(): void + { + $node = $this->buildNode(); + $node->addProperty('hasFile', true); + $node->addProperty('file', ['extension' => 'txt']); + + $errorFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(); + $fileService = $this->prophesize(FileService::class); + $fileService + ->getStorageBucketKey(Argument::that(fn ($id) => '9a1a5c5e-3f5c-4a0f-9d0e-2a3f3a3b8a11' === $id->toString()), 'txt') + ->shouldBeCalledOnce() + ->willReturn('9a/1a/5c/9a1a5c5e-3f5c-4a0f-9d0e-2a3f3a3b8a11.txt'); + $service = new ElementService($fileService->reveal(), new FilePropertyService($errorFactory), $errorFactory); + + $this->assertSame('9a/1a/5c/9a1a5c5e-3f5c-4a0f-9d0e-2a3f3a3b8a11.txt', $service->getStorageKeyOfFile($node)); + } +} diff --git a/tests/UnitTests/Service/ElementToRawServiceTest.php b/tests/UnitTests/Service/ElementToRawServiceTest.php new file mode 100644 index 00000000..37a55466 --- /dev/null +++ b/tests/UnitTests/Service/ElementToRawServiceTest.php @@ -0,0 +1,97 @@ +setLabel('Token'); + $token->addProperty('hash', 'secret-hash'); + $token->addProperty('_tokenHash', 'secret-hash'); + $token->addProperty('token', 'secret-token'); + $token->addProperty('name', 'reference token'); + + return $token; + } + + private function createDispatcherWithTokenListener(): EventDispatcher + { + $dispatcher = new EventDispatcher(); + $dispatcher->addListener( + ElementPropertyReturnEvent::class, + (new TokenElementPropertyReturnEventListener())->onElementPropertyReturnEvent(...) + ); + $dispatcher->addListener( + NormalizedValueToRawValueEvent::class, + static function (NormalizedValueToRawValueEvent $event): void { + $event->setRawValue($event->getNormalizedValue()); + } + ); + + return $dispatcher; + } + + public function testElementToRawWithBlacklistAppliedHidesTokenHash(): void + { + $service = new ElementToRawService($this->createDispatcherWithTokenListener()); + + $rawData = $service->elementToRaw($this->createToken()); + + $this->assertArrayNotHasKey('hash', $rawData['data']); + $this->assertArrayNotHasKey('_tokenHash', $rawData['data']); + $this->assertArrayNotHasKey('token', $rawData['data']); + $this->assertSame('reference token', $rawData['data']['name']); + } + + public function testElementToRawWithBlacklistDisabledKeepsTokenHash(): void + { + $service = new ElementToRawService($this->createDispatcherWithTokenListener()); + + $rawData = $service->elementToRaw($this->createToken(), false); + + $this->assertArrayHasKey('hash', $rawData['data']); + $this->assertSame('secret-hash', $rawData['data']['hash']); + $this->assertArrayHasKey('_tokenHash', $rawData['data']); + $this->assertArrayHasKey('token', $rawData['data']); + $this->assertSame('reference token', $rawData['data']['name']); + } + + public function testElementToRawWithBlacklistDisabledSkipsEventDispatchEntirely(): void + { + $dispatcher = new EventDispatcher(); + $dispatcherWasCalled = false; + $dispatcher->addListener( + ElementPropertyReturnEvent::class, + function () use (&$dispatcherWasCalled): void { + $dispatcherWasCalled = true; + } + ); + $dispatcher->addListener( + NormalizedValueToRawValueEvent::class, + static function (NormalizedValueToRawValueEvent $event): void { + $event->setRawValue($event->getNormalizedValue()); + } + ); + + $service = new ElementToRawService($dispatcher); + $service->elementToRaw($this->createToken(), false); + + $this->assertFalse($dispatcherWasCalled); + } +} diff --git a/tests/UnitTests/Service/EtagCalculatorServiceTest.php b/tests/UnitTests/Service/EtagCalculatorServiceTest.php index 0115a0bb..b4459981 100644 --- a/tests/UnitTests/Service/EtagCalculatorServiceTest.php +++ b/tests/UnitTests/Service/EtagCalculatorServiceTest.php @@ -1494,6 +1494,7 @@ public function testCalculateFileEtagUsesStoredSha256Hash(): void $id = Uuid::fromString('544e0cf6-d351-435c-828f-7a0762240ce6'); $element = new NodeElement(); + $element->addProperty('hasFile', true); $element ->addProperty('file', [ 'contentLength' => 1024, @@ -1505,15 +1506,29 @@ public function testCalculateFileEtagUsesStoredSha256Hash(): void ]) ->addProperty('name', 'some name'); + $null = null; + $queryResult = new SummarizedResult( + $null, + [ + new CypherMap([ + 'node.updated' => new DateTimeZoneId(1705772003, 646811000, 'UTC'), + 'relation.updated' => null, + ]), + ] + ); + // setup service dependencies $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getCacheEtagSeed()->shouldBeCalledOnce()->willReturn('seed'); + $emberNexusConfiguration->getCacheEtagSeed()->shouldBeCalledTimes(2)->willReturn('seed'); $elementManager = $this->prophesize(ElementManager::class); $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); + $clientInterface = $this->prophesize(ClientInterface::class); + $clientInterface->runStatement(Argument::any())->shouldBeCalledOnce()->willReturn($queryResult); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); - $cypherEntityManager->getClient()->shouldNotBeCalled(); + $cypherEntityManager->getClient()->shouldBeCalledOnce()->willReturn($clientInterface->reveal()); $logger = TestLogger::create(); @@ -1526,12 +1541,12 @@ public function testCalculateFileEtagUsesStoredSha256Hash(): void $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); - // run service method; no Cypher query (i.e. no calculateElementEtag() fallback) should have happened at - // all, per the prophecy expectation above + // run service method; the file etag now always combines the element etag with the stored hash $etag = $etagCalculatorService->calculateFileEtag($id); - $this->assertSame('K6XEZuifVAv', (string) $etag); + $this->assertNotNull($etag); // assert logs + $this->assertTrue($logger->records->includeMessagesContaining('Calculated Etag for element.')); $this->assertTrue($logger->records->includeMessagesContaining('Calculated Etag for file.')); } @@ -1541,6 +1556,7 @@ public function testCalculateFileEtagFallsBackToAlphabeticallyFirstHashWhenSha25 $id = Uuid::fromString('7f8e9d0c-1b2a-4c3d-9e8f-0a1b2c3d4e5f'); $element = new NodeElement(); + $element->addProperty('hasFile', true); $element ->addProperty('file', [ 'contentLength' => 1024, @@ -1553,15 +1569,29 @@ public function testCalculateFileEtagFallsBackToAlphabeticallyFirstHashWhenSha25 ]) ->addProperty('name', 'some name'); + $null = null; + $queryResult = new SummarizedResult( + $null, + [ + new CypherMap([ + 'node.updated' => new DateTimeZoneId(1705772003, 646811000, 'UTC'), + 'relation.updated' => null, + ]), + ] + ); + // setup service dependencies $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); - $emberNexusConfiguration->getCacheEtagSeed()->shouldBeCalledOnce()->willReturn('seed'); + $emberNexusConfiguration->getCacheEtagSeed()->shouldBeCalledTimes(2)->willReturn('seed'); $elementManager = $this->prophesize(ElementManager::class); $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); + $clientInterface = $this->prophesize(ClientInterface::class); + $clientInterface->runStatement(Argument::any())->shouldBeCalledOnce()->willReturn($queryResult); + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); - $cypherEntityManager->getClient()->shouldNotBeCalled(); + $cypherEntityManager->getClient()->shouldBeCalledOnce()->willReturn($clientInterface->reveal()); $logger = TestLogger::create(); @@ -1574,9 +1604,7 @@ public function testCalculateFileEtagFallsBackToAlphabeticallyFirstHashWhenSha25 $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() ); - // run service method; 'blake3' sorts before 'md5' alphanumerically, so it is used. The Cypher client - // prophecy above (shouldNotBeCalled) proves that a hash was actually picked here, rather than falling - // back to calculateElementEtag(). + // run service method; 'blake3' sorts before 'md5', so it is used as the hash component $etag = $etagCalculatorService->calculateFileEtag($id); $this->assertNotNull($etag); } @@ -1587,6 +1615,7 @@ public function testCalculateFileEtagFallsBackToElementEtagWhenNoHashIsPresent() $id = Uuid::fromString('9c1a0c0e-6b8f-4b3d-9b1e-2b1a0c0e6b8f'); $element = new NodeElement(); + $element->addProperty('hasFile', true); $element ->addProperty('file', ['contentLength' => 1024, 'extension' => 'png', 'mimeType' => 'image/png']); @@ -1636,13 +1665,13 @@ public function testCalculateFileEtagFallsBackToElementEtagWhenNoHashIsPresent() public function testCalculateFileEtagFallsBackToElementEtagWhenNoFilePropertyIsPresent(): void { - // an S3 object can exist without the element ever having its 'file' property set, e.g. when an upload is - // rejected after the object was already written (a mismatched Content-Digest); this must not crash. + // an element can lack the 'file' property, e.g. after an upload was rejected due to a mismatched digest // setup variables $id = Uuid::fromString('1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d'); $element = new NodeElement(); + $element->addProperty('hasFile', true); $null = null; $queryResult = new SummarizedResult( @@ -1693,6 +1722,7 @@ public function testCalculateFileEtagReturnsNullWhenElementEtagFallbackAlsoRetur $id = Uuid::fromString('662a045f-7d90-4fa2-85f8-9f972f2bdbd3'); $element = new NodeElement(); + $element->addProperty('hasFile', true); $null = null; $queryResult = new SummarizedResult( @@ -1732,4 +1762,31 @@ public function testCalculateFileEtagReturnsNullWhenElementEtagFallbackAlsoRetur $etag = $etagCalculatorService->calculateFileEtag($id); $this->assertNull($etag); } + + public function testCalculateFileEtagReturnsNullForElementWithoutFile(): void + { + $id = Uuid::fromString('544e0cf6-d351-435c-828f-7a0762240ce6'); + + $element = new NodeElement(); + $element->addProperty('hasFile', false); + + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getCacheEtagSeed()->shouldNotBeCalled(); + + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::is($id))->shouldBeCalledOnce()->willReturn($element); + + $cypherEntityManager = $this->prophesize(CypherEntityManager::class); + $cypherEntityManager->getClient()->shouldNotBeCalled(); + + $etagCalculatorService = new EtagCalculatorService( + $emberNexusConfiguration->reveal(), + $cypherEntityManager->reveal(), + $elementManager->reveal(), + TestLogger::create(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + + $this->assertNull($etagCalculatorService->calculateFileEtag($id)); + } } diff --git a/tests/UnitTests/Service/EtagServiceTest.php b/tests/UnitTests/Service/EtagServiceTest.php index ee5fa1e3..ffdb763b 100644 --- a/tests/UnitTests/Service/EtagServiceTest.php +++ b/tests/UnitTests/Service/EtagServiceTest.php @@ -10,8 +10,12 @@ use App\EventSystem\Etag\Event\IndexCollectionEtagEvent; use App\EventSystem\Etag\Event\ParentsCollectionEtagEvent; use App\EventSystem\Etag\Event\RelatedCollectionEtagEvent; +use App\Exception\Client404NotFoundException; +use App\Factory\Exception\Client404NotFoundExceptionFactory; +use App\Security\AccessChecker; use App\Security\AuthProvider; use App\Service\EtagService; +use App\Type\AccessType; use App\Type\Etag; use App\Type\EtagType; use PHPUnit\Framework\Attributes\CoversClass; @@ -29,6 +33,80 @@ class EtagServiceTest extends TestCase { use ProphecyTrait; + private function createAccessCheckerWithAccess(bool $hasAccess, ?AccessType $expectedAccessType = null): AccessChecker + { + $accessChecker = $this->prophesize(AccessChecker::class); + $accessChecker->hasAccessToElement(Argument::any(), Argument::any(), $expectedAccessType ?? Argument::any())->willReturn($hasAccess); + + return $accessChecker->reveal(); + } + + private function createNotFoundFactory(): Client404NotFoundExceptionFactory + { + $factory = $this->prophesize(Client404NotFoundExceptionFactory::class); + $factory->createFromTemplate()->willReturn(new Client404NotFoundException('type')); + + return $factory->reveal(); + } + + private function createAuthProviderWithUser(): AuthProvider + { + $authProvider = $this->prophesize(AuthProvider::class); + $authProvider->getUserId()->willReturn(Uuid::fromString('11111111-1111-4111-8111-111111111111')); + + return $authProvider->reveal(); + } + + public function testMissingAccessThrowsNotFoundAndDoesNotCalculateEtag(): void + { + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch(Argument::any())->shouldNotBeCalled(); + + $etagService = new EtagService( + $eventDispatcher->reveal(), + $this->createAuthProviderWithUser(), + $this->createAccessCheckerWithAccess(false), + $this->createNotFoundFactory() + ); + + $this->expectException(Client404NotFoundException::class); + $etagService->setCurrentRequestEtagFromRequestAndEtagType( + new Request(attributes: ['id' => '224b322a-c2a1-4971-8b05-28af080d67f1']), + EtagType::ELEMENT + ); + } + + public function testRequiredAccessTypeDependsOnMethodAndEtagType(): void + { + $cases = [ + ['GET', EtagType::ELEMENT, AccessType::READ], + ['HEAD', EtagType::FILE, AccessType::READ], + ['PATCH', EtagType::ELEMENT, AccessType::UPDATE], + ['DELETE', EtagType::ELEMENT, AccessType::DELETE], + ['DELETE', EtagType::FILE, AccessType::UPDATE], + ['POST', EtagType::FILE, AccessType::UPDATE], + ]; + foreach ($cases as [$method, $etagType, $accessType]) { + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch(Argument::any())->will(fn ($args) => $args[0]); + + $accessChecker = $this->prophesize(AccessChecker::class); + $accessChecker->hasAccessToElement(Argument::any(), Argument::any(), $accessType)->willReturn(true)->shouldBeCalledOnce(); + + $etagService = new EtagService( + $eventDispatcher->reveal(), + $this->createAuthProviderWithUser(), + $accessChecker->reveal(), + $this->createNotFoundFactory() + ); + $etagService->setCurrentRequestEtagFromRequestAndEtagType( + new Request(attributes: ['id' => '224b322a-c2a1-4971-8b05-28af080d67f1'], server: ['REQUEST_METHOD' => $method]), + $etagType + ); + } + $this->addToAssertionCount(1); + } + public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeElement(): void { $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); @@ -48,7 +126,9 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeEleme $etagService = new EtagService( $eventDispatcher->reveal(), - $authProvider->reveal() + $this->createAuthProviderWithUser(), + $this->createAccessCheckerWithAccess(true), + $this->createNotFoundFactory() ); $returnedEtag = $etagService->setCurrentRequestEtagFromRequestAndEtagType($request, EtagType::ELEMENT); @@ -74,7 +154,9 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeChild $etagService = new EtagService( $eventDispatcher->reveal(), - $authProvider->reveal() + $this->createAuthProviderWithUser(), + $this->createAccessCheckerWithAccess(true), + $this->createNotFoundFactory() ); $returnedEtag = $etagService->setCurrentRequestEtagFromRequestAndEtagType($request, EtagType::CHILDREN_COLLECTION); @@ -100,7 +182,9 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeParen $etagService = new EtagService( $eventDispatcher->reveal(), - $authProvider->reveal() + $this->createAuthProviderWithUser(), + $this->createAccessCheckerWithAccess(true), + $this->createNotFoundFactory() ); $returnedEtag = $etagService->setCurrentRequestEtagFromRequestAndEtagType($request, EtagType::PARENTS_COLLECTION); @@ -126,7 +210,9 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeRelat $etagService = new EtagService( $eventDispatcher->reveal(), - $authProvider->reveal() + $this->createAuthProviderWithUser(), + $this->createAccessCheckerWithAccess(true), + $this->createNotFoundFactory() ); $returnedEtag = $etagService->setCurrentRequestEtagFromRequestAndEtagType($request, EtagType::RELATED_COLLECTION); @@ -151,7 +237,9 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeFile( $etagService = new EtagService( $eventDispatcher->reveal(), - $this->prophesize(AuthProvider::class)->reveal() + $this->createAuthProviderWithUser(), + $this->createAccessCheckerWithAccess(true), + $this->createNotFoundFactory() ); $returnedEtag = $etagService->setCurrentRequestEtagFromRequestAndEtagType($request, EtagType::FILE); @@ -178,7 +266,9 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeIndex $etagService = new EtagService( $eventDispatcher->reveal(), - $authProvider->reveal() + $authProvider->reveal(), + $this->createAccessCheckerWithAccess(true), + $this->createNotFoundFactory() ); $returnedEtag = $etagService->setCurrentRequestEtagFromRequestAndEtagType($request, EtagType::INDEX_COLLECTION); @@ -194,7 +284,9 @@ public function testSetCurrentRequestEtagFromRequestAndEtagTypeWithEtagTypeEleme $etagService = new EtagService( $eventDispatcher->reveal(), - $authProvider->reveal() + $this->createAuthProviderWithUser(), + $this->createAccessCheckerWithAccess(true), + $this->createNotFoundFactory() ); $this->expectExceptionMessage('Route should have attribute id.'); diff --git a/tests/UnitTests/Service/ExpiredUploadDeletionAttemptServiceTest.php b/tests/UnitTests/Service/ExpiredUploadDeletionAttemptServiceTest.php new file mode 100644 index 00000000..2f5df37b --- /dev/null +++ b/tests/UnitTests/Service/ExpiredUploadDeletionAttemptServiceTest.php @@ -0,0 +1,107 @@ +prophesize(RedisClient::class); + $redis->get(self::KEY)->willReturn(null); + + $this->assertFalse((new ExpiredUploadDeletionAttemptService($redis->reveal(), new RedisKeyFactory()))->isDeferred(self::ID)); + } + + public function testUploadIsDeferredUntilNotBefore(): void + { + $redis = $this->prophesize(RedisClient::class); + $redis->get(self::KEY)->willReturn(json_encode(['attempts' => 1, 'notBefore' => time() + 100])); + $service = new ExpiredUploadDeletionAttemptService($redis->reveal(), new RedisKeyFactory()); + $this->assertTrue($service->isDeferred(self::ID)); + + $redis->get(self::KEY)->willReturn(json_encode(['attempts' => 1, 'notBefore' => time() - 1])); + $this->assertFalse($service->isDeferred(self::ID)); + } + + public function testCorruptStateIsTreatedAsNoState(): void + { + $redis = $this->prophesize(RedisClient::class); + $redis->get(self::KEY)->willReturn('garbage'); + + $this->assertFalse((new ExpiredUploadDeletionAttemptService($redis->reveal(), new RedisKeyFactory()))->isDeferred(self::ID)); + } + + public function testFirstFailureDefersForOneHour(): void + { + $redis = $this->prophesize(RedisClient::class); + $redis->get(self::KEY)->willReturn(null); + $redis->set( + self::KEY, + Argument::that(function (string $json): bool { + $data = json_decode($json, true); + + return 1 === $data['attempts'] && abs($data['notBefore'] - (time() + 3600)) <= 2; + }), + 'EX', + ExpiredUploadDeletionAttemptService::TTL_IN_SECONDS + )->shouldBeCalledOnce(); + + $this->assertSame(1, (new ExpiredUploadDeletionAttemptService($redis->reveal(), new RedisKeyFactory()))->recordFailure(self::ID)); + } + + public function testSecondFailureDefersForOneDay(): void + { + $redis = $this->prophesize(RedisClient::class); + $redis->get(self::KEY)->willReturn(json_encode(['attempts' => 1, 'notBefore' => 0])); + $redis->set( + self::KEY, + Argument::that(function (string $json): bool { + $data = json_decode($json, true); + + return 2 === $data['attempts'] && abs($data['notBefore'] - (time() + 86400)) <= 2; + }), + 'EX', + ExpiredUploadDeletionAttemptService::TTL_IN_SECONDS + )->shouldBeCalledOnce(); + + $this->assertSame(2, (new ExpiredUploadDeletionAttemptService($redis->reveal(), new RedisKeyFactory()))->recordFailure(self::ID)); + } + + public function testThirdFailureIsFinal(): void + { + $redis = $this->prophesize(RedisClient::class); + $redis->get(self::KEY)->willReturn(json_encode(['attempts' => 2, 'notBefore' => 0])); + $redis->set(self::KEY, Argument::any(), 'EX', Argument::any())->shouldBeCalledOnce(); + + $this->assertSame( + ExpiredUploadDeletionAttemptService::MAX_ATTEMPTS, + (new ExpiredUploadDeletionAttemptService($redis->reveal(), new RedisKeyFactory()))->recordFailure(self::ID) + ); + } + + public function testClearDeletesKey(): void + { + $redis = $this->prophesize(RedisClient::class); + $redis->del([self::KEY])->shouldBeCalledOnce(); + + (new ExpiredUploadDeletionAttemptService($redis->reveal(), new RedisKeyFactory()))->clear(self::ID); + } +} diff --git a/tests/UnitTests/Service/FileCreationLockServiceTest.php b/tests/UnitTests/Service/FileCreationLockServiceTest.php new file mode 100644 index 00000000..c79d9d88 --- /dev/null +++ b/tests/UnitTests/Service/FileCreationLockServiceTest.php @@ -0,0 +1,78 @@ +prophesize(Client::class); + $redis->set( + 'file:create:'.$elementId->toString(), + Argument::that(fn ($token) => is_string($token) && 32 === strlen($token)), + 'PX', + 900000, + 'NX' + )->shouldBeCalledOnce()->willReturn('OK'); + + $this->assertIsString((new FileCreationLockService($redis->reveal(), new RedisKeyFactory()))->acquire($elementId)); + } + + public function testAcquireReturnsNullIfAlreadyLocked(): void + { + $redis = $this->prophesize(Client::class); + $redis->set(Argument::cetera())->willReturn(null); + + $this->assertNull((new FileCreationLockService($redis->reveal(), new RedisKeyFactory()))->acquire(Uuid::uuid4())); + } + + #[DataProvider('isLockedProvider')] + public function testIsLocked(int $existsResult, bool $expected): void + { + $elementId = Uuid::uuid4(); + $redis = $this->prophesize(Client::class); + $redis->exists('file:create:'.$elementId->toString())->shouldBeCalledOnce()->willReturn($existsResult); + + $this->assertSame($expected, (new FileCreationLockService($redis->reveal(), new RedisKeyFactory()))->isLocked($elementId)); + } + + /** + * @return array + */ + public static function isLockedProvider(): array + { + return ['locked' => [1, true], 'not locked' => [0, false]]; + } + + public function testReleaseComparesTokenBeforeDeleting(): void + { + $elementId = Uuid::uuid4(); + $redis = $this->prophesize(Client::class); + $redis->eval( + Argument::that(fn ($script) => str_contains($script, 'get') && str_contains($script, 'del')), + 1, + 'file:create:'.$elementId->toString(), + 'my-token' + )->shouldBeCalledOnce()->willReturn(1); + + (new FileCreationLockService($redis->reveal(), new RedisKeyFactory()))->release($elementId, 'my-token'); + } +} diff --git a/tests/UnitTests/Service/FileHashServiceTest.php b/tests/UnitTests/Service/FileHashServiceTest.php index 7cafbf9c..72e49ed8 100644 --- a/tests/UnitTests/Service/FileHashServiceTest.php +++ b/tests/UnitTests/Service/FileHashServiceTest.php @@ -5,6 +5,7 @@ namespace App\Tests\UnitTests\Service; use App\Service\FileHashService; +use ArrayObject; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; @@ -13,27 +14,91 @@ #[CoversClass(FileHashService::class)] class FileHashServiceTest extends TestCase { - public function testCalculatesSha256HashOfResourceContent(): void + public function testCalculatesHashesAcrossMultipleReadChunks(): void { - $content = 'some deterministic content used to verify the calculated hash'; + // larger than the service's read chunk size, so the hash spans several reads + $content = str_repeat('0123456789abcdef', 200000); $resource = \Safe\fopen('php://memory', 'r+'); \Safe\fwrite($resource, $content); rewind($resource); $service = new FileHashService(); - $hash = $service->calculateHashFromResource($resource); + $hashes = $service->calculateHashesFromResource($resource, ['sha256']); - $this->assertSame(hash('sha256', $content), $hash); - $this->assertSame(64, strlen($hash)); + $this->assertSame(['sha256' => hash('sha256', $content)], $hashes); } - public function testCalculatesCorrectHashForEmptyResource(): void + public function testCalculatesHashOfEmptyResource(): void { $resource = \Safe\fopen('php://memory', 'r+'); $service = new FileHashService(); - $hash = $service->calculateHashFromResource($resource); - $this->assertSame(hash('sha256', ''), $hash); + $this->assertSame(['sha256' => hash('sha256', '')], $service->calculateHashesFromResource($resource, ['sha256'])); + } + + public function testCalculatesNoHashesForNoAlgorithms(): void + { + $resource = \Safe\fopen('php://memory', 'r+'); + + $service = new FileHashService(); + + $this->assertSame([], $service->calculateHashesFromResource($resource, [])); + } + + public function testGetVerifiableHashesFromArrayFileProperty(): void + { + $service = new FileHashService(); + + $this->assertSame( + ['sha256' => 'abcdef'], + $service->getVerifiableHashesFromFileProperty(['hash' => ['sha256' => 'ABCDEF']]) + ); + } + + public function testGetVerifiableHashesFromArrayAccessFileProperty(): void + { + $service = new FileHashService(); + + $this->assertSame( + ['sha256' => 'abc'], + $service->getVerifiableHashesFromFileProperty(new ArrayObject([ + 'hash' => new ArrayObject(['sha256' => 'abc']), + ])) + ); + } + + public function testGetVerifiableHashesIgnoresUnsupportedAndMalformedEntries(): void + { + $service = new FileHashService(); + + $this->assertSame( + ['sha256' => 'abc'], + $service->getVerifiableHashesFromFileProperty(['hash' => [ + 'sha256' => 'abc', + 'unknown-algorithm' => 'def', + // known to PHP, but not natively supported by the API + 'md5' => 'd41d8cd98f00b204e9800998ecf8427e', + 'blake3' => 'abc', + 0 => 'ghi', + ]]) + ); + } + + public function testGetVerifiableHashesIgnoresNonStringValues(): void + { + $service = new FileHashService(); + + $this->assertSame([], $service->getVerifiableHashesFromFileProperty(['hash' => ['sha256' => 123]])); + } + + public function testGetVerifiableHashesReturnsEmptyArrayForMissingHash(): void + { + $service = new FileHashService(); + + $this->assertSame([], $service->getVerifiableHashesFromFileProperty(null)); + $this->assertSame([], $service->getVerifiableHashesFromFileProperty('file')); + $this->assertSame([], $service->getVerifiableHashesFromFileProperty([])); + $this->assertSame([], $service->getVerifiableHashesFromFileProperty(['hash' => 'sha256'])); } } diff --git a/tests/UnitTests/Service/FilePropertyServiceTest.php b/tests/UnitTests/Service/FilePropertyServiceTest.php new file mode 100644 index 00000000..0124a8b6 --- /dev/null +++ b/tests/UnitTests/Service/FilePropertyServiceTest.php @@ -0,0 +1,133 @@ +prophesize(Server500LogicErrorExceptionFactory::class)->reveal() + ); + } + + public function testReturnsNullWhenElementHasNoFileProperty(): void + { + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty('file')->willReturn(false); + + $result = $this->buildService()->parseFilePropertyFromElement($element->reveal()); + + $this->assertNull($result); + } + + public function testParsesExtensionFromFileProperty(): void + { + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty('file')->willReturn(true); + $element->getProperty('file')->willReturn(['extension' => 'png']); + $element->getId()->willReturn(UuidV4::uuid4()); + + $result = $this->buildService()->parseFilePropertyFromElement($element->reveal()); + + $this->assertNotNull($result); + $this->assertSame('png', $result->getExtension()); + } + + public function testDefaultsExtensionWhenKeyMissing(): void + { + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty('file')->willReturn(true); + $element->getProperty('file')->willReturn([]); + $element->getId()->willReturn(UuidV4::uuid4()); + + $result = $this->buildService()->parseFilePropertyFromElement($element->reveal()); + + $this->assertNotNull($result); + $this->assertSame(FileService::DEFAULT_EXTENSION, $result->getExtension()); + } + + public function testKeepsEmptyExtensionWhichMeansNoExtension(): void + { + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty('file')->willReturn(true); + $element->getProperty('file')->willReturn(['extension' => '']); + $element->getId()->willReturn(UuidV4::uuid4()); + + $result = $this->buildService()->parseFilePropertyFromElement($element->reveal()); + + $this->assertNotNull($result); + $this->assertSame('', $result->getExtension()); + } + + public function testThrowsWhenFilePropertyIsNotAnArray(): void + { + $id = UuidV4::uuid4(); + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty('file')->willReturn(true); + $element->getProperty('file')->willReturn('not-an-array'); + $element->getId()->willReturn($id); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory + ->createFromTemplate(Argument::containingString('to be of type array, got string')) + ->shouldBeCalledOnce() + ->willReturn($exception); + + $this->expectException(Server500LogicErrorException::class); + $this->buildService($server500LogicErrorExceptionFactory->reveal())->parseFilePropertyFromElement($element->reveal()); + } + + public function testDefaultsExtensionWhenNotAStringOrNull(): void + { + foreach ([123, null, ['png'], true] as $value) { + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty('file')->willReturn(true); + $element->getProperty('file')->willReturn(['extension' => $value]); + $element->getId()->willReturn(UuidV4::uuid4()); + + $result = $this->buildService()->parseFilePropertyFromElement($element->reveal()); + + $this->assertNotNull($result); + $this->assertSame(FileService::DEFAULT_EXTENSION, $result->getExtension()); + } + } + + public function testThrowsWithNullElementIdInMessageWhenIdIsNull(): void + { + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty('file')->willReturn(true); + $element->getProperty('file')->willReturn('not-an-array'); + $element->getId()->willReturn(null); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory + ->createFromTemplate(Argument::containingString('element null')) + ->shouldBeCalledOnce() + ->willReturn($exception); + + $this->expectException(Server500LogicErrorException::class); + $this->buildService($server500LogicErrorExceptionFactory->reveal())->parseFilePropertyFromElement($element->reveal()); + } +} diff --git a/tests/UnitTests/Service/FileRangeServiceTest.php b/tests/UnitTests/Service/FileRangeServiceTest.php index 6058c5dc..7ca30b86 100644 --- a/tests/UnitTests/Service/FileRangeServiceTest.php +++ b/tests/UnitTests/Service/FileRangeServiceTest.php @@ -141,4 +141,48 @@ public function testThrowsForZeroLengthSuffixRange(): void $this->expectException(Client416RangeNotSatisfiableException::class); $service->parseRangeHeader('bytes=-0', 1000); } + + /** + * Syntactically valid, but logically inverted: the range spans no bytes at all. + */ + public function testThrowsForInvertedRange(): void + { + $service = $this->buildService(); + + $this->expectException(Client416RangeNotSatisfiableException::class); + $service->parseRangeHeader('bytes=50-10', 1000); + } + + /** + * An end which is clamped to the last byte can still end up below the start. + */ + public function testThrowsForInvertedRangeAfterClampingEndToResourceSize(): void + { + $service = $this->buildService(); + + $this->expectException(Client416RangeNotSatisfiableException::class); + $service->parseRangeHeader('bytes=900-800', 1000); + } + + public function testMissingIfRangeIsSatisfied(): void + { + $this->assertTrue($this->buildService()->isRangeConditionSatisfied(null, 'abc')); + $this->assertTrue($this->buildService()->isRangeConditionSatisfied(null, null)); + } + + public function testMatchingStrongEtagInIfRangeIsSatisfied(): void + { + $this->assertTrue($this->buildService()->isRangeConditionSatisfied('"abc"', 'abc')); + $this->assertTrue($this->buildService()->isRangeConditionSatisfied(' "abc" ', 'abc')); + } + + public function testNonMatchingWeakOrDateIfRangeIsNotSatisfied(): void + { + $service = $this->buildService(); + + $this->assertFalse($service->isRangeConditionSatisfied('"other"', 'abc')); + $this->assertFalse($service->isRangeConditionSatisfied('W/"abc"', 'abc')); + $this->assertFalse($service->isRangeConditionSatisfied('Wed, 21 Oct 2015 07:28:00 GMT', 'abc')); + $this->assertFalse($service->isRangeConditionSatisfied('"abc"', null)); + } } diff --git a/tests/UnitTests/Service/FileServiceTest.php b/tests/UnitTests/Service/FileServiceTest.php index 914bd428..e0f804fa 100644 --- a/tests/UnitTests/Service/FileServiceTest.php +++ b/tests/UnitTests/Service/FileServiceTest.php @@ -104,6 +104,82 @@ public function testGetUploadBucketKey(): void $this->assertSame('8b/a1/17/8ba117cf-8983-4f13-be93-415e175cb64d-1234.wip', $key); } + public function testGetUploadBucketKeyWithChunkId(): void + { + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileUploadChunkDigitsLength()->willReturn(4); + $emberNexusConfiguration->getFileS3UploadBucketLevels()->willReturn(3); + $emberNexusConfiguration->getFileS3UploadBucketLevelLength()->willReturn(2); + + $storageService = $this->buildFileService( + emberNexusConfiguration: $emberNexusConfiguration->reveal() + ); + + $key = $storageService->getUploadBucketKey(Uuid::fromString('8ba117cf-8983-4f13-be93-415e175cb64d'), 12, '3mJr7AoUXx2Wqd9G'); + $this->assertSame('8b/a1/17/8ba117cf-8983-4f13-be93-415e175cb64d-0012-3mJr7AoUXx2Wqd9G.wip', $key); + } + + public function testGetUploadBucketKeyRejectsInvalidChunkId(): void + { + $storageService = $this->buildFileService(); + + $this->expectException(Server500LogicErrorException::class); + $storageService->getUploadBucketKey(Uuid::fromString('8ba117cf-8983-4f13-be93-415e175cb64d'), 1, '../etc/passwd'); + } + + public function testGenerateUploadChunkIdIsRandomAndAcceptedInKeys(): void + { + $storageService = $this->buildFileService(); + + $first = $storageService->generateUploadChunkId(); + $this->assertMatchesRegularExpression('/^[0-9A-Za-z]{1,64}$/', $first); + // base58 of 16 bytes has at most 22 characters + $this->assertLessThanOrEqual(22, strlen($first)); + $this->assertGreaterThanOrEqual(16, strlen($first)); + $this->assertNotSame($first, $storageService->generateUploadChunkId()); + } + + public function testGeneratedUploadChunkIdsAreUniqueAndAcceptedInKeys(): void + { + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileUploadChunkDigitsLength()->willReturn(4); + $emberNexusConfiguration->getFileS3UploadBucketLevels()->willReturn(0); + $emberNexusConfiguration->getFileS3UploadBucketLevelLength()->willReturn(2); + $storageService = $this->buildFileService(emberNexusConfiguration: $emberNexusConfiguration->reveal()); + + $ids = []; + for ($i = 0; $i < 200; ++$i) { + $ids[] = $storageService->generateUploadChunkId(); + } + $this->assertCount(200, array_unique($ids)); + $key = $storageService->getUploadBucketKey(Uuid::fromString('8ba117cf-8983-4f13-be93-415e175cb64d'), 1, $ids[0]); + $this->assertStringContainsString('-0001-'.$ids[0].'.wip', $key); + } + + /** + * @return array + */ + public static function invalidChunkIdProvider(): array + { + return [ + 'dash' => ['abc-def'], + 'dot' => ['abc.def'], + 'plus' => ['abc+def'], + 'slash' => ['ab/cd'], + 'empty' => [''], + 'too long' => [str_repeat('a', 65)], + ]; + } + + #[DataProvider('invalidChunkIdProvider')] + public function testGetUploadBucketKeyRejectsInvalidChunkIdCharactersAndLengths(string $chunkId): void + { + $storageService = $this->buildFileService(); + + $this->expectException(Server500LogicErrorException::class); + $storageService->getUploadBucketKey(Uuid::fromString('8ba117cf-8983-4f13-be93-415e175cb64d'), 1, $chunkId); + } + public function testGetStorageBucketKeySupportsOtherExtension(): void { $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); @@ -118,6 +194,27 @@ public function testGetStorageBucketKeySupportsOtherExtension(): void $this->assertSame('5d/bd/94/5dbd948b-d9dc-4b6c-af1c-c12dc8120755.png', $key); } + public function testGetStorageBucketKeyWithEmptyExtensionHasNoTrailingDot(): void + { + $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileS3StorageBucketLevels()->shouldBeCalledOnce()->willReturn(3); + $emberNexusConfiguration->getFileS3StorageBucketLevelLength()->shouldBeCalledOnce()->willReturn(2); + + $storageService = $this->buildFileService( + emberNexusConfiguration: $emberNexusConfiguration->reveal() + ); + + $key = $storageService->getStorageBucketKey(Uuid::fromString('5dbd948b-d9dc-4b6c-af1c-c12dc8120755'), ''); + $this->assertSame('5d/bd/94/5dbd948b-d9dc-4b6c-af1c-c12dc8120755', $key); + } + + public function testAppendExtension(): void + { + $fileService = $this->buildFileService(); + $this->assertSame('path/file.txt', $fileService->appendExtension('path/file', 'txt')); + $this->assertSame('path/file', $fileService->appendExtension('path/file', '')); + } + public function testUuidToNestedFolderStructure(): void { $storageService = $this->buildFileService(); @@ -256,10 +353,13 @@ public static function fileNameFromPartsProvider(): array { return [ ['name', 'ext', 'name.ext'], - ['name', 'extensionWhichIsWayLongerThanExpected', 'name.extensionWhichIs'], - ['name', 'ext with long extension and whitespace ', 'name.extwithlongexten'], + ['name', str_repeat('e', 100), 'name.'.str_repeat('e', 64)], + ['name', 'ext with long extension and whitespace ', 'name.extwithlongextensionandwhitespace'], ['name-----1---------2---------3---------4---------5---------6---------7---------8---------9---------a---------b---------c---------d---------e---------f---------g---------h---------i---------j---------k---------l---------m---------n---------o---------p---------q---------r---------s---------t---------u---------v', 'ext', 'name-----1---------2---------3---------4---------5---------6---------7---------8---------9---------a---------b---------c---------d---------e---------f---------g---------h---------i---------j---------k---------l---------m---------n---------o---------p-.ext'], ['', 'env', '.env'], + ['Makefile', '', 'Makefile'], + [' Makefile ', '', 'Makefile'], + [str_repeat('n', 300), '', str_repeat('n', 255)], ]; } diff --git a/tests/UnitTests/Service/FileSizeLimitServiceTest.php b/tests/UnitTests/Service/FileSizeLimitServiceTest.php new file mode 100644 index 00000000..4ba783c8 --- /dev/null +++ b/tests/UnitTests/Service/FileSizeLimitServiceTest.php @@ -0,0 +1,78 @@ +prophesize(EmberNexusConfiguration::class); + $emberNexusConfiguration->getFileMaxFileSizeInBytes()->willReturn($maxFileSizeInBytes); + + return new FileSizeLimitService( + $emberNexusConfiguration->reveal(), + $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal() + ); + } + + public function testGetMaxFileSizeInBytesReturnsConfiguredValue(): void + { + $this->assertSame(1024, $this->buildService(1024)->getMaxFileSizeInBytes()); + } + + public function testExceedsMaxFileSizeIsFalseBelowLimit(): void + { + $this->assertFalse($this->buildService(1024)->exceedsMaxFileSize(1023)); + } + + public function testExceedsMaxFileSizeIsFalseExactlyAtLimit(): void + { + $this->assertFalse($this->buildService(1024)->exceedsMaxFileSize(1024)); + } + + public function testExceedsMaxFileSizeIsTrueAboveLimit(): void + { + $this->assertTrue($this->buildService(1024)->exceedsMaxFileSize(1025)); + } + + public function testExceedsMaxFileSizeIsFalseForEmptyFile(): void + { + $this->assertFalse($this->buildService(1024)->exceedsMaxFileSize(0)); + } + + public function testAssertWithinMaxFileSizePassesAtLimit(): void + { + $this->expectNotToPerformAssertions(); + $this->buildService(1024)->assertWithinMaxFileSize(1024); + } + + public function testAssertWithinMaxFileSizeThrowsAboveLimit(): void + { + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory + ->createFromDetail(Argument::containingString('at most 1024 bytes long, got 1025')) + ->shouldBeCalledOnce() + ->willReturn(new Client400BadContentException('bad-content')); + + $this->expectException(Client400BadContentException::class); + $this->buildService(1024, $client400BadContentExceptionFactory->reveal())->assertWithinMaxFileSize(1025); + } +} diff --git a/tests/UnitTests/Service/HeaderParseServiceTest.php b/tests/UnitTests/Service/HeaderParseServiceTest.php index f6f68567..ceca4e6c 100644 --- a/tests/UnitTests/Service/HeaderParseServiceTest.php +++ b/tests/UnitTests/Service/HeaderParseServiceTest.php @@ -210,7 +210,7 @@ public function testGetExtensionFromHeadersReturnsDefaultExtensionWhenHeaderIsPa $this->assertSame('bin', $result); } - public function testGetExtensionFromHeadersThrowsWhenFileNameDoesNotContainExtension(): void + public function testGetExtensionFromHeadersReturnsEmptyExtensionWhenFileNameDoesNotContainExtension(): void { $headerBag = $this->prophesize(HeaderBag::class); $headerBag->get(Argument::is('Content-Disposition'))->shouldBeCalledOnce()->willReturn('inline; filename=Test'); @@ -221,20 +221,32 @@ public function testGetExtensionFromHeadersThrowsWhenFileNameDoesNotContainExten $fileService = $this->prophesize(FileService::class); $fileService->removeReservedCharactersFromFileName(Argument::is('Test'))->shouldBeCalledOnce()->willReturn('Test'); - $exception = $this->prophesize(Client400BadContentException::class)->reveal(); + $headerParseService = $this->buildHeaderParseService( + fileService: $fileService->reveal(), + contentDispositionWrapper: $contentDispositionWrapper->reveal(), + ); - $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); - $client400BadContentExceptionFactory->createFromDetail(Argument::is("Could not parse a file extension from the filename in 'Content-Disposition': 'Test'."))->shouldBeCalledOnce()->willReturn($exception); + $this->assertSame('', $headerParseService->getExtensionFromHeaders($headerBag->reveal())); + } + + public function testGetExtensionFromHeadersTruncatesLongExtension(): void + { + $longExtension = str_repeat('a', 100); + $headerBag = $this->prophesize(HeaderBag::class); + $headerBag->get(Argument::is('Content-Disposition'))->shouldBeCalledOnce()->willReturn('inline'); + + $contentDispositionWrapper = $this->prophesize(ContentDispositionWrapper::class); + $contentDispositionWrapper->parseContentDisposition(Argument::is('inline'))->shouldBeCalledOnce()->willReturn('Test.'.$longExtension); + + $fileService = $this->prophesize(FileService::class); + $fileService->removeReservedCharactersFromFileName(Argument::is('Test.'.$longExtension))->shouldBeCalledOnce()->willReturn('Test.'.$longExtension); $headerParseService = $this->buildHeaderParseService( fileService: $fileService->reveal(), contentDispositionWrapper: $contentDispositionWrapper->reveal(), - client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal() ); - $this->expectException(Client400BadContentException::class); - - $headerParseService->getExtensionFromHeaders($headerBag->reveal()); + $this->assertSame(str_repeat('a', 64), $headerParseService->getExtensionFromHeaders($headerBag->reveal())); } public static function uploadOffset(): array diff --git a/tests/UnitTests/Service/IncrementalHashServiceTest.php b/tests/UnitTests/Service/IncrementalHashServiceTest.php index 97a16302..a82669cb 100644 --- a/tests/UnitTests/Service/IncrementalHashServiceTest.php +++ b/tests/UnitTests/Service/IncrementalHashServiceTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use stdClass; #[Small] #[CoversClass(IncrementalHashService::class)] @@ -111,6 +112,21 @@ public function testUnserializeContextFromStorageThrowsConflictExceptionOnGarbag $service->unserializeContextFromStorage('this is not valid base64-encoded serialized data!!!'); } + public function testUnserializeContextFromStorageDoesNotInstantiateOtherClasses(): void + { + $exception = $this->prophesize(Client409ConflictException::class)->reveal(); + + $client409ConflictExceptionFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $client409ConflictExceptionFactory->createFromDetail(Argument::type('string'))->shouldBeCalledOnce()->willReturn($exception); + + $service = $this->buildService($client409ConflictExceptionFactory->reveal()); + + // with allowed_classes the object becomes __PHP_Incomplete_Class, so no class code (e.g. __wakeup) is reached + $this->expectException(Client409ConflictException::class); + + $service->unserializeContextFromStorage(base64_encode(serialize(new stdClass()))); + } + public function testUnserializeContextFromStorageThrowsConflictExceptionWhenDecodedValueIsNotAHashContext(): void { $exception = $this->prophesize(Client409ConflictException::class)->reveal(); diff --git a/tests/UnitTests/Service/PropertyParseServiceTest.php b/tests/UnitTests/Service/PropertyParseServiceTest.php new file mode 100644 index 00000000..d9da5b25 --- /dev/null +++ b/tests/UnitTests/Service/PropertyParseServiceTest.php @@ -0,0 +1,291 @@ +prophesize(Client400BadContentExceptionFactory::class); + $factory + ->createFromDetail(Argument::type('string')) + ->will(fn (array $args) => new Client400BadContentException('bad-content', detail: $args[0])); + + return new PropertyParseService($factory->reveal()); + } + + /** + * @param callable(PropertyParseService): mixed $call + */ + private function assertBadContent(callable $call, string $expectedDetail): void + { + try { + $call($this->buildService()); + } catch (Client400BadContentException $exception) { + $this->assertSame($expectedDetail, $exception->getDetail()); + + return; + } + $this->fail('Expected Client400BadContentException.'); + } + + public function testUploadLengthIsNullWhenAbsent(): void + { + $this->assertNull($this->buildService()->getUploadLengthFromProperties([])); + } + + public function testUploadLengthIsReturned(): void + { + $this->assertSame(42, $this->buildService()->getUploadLengthFromProperties(['uploadLength' => 42])); + } + + #[DataProvider('nonIntProvider')] + public function testUploadLengthMustBeInt(mixed $value): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getUploadLengthFromProperties(['uploadLength' => $value]), + 'Upload expects property uploadLength to be either int or to be absent.' + ); + } + + public function testUploadOffsetMustBePresent(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getUploadOffsetFromProperties([]), + 'Upload expects property uploadOffset to be present.' + ); + } + + #[DataProvider('nonIntProvider')] + public function testUploadOffsetMustBeInt(mixed $value): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getUploadOffsetFromProperties(['uploadOffset' => $value]), + 'Upload expects property uploadOffset to be int.' + ); + } + + public function testUploadOffsetMustNotBeNegative(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getUploadOffsetFromProperties(['uploadOffset' => -1]), + 'Upload expects property uploadOffset to be positive int.' + ); + } + + public function testUploadOffsetIsReturned(): void + { + $this->assertSame(0, $this->buildService()->getUploadOffsetFromProperties(['uploadOffset' => 0])); + $this->assertSame(7, $this->buildService()->getUploadOffsetFromProperties(['uploadOffset' => 7])); + } + + public function testIsUploadCompleteDefaultsToFalse(): void + { + $this->assertFalse($this->buildService()->getIsUploadCompleteFromProperties([])); + } + + public function testIsUploadCompleteIsReturned(): void + { + $this->assertTrue($this->buildService()->getIsUploadCompleteFromProperties(['uploadComplete' => true])); + $this->assertFalse($this->buildService()->getIsUploadCompleteFromProperties(['uploadComplete' => false])); + } + + public function testIsUploadCompleteMustBeBool(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getIsUploadCompleteFromProperties(['uploadComplete' => 1]), + 'Upload expects property uploadComplete to be bool.' + ); + } + + public function testUploadTargetMustBePresent(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getUploadTargetFromProperties([]), + 'Upload expects property uploadTarget to be present.' + ); + } + + public function testUploadTargetMustBeUuid(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getUploadTargetFromProperties(['uploadTarget' => 12]), + 'Upload expects property uploadTarget to be a uuid, got int.' + ); + } + + public function testUploadTargetIsParsedFromString(): void + { + $uuid = Uuid::uuid4(); + $this->assertTrue($uuid->equals($this->buildService()->getUploadTargetFromProperties(['uploadTarget' => $uuid->toString()]))); + } + + public function testUploadTargetIsReturnedFromUuid(): void + { + $uuid = Uuid::uuid4(); + $this->assertSame($uuid, $this->buildService()->getUploadTargetFromProperties(['uploadTarget' => $uuid])); + } + + public function testChunkIdsDefaultToEmptyList(): void + { + $this->assertSame([], $this->buildService()->getChunkIdsFromProperties([])); + } + + public function testChunkIdsAreReturned(): void + { + $this->assertSame(['a', 'b'], $this->buildService()->getChunkIdsFromProperties(['chunkIds' => ['a', 'b']])); + } + + public function testChunkIdsMustBeList(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getChunkIdsFromProperties(['chunkIds' => 'a']), + 'Upload expects property chunkIds to be a list of strings.' + ); + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getChunkIdsFromProperties(['chunkIds' => ['x' => 'a']]), + 'Upload expects property chunkIds to be a list of strings.' + ); + } + + public function testChunkIdsMustContainStrings(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getChunkIdsFromProperties(['chunkIds' => ['a', 3]]), + 'Upload expects property chunkIds to be a list of strings.' + ); + } + + public function testUploadOwnerMustBePresent(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getUploadOwnerFromProperties([]), + 'Upload expects property uploadOwner to be present.' + ); + } + + public function testUploadOwnerMustBeUuid(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getUploadOwnerFromProperties(['uploadOwner' => null]), + 'Upload expects property uploadOwner to be a uuid, got null.' + ); + } + + public function testUploadOwnerIsParsedFromString(): void + { + $uuid = Uuid::uuid4(); + $this->assertTrue($uuid->equals($this->buildService()->getUploadOwnerFromProperties(['uploadOwner' => $uuid->toString()]))); + } + + public function testUploadOwnerIsReturnedFromUuid(): void + { + $uuid = Uuid::uuid4(); + $this->assertSame($uuid, $this->buildService()->getUploadOwnerFromProperties(['uploadOwner' => $uuid])); + } + + public function testExtensionDefaultsToDefaultExtension(): void + { + $this->assertSame(FileService::DEFAULT_EXTENSION, $this->buildService()->getExtensionFromProperties([])); + } + + public function testExtensionIsReturned(): void + { + $this->assertSame('png', $this->buildService()->getExtensionFromProperties(['extension' => 'png'])); + } + + public function testExtensionDefaultsWhenNotAString(): void + { + $this->assertSame(FileService::DEFAULT_EXTENSION, $this->buildService()->getExtensionFromProperties(['extension' => 5])); + $this->assertSame(FileService::DEFAULT_EXTENSION, $this->buildService()->getExtensionFromProperties(['extension' => null])); + } + + public function testEmptyExtensionMeansNoExtension(): void + { + $this->assertSame('', $this->buildService()->getExtensionFromProperties(['extension' => ''])); + } + + public function testHashStateIsNullWhenAbsent(): void + { + $this->assertNull($this->buildService()->getHashStateFromProperties([])); + } + + public function testHashStateIsReturned(): void + { + $this->assertSame('abc', $this->buildService()->getHashStateFromProperties(['hashState' => 'abc'])); + } + + public function testHashStateMustBeString(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getHashStateFromProperties(['hashState' => null]), + 'Upload expects property hashState to be either string or to be absent.' + ); + } + + public function testExpiresMustBePresent(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getExpiresFromProperties([]), + 'Upload expects property expires to be present.' + ); + } + + public function testExpiresMustBeDateTime(): void + { + $this->assertBadContent( + fn (PropertyParseService $service) => $service->getExpiresFromProperties(['expires' => '2020-01-01']), + 'Upload expects property expires to be a DateTime, got string.' + ); + } + + public function testExpiresIsReturnedFromDateTime(): void + { + $expires = new DateTime('2020-01-01T00:00:00+00:00'); + $this->assertSame($expires, $this->buildService()->getExpiresFromProperties(['expires' => $expires])); + } + + public function testExpiresIsConvertedFromImmutableVariants(): void + { + foreach ([new DateTimeImmutable('2020-01-01T00:00:00+00:00'), new NativeDateTimeImmutable('2020-01-01T00:00:00+00:00')] as $immutable) { + $result = $this->buildService()->getExpiresFromProperties(['expires' => $immutable]); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame($immutable->getTimestamp(), $result->getTimestamp()); + } + } + + /** + * @return array + */ + public static function nonIntProvider(): array + { + return [ + 'string' => ['5'], + 'float' => [1.5], + 'bool' => [true], + 'null' => [null], + 'array' => [[1]], + ]; + } +} diff --git a/tests/UnitTests/Service/QueueServiceTest.php b/tests/UnitTests/Service/QueueServiceTest.php index c77b8cb8..2b5f61d2 100644 --- a/tests/UnitTests/Service/QueueServiceTest.php +++ b/tests/UnitTests/Service/QueueServiceTest.php @@ -8,12 +8,16 @@ use App\Type\RabbitMQQueueType; use PhpAmqpLib\Channel\AMQPChannel; use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Exception\AMQPProtocolChannelException; use PhpAmqpLib\Message\AMQPMessage; +use PhpAmqpLib\Wire\AMQPTable; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Log\LoggerInterface; +use RuntimeException; #[Small] #[CoversClass(QueueService::class)] @@ -35,7 +39,7 @@ public function testPublishEventPublishesJsonEncodedMessageAndClosesChannel(): v $connection = $this->prophesize(AMQPStreamConnection::class); $connection->channel()->willReturn($channel->reveal()); - $queueService = new QueueService($connection->reveal()); + $queueService = new QueueService($connection->reveal(), $this->prophesize(LoggerInterface::class)->reveal()); $queueService->publishEvent(RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, ['elementId' => 'abc']); } @@ -49,7 +53,7 @@ public function testConsumeQueueReturnsZeroForEmptyQueueWithoutCallingHandler(): $connection = $this->prophesize(AMQPStreamConnection::class); $connection->channel()->willReturn($channel->reveal()); - $queueService = new QueueService($connection->reveal()); + $queueService = new QueueService($connection->reveal(), $this->prophesize(LoggerInterface::class)->reveal()); $processedMessages = $queueService->consumeQueue( RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, function (): void { @@ -79,7 +83,7 @@ public function testConsumeQueueDrainsAllMessagesAndAcknowledgesEach(): void $handledElementIds = []; - $queueService = new QueueService($connection->reveal()); + $queueService = new QueueService($connection->reveal(), $this->prophesize(LoggerInterface::class)->reveal()); $processedMessages = $queueService->consumeQueue( RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, function (array $eventData) use (&$handledElementIds): void { @@ -90,4 +94,149 @@ function (array $eventData) use (&$handledElementIds): void { $this->assertSame(2, $processedMessages); $this->assertSame(['1', '2'], $handledElementIds); } + + public function testConsumeQueueRequeuesFailingMessageWithIncreasedTryCounterAfterDrainingQueue(): void + { + $failingMessage = new AMQPMessage('{"elementId":"bad"}'); + $goodMessage = new AMQPMessage('{"elementId":"good"}'); + + $channel = $this->prophesize(AMQPChannel::class); + $channel->queue_declare('ELASTICSEARCH_REINDEX_FILE', false, false, false, false)->shouldBeCalledOnce(); + $channel->basic_get('ELASTICSEARCH_REINDEX_FILE')->shouldBeCalledTimes(3)->willReturn($failingMessage, $goodMessage, null); + $channel->basic_publish( + Argument::that(fn (AMQPMessage $message) => '{"elementId":"bad"}' === $message->getBody() + && 1 === $message->get('application_headers')->getNativeData()['x-try-count']), + '', + 'ELASTICSEARCH_REINDEX_FILE' + )->shouldBeCalledOnce(); + $channel->close()->shouldBeCalledOnce(); + $failingMessage->setChannel($channel->reveal()); + $goodMessage->setChannel($channel->reveal()); + $channel->basic_ack(Argument::any(), Argument::any())->shouldBeCalledTimes(2); + + $connection = $this->prophesize(AMQPStreamConnection::class); + $connection->channel()->willReturn($channel->reveal()); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->error(Argument::containingString('try 1 of 3'))->shouldBeCalledOnce(); + + $queueService = new QueueService($connection->reveal(), $logger->reveal()); + $processedMessages = $queueService->consumeQueue( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + function (array $eventData): void { + if ('bad' === $eventData['elementId']) { + throw new RuntimeException('boom'); + } + } + ); + + $this->assertSame(1, $processedMessages); + } + + public function testConsumeQueueDropsMessageAfterThirdFailedTry(): void + { + $message = new AMQPMessage('{"elementId":"bad"}', [ + 'application_headers' => new AMQPTable(['x-try-count' => 2]), + ]); + + $channel = $this->prophesize(AMQPChannel::class); + $channel->queue_declare(Argument::cetera())->shouldBeCalledOnce(); + $channel->basic_get('ELASTICSEARCH_REINDEX_FILE')->willReturn($message, null); + $channel->basic_publish(Argument::cetera())->shouldNotBeCalled(); + $channel->close()->shouldBeCalledOnce(); + $message->setChannel($channel->reveal()); + $channel->basic_ack(Argument::any(), Argument::any())->shouldBeCalledOnce(); + + $connection = $this->prophesize(AMQPStreamConnection::class); + $connection->channel()->willReturn($channel->reveal()); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->error(Argument::containingString('Dropping message'))->shouldBeCalledOnce(); + + $queueService = new QueueService($connection->reveal(), $logger->reveal()); + $this->assertSame(0, $queueService->consumeQueue( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + function (): void { + throw new RuntimeException('boom'); + } + )); + } + + public function testPublishEventDeclaresDurableQueueWithArgumentsAndPersistentMessageForOwnershipQueue(): void + { + $channel = $this->prophesize(AMQPChannel::class); + $channel->queue_declare('ELASTICSEARCH_UPDATE_OWNERSHIP.dead-letter', false, true, false, false, false, Argument::type(AMQPTable::class))->shouldBeCalledOnce(); + $channel->queue_declare('ELASTICSEARCH_UPDATE_OWNERSHIP', false, true, false, false, false, Argument::that( + function (AMQPTable $table) { + $data = $table->getNativeData(); + + return 100_000 === $data['x-max-length'] + && '' === $data['x-dead-letter-exchange'] + && 'ELASTICSEARCH_UPDATE_OWNERSHIP.dead-letter' === $data['x-dead-letter-routing-key']; + } + ))->shouldBeCalledOnce(); + $channel->basic_publish( + Argument::that(fn (AMQPMessage $message) => AMQPMessage::DELIVERY_MODE_PERSISTENT === $message->get('delivery_mode')), + '', + 'ELASTICSEARCH_UPDATE_OWNERSHIP' + )->shouldBeCalledOnce(); + $channel->close()->shouldBeCalledOnce(); + + $connection = $this->prophesize(AMQPStreamConnection::class); + $connection->channel()->willReturn($channel->reveal()); + + $queueService = new QueueService($connection->reveal(), $this->prophesize(LoggerInterface::class)->reveal()); + $queueService->publishEvent(RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE, ['relationId' => 'abc']); + } + + public function testDeclareQueueRecoversFromPreconditionFailedByRedeclaringOnAFreshChannel(): void + { + $failingChannel = $this->prophesize(AMQPChannel::class); + $failingChannel->queue_declare('ELASTICSEARCH_UPDATE_OWNERSHIP.dead-letter', false, true, false, false, false, Argument::type(AMQPTable::class)) + ->willThrow(new AMQPProtocolChannelException(406, 'PRECONDITION_FAILED', [10, 50])); + + $freshChannel = $this->prophesize(AMQPChannel::class); + $freshChannel->queue_delete('ELASTICSEARCH_UPDATE_OWNERSHIP.dead-letter')->shouldBeCalledOnce(); + $freshChannel->queue_delete('ELASTICSEARCH_UPDATE_OWNERSHIP')->shouldBeCalledOnce(); + $freshChannel->queue_declare('ELASTICSEARCH_UPDATE_OWNERSHIP.dead-letter', false, true, false, false, false, Argument::type(AMQPTable::class))->shouldBeCalledOnce(); + $freshChannel->queue_declare('ELASTICSEARCH_UPDATE_OWNERSHIP', false, true, false, false, false, Argument::type(AMQPTable::class))->shouldBeCalledOnce(); + $freshChannel->basic_publish(Argument::cetera())->shouldBeCalledOnce(); + $freshChannel->close()->shouldBeCalledOnce(); + + $connection = $this->prophesize(AMQPStreamConnection::class); + $connection->channel()->willReturn($failingChannel->reveal(), $freshChannel->reveal()); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->warning(Argument::containingString('ELASTICSEARCH_UPDATE_OWNERSHIP'))->shouldBeCalledOnce(); + + $queueService = new QueueService($connection->reveal(), $logger->reveal()); + $queueService->publishEvent(RabbitMQQueueType::ELASTICSEARCH_UPDATE_OWNERSHIP_QUEUE, ['relationId' => 'abc']); + } + + public function testConsumeQueueTreatsMalformedJsonAsFailedMessage(): void + { + $message = new AMQPMessage('not json'); + + $channel = $this->prophesize(AMQPChannel::class); + $channel->queue_declare(Argument::cetera())->shouldBeCalledOnce(); + $channel->basic_get('ELASTICSEARCH_REINDEX_FILE')->willReturn($message, null); + $channel->basic_publish(Argument::cetera())->shouldBeCalledOnce(); + $channel->close()->shouldBeCalledOnce(); + $message->setChannel($channel->reveal()); + $channel->basic_ack(Argument::any(), Argument::any())->shouldBeCalledOnce(); + + $connection = $this->prophesize(AMQPStreamConnection::class); + $connection->channel()->willReturn($channel->reveal()); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->error(Argument::any())->shouldBeCalledOnce(); + + $queueService = new QueueService($connection->reveal(), $logger->reveal()); + $this->assertSame(0, $queueService->consumeQueue( + RabbitMQQueueType::ELASTICSEARCH_REINDEX_FILE_QUEUE, + function (): void { + $this->fail('Handler must not be called for undecodable messages.'); + } + )); + } } diff --git a/tests/UnitTests/Service/RequestContentServiceTest.php b/tests/UnitTests/Service/RequestContentServiceTest.php new file mode 100644 index 00000000..f44a5a89 --- /dev/null +++ b/tests/UnitTests/Service/RequestContentServiceTest.php @@ -0,0 +1,61 @@ +createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('http://example.com/error/408/request-timeout'); + + return new RequestContentService(new Client408RequestTimeoutExceptionFactory($urlGenerator)); + } + + public function testContentWithoutContentLengthIsReturned(): void + { + $request = new Request(content: '{"a":1}'); + + $this->assertSame('{"a":1}', $this->buildService()->getContent($request)); + } + + public function testContentMatchingContentLengthIsReturned(): void + { + $request = new Request(server: ['CONTENT_LENGTH' => '7'], content: '{"a":1}'); + + $this->assertSame('{"a":1}', $this->buildService()->getContent($request)); + } + + public function testContentShorterThanContentLengthIsAnsweredWithRequestTimeout(): void + { + $request = new Request(server: ['CONTENT_LENGTH' => '20'], content: '{"a":1}'); + + try { + $this->buildService()->getContent($request); + $this->fail('Expected request timeout.'); + } catch (Client408RequestTimeoutException $exception) { + $this->assertSame(408, $exception->getStatus()); + $this->assertStringContainsString('received 7 of 20 bytes', $exception->getDetail()); + } + } + + public function testInvalidContentLengthIsIgnored(): void + { + $request = new Request(server: ['CONTENT_LENGTH' => 'abc'], content: '{"a":1}'); + + $this->assertSame('{"a":1}', $this->buildService()->getContent($request)); + } +} diff --git a/tests/UnitTests/Service/RequestIdServiceTest.php b/tests/UnitTests/Service/RequestIdServiceTest.php index 30ef9abb..d2f7a5ec 100644 --- a/tests/UnitTests/Service/RequestIdServiceTest.php +++ b/tests/UnitTests/Service/RequestIdServiceTest.php @@ -8,15 +8,69 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; -use Ramsey\Uuid\UuidInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; #[Small] #[CoversClass(RequestIdService::class)] class RequestIdServiceTest extends TestCase { - public function testValidateTypeFromBody(): void + private function buildService(?Request $request): RequestIdService { - $requestIdService = new RequestIdService(); - $this->assertInstanceOf(UuidInterface::class, $requestIdService->getRequestId()); + $requestStack = new RequestStack(); + if (null !== $request) { + $requestStack->push($request); + } + + return new RequestIdService($requestStack); + } + + public function testRequestIdIsTakenFromHeaderOfTheMainRequest(): void + { + $request = new Request(); + $request->headers->set('X-Request-Id', '3950e94a-2cd0-43ef-ac5a-8e116a4263c8'); + + $this->assertSame('3950e94a-2cd0-43ef-ac5a-8e116a4263c8', $this->buildService($request)->getRequestId()->toString()); + } + + public function testRequestIdIsGeneratedWithoutHeader(): void + { + $requestId = $this->buildService(new Request())->getRequestId(); + + $this->assertSame(4, $requestId->getFields()->getVersion()); + } + + public function testRequestIdIsGeneratedWithoutRequest(): void + { + $this->assertSame(4, $this->buildService(null)->getRequestId()->getFields()->getVersion()); + } + + public function testInvalidHeaderIsIgnored(): void + { + $request = new Request(); + $request->headers->set('X-Request-Id', 'not-a-uuid\n{"injected":true}'); + + $requestId = $this->buildService($request)->getRequestId(); + + $this->assertNotSame('not-a-uuid', $requestId->toString()); + $this->assertSame(4, $requestId->getFields()->getVersion()); + } + + public function testRequestIdStaysTheSameDuringTheRequest(): void + { + $service = $this->buildService(new Request()); + + $this->assertSame($service->getRequestId(), $service->getRequestId()); + } + + public function testRequestIdIsReadFromTheRequestWhichIsAvailableWhenItIsFirstNeeded(): void + { + $requestStack = new RequestStack(); + $service = new RequestIdService($requestStack); + $request = new Request(); + $request->headers->set('X-Request-Id', '3950e94a-2cd0-43ef-ac5a-8e116a4263c8'); + $requestStack->push($request); + + $this->assertSame('3950e94a-2cd0-43ef-ac5a-8e116a4263c8', $service->getRequestId()->toString()); } } diff --git a/tests/UnitTests/Service/RequestUtilServiceTest.php b/tests/UnitTests/Service/RequestUtilServiceTest.php index df6d519a..a1076e10 100644 --- a/tests/UnitTests/Service/RequestUtilServiceTest.php +++ b/tests/UnitTests/Service/RequestUtilServiceTest.php @@ -9,13 +9,11 @@ use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Client400MissingPropertyExceptionFactory; use App\Service\RequestUtilService; -use EmberNexusBundle\Service\EmberNexusConfiguration; use Exception; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; -use ReflectionMethod; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; #[Small] @@ -24,7 +22,6 @@ class RequestUtilServiceTest extends TestCase { private function getRequestUtilService( - ?EmberNexusConfiguration $emberNexusConfiguration = null, ?Client400BadContentExceptionFactory $client400BadContentExceptionFactory = null, ?Client400MissingPropertyExceptionFactory $client400MissingPropertyExceptionFactory = null, ): RequestUtilService { @@ -34,7 +31,6 @@ private function getRequestUtilService( $client400MissingPropertyExceptionFactory = new Client400MissingPropertyExceptionFactory($urlGenerator); return new RequestUtilService( - $emberNexusConfiguration ?? $this->createMock(EmberNexusConfiguration::class), $client400BadContentExceptionFactory, $client400MissingPropertyExceptionFactory ); @@ -102,137 +98,13 @@ public function testValidateTypeFromBody(): void $requestUtilService->validateTypeFromBody('Test', $body); } - public function testGetUniqueUserIdentifierFromBodyAndDataOld(): void - { - $emberNexusConfiguration = $this->createMock(EmberNexusConfiguration::class); - $emberNexusConfiguration->method('getRegisterUniqueIdentifier')->willReturn('email'); - $requestUtilService = $this->getRequestUtilService( - emberNexusConfiguration: $emberNexusConfiguration - ); - $method = new ReflectionMethod(RequestUtilService::class, 'getUniqueUserIdentifierFromBodyAndDataOld'); - - $body = []; - $data = []; - try { - $method->invokeArgs($requestUtilService, [$body, $data]); - } catch (Exception $e) { - $this->assertInstanceOf(Client400MissingPropertyException::class, $e); - /** - * @var Client400MissingPropertyException $e - */ - $this->assertSame("Endpoint requires that the request contains property 'data.email' to be set to string.", $e->getDetail()); - } - - $body = []; - $data = [ - 'email' => 1234, - ]; - try { - $method->invokeArgs($requestUtilService, [$body, $data]); - } catch (Exception $e) { - $this->assertInstanceOf(Client400BadContentException::class, $e); - /** - * @var Client400BadContentException $e - */ - $this->assertSame("Endpoint expects property 'data.email' to be string, got int 1234.", $e->getDetail()); - } - - $body = []; - $data = [ - 'email' => [ - 'some' => 'object', - ], - ]; - try { - $method->invokeArgs($requestUtilService, [$body, $data]); - } catch (Exception $e) { - $this->assertInstanceOf(Client400BadContentException::class, $e); - /** - * @var Client400BadContentException $e - */ - $this->assertSame("Endpoint expects property 'data.email' to be string, got array with one element.", $e->getDetail()); - } - - $body = []; - $data = [ - 'email' => 'test@localhost.dev', - ]; - $uniqueUserIdentifier = $method->invokeArgs($requestUtilService, [$body, $data]); - $this->assertSame('test@localhost.dev', $uniqueUserIdentifier); - - $body = [ - 'user' => 'test@localhost.dev', - ]; - $data = []; - $uniqueUserIdentifier = $method->invokeArgs($requestUtilService, [$body, $data]); - $this->assertSame('test@localhost.dev', $uniqueUserIdentifier); - } - - public function testGetUniqueUserIdentifierFromDataNew(): void + public function testGetUniqueUserIdentifierFromBodyAndData(): void { $requestUtilService = $this->getRequestUtilService(); - $method = new ReflectionMethod(RequestUtilService::class, 'getUniqueUserIdentifierFromBodyNew'); $body = []; try { - $method->invokeArgs($requestUtilService, [$body]); - } catch (Exception $e) { - $this->assertInstanceOf(Client400MissingPropertyException::class, $e); - /** - * @var Client400MissingPropertyException $e - */ - $this->assertSame("Endpoint requires that the request contains property 'uniqueUserIdentifier' to be set to string.", $e->getDetail()); - } - - $body = [ - 'uniqueUserIdentifier' => 1234, - ]; - try { - $method->invokeArgs($requestUtilService, [$body]); - } catch (Exception $e) { - $this->assertInstanceOf(Client400BadContentException::class, $e); - /** - * @var Client400BadContentException $e - */ - $this->assertSame("Endpoint expects property 'uniqueUserIdentifier' to be string, got int 1234.", $e->getDetail()); - } - - $body = [ - 'uniqueUserIdentifier' => [ - 'some' => 'object', - ], - ]; - try { - $method->invokeArgs($requestUtilService, [$body]); - } catch (Exception $e) { - $this->assertInstanceOf(Client400BadContentException::class, $e); - /** - * @var Client400BadContentException $e - */ - $this->assertSame("Endpoint expects property 'uniqueUserIdentifier' to be string, got array with one element.", $e->getDetail()); - } - - $body = [ - 'uniqueUserIdentifier' => 'test@localhost.dev', - ]; - $uniqueUserIdentifier = $method->invokeArgs($requestUtilService, [$body]); - $this->assertSame('test@localhost.dev', $uniqueUserIdentifier); - } - - public function testGetUniqueUserIdentifierFromBodyAndDataWithOldWayEnabled(): void - { - $emberNexusConfiguration = $this->createMock(EmberNexusConfiguration::class); - $emberNexusConfiguration->method('getRegisterUniqueIdentifier')->willReturn('email'); - $emberNexusConfiguration->method('isFeatureFlag280OldUniqueUserIdentifierDisabled')->willReturn(false); - $requestUtilService = $this->getRequestUtilService( - emberNexusConfiguration: $emberNexusConfiguration - ); - - $body = [ - 'data' => [], - ]; - try { - $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body, $body['data']); + $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body); } catch (Exception $e) { $this->assertInstanceOf(Client400MissingPropertyException::class, $e); /** @@ -243,10 +115,9 @@ public function testGetUniqueUserIdentifierFromBodyAndDataWithOldWayEnabled(): v $body = [ 'uniqueUserIdentifier' => 1234, - 'data' => [], ]; try { - $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body, $body['data']); + $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body); } catch (Exception $e) { $this->assertInstanceOf(Client400BadContentException::class, $e); /** @@ -259,10 +130,9 @@ public function testGetUniqueUserIdentifierFromBodyAndDataWithOldWayEnabled(): v 'uniqueUserIdentifier' => [ 'some' => 'object', ], - 'data' => [], ]; try { - $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body, $body['data']); + $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body); } catch (Exception $e) { $this->assertInstanceOf(Client400BadContentException::class, $e); /** @@ -273,26 +143,8 @@ public function testGetUniqueUserIdentifierFromBodyAndDataWithOldWayEnabled(): v $body = [ 'uniqueUserIdentifier' => 'test@localhost.dev', - 'data' => [], - ]; - $uniqueUserIdentifier = $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body, $body['data']); - $this->assertSame('test@localhost.dev', $uniqueUserIdentifier); - - $body = [ - 'uniqueUserIdentifier' => 'testNew@localhost.dev', - 'data' => [ - 'email' => 'testOld@localhost.dev', - ], - ]; - $uniqueUserIdentifier = $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body, $body['data']); - $this->assertSame('testOld@localhost.dev', $uniqueUserIdentifier); - - $body = [ - 'data' => [ - 'email' => 'test@localhost.dev', - ], ]; - $uniqueUserIdentifier = $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body, $body['data']); + $uniqueUserIdentifier = $requestUtilService->getUniqueUserIdentifierFromBodyAndData($body); $this->assertSame('test@localhost.dev', $uniqueUserIdentifier); } diff --git a/tests/UnitTests/Service/S3ServiceTest.php b/tests/UnitTests/Service/S3ServiceTest.php index 29d5ee0b..d87398f9 100644 --- a/tests/UnitTests/Service/S3ServiceTest.php +++ b/tests/UnitTests/Service/S3ServiceTest.php @@ -6,24 +6,28 @@ use App\Contract\S3\FileOperationInterface; use App\Contract\S3\MergeFileChunksOperationInterface; +use App\Contract\S3\S3TechnicalLimitsInterface; use App\Contract\S3\UploadFileChunkOperationInterface; use App\Contract\S3\UploadFileOperationInterface; use App\Exception\Client400BadContentException; use App\Exception\Server500LogicErrorException; use App\Factory\Exception\Client400BadContentExceptionFactory; use App\Factory\Exception\Server500LogicErrorExceptionFactory; -use App\Factory\Type\S3\UploadFileChunkOperationFactory; +use App\Factory\Type\S3\S3OperationFactory; use App\Service\MimeTypeService; use App\Service\S3Service; use App\Service\S3TechnicalLimitsValidator; +use App\Type\S3\S3TechnicalLimits; use App\Wrapper\S3ClientWrapper; use AsyncAws\Core\Stream\ResultStream; +use AsyncAws\S3\Result\AbortMultipartUploadOutput; use AsyncAws\S3\Result\CopyObjectOutput; use AsyncAws\S3\Result\CreateMultipartUploadOutput; use AsyncAws\S3\Result\GetObjectOutput; use AsyncAws\S3\Result\HeadObjectOutput; use AsyncAws\S3\Result\ObjectExistsWaiter; use AsyncAws\S3\Result\UploadPartCopyOutput; +use AsyncAws\S3\Result\UploadPartOutput; use AsyncAws\S3\S3Client; use AsyncAws\S3\ValueObject\CopyPartResult; use Exception; @@ -32,9 +36,13 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Log\LoggerInterface; +use RuntimeException; /** * @SuppressWarnings(PHPMD.ExcessiveClassLength) + * @SuppressWarnings("PHPMD.ExcessiveParameterList") + * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") */ #[Small] #[CoversClass(S3Service::class)] @@ -44,23 +52,31 @@ class S3ServiceTest extends TestCase private function buildS3Service( ?S3Client $s3Client = null, - ?UploadFileChunkOperationFactory $fileChunkOperationFactory = null, + ?S3OperationFactory $s3OperationFactory = null, ?S3ClientWrapper $s3ClientWrapper = null, ?MimeTypeService $mimeTypeService = null, ?Client400BadContentExceptionFactory $client400BadContentExceptionFactory = null, ?Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory = null, ?S3TechnicalLimitsValidator $s3TechnicalLimitsValidator = null, + ?S3TechnicalLimitsInterface $s3TechnicalLimits = null, + ?LoggerInterface $logger = null, + ?int $multipartUploadThresholdInBytes = null, + ?int $multipartUploadPartSizeInBytes = null, ): S3Service { $s3TechnicalLimitsValidator ??= $this->prophesize(S3TechnicalLimitsValidator::class)->reveal(); return new S3Service( $s3Client ?? $this->prophesize(S3Client::class)->reveal(), - $fileChunkOperationFactory ?? $this->prophesize(UploadFileChunkOperationFactory::class)->reveal(), + $s3OperationFactory ?? $this->prophesize(S3OperationFactory::class)->reveal(), $s3ClientWrapper ?? $this->prophesize(S3ClientWrapper::class)->reveal(), $mimeTypeService ?? $this->prophesize(MimeTypeService::class)->reveal(), $client400BadContentExceptionFactory ?? $this->prophesize(Client400BadContentExceptionFactory::class)->reveal(), $server500LogicErrorExceptionFactory ?? $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + $s3TechnicalLimits ?? new S3TechnicalLimits(), + $logger ?? $this->prophesize(LoggerInterface::class)->reveal(), $s3TechnicalLimitsValidator, + $multipartUploadThresholdInBytes ?? S3Service::MULTIPART_UPLOAD_THRESHOLD_IN_BYTES, + $multipartUploadPartSizeInBytes ?? S3Service::MULTIPART_UPLOAD_PART_SIZE_IN_BYTES, ); } @@ -98,7 +114,7 @@ public function testCreateMultipartUploadFromMergeFileChunksOperation(): void $s3Client->getObject(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key-0001', - 'Range' => 'bytes=0-12', + 'Range' => 'bytes=0-11', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); $s3Client->headObject(Argument::is([ 'Bucket' => 'upload-bucket', @@ -148,7 +164,7 @@ public function testCreateMultipartUploadFromMergeFileChunksOperationThrowsWhenU $s3Client->getObject(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key-0001', - 'Range' => 'bytes=0-12', + 'Range' => 'bytes=0-11', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); $s3Client->headObject(Argument::is([ 'Bucket' => 'upload-bucket', @@ -316,7 +332,7 @@ public function testMergeFileChunks(): void $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(6)->willReturn('storage-key'); $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(4)->willReturn('upload-bucket'); $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledTimes(2)->willReturn(['upload-key-0001', 'upload-key-0002', 'upload-key-0003']); - $mergeFileChunksOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn(null); + $mergeFileChunksOperation->getPreviousStorageKey()->shouldNotBeCalled(); $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); @@ -405,7 +421,7 @@ public function testMergeFileChunks(): void $s3Client->getObject(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key-0001', - 'Range' => 'bytes=0-12', + 'Range' => 'bytes=0-11', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput1->reveal()); $s3Client->headObject(Argument::is([ 'Bucket' => 'storage-bucket', @@ -521,7 +537,7 @@ public function testMergeFileChunksAbortsUploadWhenExceptionIsThrownDuringUpload $s3Client->getObject(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key-0001', - 'Range' => 'bytes=0-12', + 'Range' => 'bytes=0-11', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput1->reveal()); $s3Client->abortMultipartUpload(Argument::is([ 'Bucket' => 'storage-bucket', @@ -540,14 +556,14 @@ public function testMergeFileChunksAbortsUploadWhenExceptionIsThrownDuringUpload $s3Service->mergeFileChunks($mergeFileChunksOperation); } - public function testMergeFileChunksDeletesPreviousFileIfAvailable(): void + public function testMergeFileChunksDoesNotDeleteThePreviousFile(): void { $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); - $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledTimes(5)->willReturn('storage-bucket'); - $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(5)->willReturn('storage-key'); + $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledTimes(4)->willReturn('storage-bucket'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(4)->willReturn('storage-key'); $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledTimes(2)->willReturn(['upload-key-0001']); - $mergeFileChunksOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn('storage-key.prev'); + $mergeFileChunksOperation->getPreviousStorageKey()->shouldNotBeCalled(); $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); @@ -573,8 +589,6 @@ public function testMergeFileChunksDeletesPreviousFileIfAvailable(): void $mimeTypeService = $this->prophesize(MimeTypeService::class); $mimeTypeService->getMimeTypeFromResource(Argument::is('some content'))->shouldBeCalledOnce()->willReturn('text/plain'); - $objectExistsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); - $s3Client = $this->prophesize(S3Client::class); $s3Client->createMultipartUpload(Argument::is([ 'Bucket' => 'storage-bucket', @@ -608,23 +622,17 @@ public function testMergeFileChunksDeletesPreviousFileIfAvailable(): void $s3Client->getObject(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key-0001', - 'Range' => 'bytes=0-12', + 'Range' => 'bytes=0-11', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput1->reveal()); $s3Client->headObject(Argument::is([ 'Bucket' => 'storage-bucket', 'Key' => 'storage-key', ]))->shouldBeCalledOnce()->willReturn($headObjectOutput2->reveal()); - $s3Client->objectExists(Argument::is([ - 'Bucket' => 'storage-bucket', - 'Key' => 'storage-key.prev', - ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter); - $s3Client->deleteObject(Argument::is([ - 'Bucket' => 'storage-bucket', - 'Key' => 'storage-key.prev', - ]))->shouldBeCalledOnce(); + // the previous file is deleted by the caller after the element was flushed + $s3Client->deleteObject(Argument::cetera())->shouldNotBeCalled(); $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); - $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledTimes(2)->willReturn(true, false); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::any())->shouldNotBeCalled(); $s3Service = $this->buildS3Service( s3Client: $s3Client->reveal(), @@ -637,20 +645,17 @@ public function testMergeFileChunksDeletesPreviousFileIfAvailable(): void } /** - * A replace which keeps the same extension reuses the same storage key for the new file as the previous one - * (see FileService::getStorageBucketKey()), so `getPreviousStorageKey()` equals `getStorageKey()` - deleting - * it would delete the file that was just written, not an orphan. Complements - * testMergeFileChunksDeletesPreviousFileIfAvailable() (different key: gets deleted) and testMergeFileChunks() - * (no previous key at all: nothing to delete). + * A same-extension replace reuses the storage key, so the "previous" file is the one just written and must + * not be deleted. */ public function testMergeFileChunksDoesNotDeletePreviousFileWhenItIsTheSameAsTheNewOne(): void { $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); $mergeFileChunksOperation->getStorageBucket()->shouldBeCalledTimes(4)->willReturn('storage-bucket'); - $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(5)->willReturn('storage-key'); + $mergeFileChunksOperation->getStorageKey()->shouldBeCalledTimes(4)->willReturn('storage-key'); $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledTimes(2)->willReturn(['upload-key-0001']); - $mergeFileChunksOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $mergeFileChunksOperation->getPreviousStorageKey()->shouldNotBeCalled(); $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); @@ -709,14 +714,12 @@ public function testMergeFileChunksDoesNotDeletePreviousFileWhenItIsTheSameAsThe $s3Client->getObject(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key-0001', - 'Range' => 'bytes=0-12', + 'Range' => 'bytes=0-11', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput1->reveal()); $s3Client->headObject(Argument::is([ 'Bucket' => 'storage-bucket', 'Key' => 'storage-key', ]))->shouldBeCalledOnce()->willReturn($headObjectOutput2->reveal()); - // the just-written file must never be probed for existence or deleted just because it happens to equal - // the previous key $s3Client->objectExists(Argument::any())->shouldNotBeCalled(); $s3Client->deleteObject(Argument::any())->shouldNotBeCalled(); @@ -729,6 +732,38 @@ public function testMergeFileChunksDoesNotDeletePreviousFileWhenItIsTheSameAsThe $this->assertSame(12345678, $contentLength); } + public function testGetChunksContentLengthSumsUpAllChunks(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getUploadKeys()->willReturn(['key-1', 'key-2']); + $mergeFileChunksOperation->getUploadBucket()->willReturn('upload-bucket'); + + $s3Client = $this->prophesize(S3Client::class); + foreach (['key-1' => 7, 'key-2' => 5] as $key => $length) { + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->willReturn($length); + $s3Client->headObject(Argument::is(['Bucket' => 'upload-bucket', 'Key' => $key]))->willReturn($headObjectOutput->reveal()); + } + + $s3Service = $this->buildS3Service(s3Client: $s3Client->reveal()); + + $this->assertSame(12, $s3Service->getChunksContentLength($mergeFileChunksOperation->reveal())); + } + + public function testGetChunksContentLengthReturnsNullForMissingChunk(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getUploadKeys()->willReturn(['key-1']); + $mergeFileChunksOperation->getUploadBucket()->willReturn('upload-bucket'); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::any())->willThrow(new RuntimeException('not found')); + + $s3Service = $this->buildS3Service(s3Client: $s3Client->reveal()); + + $this->assertNull($s3Service->getChunksContentLength($mergeFileChunksOperation->reveal())); + } + public function testDeleteFileChunks(): void { $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); @@ -982,12 +1017,13 @@ public function testUploadFileChunkWithProvidedIncorrectContentLengthThrowsExcep public function testUploadFile(): void { $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getContentLength()->shouldBeCalledOnce()->willReturn(null); $uploadFileOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); $uploadFileOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); $uploadFileOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); $uploadFileOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); - $uploadFileOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn(null); + $uploadFileOperation->getPreviousStorageKey()->shouldNotBeCalled(); $uploadFileOperation = $uploadFileOperation->reveal(); $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); @@ -1035,13 +1071,13 @@ public function testUploadFile(): void $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledTimes(2)->willReturn(true, false); $s3ClientWrapper->resolveCopyObjectOutput(Argument::is($copyObjectOutput))->shouldBeCalledOnce(); - $uploadFileChunkOperationFactory = $this->prophesize(UploadFileChunkOperationFactory::class); - $uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) + $s3OperationFactory = $this->prophesize(S3OperationFactory::class); + $s3OperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) ->shouldBeCalledOnce()->willReturn($uploadFileChunkOperation); $s3Service = $this->buildS3Service( s3Client: $s3Client->reveal(), - fileChunkOperationFactory: $uploadFileChunkOperationFactory->reveal(), + s3OperationFactory: $s3OperationFactory->reveal(), s3ClientWrapper: $s3ClientWrapper->reveal() ); @@ -1049,15 +1085,16 @@ public function testUploadFile(): void $this->assertSame(1234, $contentLength); } - public function testUploadFileDeletesPreviousFileIfItExists(): void + public function testUploadFileDoesNotDeleteThePreviousFile(): void { $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); - $uploadFileOperation->getStorageBucket()->shouldBeCalledTimes(2)->willReturn('storage-bucket'); - $uploadFileOperation->getStorageKey()->shouldBeCalledTimes(2)->willReturn('storage-key'); + $uploadFileOperation->getContentLength()->shouldBeCalledOnce()->willReturn(null); + $uploadFileOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); + $uploadFileOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); $uploadFileOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); $uploadFileOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); - $uploadFileOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn('storage-key.prev'); + $uploadFileOperation->getPreviousStorageKey()->shouldNotBeCalled(); $uploadFileOperation = $uploadFileOperation->reveal(); $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); @@ -1072,7 +1109,6 @@ public function testUploadFileDeletesPreviousFileIfItExists(): void $headObjectOutput = $this->prophesize(HeadObjectOutput::class); $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1234); - $objectExistsWaiter1 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); $objectExistsWaiter2 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); $s3Client = $this->prophesize(S3Client::class); @@ -1093,14 +1129,11 @@ public function testUploadFileDeletesPreviousFileIfItExists(): void 'ContentType' => 'text/plain', 'MetadataDirective' => 'REPLACE', ]))->shouldBeCalledOnce()->willReturn($copyObjectOutput); - $s3Client->objectExists(Argument::is([ - 'Bucket' => 'storage-bucket', - 'Key' => 'storage-key.prev', - ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter1); + // the previous file is deleted by the caller after the element was flushed $s3Client->deleteObject(Argument::is([ 'Bucket' => 'storage-bucket', 'Key' => 'storage-key.prev', - ]))->shouldBeCalledOnce(); + ]))->shouldNotBeCalled(); $s3Client->objectExists(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key', @@ -1111,17 +1144,16 @@ public function testUploadFileDeletesPreviousFileIfItExists(): void ]))->shouldBeCalledOnce(); $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); - $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter1))->shouldBeCalledTimes(2)->willReturn(true, false); $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter2))->shouldBeCalledTimes(2)->willReturn(true, false); $s3ClientWrapper->resolveCopyObjectOutput(Argument::is($copyObjectOutput))->shouldBeCalledOnce(); - $uploadFileChunkOperationFactory = $this->prophesize(UploadFileChunkOperationFactory::class); - $uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) + $s3OperationFactory = $this->prophesize(S3OperationFactory::class); + $s3OperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) ->shouldBeCalledOnce()->willReturn($uploadFileChunkOperation); $s3Service = $this->buildS3Service( s3Client: $s3Client->reveal(), - fileChunkOperationFactory: $uploadFileChunkOperationFactory->reveal(), + s3OperationFactory: $s3OperationFactory->reveal(), s3ClientWrapper: $s3ClientWrapper->reveal() ); @@ -1130,22 +1162,19 @@ public function testUploadFileDeletesPreviousFileIfItExists(): void } /** - * A replace which keeps the same extension reuses the same storage key for the new file as the previous one - * (see FileService::getStorageBucketKey()), so `getPreviousStorageKey()` equals `getStorageKey()` - deleting - * it would delete the file that was just written, not an orphan. Complements - * testUploadFileDeletesPreviousFileIfItExists() (different key: gets deleted) and testUploadFile() (no - * previous key at all: nothing to delete). The intermediate upload-bucket object is still cleaned up - * regardless, since that cleanup is unconditional. + * A same-extension replace reuses the storage key, so the "previous" file is the one just written and must + * not be deleted. The intermediate upload-bucket object is still cleaned up. */ public function testUploadFileDoesNotDeletePreviousFileWhenItIsTheSameAsTheNewOne(): void { $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getContentLength()->shouldBeCalledOnce()->willReturn(null); $uploadFileOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); - $uploadFileOperation->getStorageKey()->shouldBeCalledTimes(2)->willReturn('storage-key'); + $uploadFileOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); $uploadFileOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); $uploadFileOperation->getUploadKey()->shouldBeCalledTimes(2)->willReturn('upload-key'); $uploadFileOperation->getMimeType()->shouldBeCalledOnce()->willReturn('text/plain'); - $uploadFileOperation->getPreviousStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); + $uploadFileOperation->getPreviousStorageKey()->shouldNotBeCalled(); $uploadFileOperation = $uploadFileOperation->reveal(); $uploadFileChunkOperation = $this->prophesize(UploadFileChunkOperationInterface::class); @@ -1180,8 +1209,7 @@ public function testUploadFileDoesNotDeletePreviousFileWhenItIsTheSameAsTheNewOn 'ContentType' => 'text/plain', 'MetadataDirective' => 'REPLACE', ]))->shouldBeCalledOnce()->willReturn($copyObjectOutput); - // only the intermediate upload-bucket object is ever probed/deleted - the just-written storage object - // must never be, just because it happens to equal the previous key + // only the intermediate upload-bucket object is probed/deleted $s3Client->objectExists(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key', @@ -1203,13 +1231,13 @@ public function testUploadFileDoesNotDeletePreviousFileWhenItIsTheSameAsTheNewOn $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter))->shouldBeCalledTimes(2)->willReturn(true, false); $s3ClientWrapper->resolveCopyObjectOutput(Argument::is($copyObjectOutput))->shouldBeCalledOnce(); - $uploadFileChunkOperationFactory = $this->prophesize(UploadFileChunkOperationFactory::class); - $uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) + $s3OperationFactory = $this->prophesize(S3OperationFactory::class); + $s3OperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) ->shouldBeCalledOnce()->willReturn($uploadFileChunkOperation); $s3Service = $this->buildS3Service( s3Client: $s3Client->reveal(), - fileChunkOperationFactory: $uploadFileChunkOperationFactory->reveal(), + s3OperationFactory: $s3OperationFactory->reveal(), s3ClientWrapper: $s3ClientWrapper->reveal() ); @@ -1220,6 +1248,7 @@ public function testUploadFileDoesNotDeletePreviousFileWhenItIsTheSameAsTheNewOn public function testUploadFileRethrowsExceptionDuringUpload(): void { $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getContentLength()->shouldBeCalledOnce()->willReturn(null); $uploadFileOperation->getStorageBucket()->shouldBeCalledOnce()->willReturn('storage-bucket'); $uploadFileOperation->getStorageKey()->shouldBeCalledOnce()->willReturn('storage-key'); $uploadFileOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); @@ -1269,13 +1298,13 @@ public function testUploadFileRethrowsExceptionDuringUpload(): void $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); $s3ClientWrapper->resolveCopyObjectOutput(Argument::is($copyObjectOutput))->shouldBeCalledOnce()->willThrow($originalException); - $uploadFileChunkOperationFactory = $this->prophesize(UploadFileChunkOperationFactory::class); - $uploadFileChunkOperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) + $s3OperationFactory = $this->prophesize(S3OperationFactory::class); + $s3OperationFactory->createUploadFileChunkOperationFromUploadFileOperation(Argument::is($uploadFileOperation)) ->shouldBeCalledOnce()->willReturn($uploadFileChunkOperation); $s3Service = $this->buildS3Service( s3Client: $s3Client->reveal(), - fileChunkOperationFactory: $uploadFileChunkOperationFactory->reveal(), + s3OperationFactory: $s3OperationFactory->reveal(), s3ClientWrapper: $s3ClientWrapper->reveal(), server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() ); @@ -1387,7 +1416,7 @@ public function testGetFileRangeAsResourceClipsResourceLengthToMaxContentLength( $s3Client->getObject(Argument::is([ 'Bucket' => 'storage-bucket', 'Key' => 'storage-key.ext', - 'Range' => 'bytes=0-5000000', + 'Range' => 'bytes=0-4999999', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); $s3Service = $this->buildS3Service( @@ -1423,7 +1452,7 @@ public function testGetFileRangeAsResourceClipsResourceLengthToActualContentLeng $s3Client->getObject(Argument::is([ 'Bucket' => 'storage-bucket', 'Key' => 'storage-key.ext', - 'Range' => 'bytes=0-4321', + 'Range' => 'bytes=0-4320', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); $s3Service = $this->buildS3Service( @@ -1449,8 +1478,7 @@ public function testGetFileRangeAsResourceReturnsEmptyResourceWithoutCallingS3Wh 'Bucket' => 'storage-bucket', 'Key' => 'storage-key.ext', ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); - // a byte-range request against an empty object is invalid (S3 rejects it with a 416), so getObject must - // not be called at all here + // S3 rejects byte-range requests against empty objects with 416 $s3Client->getObject(Argument::any())->shouldNotBeCalled(); $s3Service = $this->buildS3Service( @@ -1462,6 +1490,107 @@ public function testGetFileRangeAsResourceReturnsEmptyResourceWithoutCallingS3Wh $this->assertSame('', \Safe\stream_get_contents($resource)); } + public function testGetFileRangeAsResourceRequestsSingleByteForOneByteFile(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledTimes(2)->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledTimes(2)->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('x'); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class); + $getObjectOutput->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $s3Client->getObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + 'Range' => 'bytes=0-0', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $resource = $s3Service->getFileRangeAsResource($fileOperation, 5000000); + $this->assertSame('x', $resource); + } + + public function testGetFileRangeAsResourceRequestsWholeFileWhenContentLengthEqualsMaxContentLength(): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldBeCalledTimes(2)->willReturn('storage-bucket'); + $fileOperation->getKey()->shouldBeCalledTimes(2)->willReturn('storage-key.ext'); + $fileOperation = $fileOperation->reveal(); + + $headObjectOutput = $this->prophesize(HeadObjectOutput::class); + $headObjectOutput->getContentLength()->shouldBeCalledOnce()->willReturn(1000); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + ]))->shouldBeCalledOnce()->willReturn($headObjectOutput->reveal()); + + $resultStream = $this->prophesize(ResultStream::class); + $resultStream->getContentAsResource()->shouldBeCalledOnce()->willReturn('some content'); + + $getObjectOutput = $this->prophesize(GetObjectOutput::class); + $getObjectOutput->getBody()->shouldBeCalledOnce()->willReturn($resultStream->reveal()); + + $s3Client->getObject(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key.ext', + 'Range' => 'bytes=0-999', + ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $resource = $s3Service->getFileRangeAsResource($fileOperation, 1000); + $this->assertSame('some content', $resource); + } + + public function testGetFileRangeAsResourceReturnsEmptyResourceWithoutCallingS3WhenMaxContentLengthIsZero(): void + { + $this->assertEmptyResourceWithoutS3CallsForMaxContentLength(0); + } + + public function testGetFileRangeAsResourceReturnsEmptyResourceWithoutCallingS3WhenMaxContentLengthIsNegative(): void + { + $this->assertEmptyResourceWithoutS3CallsForMaxContentLength(-1); + } + + private function assertEmptyResourceWithoutS3CallsForMaxContentLength(int $maxContentLength): void + { + $fileOperation = $this->prophesize(FileOperationInterface::class); + $fileOperation->getBucket()->shouldNotBeCalled(); + $fileOperation->getKey()->shouldNotBeCalled(); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->headObject(Argument::any())->shouldNotBeCalled(); + $s3Client->getObject(Argument::any())->shouldNotBeCalled(); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal() + ); + + $resource = $s3Service->getFileRangeAsResource($fileOperation->reveal(), $maxContentLength); + $this->assertIsResource($resource); + $this->assertSame('', \Safe\stream_get_contents($resource)); + } + public function testGetFileRangeAsResourceThrowsWhenContentLengthIsNotDeterminable(): void { $fileOperation = $this->prophesize(FileOperationInterface::class); @@ -1628,7 +1757,7 @@ public function testGetMimeTypeFromFile(): void $s3Client->getObject(Argument::is([ 'Bucket' => 'storage-bucket', 'Key' => 'storage-key.ext', - 'Range' => 'bytes=0-5242880', + 'Range' => 'bytes=0-5242879', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); $mimeTypeService = $this->prophesize(MimeTypeService::class); @@ -1668,7 +1797,7 @@ public function testGetMimeTypeFromMergeFileChunksOperation(): void $s3Client->getObject(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key-0001.bin', - 'Range' => 'bytes=0-5242880', + 'Range' => 'bytes=0-5242879', ]))->shouldBeCalledOnce()->willReturn($getObjectOutput->reveal()); $mimeTypeService = $this->prophesize(MimeTypeService::class); @@ -1702,4 +1831,365 @@ public function testGetMimeTypeFromMergeFileChunksOperationThrowsWhenMergeContai $this->expectException(Client400BadContentException::class); $s3Service->getMimeTypeFromMergeFileChunksOperation($mergeFileChunksOperation); } + + /** + * @param resource $resource + */ + private function buildMultipartUploadFileOperation($resource, int $contentLength, ?string $previousStorageKey = null): UploadFileOperationInterface + { + $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getContentLength()->willReturn($contentLength); + $uploadFileOperation->getStorageBucket()->willReturn('storage-bucket'); + $uploadFileOperation->getStorageKey()->willReturn('storage-key'); + $uploadFileOperation->getMimeType()->willReturn('text/plain'); + $uploadFileOperation->getContent()->willReturn($resource); + $uploadFileOperation->getPreviousStorageKey()->willReturn($previousStorageKey); + + return $uploadFileOperation->reveal(); + } + + private function buildUploadPartOutput(string $etag): UploadPartOutput + { + $uploadPartOutput = $this->prophesize(UploadPartOutput::class); + $uploadPartOutput->getETag()->willReturn($etag); + + return $uploadPartOutput->reveal(); + } + + private function buildCreateMultipartUploadOutput(?string $uploadId): CreateMultipartUploadOutput + { + $createMultipartUploadOutput = $this->prophesize(CreateMultipartUploadOutput::class); + $createMultipartUploadOutput->getUploadId()->willReturn($uploadId); + + return $createMultipartUploadOutput->reveal(); + } + + public function testUploadFileUsesMultipartUploadAtOrAboveThreshold(): void + { + $content = str_repeat('a', 25); + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($resource, $content); + \Safe\rewind($resource); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'ContentType' => 'text/plain', + ]))->shouldBeCalledOnce()->willReturn($this->buildCreateMultipartUploadOutput('multipart-upload-id')); + $s3Client->uploadPart(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'multipart-upload-id', + 'PartNumber' => 1, + 'Body' => substr($content, 0, 10), + ]))->shouldBeCalledOnce()->willReturn($this->buildUploadPartOutput('etag-1')); + $s3Client->uploadPart(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'multipart-upload-id', + 'PartNumber' => 2, + 'Body' => substr($content, 10, 10), + ]))->shouldBeCalledOnce()->willReturn($this->buildUploadPartOutput('etag-2')); + $s3Client->uploadPart(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'multipart-upload-id', + 'PartNumber' => 3, + 'Body' => substr($content, 20, 5), + ]))->shouldBeCalledOnce()->willReturn($this->buildUploadPartOutput('etag-3')); + $s3Client->completeMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'multipart-upload-id', + 'MultipartUpload' => [ + 'Parts' => [ + ['PartNumber' => 1, 'ETag' => 'etag-1'], + ['PartNumber' => 2, 'ETag' => 'etag-2'], + ['PartNumber' => 3, 'ETag' => 'etag-3'], + ], + ], + ]))->shouldBeCalledOnce(); + // the intermediate upload bucket is skipped entirely on this path + $s3Client->putObject(Argument::any())->shouldNotBeCalled(); + $s3Client->copyObject(Argument::any())->shouldNotBeCalled(); + + $s3Service = $this->buildS3Service(s3Client: $s3Client->reveal(), multipartUploadThresholdInBytes: 20, multipartUploadPartSizeInBytes: 10); + + $this->assertSame(25, $s3Service->uploadFile($this->buildMultipartUploadFileOperation($resource, 25))); + } + + public function testUploadFileViaMultipartUploadRewindsAlreadyConsumedStream(): void + { + $content = str_repeat('b', 15); + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($resource, $content); + // mime type detection and hashing leave the stream at its end + \Safe\rewind($resource); + \Safe\stream_get_contents($resource); + $this->assertTrue(feof($resource)); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::any())->shouldBeCalledOnce()->willReturn($this->buildCreateMultipartUploadOutput('multipart-upload-id')); + $s3Client->uploadPart(Argument::withEntry('Body', substr($content, 0, 10)))->shouldBeCalledOnce()->willReturn($this->buildUploadPartOutput('etag-1')); + $s3Client->uploadPart(Argument::withEntry('Body', substr($content, 10, 5)))->shouldBeCalledOnce()->willReturn($this->buildUploadPartOutput('etag-2')); + $s3Client->completeMultipartUpload(Argument::any())->shouldBeCalledOnce(); + + $s3Service = $this->buildS3Service(s3Client: $s3Client->reveal(), multipartUploadThresholdInBytes: 10, multipartUploadPartSizeInBytes: 10); + + $this->assertSame(15, $s3Service->uploadFile($this->buildMultipartUploadFileOperation($resource, 15))); + } + + public function testUploadFileStaysOnSinglePutBelowThreshold(): void + { + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::any())->shouldNotBeCalled(); + + $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getContentLength()->shouldBeCalledOnce()->willReturn(19); + $uploadFileOperation->getStorageBucket()->willReturn('storage-bucket'); + $uploadFileOperation->getStorageKey()->willReturn('storage-key'); + $uploadFileOperation->getUploadBucket()->willReturn('upload-bucket'); + $uploadFileOperation->getUploadKey()->willReturn('upload-key'); + $uploadFileOperation->getMimeType()->willReturn('text/plain'); + $uploadFileOperation->getPreviousStorageKey()->willReturn(null); + + $s3OperationFactory = $this->prophesize(S3OperationFactory::class); + $s3OperationFactory + ->createUploadFileChunkOperationFromUploadFileOperation(Argument::any()) + ->willThrow(new Exception('single-PUT path reached')); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3OperationFactory: $s3OperationFactory->reveal(), + multipartUploadThresholdInBytes: 20 + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('single-PUT path reached'); + $s3Service->uploadFile($uploadFileOperation->reveal()); + } + + public function testUploadFileViaMultipartUploadDoesNotDeleteThePreviousFile(): void + { + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($resource, 'abcdefghij'); + \Safe\rewind($resource); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::any())->willReturn($this->buildCreateMultipartUploadOutput('multipart-upload-id')); + $s3Client->uploadPart(Argument::any())->willReturn($this->buildUploadPartOutput('etag-1')); + $s3Client->completeMultipartUpload(Argument::any())->shouldBeCalledOnce(); + // the previous file is deleted by the caller after the element was flushed + $s3Client->deleteObject(Argument::any())->shouldNotBeCalled(); + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + + $s3Service = $this->buildS3Service(s3Client: $s3Client->reveal(), s3ClientWrapper: $s3ClientWrapper->reveal(), multipartUploadThresholdInBytes: 5, multipartUploadPartSizeInBytes: 1000); + + $this->assertSame(10, $s3Service->uploadFile($this->buildMultipartUploadFileOperation($resource, 10, 'previous-storage-key'))); + } + + public function testUploadFileViaMultipartUploadAbortsAndRethrowsOnFailure(): void + { + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($resource, 'abcdefghij'); + \Safe\rewind($resource); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::any())->willReturn($this->buildCreateMultipartUploadOutput('multipart-upload-id')); + $partException = new Exception('part upload exploded'); + $s3Client->uploadPart(Argument::any())->willThrow($partException); + $s3Client->abortMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'multipart-upload-id', + ]))->shouldBeCalledOnce(); + $s3Client->completeMultipartUpload(Argument::any())->shouldNotBeCalled(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory + ->createFromTemplate(Argument::containingString('part upload exploded'), Argument::is([]), Argument::is($partException)) + ->shouldBeCalledOnce() + ->willReturn($this->prophesize(Server500LogicErrorException::class)->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal(), + multipartUploadThresholdInBytes: 5, + multipartUploadPartSizeInBytes: 1000 + ); + + $this->expectException(Server500LogicErrorException::class); + $s3Service->uploadFile($this->buildMultipartUploadFileOperation($resource, 10)); + } + + public function testUploadFileViaMultipartUploadRejectsMismatchedContentLength(): void + { + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($resource, 'abcdefghij'); + \Safe\rewind($resource); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::any())->willReturn($this->buildCreateMultipartUploadOutput('multipart-upload-id')); + $s3Client->uploadPart(Argument::any())->willReturn($this->buildUploadPartOutput('etag-1')); + // nothing may be published at the storage key when the length does not add up + $s3Client->completeMultipartUpload(Argument::any())->shouldNotBeCalled(); + $s3Client->abortMultipartUpload(Argument::is([ + 'Bucket' => 'storage-bucket', + 'Key' => 'storage-key', + 'UploadId' => 'multipart-upload-id', + ]))->shouldBeCalledOnce(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory + ->createFromDetail(Argument::containingString('Inconsistent length values'), Argument::cetera()) + ->shouldBeCalledOnce() + ->willReturn($this->prophesize(Client400BadContentException::class)->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal(), + multipartUploadThresholdInBytes: 5, + multipartUploadPartSizeInBytes: 1000 + ); + + $this->expectException(Client400BadContentException::class); + $s3Service->uploadFile($this->buildMultipartUploadFileOperation($resource, 99)); + } + + public function testUploadFileViaMultipartUploadKeepsOriginalExceptionWhenAbortFails(): void + { + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($resource, 'abcdefghij'); + \Safe\rewind($resource); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::any())->willReturn($this->buildCreateMultipartUploadOutput('multipart-upload-id')); + $partException = new Exception('part upload exploded'); + $s3Client->uploadPart(Argument::any())->willThrow($partException); + $abortException = new Exception('abort exploded'); + $s3Client->abortMultipartUpload(Argument::any())->shouldBeCalledOnce()->willThrow($abortException); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->warning('Unable to abort multipart upload.', Argument::is([ + 'bucket' => 'storage-bucket', + 'key' => 'storage-key', + 'uploadId' => 'multipart-upload-id', + 'exception' => $abortException, + ]))->shouldBeCalledOnce(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory + ->createFromTemplate(Argument::containingString('part upload exploded'), Argument::is([]), Argument::is($partException)) + ->shouldBeCalledOnce() + ->willReturn($this->prophesize(Server500LogicErrorException::class)->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal(), + logger: $logger->reveal(), + multipartUploadThresholdInBytes: 5, + multipartUploadPartSizeInBytes: 1000 + ); + + $this->expectException(Server500LogicErrorException::class); + $s3Service->uploadFile($this->buildMultipartUploadFileOperation($resource, 10)); + } + + public function testUploadFileViaMultipartUploadKeepsClientErrorWhenLazyAbortFails(): void + { + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($resource, 'abcdefghij'); + \Safe\rewind($resource); + + $abortException = new Exception('abort exploded'); + $abortOutput = $this->prophesize(AbortMultipartUploadOutput::class)->reveal(); + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->resolveAbortMultipartUploadOutput($abortOutput)->shouldBeCalledOnce()->willThrow($abortException); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::any())->willReturn($this->buildCreateMultipartUploadOutput('multipart-upload-id')); + $s3Client->uploadPart(Argument::any())->willReturn($this->buildUploadPartOutput('etag-1')); + $s3Client->completeMultipartUpload(Argument::any())->shouldNotBeCalled(); + $s3Client->abortMultipartUpload(Argument::any())->shouldBeCalledOnce()->willReturn($abortOutput); + + $logger = $this->prophesize(LoggerInterface::class); + $logger->warning('Unable to abort multipart upload.', Argument::withEntry('exception', $abortException))->shouldBeCalledOnce(); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory + ->createFromDetail(Argument::containingString('Inconsistent length values'), Argument::cetera()) + ->shouldBeCalledOnce() + ->willReturn($this->prophesize(Client400BadContentException::class)->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal(), + client400BadContentExceptionFactory: $client400BadContentExceptionFactory->reveal(), + logger: $logger->reveal(), + multipartUploadThresholdInBytes: 5, + multipartUploadPartSizeInBytes: 1000 + ); + + $this->expectException(Client400BadContentException::class); + $s3Service->uploadFile($this->buildMultipartUploadFileOperation($resource, 99)); + } + + public function testUploadFileViaMultipartUploadFailsWhenNoUploadIdIsReturned(): void + { + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\rewind($resource); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::any())->willReturn($this->buildCreateMultipartUploadOutput(null)); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory + ->createFromTemplate(Argument::containingString('Unable to create multipart upload.'), Argument::cetera()) + ->shouldBeCalledOnce() + ->willReturn($this->prophesize(Server500LogicErrorException::class)->reveal()); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal(), + multipartUploadThresholdInBytes: 5 + ); + + $this->expectException(Server500LogicErrorException::class); + $s3Service->uploadFile($this->buildMultipartUploadFileOperation($resource, 10)); + } + + /** + * A fixed part size would exceed the backend's maximum part count for a large enough file, so the part size + * has to grow with the file instead. + */ + public function testMultipartUploadPartSizeScalesWithMaxChunkCount(): void + { + $content = str_repeat('a', 20); + $resource = \Safe\fopen('php://memory', 'r+'); + \Safe\fwrite($resource, $content); + \Safe\rewind($resource); + + $s3TechnicalLimits = $this->prophesize(S3TechnicalLimitsInterface::class); + $s3TechnicalLimits->getMaxSinglePutSizeInBytes()->willReturn(5 * 1024 * 1024 * 1024); + // only 2 parts allowed for a 20 byte file -> part size has to become 10, not the configured 1 + $s3TechnicalLimits->getMaxChunkCount()->willReturn(2); + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->createMultipartUpload(Argument::any())->willReturn($this->buildCreateMultipartUploadOutput('multipart-upload-id')); + $s3Client->uploadPart(Argument::withEntry('PartNumber', 1))->shouldBeCalledOnce()->willReturn($this->buildUploadPartOutput('etag-1')); + $s3Client->uploadPart(Argument::withEntry('PartNumber', 2))->shouldBeCalledOnce()->willReturn($this->buildUploadPartOutput('etag-2')); + $s3Client->uploadPart(Argument::withEntry('PartNumber', 3))->shouldNotBeCalled(); + $s3Client->completeMultipartUpload(Argument::any())->shouldBeCalledOnce(); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3TechnicalLimits: $s3TechnicalLimits->reveal(), + multipartUploadThresholdInBytes: 5, + multipartUploadPartSizeInBytes: 1 + ); + + $this->assertSame(20, $s3Service->uploadFile($this->buildMultipartUploadFileOperation($resource, 20))); + } } diff --git a/tests/UnitTests/Service/S3TechnicalLimitsValidatorTest.php b/tests/UnitTests/Service/S3TechnicalLimitsValidatorTest.php index 0c50881f..ac747bc6 100644 --- a/tests/UnitTests/Service/S3TechnicalLimitsValidatorTest.php +++ b/tests/UnitTests/Service/S3TechnicalLimitsValidatorTest.php @@ -28,6 +28,7 @@ private function buildValidator( int $technicalMinChunkSize = 5 * 1024 * 1024, int $technicalMaxChunkCount = 10_000, int $technicalMaxObjectSize = 5 * 1024 * 1024 * 1024 * 1024, + int $technicalMaxSinglePutSize = 5 * 1024 * 1024 * 1024, ?Server500LogicErrorExceptionFactory $server500LogicErrorExceptionFactory = null, ): S3TechnicalLimitsValidator { $emberNexusConfiguration = $this->prophesize(EmberNexusConfiguration::class); @@ -39,6 +40,7 @@ private function buildValidator( $s3TechnicalLimits->getMinChunkSizeInBytes()->willReturn($technicalMinChunkSize); $s3TechnicalLimits->getMaxChunkCount()->willReturn($technicalMaxChunkCount); $s3TechnicalLimits->getMaxObjectSizeInBytes()->willReturn($technicalMaxObjectSize); + $s3TechnicalLimits->getMaxSinglePutSizeInBytes()->willReturn($technicalMaxSinglePutSize); return new S3TechnicalLimitsValidator( $emberNexusConfiguration->reveal(), diff --git a/tests/UnitTests/Service/TtyDetectorServiceTest.php b/tests/UnitTests/Service/TtyDetectorServiceTest.php new file mode 100644 index 00000000..99bb9088 --- /dev/null +++ b/tests/UnitTests/Service/TtyDetectorServiceTest.php @@ -0,0 +1,28 @@ +assertIsBool($service->isInteractive()); + } +} diff --git a/tests/UnitTests/Service/UploadAccessServiceTest.php b/tests/UnitTests/Service/UploadAccessServiceTest.php new file mode 100644 index 00000000..b4c3a0f5 --- /dev/null +++ b/tests/UnitTests/Service/UploadAccessServiceTest.php @@ -0,0 +1,132 @@ +upload = $this->prophesize(UploadInterface::class); + $this->upload->getId()->willReturn(Uuid::fromString(self::UPLOAD_ID)); + $this->upload->getUploadOwner()->willReturn($isOwner ? $userId : Uuid::uuid4()); + $this->upload->getUploadTarget()->willReturn($targetId); + $this->upload->getExpires()->willReturn(new DateTime($expires)); + + $this->uploadElement = $this->prophesize(NodeElementInterface::class)->reveal(); + $elementManager = $this->prophesize(ElementManager::class); + $elementManager->getElementOrFail(Argument::any())->willReturn($this->uploadElement); + $this->uploadFactory = $this->prophesize(UploadFactory::class); + $this->uploadFactory->createUploadFromElement($this->uploadElement)->willReturn($this->upload->reveal()); + + $authProvider = $this->prophesize(AuthProvider::class); + $authProvider->getUserId()->willReturn($userId); + $this->accessChecker = $this->prophesize(AccessChecker::class); + $this->accessChecker->hasAccessToElement($userId, $targetId, AccessType::UPDATE)->willReturn($hasAccess); + + $this->uploadCancellationService = $this->prophesize(UploadCancellationService::class); + $this->uploadConsistencyService = $this->prophesize(UploadConsistencyService::class); + + $notFound = $this->prophesize(Client404NotFoundExceptionFactory::class); + $notFound->createFromTemplate()->will(fn () => new Client404NotFoundException('not found')); + $gone = $this->prophesize(Client410GoneExceptionFactory::class); + $gone->createFromTemplate()->will(fn () => new Client410GoneException('gone')); + + return new UploadAccessService( + $authProvider->reveal(), + $this->accessChecker->reveal(), + $elementManager->reveal(), + $this->uploadFactory->reveal(), + $this->uploadCancellationService->reveal(), + $this->uploadConsistencyService->reveal(), + $notFound->reveal(), + $gone->reveal(), + ); + } + + public function testOwnerWithAccessGetsConsistentUpload(): void + { + $service = $this->createService(); + $this->uploadConsistencyService->assertConsistent($this->uploadElement, $this->upload->reveal())->shouldBeCalledOnce(); + + $this->assertSame($this->upload->reveal(), $service->loadAuthorizedUpload(self::UPLOAD_ID)); + } + + public function testElementWhichIsNoUploadIsNotFound(): void + { + $service = $this->createService(); + $this->uploadFactory->createUploadFromElement($this->uploadElement)->willThrow(new Exception('no upload')); + + $this->expectException(Client404NotFoundException::class); + $service->loadAuthorizedUpload(self::UPLOAD_ID); + } + + public function testUploadOfSomebodyElseIsNotFound(): void + { + $service = $this->createService(isOwner: false); + $this->accessChecker->hasAccessToElement(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client404NotFoundException::class); + $service->loadAuthorizedUpload(self::UPLOAD_ID); + } + + public function testLostAccessCancelsUploadAndIsNotFound(): void + { + $service = $this->createService(hasAccess: false); + $this->uploadCancellationService->cancelUpload($this->upload->reveal())->shouldBeCalledOnce(); + $this->uploadConsistencyService->assertConsistent(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client404NotFoundException::class); + $service->loadAuthorizedUpload(self::UPLOAD_ID); + } + + public function testExpiredUploadIsGone(): void + { + $service = $this->createService('-1 hour'); + $this->uploadConsistencyService->assertConsistent(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client410GoneException::class); + $service->loadAuthorizedUpload(self::UPLOAD_ID); + } +} diff --git a/tests/UnitTests/Service/UploadAppendServiceTest.php b/tests/UnitTests/Service/UploadAppendServiceTest.php new file mode 100644 index 00000000..e7a4e813 --- /dev/null +++ b/tests/UnitTests/Service/UploadAppendServiceTest.php @@ -0,0 +1,329 @@ +upload = $this->prophesize(UploadInterface::class); + $this->upload->getId()->willReturn(Uuid::fromString(self::UPLOAD_ID)); + $this->upload->getUploadOffset()->willReturn(200); + $this->upload->getUploadLength()->willReturn(1000); + $this->upload->getHashState()->willReturn($hashState); + $this->upload->getChunkIds()->willReturn($chunkIds); + $this->upload->getAlreadyUploadedChunks()->willReturn(count($chunkIds)); + $this->upload->getUploadTarget()->willReturn($targetId); + $this->upload->targetHadFileAtCreation()->willReturn(false); + + $this->targetElement = $this->prophesize(NodeElementInterface::class); + $this->elementManager = $this->prophesize(ElementManager::class); + $this->elementManager->getElementOrFail($targetId)->willReturn($this->targetElement->reveal()); + $this->elementService = $this->prophesize(ElementService::class); + $this->elementService->hasFile($this->targetElement->reveal())->willReturn($targetHasFile); + + $this->nextUpload = $this->prophesize(UploadInterface::class); + $this->completedUpload = $this->prophesize(UploadInterface::class); + + $this->resource = fopen('php://memory', 'r+'); + fwrite($this->resource, str_repeat('a', $bodyLength)); + rewind($this->resource); + + $this->partialUploadRequest = $this->prophesize(PartialUploadRequestInterface::class); + $this->partialUploadRequest->getUploadOffset()->willReturn($requestOffset); + $this->partialUploadRequest->getContentLength()->willReturn($declaredLength); + $this->partialUploadRequest->isUploadComplete()->willReturn($isComplete); + $this->partialUploadRequest->getContent()->willReturn($this->resource); + + $this->uploadFactory = $this->prophesize(UploadFactory::class); + $this->uploadFactory->addNewChunkToUpload(Argument::cetera())->willReturn($this->nextUpload->reveal()); + $this->uploadFactory->markUploadAsComplete(Argument::any())->willReturn($this->completedUpload->reveal()); + + $this->uploadService = $this->prophesize(UploadService::class); + $this->uploadService->appendChunkIfOffsetMatches(Argument::cetera())->willReturn($offsetMatches); + $this->uploadFinalizationService = $this->prophesize(UploadFinalizationService::class); + $this->uploadChunkValidator = $this->prophesize(UploadChunkValidator::class); + + $this->s3Service = $this->prophesize(S3Service::class); + $this->s3Service->uploadFileChunk(Argument::any())->willReturn($bodyLength); + $this->s3Service->deleteFile(Argument::any())->will(function () {}); + $this->s3OperationFactory = $this->prophesize(S3OperationFactory::class); + $this->s3OperationFactory + ->createUploadFileChunkOperationFromPartialUploadRequest(Argument::cetera()) + ->willReturn($this->prophesize(UploadFileChunkOperationInterface::class)->reveal()); + $this->chunkFileOperation = $this->prophesize(FileOperationInterface::class)->reveal(); + $this->s3OperationFactory->createFileOperationFromUpload(Argument::cetera())->willReturn($this->chunkFileOperation); + + $this->incrementalHashService = $this->prophesize(IncrementalHashService::class); + $this->incrementalHashService->createContext(Argument::any())->willReturn(hash_init('sha256')); + $this->incrementalHashService->unserializeContextFromStorage(Argument::any())->willReturn(hash_init('sha256')); + $this->incrementalHashService->updateFromResource(Argument::cetera())->will(function () {}); + $this->incrementalHashService->serializeContextForStorage(Argument::any())->willReturn('next-state'); + + $fileService = $this->prophesize(FileService::class); + $fileService->generateUploadChunkId()->willReturn('aaaaaaaaaaaaaaaa'); + + $client409ConflictExceptionFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $client409ConflictExceptionFactory->createFromDetail(Argument::cetera())->will( + fn ($args) => new Client409ConflictException('type', detail: $args[0]) + ); + + return new UploadAppendService( + $this->uploadFactory->reveal(), + $this->uploadService->reveal(), + $this->uploadFinalizationService->reveal(), + $this->uploadChunkValidator->reveal(), + $this->s3OperationFactory->reveal(), + $this->s3Service->reveal(), + $this->incrementalHashService->reveal(), + $fileService->reveal(), + $client409ConflictExceptionFactory->reveal(), + $this->elementManager->reveal(), + $this->elementService->reveal(), + ); + } + + public function testChunkIsRejectedWhenTargetAlreadyHasAFile(): void + { + $service = $this->createService(500, false, 500, targetHasFile: true); + $this->s3Service->uploadFileChunk(Argument::any())->shouldNotBeCalled(); + + try { + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + $this->fail('Expected exception was not thrown.'); + } catch (Client409ConflictException $exception) { + $this->assertStringContainsString('already has an associated file', $exception->getDetail()); + } + } + + public function testChunkIsAcceptedWhenTargetAlreadyHadAFileAtUploadCreation(): void + { + $service = $this->createService(500, false, 500, targetHasFile: true); + $this->upload->targetHadFileAtCreation()->willReturn(true); + + $this->assertSame($this->nextUpload->reveal(), $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null)); + } + + public function testOffsetOfRequestHasToMatchTheUpload(): void + { + $service = $this->createService(500, false, 500, requestOffset: 100); + $this->s3Service->uploadFileChunk(Argument::any())->shouldNotBeCalled(); + + try { + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + $this->fail('Expected exception was not thrown.'); + } catch (Client409ConflictException $exception) { + $this->assertSame('Offset from request does not match offset of resource.', $exception->getDetail()); + } + } + + public function testDeclaredEmptyIntermediateChunkIsANoOp(): void + { + $service = $this->createService(0, false, 0); + $this->s3Service->uploadFileChunk(Argument::any())->shouldNotBeCalled(); + $this->uploadService->appendChunkIfOffsetMatches(Argument::cetera())->shouldNotBeCalled(); + + $this->assertSame($this->upload->reveal(), $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null)); + $this->assertFalse(is_resource($this->resource)); + } + + public function testUndeclaredEmptyIntermediateChunkIsANoOp(): void + { + $service = $this->createService(null, false, 0); + $this->s3Service->uploadFileChunk(Argument::any())->shouldNotBeCalled(); + + $this->assertSame($this->upload->reveal(), $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null)); + } + + public function testDeclaredInvalidChunkIsRejectedBeforeAnythingIsStored(): void + { + $service = $this->createService(50, false, 50); + $this->uploadChunkValidator->assertValidChunk(50, false, 200, 1000)->willThrow(new Client400BadContentException('type', detail: 'too short'))->shouldBeCalledOnce(); + $this->s3Service->uploadFileChunk(Argument::any())->shouldNotBeCalled(); + + $this->expectException(Client400BadContentException::class); + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + } + + public function testIntermediateChunkContinuesTheStoredHashAndIsAppended(): void + { + $service = $this->createService(500, false, 500); + $this->incrementalHashService->unserializeContextFromStorage('stored-state')->willReturn(hash_init('sha256'))->shouldBeCalledOnce(); + $this->incrementalHashService->createContext(Argument::any())->shouldNotBeCalled(); + $this->uploadChunkValidator->assertValidChunk(500, false, 200, 1000)->shouldBeCalledTimes(2); + $this->uploadFactory->addNewChunkToUpload($this->upload->reveal(), 500, 'aaaaaaaaaaaaaaaa', 'next-state')->willReturn($this->nextUpload->reveal())->shouldBeCalledOnce(); + $this->uploadService->appendChunkIfOffsetMatches($this->upload->reveal(), $this->nextUpload->reveal())->willReturn(true)->shouldBeCalledOnce(); + $this->uploadFinalizationService->finalize(Argument::cetera())->shouldNotBeCalled(); + + $this->assertSame($this->nextUpload->reveal(), $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null)); + $this->assertFalse(is_resource($this->resource)); + } + + public function testFirstIntermediateChunkStartsANewHash(): void + { + $service = $this->createService(500, false, 500, hashState: null); + $this->incrementalHashService->createContext('sha256')->willReturn(hash_init('sha256'))->shouldBeCalledOnce(); + $this->incrementalHashService->unserializeContextFromStorage(Argument::any())->shouldNotBeCalled(); + + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + } + + public function testChunkWithoutDeclaredLengthIsValidatedWithItsRealLength(): void + { + $service = $this->createService(null, false, 500); + $this->uploadChunkValidator->assertValidChunk(500, false, 200, 1000)->shouldBeCalledOnce(); + + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + } + + public function testCompletingChunkWithDataIsFinalizedWithTheHashContext(): void + { + $service = $this->createService(500, true, 500); + $this->uploadFactory->addNewChunkToUpload($this->upload->reveal(), 500, 'aaaaaaaaaaaaaaaa', null)->willReturn($this->nextUpload->reveal())->shouldBeCalledOnce(); + $this->uploadFactory->markUploadAsComplete($this->nextUpload->reveal())->willReturn($this->completedUpload->reveal())->shouldBeCalledOnce(); + $this->uploadService->appendChunkIfOffsetMatches($this->upload->reveal(), $this->completedUpload->reveal())->willReturn(true)->shouldBeCalledOnce(); + $this->uploadFinalizationService->finalize($this->completedUpload->reveal(), Argument::type(HashContext::class), 'sha-256=:abc=:')->shouldBeCalledOnce(); + + $this->assertSame($this->completedUpload->reveal(), $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), 'sha-256=:abc=:')); + } + + public function testCompletingRequestWithoutBodyCompletesAnUploadWithChunksWithoutStoringAnything(): void + { + $service = $this->createService(0, true, 0); + $this->s3Service->uploadFileChunk(Argument::any())->shouldNotBeCalled(); + $this->uploadFactory->addNewChunkToUpload(Argument::cetera())->shouldNotBeCalled(); + $this->uploadFactory->markUploadAsComplete($this->upload->reveal())->willReturn($this->completedUpload->reveal())->shouldBeCalledOnce(); + $this->uploadChunkValidator->assertValidChunk(0, true, 200, 1000)->shouldBeCalledTimes(2); + $this->uploadService->appendChunkIfOffsetMatches($this->upload->reveal(), $this->completedUpload->reveal())->willReturn(true)->shouldBeCalledOnce(); + $this->uploadFinalizationService->finalize($this->completedUpload->reveal(), Argument::type(HashContext::class), null)->shouldBeCalledOnce(); + + $this->assertSame($this->completedUpload->reveal(), $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null)); + $this->assertFalse(is_resource($this->resource)); + } + + public function testCompletingRequestWithoutBodyStoresTheEmptyChunkIfTheUploadHasNoChunk(): void + { + $service = $this->createService(0, true, 0, chunkIds: [], hashState: null); + $this->s3Service->uploadFileChunk(Argument::any())->willReturn(0)->shouldBeCalledOnce(); + $this->uploadFactory->addNewChunkToUpload($this->upload->reveal(), 0, 'aaaaaaaaaaaaaaaa', null)->willReturn($this->nextUpload->reveal())->shouldBeCalledOnce(); + $this->uploadFactory->markUploadAsComplete($this->nextUpload->reveal())->willReturn($this->completedUpload->reveal()); + + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + } + + public function testChunkWhichIsRejectedAfterItWasStoredIsDeleted(): void + { + $service = $this->createService(null, false, 500); + // without declared length the chunk can only be validated once its real length is known + $this->uploadChunkValidator->assertValidChunk(500, false, 200, 1000)->willThrow(new Client400BadContentException('type', detail: 'real length is invalid'))->shouldBeCalledOnce(); + $this->s3OperationFactory->createFileOperationFromUpload($this->upload->reveal(), 2, 'aaaaaaaaaaaaaaaa')->willReturn($this->chunkFileOperation)->shouldBeCalledOnce(); + $this->s3Service->deleteFile($this->chunkFileOperation)->shouldBeCalledOnce(); + $this->uploadService->appendChunkIfOffsetMatches(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client400BadContentException::class); + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + } + + public function testConcurrentModificationIsAnsweredWithConflictAndDeletesOwnChunk(): void + { + $service = $this->createService(500, false, 500, offsetMatches: false); + $this->s3Service->deleteFile($this->chunkFileOperation)->shouldBeCalledOnce(); + $this->uploadFinalizationService->finalize(Argument::cetera())->shouldNotBeCalled(); + + try { + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + $this->fail('Expected exception was not thrown.'); + } catch (Client409ConflictException $exception) { + $this->assertStringContainsString('modified by another request', $exception->getDetail()); + } + } + + public function testConcurrentModificationOfACompletingRequestWithoutBodyDeletesNothing(): void + { + $service = $this->createService(0, true, 0, offsetMatches: false); + $this->s3Service->deleteFile(Argument::any())->shouldNotBeCalled(); + $this->uploadFinalizationService->finalize(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client409ConflictException::class); + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + } + + public function testFailedFinalizationIsNotHandledHere(): void + { + $service = $this->createService(500, true, 500); + $this->uploadFinalizationService->finalize(Argument::cetera())->willThrow(new RuntimeException('merge failed')); + // the chunk is part of the upload now, it must survive so that the completion can be retried + $this->s3Service->deleteFile(Argument::any())->shouldNotBeCalled(); + + $this->expectException(RuntimeException::class); + $service->append($this->upload->reveal(), $this->partialUploadRequest->reveal(), null); + } +} diff --git a/tests/UnitTests/Service/UploadBodyLimitServiceTest.php b/tests/UnitTests/Service/UploadBodyLimitServiceTest.php new file mode 100644 index 00000000..59634d2a --- /dev/null +++ b/tests/UnitTests/Service/UploadBodyLimitServiceTest.php @@ -0,0 +1,118 @@ +prophesize(EmberNexusConfiguration::class); + $configuration->getFileUploadMaxChunkSizeInBytes()->willReturn(10); + $factory = $this->prophesize(Client400BadContentExceptionFactory::class); + $factory->createFromDetail(Argument::any())->will(fn ($args) => new Client400BadContentException('type', detail: $args[0])); + + $timeoutFactory = $this->prophesize(Client408RequestTimeoutExceptionFactory::class); + $timeoutFactory->createFromIncompleteBody(Argument::cetera())->will(fn ($args) => new Client408RequestTimeoutException('type', detail: sprintf('%d of %d', $args[0], $args[1]))); + + return new UploadBodyLimitService($configuration->reveal(), $factory->reveal(), $timeoutFactory->reveal()); + } + + /** + * @return resource + */ + private function resource(string $content) + { + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $content); + rewind($resource); + + return $resource; + } + + public function testDeclaredLengthAtOrBelowLimitOrMissingIsAccepted(): void + { + $service = $this->buildService(); + $service->assertDeclaredLengthWithinLimit(null); + $service->assertDeclaredLengthWithinLimit(10); + $this->expectNotToPerformAssertions(); + } + + public function testDeclaredLengthAboveLimitIsRejected(): void + { + try { + $this->buildService()->assertDeclaredLengthWithinLimit(11); + } catch (Client400BadContentException $exception) { + $this->assertStringContainsString('at most 10 bytes long, got 11', $exception->getDetail()); + + return; + } + $this->fail('Expected rejection.'); + } + + public function testBoundContentReturnsRewoundCopyWithinLimit(): void + { + $bounded = $this->buildService()->boundContent($this->resource('0123456789'), null); + + $this->assertSame('0123456789', stream_get_contents($bounded)); + } + + public function testBoundContentRejectsOversizedBodyWithoutContentLength(): void + { + $this->expectException(Client400BadContentException::class); + $this->buildService()->boundContent($this->resource('01234567890'), null); + } + + public function testBoundContentRejectsDeclaredOversizeBeforeReading(): void + { + $resource = $this->resource('abc'); + + try { + $this->buildService()->boundContent($resource, 11); + $this->fail('Expected rejection.'); + } catch (Client400BadContentException) { + $this->assertSame('abc', stream_get_contents($resource)); + } + } + + public function testBoundContentAcceptsBodyMatchingDeclaredLength(): void + { + $bounded = $this->buildService()->boundContent($this->resource('0123456789'), 10); + + $this->assertSame('0123456789', stream_get_contents($bounded)); + } + + public function testBoundContentRejectsBodyShorterThanDeclaredLength(): void + { + // e.g. a body which was cut off by the request timeout of the web server + try { + $this->buildService()->boundContent($this->resource('01234'), 10); + $this->fail('Expected request timeout.'); + } catch (Client408RequestTimeoutException $exception) { + $this->assertSame('5 of 10', $exception->getDetail()); + } + } + + public function testBoundContentRejectsBodyLongerThanDeclaredLength(): void + { + $this->expectException(Client400BadContentException::class); + $this->buildService()->boundContent($this->resource('0123456789'), 5); + } +} diff --git a/tests/UnitTests/Service/UploadCancellationServiceTest.php b/tests/UnitTests/Service/UploadCancellationServiceTest.php new file mode 100644 index 00000000..bbd6ff0d --- /dev/null +++ b/tests/UnitTests/Service/UploadCancellationServiceTest.php @@ -0,0 +1,164 @@ +accessChecker = $this->prophesize(AccessChecker::class); + $this->elementManager = $this->prophesize(ElementManager::class); + $this->elementManager->flush()->willReturn($this->elementManager->reveal()); + $this->uploadFactory = $this->prophesize(UploadFactory::class); + $this->uploadService = $this->prophesize(UploadService::class); + $this->uploadLockService = $this->prophesize(UploadLockService::class); + $this->uploadLockService->acquire(Argument::any())->willReturn('token'); + $this->uploadLockService->release(Argument::cetera())->will(function () {}); + $this->cypherEntityManager = $this->prophesize(CypherEntityManager::class); + + $this->service = new UploadCancellationService( + $this->accessChecker->reveal(), + $this->elementManager->reveal(), + $this->uploadFactory->reveal(), + $this->uploadService->reveal(), + $this->uploadLockService->reveal(), + $this->cypherEntityManager->reveal(), + $this->prophesize(Server500LogicErrorExceptionFactory::class)->reveal(), + ); + } + + private function buildUpload(UuidInterface $id): ObjectProphecy + { + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->willReturn($id); + $upload->getUploadOwner()->willReturn(UuidV4::fromString('11111111-1111-4111-8111-111111111111')); + $upload->getUploadTarget()->willReturn(UuidV4::fromString('22222222-2222-4222-8222-222222222222')); + + return $upload; + } + + /** + * @param string[] $uploadIds + */ + private function configureUploadIds(array $uploadIds): void + { + $null = null; + $result = new SummarizedResult($null, array_map(fn (string $id) => new CypherMap(['u.id' => $id]), $uploadIds)); + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::cetera())->willReturn($result); + $this->cypherEntityManager->getClient()->willReturn($client->reveal()); + } + + public function testUploadsWithoutAccessAreCancelledAndOthersKept(): void + { + $lostId = UuidV4::fromString('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + $keptId = UuidV4::fromString('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); + $this->configureUploadIds([$lostId->toString(), $keptId->toString()]); + + $lostUpload = $this->buildUpload($lostId); + $keptUpload = $this->buildUpload($keptId); + $lostElement = (new NodeElement())->setId($lostId)->setLabel('Upload'); + $keptElement = (new NodeElement())->setId($keptId)->setLabel('Upload'); + $this->elementManager->getElement($lostId)->willReturn($lostElement); + $this->elementManager->getElement($keptId)->willReturn($keptElement); + $this->uploadFactory->createUploadFromElement($lostElement)->willReturn($lostUpload->reveal()); + $this->uploadFactory->createUploadFromElement($keptElement)->willReturn($keptUpload->reveal()); + $owner = UuidV4::fromString('11111111-1111-4111-8111-111111111111'); + $target = UuidV4::fromString('22222222-2222-4222-8222-222222222222'); + $this->accessChecker->hasAccessToElement($owner, $target, AccessType::UPDATE)->willReturn(false, true); + + $this->uploadService->deleteUploadAndChunks($lostUpload->reveal())->shouldBeCalledOnce(); + $this->uploadService->deleteUploadAndChunks($keptUpload->reveal())->shouldNotBeCalled(); + + $this->assertSame(1, $this->service->cancelUploadsWithoutAccess()); + } + + public function testMissingAndMalformedUploadElementsAreSkipped(): void + { + $missingId = UuidV4::fromString('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + $malformedId = UuidV4::fromString('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); + $this->configureUploadIds([$missingId->toString(), $malformedId->toString()]); + $malformedElement = (new NodeElement())->setId($malformedId)->setLabel('Upload'); + $this->elementManager->getElement($missingId)->willReturn(null); + $this->elementManager->getElement($malformedId)->willReturn($malformedElement); + $this->uploadFactory->createUploadFromElement($malformedElement)->willThrow(new Exception('malformed')); + $this->uploadService->deleteUploadAndChunks(Argument::any())->shouldNotBeCalled(); + + $this->assertSame(0, $this->service->cancelUploadsWithoutAccess()); + } + + public function testLockedUploadIsNotCancelled(): void + { + $id = UuidV4::fromString('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + $upload = $this->buildUpload($id); + $this->uploadLockService->acquire($id)->willReturn(null); + $this->uploadService->deleteUploadAndChunks(Argument::any())->shouldNotBeCalled(); + $this->uploadLockService->release(Argument::cetera())->shouldNotBeCalled(); + + $this->assertFalse($this->service->cancelUpload($upload->reveal())); + } + + public function testCancelUploadReloadsUploadUnderLockAndDeletesChunksAndNode(): void + { + $id = UuidV4::fromString('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + $upload = $this->buildUpload($id); + $freshUpload = $this->buildUpload($id); + $element = (new NodeElement())->setId($id)->setLabel('Upload'); + $this->elementManager->getElement($id)->willReturn($element); + $this->uploadFactory->createUploadFromElement($element)->willReturn($freshUpload->reveal()); + $this->uploadService->deleteUploadAndChunks($freshUpload->reveal())->shouldBeCalledOnce(); + $this->elementManager->flush()->shouldBeCalledOnce()->willReturn($this->elementManager->reveal()); + $this->uploadLockService->release($id, 'token')->shouldBeCalledOnce(); + + $this->assertTrue($this->service->cancelUpload($upload->reveal())); + } + + public function testCancelUploadOfAlreadyDeletedUploadSucceedsWithoutDeleting(): void + { + $id = UuidV4::fromString('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + $upload = $this->buildUpload($id); + $this->elementManager->getElement($id)->willReturn(null); + $this->uploadService->deleteUploadAndChunks(Argument::any())->shouldNotBeCalled(); + $this->uploadLockService->release($id, 'token')->shouldBeCalledOnce(); + + $this->assertTrue($this->service->cancelUpload($upload->reveal())); + } +} diff --git a/tests/UnitTests/Service/UploadChunkValidatorTest.php b/tests/UnitTests/Service/UploadChunkValidatorTest.php new file mode 100644 index 00000000..bfc01d41 --- /dev/null +++ b/tests/UnitTests/Service/UploadChunkValidatorTest.php @@ -0,0 +1,155 @@ +prophesize(EmberNexusConfiguration::class); + $configuration->getFileUploadMinChunkSizeInBytes()->willReturn(self::MIN_CHUNK_SIZE); + $configuration->getFileUploadMaxChunkSizeInBytes()->willReturn(self::MAX_CHUNK_SIZE); + + $this->fileSizeLimitService = $this->prophesize(FileSizeLimitService::class); + + $client400BadContentExceptionFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $client400BadContentExceptionFactory->createFromDetail(Argument::any())->will( + fn ($args) => new Client400BadContentException('type', detail: $args[0]) + ); + $client409ConflictExceptionFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $client409ConflictExceptionFactory->createFromDetail(Argument::cetera())->will( + fn ($args) => new Client409ConflictException('type', detail: $args[0]) + ); + + return new UploadChunkValidator( + $configuration->reveal(), + $this->fileSizeLimitService->reveal(), + $client400BadContentExceptionFactory->reveal(), + $client409ConflictExceptionFactory->reveal(), + ); + } + + /** + * @return array + */ + public static function validChunkProvider(): array + { + return [ + 'intermediate chunk at minimum' => [100, false, 0, null], + 'intermediate chunk at maximum' => [1000, false, 0, null], + 'final chunk below minimum' => [1, true, 500, null], + 'empty final chunk' => [0, true, 500, null], + 'final chunk at maximum' => [1000, true, 0, null], + 'intermediate chunk within declared length' => [100, false, 100, 300], + 'intermediate chunk exactly reaching declared length' => [100, false, 200, 300], + 'final chunk matching declared length' => [50, true, 250, 300], + ]; + } + + #[DataProvider('validChunkProvider')] + public function testValidChunksAreAccepted(int $chunkLength, bool $isFinalChunk, int $uploadOffset, ?int $uploadLength): void + { + $validator = $this->createValidator(); + $this->fileSizeLimitService->assertWithinMaxFileSize($uploadOffset + $chunkLength)->shouldBeCalledOnce(); + + $validator->assertValidChunk($chunkLength, $isFinalChunk, $uploadOffset, $uploadLength); + $this->addToAssertionCount(1); + } + + /** + * @return array + */ + public static function invalidChunkProvider(): array + { + return [ + 'intermediate chunk below minimum' => [99, false, 0, null, Client400BadContentException::class, 'at least 100 bytes long, got 99'], + 'empty intermediate chunk' => [0, false, 0, null, Client400BadContentException::class, 'at least 100 bytes long, got 0'], + 'intermediate chunk above maximum' => [1001, false, 0, null, Client400BadContentException::class, 'at most 1000 bytes long, got 1001'], + 'final chunk above maximum' => [1001, true, 0, null, Client400BadContentException::class, 'at most 1000 bytes long, got 1001'], + 'chunk exceeding declared length' => [200, false, 200, 300, Client409ConflictException::class, 'exceeds defined upload length'], + 'final chunk exceeding declared length' => [50, true, 260, 300, Client409ConflictException::class, 'exceeds defined upload length'], + 'final chunk below declared length' => [50, true, 200, 300, Client409ConflictException::class, 'has 250 bytes, but the defined upload length is 300 bytes'], + ]; + } + + /** + * @param class-string $exceptionClass + */ + #[DataProvider('invalidChunkProvider')] + public function testInvalidChunksAreRejected(int $chunkLength, bool $isFinalChunk, int $uploadOffset, ?int $uploadLength, string $exceptionClass, string $messagePart): void + { + $validator = $this->createValidator(); + $this->fileSizeLimitService->assertWithinMaxFileSize(Argument::any())->will(function () {}); + + try { + $validator->assertValidChunk($chunkLength, $isFinalChunk, $uploadOffset, $uploadLength); + $this->fail('Expected exception was not thrown.'); + } catch (Client400BadContentException|Client409ConflictException $exception) { + $this->assertInstanceOf($exceptionClass, $exception); + $this->assertStringContainsString($messagePart, $exception->getDetail()); + } + } + + public function testRunningTotalAboveMaxFileSizeIsRejected(): void + { + $validator = $this->createValidator(); + $this->fileSizeLimitService->assertWithinMaxFileSize(1500)->willThrow(new Client400BadContentException('type', detail: 'too big')); + + $this->expectException(Client400BadContentException::class); + $validator->assertChunkSize(500, false, 1000); + } + + public function testChunkSizeCheckIgnoresDeclaredLength(): void + { + $validator = $this->createValidator(); + $this->fileSizeLimitService->assertWithinMaxFileSize(Argument::any())->will(function () {}); + + // only assertWithinDeclaredLength() knows about the declared length + $validator->assertChunkSize(500, false, 0); + $this->addToAssertionCount(1); + } + + public function testDeclaredLengthIsOptional(): void + { + $validator = $this->createValidator(); + + $validator->assertWithinDeclaredLength(999999, true, 999999, null); + $this->addToAssertionCount(1); + } + + public function testDeclaredLengthCheckIgnoresChunkSizeLimits(): void + { + $validator = $this->createValidator(); + + // 50 bytes intermediate chunk is below the minimum, which is not the concern of this check + $validator->assertWithinDeclaredLength(50, false, 0, 300); + $this->addToAssertionCount(1); + } +} diff --git a/tests/UnitTests/Service/UploadConsistencyServiceTest.php b/tests/UnitTests/Service/UploadConsistencyServiceTest.php new file mode 100644 index 00000000..bb5239ef --- /dev/null +++ b/tests/UnitTests/Service/UploadConsistencyServiceTest.php @@ -0,0 +1,156 @@ +elementManager = $this->prophesize(ElementManager::class); + $this->uploadService = $this->prophesize(UploadService::class); + $this->s3Service = $this->prophesize(S3Service::class); + $this->logger = $this->prophesize(LoggerInterface::class); + + $fileOperationFactory = $this->prophesize(S3OperationFactory::class); + $fileOperationFactory->createFileOperationFromUpload(Argument::cetera())->willReturn($this->prophesize(FileOperationInterface::class)->reveal()); + $conflictFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $conflictFactory->createFromDetail(Argument::type('string'))->will( + fn (array $args) => new Client409ConflictException('conflict', detail: $args[0]) + ); + + $this->service = new UploadConsistencyService( + $this->elementManager->reveal(), + $this->uploadService->reveal(), + $this->s3Service->reveal(), + $fileOperationFactory->reveal(), + $conflictFactory->reveal(), + $this->logger->reveal(), + ); + } + + /** + * @param list $chunkIds + */ + private function createUpload(array $chunkIds, int $offset): UploadInterface + { + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->willReturn(Uuid::uuid4()); + $upload->getChunkIds()->willReturn($chunkIds); + $upload->getLastChunkId()->willReturn([] === $chunkIds ? null : $chunkIds[array_key_last($chunkIds)]); + $upload->getUploadOffset()->willReturn($offset); + + return $upload->reveal(); + } + + private function createElement(mixed $lastChunkId, bool $hasProperty = true): NodeElementInterface + { + $element = $this->prophesize(NodeElementInterface::class); + $element->hasProperty('lastChunkId')->willReturn($hasProperty); + $element->getProperty('lastChunkId')->willReturn($lastChunkId); + + return $element->reveal(); + } + + private function expectNothingToBeDeleted(): void + { + $this->uploadService->deleteUploadAndChunks(Argument::any())->shouldNotBeCalled(); + $this->s3Service->deleteFile(Argument::any())->shouldNotBeCalled(); + $this->elementManager->flush()->shouldNotBeCalled(); + } + + public function testUploadWithoutChunksIsConsistent(): void + { + $this->expectNothingToBeDeleted(); + + $this->service->assertConsistent($this->createElement(null, false), $this->createUpload([], 0)); + $this->service->assertConsistent($this->createElement(null), $this->createUpload([], 0)); + } + + public function testUploadWithMatchingLastChunkIdIsConsistent(): void + { + $this->expectNothingToBeDeleted(); + + $this->service->assertConsistent($this->createElement('bbbb'), $this->createUpload(['aaaa', 'bbbb'], 10)); + } + + public function testUploadWithLastChunkIdMissingInListIsDeletedIncludingTheUnlistedChunk(): void + { + $upload = $this->createUpload(['aaaa'], 10); + $this->s3Service->deleteFile(Argument::any())->shouldBeCalledOnce(); + $this->uploadService->deleteUploadAndChunks($upload)->shouldBeCalledOnce(); + $this->elementManager->flush()->shouldBeCalledOnce()->willReturn($this->elementManager->reveal()); + $this->logger->error(Argument::type('string'))->shouldBeCalledOnce(); + + try { + $this->service->assertConsistent($this->createElement('bbbb'), $upload); + $this->fail('Expected inconsistent upload to be rejected.'); + } catch (Client409ConflictException $exception) { + $this->assertStringContainsString('restart the upload', $exception->getDetail()); + } + } + + public function testUploadWithListButWithoutLastChunkIdInGraphIsDeleted(): void + { + $upload = $this->createUpload(['aaaa'], 10); + $this->s3Service->deleteFile(Argument::any())->shouldNotBeCalled(); + $this->uploadService->deleteUploadAndChunks($upload)->shouldBeCalledOnce(); + $this->elementManager->flush()->shouldBeCalledOnce()->willReturn($this->elementManager->reveal()); + $this->logger->error(Argument::type('string'))->shouldBeCalledOnce(); + + $this->expectException(Client409ConflictException::class); + $this->service->assertConsistent($this->createElement(null, false), $upload); + } + + public function testUploadWithOffsetButWithoutChunksIsDeleted(): void + { + $upload = $this->createUpload([], 10); + $this->uploadService->deleteUploadAndChunks($upload)->shouldBeCalledOnce(); + $this->elementManager->flush()->shouldBeCalledOnce()->willReturn($this->elementManager->reveal()); + $this->logger->error(Argument::type('string'))->shouldBeCalledOnce(); + + $this->expectException(Client409ConflictException::class); + $this->service->assertConsistent($this->createElement(null), $upload); + } + + public function testUploadWithInvalidLastChunkIdIsDeleted(): void + { + $upload = $this->createUpload(['aaaa'], 10); + $this->uploadService->deleteUploadAndChunks($upload)->shouldBeCalledOnce(); + $this->elementManager->flush()->shouldBeCalledOnce()->willReturn($this->elementManager->reveal()); + $this->logger->error(Argument::type('string'))->shouldBeCalledOnce(); + + $this->expectException(Client409ConflictException::class); + $this->service->assertConsistent($this->createElement(['not', 'a', 'string']), $upload); + } +} diff --git a/tests/UnitTests/Service/UploadCreationServiceTest.php b/tests/UnitTests/Service/UploadCreationServiceTest.php new file mode 100644 index 00000000..65973b7e --- /dev/null +++ b/tests/UnitTests/Service/UploadCreationServiceTest.php @@ -0,0 +1,537 @@ + */ + private ObjectProphecy $element; + /** @var ObjectProphecy */ + private ObjectProphecy $resumableUploadRequest; + /** @var ObjectProphecy */ + private ObjectProphecy $s3Service; + /** @var ObjectProphecy */ + private ObjectProphecy $elementManager; + /** @var ObjectProphecy */ + private ObjectProphecy $uploadService; + /** @var ObjectProphecy */ + private ObjectProphecy $eventDispatcher; + /** @var ObjectProphecy */ + private ObjectProphecy $elementFileDeletionService; + /** @var ObjectProphecy */ + private ObjectProphecy $elementService; + private UploadCreationService $service; + /** @var ObjectProphecy */ + private ObjectProphecy $conflictFactory; + + protected function setUp(): void + { + $this->elementId = Uuid::uuid4(); + $this->element = $this->prophesize(NodeElementInterface::class); + $this->resumableUploadRequest = $this->prophesize(ResumableUploadRequestInterface::class); + $this->resumableUploadRequest->getElementId()->willReturn($this->elementId); + $this->resumableUploadRequest->getExtension()->willReturn('bin'); + $this->resumableUploadRequest->getUploadLength()->willReturn(null); + $this->resumableUploadRequest->getContentLength()->willReturn(null); + + $this->elementManager = $this->prophesize(ElementManager::class); + $this->elementManager->getElementOrFail(Argument::any())->willReturn($this->element->reveal()); + $this->s3Service = $this->prophesize(S3Service::class); + $this->uploadService = $this->prophesize(UploadService::class); + $this->elementManager->merge(Argument::any())->willReturn($this->elementManager->reveal()); + $this->elementManager->flush()->willReturn($this->elementManager->reveal()); + $this->elementFileDeletionService = $this->prophesize(ElementFileDeletionService::class); + $this->elementFileDeletionService->deletePreviousFileAfterReplace(Argument::any())->will(function () {}); + $this->elementService = $this->prophesize(ElementService::class); + $this->elementService->hasFile(Argument::any())->willReturn(false); + $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->eventDispatcher->dispatch(Argument::any())->will(fn (array $args) => $args[0]); + + $configuration = $this->prophesize(EmberNexusConfiguration::class); + $configuration->getFileMaxFileSizeInBytes()->willReturn(self::MAX_FILE_SIZE); + $configuration->getFileUploadMinChunkSizeInBytes()->willReturn(self::MIN_CHUNK_SIZE); + $configuration->getFileUploadMaxChunkSizeInBytes()->willReturn(self::MAX_CHUNK_SIZE); + $configuration->getFileUploadExpiresInSecondsAfterFirstRequest()->willReturn(3600); + $configuration = $configuration->reveal(); + + $this->conflictFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $this->conflictFactory + ->createFromDetail(Argument::cetera()) + ->will(fn (array $args) => new Client409ConflictException('conflict', detail: $args[0])); + + $badContentFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $badContentFactory + ->createFromDetail(Argument::type('string')) + ->will(fn (array $args) => new Client400BadContentException('bad-content', detail: $args[0])); + $badContentFactory = $badContentFactory->reveal(); + + $fileService = $this->prophesize(FileService::class); + $fileService->generateUploadChunkId()->willReturn('0123456789abcdef'); + + $authProvider = $this->prophesize(AuthProvider::class); + $authProvider->getUserId()->willReturn(Uuid::uuid4()); + + $requestFactory = $this->prophesize(ResumableUploadRequestFactory::class); + $requestFactory + ->createResumableUploadRequestFromRequest(Argument::any(), Argument::any()) + ->willReturn($this->resumableUploadRequest->reveal()); + + $s3OperationFactory = $this->prophesize(S3OperationFactory::class); + $uploadFileOperation = $this->prophesize(UploadFileOperationInterface::class); + $uploadFileOperation->getContentLength()->willReturn(5); + $uploadFileOperation->getMimeType()->willReturn('application/octet-stream'); + $s3OperationFactory + ->createUploadFileOperationFromResumableUploadRequest(Argument::any()) + ->willReturn($uploadFileOperation->reveal()); + $s3OperationFactory + ->createUploadFileChunkOperationFromResumableUploadRequest(Argument::cetera()) + ->willReturn($this->prophesize(UploadFileChunkOperationInterface::class)->reveal()); + + $urlGenerator = $this->prophesize(UrlGeneratorInterface::class); + $urlGenerator->generate(Argument::cetera())->willReturn('/upload/x'); + + $fileSizeLimitService = new FileSizeLimitService($configuration, $badContentFactory); + $this->service = new UploadCreationService( + $authProvider->reveal(), + $configuration, + $this->s3Service->reveal(), + new IncrementalHashService($this->prophesize(Client409ConflictExceptionFactory::class)->reveal()), + new DigestService(), + $this->elementManager->reveal(), + $s3OperationFactory->reveal(), + $requestFactory->reveal(), + $this->eventDispatcher->reveal(), + new NoContentResponseFactory($configuration), + $urlGenerator->reveal(), + $this->uploadService->reveal(), + $fileSizeLimitService, + new UploadBodyLimitService($configuration, $badContentFactory, new Client408RequestTimeoutExceptionFactory($urlGenerator->reveal())), + $fileService->reveal(), + $badContentFactory, + new UploadChunkValidator($configuration, $fileSizeLimitService, $badContentFactory, $this->conflictFactory->reveal()), + $this->elementFileDeletionService->reveal(), + $this->elementService->reveal(), + ); + } + + /** + * @return resource + */ + private function contentResource(string $content = 'hello') + { + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $content); + rewind($resource); + + return $resource; + } + + private function configureDirectUpload(?string $content = 'hello'): void + { + $this->resumableUploadRequest->isUploadComplete()->willReturn(true); + $this->resumableUploadRequest->getContent()->willReturn($this->contentResource($content)); + } + + private function configureResumableUpload(?int $uploadLength, ?int $contentLength, bool $isComplete = false): void + { + $this->resumableUploadRequest->isUploadComplete()->willReturn($isComplete); + $this->resumableUploadRequest->getUploadLength()->willReturn($uploadLength); + $this->resumableUploadRequest->getContentLength()->willReturn($contentLength); + $this->resumableUploadRequest->getContent()->willReturn($this->contentResource()); + } + + /** + * @param array $additionalHeaders + */ + private function handle(?string $digestHeader = null, string $headerName = 'Repr-Digest', array $additionalHeaders = []): mixed + { + $request = new Request(); + if (null !== $digestHeader) { + $request->headers->set($headerName, $digestHeader); + } + foreach ($additionalHeaders as $name => $value) { + $request->headers->set($name, $value); + } + + return $this->service->handleUploadCreationFromRequest($this->elementId, $request); + } + + private function assertBadContentContaining(string $needle, callable $call): void + { + try { + $call(); + } catch (Client400BadContentException $exception) { + $this->assertStringContainsString($needle, $exception->getDetail()); + + return; + } + $this->fail('Expected Client400BadContentException.'); + } + + public function testDirectUploadWithoutDigestSucceeds(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->shouldBeCalledOnce()->willReturn(5); + $this->element->addProperty('hasFile', true)->shouldBeCalledOnce()->willReturn($this->element->reveal()); + $this->element->addProperty('file', Argument::that( + fn (array $file) => 5 === $file['contentLength'] + && 'bin' === $file['extension'] + && hash('sha256', 'hello') === $file['hash']['sha256'] + ))->shouldBeCalledOnce()->willReturn($this->element->reveal()); + $this->elementManager->merge(Argument::any())->shouldBeCalledOnce()->willReturn($this->elementManager->reveal()); + $this->elementManager->flush()->shouldBeCalledOnce()->willReturn($this->elementManager->reveal()); + + $this->assertInstanceOf(CreatedResponse::class, $this->handle()); + } + + public function testDirectUploadDeletesPreviousFileOnlyAfterFlushAndDispatchesEventAfterwards(): void + { + $this->configureDirectUpload(); + $log = []; + $this->s3Service->uploadFile(Argument::any())->will(function () use (&$log) { + $log[] = 'upload'; + + return 5; + }); + $this->element->addProperty(Argument::cetera())->willReturn($this->element->reveal()); + $this->elementManager->merge(Argument::any())->will(function () use (&$log) { + $log[] = 'merge'; + + return $this->reveal(); + }); + $this->elementManager->flush()->will(function () use (&$log) { + $log[] = 'flush'; + + return $this->reveal(); + }); + $this->elementFileDeletionService->deletePreviousFileAfterReplace(Argument::any())->will(function () use (&$log) { + $log[] = 'delete-previous'; + }); + $this->eventDispatcher->dispatch(Argument::any())->will(function (array $args) use (&$log) { + $log[] = 'event'; + + return $args[0]; + }); + + $this->handle(); + + $this->assertSame(['upload', 'merge', 'flush', 'delete-previous', 'event'], $log); + } + + public function testDirectUploadWithFailingFlushDoesNotDeletePreviousFile(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->willReturn(5); + $this->element->addProperty(Argument::cetera())->willReturn($this->element->reveal()); + $this->elementManager->flush()->willThrow(new RuntimeException('flush failed')); + $this->elementFileDeletionService->deletePreviousFileAfterReplace(Argument::any())->shouldNotBeCalled(); + $this->eventDispatcher->dispatch(Argument::any())->shouldNotBeCalled(); + + $this->expectException(RuntimeException::class); + $this->handle(); + } + + public function testDirectUploadWithMatchingDigestSucceeds(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->shouldBeCalledOnce()->willReturn(5); + $this->element->addProperty(Argument::cetera())->shouldBeCalled()->willReturn($this->element->reveal()); + $digest = (new DigestService())->formatDigestHeaderValue(hash('sha256', 'hello')); + + $this->assertInstanceOf(CreatedResponse::class, $this->handle($digest)); + } + + public function testDirectUploadAcceptsContentDigestHeader(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->shouldBeCalledOnce()->willReturn(5); + $this->element->addProperty(Argument::cetera())->shouldBeCalled()->willReturn($this->element->reveal()); + $digest = (new DigestService())->formatDigestHeaderValue(hash('sha256', 'hello')); + + $this->assertInstanceOf(CreatedResponse::class, $this->handle($digest, 'Content-Digest')); + } + + public function testDirectUploadWithBothMatchingDigestHeadersSucceeds(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->shouldBeCalledOnce()->willReturn(5); + $this->element->addProperty(Argument::cetera())->shouldBeCalled()->willReturn($this->element->reveal()); + $digest = (new DigestService())->formatDigestHeaderValue(hash('sha256', 'hello')); + + $this->assertInstanceOf(CreatedResponse::class, $this->handle($digest, 'Repr-Digest', ['Content-Digest' => $digest])); + } + + public function testDirectUploadWithCorrectReprAndWrongContentDigestIsRejected(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->shouldNotBeCalled(); + $this->elementManager->merge(Argument::any())->shouldNotBeCalled(); + $correct = (new DigestService())->formatDigestHeaderValue(hash('sha256', 'hello')); + $wrong = (new DigestService())->formatDigestHeaderValue(hash('sha256', 'something else')); + + $this->assertBadContentContaining( + "'Content-Digest'", + fn () => $this->handle($correct, 'Repr-Digest', ['Content-Digest' => $wrong]) + ); + } + + public function testDirectUploadWithCorrectContentAndWrongReprDigestIsRejected(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->shouldNotBeCalled(); + $this->elementManager->merge(Argument::any())->shouldNotBeCalled(); + $correct = (new DigestService())->formatDigestHeaderValue(hash('sha256', 'hello')); + $wrong = (new DigestService())->formatDigestHeaderValue(hash('sha256', 'something else')); + + $this->assertBadContentContaining( + "'Repr-Digest'", + fn () => $this->handle($wrong, 'Repr-Digest', ['Content-Digest' => $correct]) + ); + } + + public function testDirectUploadWithUnsupportedContentDigestAlgorithmIsRejected(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->shouldNotBeCalled(); + + $this->assertBadContentContaining( + "'Content-Digest' header", + fn () => $this->handle('md5=:1B2M2Y8AsgTpgAmY7PhCfg==:', 'Content-Digest') + ); + } + + public function testDirectUploadWithUnsupportedDigestAlgorithmIsRejected(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->shouldNotBeCalled(); + $this->elementManager->merge(Argument::any())->shouldNotBeCalled(); + + $this->assertBadContentContaining( + 'does not declare a supported digest algorithm', + fn () => $this->handle('md5=:1B2M2Y8AsgTpgAmY7PhCfg==:') + ); + } + + public function testDirectUploadWithMismatchingDigestIsRejected(): void + { + $this->configureDirectUpload(); + $this->s3Service->uploadFile(Argument::any())->shouldNotBeCalled(); + $this->elementManager->merge(Argument::any())->shouldNotBeCalled(); + $digest = (new DigestService())->formatDigestHeaderValue(hash('sha256', 'something else')); + + $this->assertBadContentContaining( + 'does not match the uploaded file', + fn () => $this->handle($digest) + ); + } + + public function testDirectUploadAboveMaxFileSizeIsRejectedBeforeReadingContent(): void + { + $this->resumableUploadRequest->isUploadComplete()->willReturn(true); + $this->resumableUploadRequest->getContentLength()->willReturn(self::MAX_FILE_SIZE + 1); + $this->resumableUploadRequest->getContent()->shouldNotBeCalled(); + $this->s3Service->uploadFile(Argument::any())->shouldNotBeCalled(); + + $this->assertBadContentContaining('at most 1000 bytes long', fn () => $this->handle()); + } + + public function testDirectUploadExactlyAtMaxChunkSizeIsAccepted(): void + { + $this->configureDirectUpload(); + $this->resumableUploadRequest->getContentLength()->willReturn(self::MAX_CHUNK_SIZE); + $this->s3Service->uploadFile(Argument::any())->shouldBeCalledOnce()->willReturn(self::MAX_CHUNK_SIZE); + $this->element->addProperty(Argument::cetera())->shouldBeCalled()->willReturn($this->element->reveal()); + + $this->assertInstanceOf(CreatedResponse::class, $this->handle()); + } + + public function testDirectUploadAboveMaxChunkSizeIsRejectedBeforeReadingContent(): void + { + $this->resumableUploadRequest->isUploadComplete()->willReturn(true); + $this->resumableUploadRequest->getContentLength()->willReturn(self::MAX_CHUNK_SIZE + 1); + $this->resumableUploadRequest->getContent()->shouldNotBeCalled(); + $this->s3Service->uploadFile(Argument::any())->shouldNotBeCalled(); + + $this->assertBadContentContaining('at most 100 bytes long, got 101', fn () => $this->handle()); + } + + public function testDirectUploadWithoutContentLengthIsAcceptedInService(): void + { + // the stream itself is bound in the request factory, the service only checks a declared length + $this->configureDirectUpload(); + $this->resumableUploadRequest->getContentLength()->willReturn(null); + $this->s3Service->uploadFile(Argument::any())->shouldBeCalledOnce()->willReturn(5); + $this->element->addProperty(Argument::cetera())->shouldBeCalled()->willReturn($this->element->reveal()); + + $this->assertInstanceOf(CreatedResponse::class, $this->handle()); + } + + public function testUploadLengthAboveMaxFileSizeIsRejected(): void + { + $this->configureResumableUpload(self::MAX_FILE_SIZE + 1, 0); + $this->uploadService->mergeUploadElement(Argument::any())->shouldNotBeCalled(); + + $this->assertBadContentContaining('at most 1000 bytes long', fn () => $this->handle()); + } + + public function testResumableUploadWithoutContentCreatesEmptyUpload(): void + { + $this->configureResumableUpload(500, 0); + $this->s3Service->uploadFileChunk(Argument::any())->shouldNotBeCalled(); + $this->uploadService->mergeUploadElement(Argument::that( + fn ($upload) => 0 === $upload->getUploadOffset() + && 500 === $upload->getUploadLength() + && 0 === $upload->getAlreadyUploadedChunks() + && null === $upload->getHashState() + ))->shouldBeCalledOnce(); + $this->elementManager->flush()->shouldBeCalledOnce()->willReturn($this->elementManager->reveal()); + + $response = $this->handle(); + + $this->assertInstanceOf(NoContentResponse::class, $response); + $this->assertSame('0', $response->headers->get('Upload-Offset')); + $this->assertSame('/upload/x', $response->headers->get('Location')); + } + + public function testResumableUploadWithValidFirstChunkStoresOffsetAndHashState(): void + { + $this->configureResumableUpload(500, 50); + $this->s3Service->uploadFileChunk(Argument::any())->shouldBeCalledOnce()->willReturn(50); + $this->uploadService->mergeUploadElement(Argument::that( + fn ($upload) => 50 === $upload->getUploadOffset() + && 1 === $upload->getAlreadyUploadedChunks() + && ['0123456789abcdef'] === $upload->getChunkIds() + && null !== $upload->getHashState() + ))->shouldBeCalledOnce(); + + $this->assertSame('50', $this->handle()->headers->get('Upload-Offset')); + } + + public function testFirstChunkBelowMinChunkSizeIsRejected(): void + { + $this->configureResumableUpload(500, 5); + $this->s3Service->uploadFileChunk(Argument::any())->willReturn(5); + $this->uploadService->mergeUploadElement(Argument::any())->shouldNotBeCalled(); + + $this->assertBadContentContaining('at least 10 bytes long, got 5', fn () => $this->handle()); + } + + public function testFirstChunkAboveMaxChunkSizeIsRejected(): void + { + $this->configureResumableUpload(500, 101); + $this->s3Service->uploadFileChunk(Argument::any())->willReturn(101); + $this->uploadService->mergeUploadElement(Argument::any())->shouldNotBeCalled(); + + $this->assertBadContentContaining('at most 100 bytes long, got 101', fn () => $this->handle()); + } + + public function testFirstChunkAtChunkSizeBoundariesIsAccepted(): void + { + foreach ([self::MIN_CHUNK_SIZE, self::MAX_CHUNK_SIZE] as $size) { + $this->configureResumableUpload(500, $size); + $this->s3Service->uploadFileChunk(Argument::any())->willReturn($size); + $this->uploadService->mergeUploadElement(Argument::any())->shouldBeCalled(); + + $this->assertSame((string) $size, $this->handle()->headers->get('Upload-Offset')); + } + } + + public function testResumableUploadWithEmptyBodyAndWithoutContentLengthCreatesEmptyUpload(): void + { + $this->resumableUploadRequest->isUploadComplete()->willReturn(false); + $this->resumableUploadRequest->getUploadLength()->willReturn(500); + $this->resumableUploadRequest->getContentLength()->willReturn(null); + $this->resumableUploadRequest->getContent()->willReturn($this->contentResource('')); + $this->s3Service->uploadFileChunk(Argument::any())->shouldNotBeCalled(); + $this->uploadService->mergeUploadElement(Argument::that( + fn ($upload) => 0 === $upload->getUploadOffset() + && 0 === $upload->getAlreadyUploadedChunks() + && null === $upload->getHashState() + ))->shouldBeCalledOnce(); + + $this->assertSame('0', $this->handle()->headers->get('Upload-Offset')); + } + + public function testFirstChunkExceedingUploadLengthIsRejectedBeforeS3(): void + { + $this->configureResumableUpload(40, 50); + $this->s3Service->uploadFileChunk(Argument::any())->shouldNotBeCalled(); + $this->uploadService->mergeUploadElement(Argument::any())->shouldNotBeCalled(); + + $this->expectException(Client409ConflictException::class); + $this->handle(); + } + + public function testFirstChunkMatchingUploadLengthIsAccepted(): void + { + $this->configureResumableUpload(50, 50); + $this->s3Service->uploadFileChunk(Argument::any())->shouldBeCalledOnce()->willReturn(50); + $this->uploadService->mergeUploadElement(Argument::any())->shouldBeCalledOnce(); + + $this->assertSame('50', $this->handle()->headers->get('Upload-Offset')); + } + + public function testResumableUploadRecordsWhetherTargetAlreadyHadAFile(): void + { + $this->configureResumableUpload(500, 50); + $this->elementService->hasFile($this->element->reveal())->willReturn(true); + $this->s3Service->uploadFileChunk(Argument::any())->willReturn(50); + $this->uploadService->mergeUploadElement(Argument::that( + fn ($upload) => true === $upload->targetHadFileAtCreation() + ))->shouldBeCalledOnce(); + + $this->handle(); + } +} diff --git a/tests/UnitTests/Service/UploadFinalizationServiceTest.php b/tests/UnitTests/Service/UploadFinalizationServiceTest.php new file mode 100644 index 00000000..584e9853 --- /dev/null +++ b/tests/UnitTests/Service/UploadFinalizationServiceTest.php @@ -0,0 +1,362 @@ +targetId = Uuid::uuid4(); + $this->upload = $this->prophesize(UploadInterface::class); + $this->upload->getUploadTarget()->willReturn($this->targetId); + $this->upload->targetHadFileAtCreation()->willReturn(false); + $this->upload->getId()->willReturn(Uuid::uuid4()); + $this->upload->getUploadOffset()->willReturn(5); + $this->upload->getExtension()->willReturn('txt'); + + $this->element = $this->prophesize(NodeElementInterface::class); + $this->elementManager = $this->prophesize(ElementManager::class); + $this->elementManager->getElementOrFail(Argument::any())->willReturn($this->element->reveal()); + $this->elementManager->merge(Argument::any())->willReturn($this->elementManager->reveal()); + $this->elementManager->delete(Argument::any())->willReturn($this->elementManager->reveal()); + $this->elementManager->flush()->willReturn($this->elementManager->reveal()); + + $mergeOperation = $this->prophesize(MergeFileChunksOperationInterface::class)->reveal(); + $mergeFactory = $this->prophesize(S3OperationFactory::class); + $mergeFactory->createMergeFileOperationFromUpload(Argument::any())->willReturn($mergeOperation); + + $this->s3Service = $this->prophesize(S3Service::class); + $this->s3Service->getChunksContentLength(Argument::any())->willReturn(5); + $this->s3Service->mergeFileChunks(Argument::any())->willReturn(5); + $this->s3Service->getMimeTypeFromMergeFileChunksOperation(Argument::any())->willReturn('text/plain'); + $this->s3Service->deleteFileChunks(Argument::any())->will(function () {}); + + $this->uploadService = $this->prophesize(UploadService::class); + $this->uploadService->deleteUpload(Argument::any())->will(function () {}); + $this->uploadService->markUploadAsUnfinalized(Argument::cetera())->willReturn(true); + $this->incrementalHashService = $this->prophesize(IncrementalHashService::class); + $this->incrementalHashService->serializeContextForStorage(Argument::any())->willReturn('final-state'); + $this->incrementalHashService->finalize(Argument::any())->willReturn(self::HASH); + $this->logger = $this->prophesize(LoggerInterface::class); + $this->elementFileDeletionService = $this->prophesize(ElementFileDeletionService::class); + $this->elementFileDeletionService->deletePreviousFileAfterReplace(Argument::any())->will(function () {}); + $this->fileSizeLimitService = $this->prophesize(FileSizeLimitService::class); + $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->eventDispatcher->dispatch(Argument::any())->will(fn (array $args) => $args[0]); + + $badContentFactory = $this->prophesize(Client400BadContentExceptionFactory::class); + $badContentFactory->createFromDetail(Argument::type('string'))->will( + fn (array $args) => new Client400BadContentException('bad-content', detail: $args[0]) + ); + + $conflictFactory = $this->prophesize(Client409ConflictExceptionFactory::class); + $conflictFactory->createFromDetail(Argument::type('string'))->will( + fn (array $args) => new Client409ConflictException('conflict', detail: $args[0]) + ); + + $this->elementService = $this->prophesize(ElementService::class); + $this->elementService->hasFile(Argument::any())->willReturn(false); + + $this->fileCreationLockService = $this->prophesize(FileCreationLockService::class); + $this->fileCreationLockService->acquire(Argument::any())->willReturn('lock-token'); + $this->fileCreationLockService->release(Argument::any(), Argument::any())->will(function () {}); + + $this->service = new UploadFinalizationService( + $this->elementManager->reveal(), + $this->eventDispatcher->reveal(), + $mergeFactory->reveal(), + $this->s3Service->reveal(), + $this->uploadService->reveal(), + new DigestService(), + $this->fileSizeLimitService->reveal(), + $badContentFactory->reveal(), + $conflictFactory->reveal(), + $this->logger->reveal(), + $this->elementFileDeletionService->reveal(), + $this->incrementalHashService->reveal(), + $this->elementService->reveal(), + $this->fileCreationLockService->reveal(), + ); + } + + private function digestHeader(string $hexHash = self::HASH): string + { + return sprintf('sha-256=:%s:', base64_encode(\Safe\hex2bin($hexHash))); + } + + private function expectFileToBeMerged(): void + { + $this->s3Service->mergeFileChunks(Argument::any())->shouldBeCalledOnce()->willReturn(5); + $this->element->addProperty('file', [ + 'contentLength' => 5, + 'extension' => 'txt', + 'mimeType' => 'text/plain', + 'hash' => ['sha256' => self::HASH], + ])->shouldBeCalledOnce()->willReturn($this->element->reveal()); + $this->element->addProperty('hasFile', true)->shouldBeCalledOnce()->willReturn($this->element->reveal()); + $this->uploadService->deleteUpload(Argument::any())->shouldBeCalledOnce(); + $this->eventDispatcher->dispatch(Argument::that( + fn ($event) => $event instanceof ElementFileReplaceEvent + ))->shouldBeCalledOnce(); + } + + private function expectUploadToBeDiscarded(): void + { + $this->s3Service->mergeFileChunks(Argument::any())->shouldNotBeCalled(); + $this->s3Service->deleteFileChunks(Argument::any())->shouldBeCalledOnce(); + $this->uploadService->deleteUpload(Argument::any())->shouldBeCalledOnce(); + $this->eventDispatcher->dispatch(Argument::any())->shouldNotBeCalled(); + } + + public function testFinalizeWithoutDigestMergesChunksAndSetsFile(): void + { + $this->expectFileToBeMerged(); + + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + } + + public function testFinalizeDeletesPreviousFileOnlyAfterFlushOfTheElement(): void + { + $log = []; + $this->s3Service->mergeFileChunks(Argument::any())->will(function () use (&$log) { + $log[] = 'merge-chunks'; + + return 5; + }); + $this->element->addProperty(Argument::cetera())->willReturn($this->element->reveal()); + $this->elementManager->flush()->will(function () use (&$log) { + $log[] = 'flush'; + + return $this->reveal(); + }); + $this->elementFileDeletionService->deletePreviousFileAfterReplace(Argument::any())->will(function () use (&$log) { + $log[] = 'delete-previous'; + }); + $this->s3Service->deleteFileChunks(Argument::any())->will(function () use (&$log) { + $log[] = 'delete-chunks'; + }); + + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + + $this->assertSame(['merge-chunks', 'flush', 'delete-previous', 'delete-chunks', 'flush'], $log); + } + + public function testFinalizeWithFailingFlushDoesNotDeletePreviousFileOrChunks(): void + { + $this->element->addProperty(Argument::cetera())->willReturn($this->element->reveal()); + $this->elementManager->flush()->willThrow(new RuntimeException('flush failed')); + $this->elementFileDeletionService->deletePreviousFileAfterReplace(Argument::any())->shouldNotBeCalled(); + $this->s3Service->deleteFileChunks(Argument::any())->shouldNotBeCalled(); + + $this->expectException(RuntimeException::class); + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + } + + public function testFinalizeWithMatchingReprDigestMergesChunks(): void + { + $this->expectFileToBeMerged(); + + $this->service->finalize($this->upload->reveal(), hash_init('sha256'), $this->digestHeader()); + } + + public function testFinalizeWithMismatchingDigestDiscardsUpload(): void + { + $this->expectUploadToBeDiscarded(); + + try { + $this->service->finalize($this->upload->reveal(), hash_init('sha256'), $this->digestHeader(hash('sha256', 'other'))); + $this->fail('Expected digest mismatch to be rejected.'); + } catch (Client400BadContentException $exception) { + $this->assertStringContainsString('does not match', $exception->getDetail()); + } + } + + public function testFinalizeWithUnsupportedDigestAlgorithmDiscardsUpload(): void + { + $this->expectUploadToBeDiscarded(); + + try { + $this->service->finalize($this->upload->reveal(), hash_init('sha256'), 'md5=:AAAA:'); + $this->fail('Expected unsupported digest algorithm to be rejected.'); + } catch (Client400BadContentException $exception) { + $this->assertStringContainsString("'Repr-Digest' header", $exception->getDetail()); + $this->assertStringContainsString('only \'sha-256\' is supported', $exception->getDetail()); + } + } + + public function testFinalizeAboveMaxFileSizeDiscardsUpload(): void + { + $this->fileSizeLimitService->assertWithinMaxFileSize(5)->shouldBeCalledOnce()->willThrow(new Client400BadContentException('too-big')); + $this->expectUploadToBeDiscarded(); + + $this->expectException(Client400BadContentException::class); + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + } + + public function testFinalizeWithStoredChunksNotAddingUpToOffsetDiscardsUpload(): void + { + $this->s3Service->getChunksContentLength(Argument::any())->willReturn(4); + $this->expectUploadToBeDiscarded(); + + try { + $this->service->finalize($this->upload->reveal(), hash_init('sha256'), $this->digestHeader()); + $this->fail('Expected inconsistent upload to be rejected.'); + } catch (Client409ConflictException $exception) { + $this->assertStringContainsString('restart the upload', $exception->getDetail()); + } + } + + public function testFinalizeWithMissingChunkDiscardsUpload(): void + { + $this->s3Service->getChunksContentLength(Argument::any())->willReturn(null); + $this->expectUploadToBeDiscarded(); + + $this->expectException(Client409ConflictException::class); + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + } + + public function testFailedMergeOfTheChunksPutsTheUploadBackToUnfinalized(): void + { + $this->s3Service->mergeFileChunks(Argument::any())->willThrow(new RuntimeException('merge failed')); + $this->uploadService->markUploadAsUnfinalized($this->upload->reveal(), 'final-state')->willReturn(true)->shouldBeCalledOnce(); + // all chunks stay, so that the client can complete the upload again + $this->s3Service->deleteFileChunks(Argument::any())->shouldNotBeCalled(); + $this->uploadService->deleteUpload(Argument::any())->shouldNotBeCalled(); + $this->eventDispatcher->dispatch(Argument::any())->shouldNotBeCalled(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('merge failed'); + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + } + + public function testFailedFlushOfTheElementPutsTheUploadBackToUnfinalized(): void + { + $this->element->addProperty(Argument::cetera())->willReturn($this->element->reveal()); + $this->elementManager->flush()->willThrow(new RuntimeException('flush failed')); + $this->uploadService->markUploadAsUnfinalized($this->upload->reveal(), 'final-state')->willReturn(true)->shouldBeCalledOnce(); + $this->elementFileDeletionService->deletePreviousFileAfterReplace(Argument::any())->shouldNotBeCalled(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('flush failed'); + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + } + + public function testFailureWhilePuttingTheUploadBackIsLoggedAndTheOriginalFailureIsThrown(): void + { + $this->s3Service->mergeFileChunks(Argument::any())->willThrow(new RuntimeException('merge failed')); + $this->uploadService->markUploadAsUnfinalized(Argument::cetera())->willThrow(new RuntimeException('graph down')); + $this->logger->error(Argument::containingString('graph down'))->shouldBeCalledOnce(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('merge failed'); + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + } + + public function testUploadWhichCanNotBePutBackIsLogged(): void + { + $this->s3Service->mergeFileChunks(Argument::any())->willThrow(new RuntimeException('merge failed')); + $this->uploadService->markUploadAsUnfinalized(Argument::cetera())->willReturn(false); + $this->logger->error(Argument::containingString('does not exist or is not complete anymore'))->shouldBeCalledOnce(); + + $this->expectException(RuntimeException::class); + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + } + + public function testFinalizeWithHasFileAlreadyTrueDiscardsUpload(): void + { + $this->elementService->hasFile(Argument::any())->willReturn(true); + $this->expectUploadToBeDiscarded(); + + try { + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + $this->fail('Expected an already existing file to be rejected.'); + } catch (Client409ConflictException $exception) { + $this->assertStringContainsString('already has an associated file', $exception->getDetail()); + } + } + + public function testFinalizeSucceedsWithHasFileTrueWhenTargetAlreadyHadAFileAtUploadCreation(): void + { + $this->upload->targetHadFileAtCreation()->willReturn(true); + $this->elementService->hasFile(Argument::any())->willReturn(true); + $this->expectFileToBeMerged(); + + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + } + + public function testFinalizeWithFailedLockAcquisitionDiscardsUpload(): void + { + $this->fileCreationLockService->acquire(Argument::any())->willReturn(null); + $this->fileCreationLockService->release(Argument::cetera())->shouldNotBeCalled(); + $this->expectUploadToBeDiscarded(); + + try { + $this->service->finalize($this->upload->reveal(), hash_init('sha256')); + $this->fail('Expected a failed lock acquisition to be rejected.'); + } catch (Client409ConflictException $exception) { + $this->assertStringContainsString('currently creating the file', $exception->getDetail()); + } + } + + public function testVerdictsAboutTheWholeUploadDoNotPutItBack(): void + { + $this->uploadService->markUploadAsUnfinalized(Argument::cetera())->shouldNotBeCalled(); + + $this->expectException(Client400BadContentException::class); + $this->service->finalize($this->upload->reveal(), hash_init('sha256'), $this->digestHeader(hash('sha256', 'other'))); + } +} diff --git a/tests/UnitTests/Service/UploadLockServiceTest.php b/tests/UnitTests/Service/UploadLockServiceTest.php new file mode 100644 index 00000000..203e1c14 --- /dev/null +++ b/tests/UnitTests/Service/UploadLockServiceTest.php @@ -0,0 +1,61 @@ +prophesize(Client::class); + $redis->set( + 'upload-lock:'.$uploadId->toString(), + Argument::that(fn ($token) => is_string($token) && 32 === strlen($token)), + 'PX', + UploadLockService::TTL_IN_MILLISECONDS, + 'NX' + )->shouldBeCalledOnce()->willReturn('OK'); + + $token = (new UploadLockService($redis->reveal(), new RedisKeyFactory()))->acquire($uploadId); + + $this->assertIsString($token); + } + + public function testAcquireReturnsNullIfAlreadyLocked(): void + { + $redis = $this->prophesize(Client::class); + $redis->set(Argument::cetera())->willReturn(null); + + $this->assertNull((new UploadLockService($redis->reveal(), new RedisKeyFactory()))->acquire(Uuid::uuid4())); + } + + public function testReleaseComparesTokenBeforeDeleting(): void + { + $uploadId = Uuid::uuid4(); + $redis = $this->prophesize(Client::class); + $redis->eval( + Argument::that(fn ($script) => str_contains($script, 'get') && str_contains($script, 'del')), + 1, + 'upload-lock:'.$uploadId->toString(), + 'my-token' + )->shouldBeCalledOnce()->willReturn(1); + + (new UploadLockService($redis->reveal(), new RedisKeyFactory()))->release($uploadId, 'my-token'); + } +} diff --git a/tests/UnitTests/Service/UploadServiceTest.php b/tests/UnitTests/Service/UploadServiceTest.php new file mode 100644 index 00000000..0ce94ec2 --- /dev/null +++ b/tests/UnitTests/Service/UploadServiceTest.php @@ -0,0 +1,427 @@ +, 2: ObjectProphecy, 3: ObjectProphecy, 4: ObjectProphecy, 5: ObjectProphecy, 6: ObjectProphecy} + */ + private function buildService( + ?ObjectProphecy $elementManager = null, + ?ObjectProphecy $server500LogicErrorExceptionFactory = null, + ?ObjectProphecy $fileOperationFactory = null, + ?ObjectProphecy $s3Service = null, + ?ObjectProphecy $uploadFactory = null, + ?ObjectProphecy $cypherEntityManager = null, + ): array { + $elementManager ??= $this->prophesize(ElementManager::class); + $server500LogicErrorExceptionFactory ??= $this->prophesize(Server500LogicErrorExceptionFactory::class); + $fileOperationFactory ??= $this->prophesize(S3OperationFactory::class); + $s3Service ??= $this->prophesize(S3Service::class); + $uploadFactory ??= $this->prophesize(UploadFactory::class); + $cypherEntityManager ??= $this->prophesize(CypherEntityManager::class); + + $service = new UploadService( + $elementManager->reveal(), + $server500LogicErrorExceptionFactory->reveal(), + $fileOperationFactory->reveal(), + $s3Service->reveal(), + $uploadFactory->reveal(), + $cypherEntityManager->reveal(), + ); + + return [$service, $elementManager, $server500LogicErrorExceptionFactory, $fileOperationFactory, $s3Service, $uploadFactory, $cypherEntityManager]; + } + + /** + * @return ObjectProphecy + */ + private function buildUpload( + ?UuidInterface $id = null, + ?int $uploadLength = 1000, + int $uploadOffset = 0, + bool $uploadComplete = false, + ?UuidInterface $uploadTarget = null, + array $chunkIds = [], + ?UuidInterface $uploadOwner = null, + string $extension = 'bin', + ?string $hashState = null, + ): ObjectProphecy { + $upload = $this->prophesize(UploadInterface::class); + $upload->getId()->willReturn($id ?? UuidV4::uuid4()); + $upload->getUploadLength()->willReturn($uploadLength); + $upload->getUploadOffset()->willReturn($uploadOffset); + $upload->isUploadComplete()->willReturn($uploadComplete); + $upload->getUploadTarget()->willReturn($uploadTarget ?? UuidV4::uuid4()); + $upload->getAlreadyUploadedChunks()->willReturn(count($chunkIds)); + $upload->getChunkIds()->willReturn($chunkIds); + $upload->getLastChunkId()->willReturn([] === $chunkIds ? null : $chunkIds[array_key_last($chunkIds)]); + $upload->getUploadOwner()->willReturn($uploadOwner ?? UuidV4::uuid4()); + $upload->getExtension()->willReturn($extension); + $upload->getExpires()->willReturn(new DateTime('2099-01-01')); + $upload->getHashState()->willReturn($hashState); + $upload->targetHadFileAtCreation()->willReturn(false); + + return $upload; + } + + public function testMergeUploadElementCreatesNewElementWhenNoneExists(): void + { + $id = UuidV4::uuid4(); + $uploadTarget = UuidV4::uuid4(); + $uploadOwner = UuidV4::uuid4(); + $upload = $this->buildUpload(id: $id, uploadTarget: $uploadTarget, uploadOwner: $uploadOwner, extension: 'png', hashState: 'abc'); + + $capturedElement = null; + [$service, $elementManager] = $this->buildService(); + $elementManager->getElement($id)->willReturn(null); + $elementManager->merge(Argument::that(function (NodeElement $element) use (&$capturedElement) { + $capturedElement = $element; + + return true; + }))->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + + $service->mergeUploadElement($upload->reveal()); + + $mergedElement = $capturedElement; + $this->assertInstanceOf(NodeElement::class, $mergedElement); + $this->assertTrue($mergedElement->getId()?->equals($id)); + $this->assertSame('Upload', $mergedElement->getLabel()); + $this->assertSame( + [ + 'uploadLength' => 1000, + 'uploadOffset' => 0, + 'uploadComplete' => false, + 'uploadTarget' => $uploadTarget->toString(), + 'chunkIds' => [], + 'lastChunkId' => null, + 'uploadOwner' => $uploadOwner->toString(), + 'extension' => 'png', + 'expires' => $mergedElement->getProperty('expires'), + 'hashState' => 'abc', + 'targetHadFileAtCreation' => false, + ], + $mergedElement->getProperties() + ); + } + + public function testMergeUploadElementUpdatesExistingUploadElement(): void + { + $id = UuidV4::uuid4(); + $existingElement = (new NodeElement())->setId($id)->setLabel('Upload'); + $upload = $this->buildUpload(id: $id, uploadLength: null, uploadOffset: 42); + + [$service, $elementManager] = $this->buildService(); + $elementManager->getElement($id)->willReturn($existingElement); + $elementManager->merge($existingElement)->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + + $service->mergeUploadElement($upload->reveal()); + + $this->assertNull($existingElement->getProperty('uploadLength')); + $this->assertSame(42, $existingElement->getProperty('uploadOffset')); + } + + public function testMergeUploadElementThrowsWhenExistingElementIsNotANode(): void + { + $id = UuidV4::uuid4(); + $relationElement = $this->prophesize(RelationElementInterface::class)->reveal(); + $upload = $this->buildUpload(id: $id); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + [$service, $elementManager, $server500LogicErrorExceptionFactory] = $this->buildService(); + $elementManager->getElement($id)->willReturn($relationElement); + $server500LogicErrorExceptionFactory->createFromTemplate(Argument::containingString('Expected upload element to be a node')) + ->shouldBeCalledOnce() + ->willReturn($exception); + + $this->expectException(Server500LogicErrorException::class); + $service->mergeUploadElement($upload->reveal()); + } + + public function testMergeUploadElementThrowsWhenExistingElementHasWrongLabel(): void + { + $id = UuidV4::uuid4(); + $existingElement = (new NodeElement())->setId($id)->setLabel('SomethingElse'); + $upload = $this->buildUpload(id: $id); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + [$service, $elementManager, $server500LogicErrorExceptionFactory] = $this->buildService(); + $elementManager->getElement($id)->willReturn($existingElement); + $server500LogicErrorExceptionFactory->createFromTemplate(Argument::containingString("not 'SomethingElse'")) + ->shouldBeCalledOnce() + ->willReturn($exception); + + $this->expectException(Server500LogicErrorException::class); + $service->mergeUploadElement($upload->reveal()); + } + + public function testMergeUploadElementThrowsWhenExistingElementHasNullLabel(): void + { + $id = UuidV4::uuid4(); + $existingElement = (new NodeElement())->setId($id); + $upload = $this->buildUpload(id: $id); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + [$service, $elementManager, $server500LogicErrorExceptionFactory] = $this->buildService(); + $elementManager->getElement($id)->willReturn($existingElement); + $server500LogicErrorExceptionFactory->createFromTemplate(Argument::containingString("not 'null'")) + ->shouldBeCalledOnce() + ->willReturn($exception); + + $this->expectException(Server500LogicErrorException::class); + $service->mergeUploadElement($upload->reveal()); + } + + public function testDeleteUploadDeletesElement(): void + { + $id = UuidV4::uuid4(); + $upload = $this->buildUpload(id: $id); + $element = (new NodeElement())->setId($id)->setLabel('Upload'); + + [$service, $elementManager] = $this->buildService(); + $elementManager->getElementOrFail($id)->willReturn($element)->shouldBeCalledOnce(); + $elementManager->delete($element)->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + + $service->deleteUpload($upload->reveal()); + } + + public function testDeleteUploadAndChunksDeletesOnlyUploadWhenNoChunkUploadedYet(): void + { + $id = UuidV4::uuid4(); + $upload = $this->buildUpload(id: $id); + $element = (new NodeElement())->setId($id)->setLabel('Upload'); + + [$service, $elementManager, , $fileOperationFactory, $s3Service] = $this->buildService(); + $fileOperation = $this->prophesize(FileOperationInterface::class)->reveal(); + $fileOperationFactory->createFileOperationFromUpload(Argument::cetera())->shouldNotBeCalled(); + $s3Service->deleteFile(Argument::any())->shouldNotBeCalled(); + $elementManager->getElementOrFail($id)->willReturn($element); + $elementManager->delete($element)->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + + $service->deleteUploadAndChunks($upload->reveal()); + } + + public function testDeleteUploadAndChunksDeletesAllChunks(): void + { + $id = UuidV4::uuid4(); + $upload = $this->buildUpload(id: $id, chunkIds: ['aaaaaaaaaaaaaaa1', 'aaaaaaaaaaaaaaa2']); + $element = (new NodeElement())->setId($id)->setLabel('Upload'); + + [$service, $elementManager, , $fileOperationFactory, $s3Service] = $this->buildService(); + $fileOperation = $this->prophesize(FileOperationInterface::class)->reveal(); + foreach ([1, 2] as $chunk) { + $fileOperationFactory->createFileOperationFromUpload($upload->reveal(), $chunk, 'aaaaaaaaaaaaaaa'.$chunk)->willReturn($fileOperation)->shouldBeCalledOnce(); + } + $s3Service->deleteFile($fileOperation)->shouldBeCalledTimes(2); + $elementManager->getElementOrFail($id)->willReturn($element); + $elementManager->delete($element)->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + + $service->deleteUploadAndChunks($upload->reveal()); + } + + private function buildSummarizedResultOf(CypherMap ...$maps): SummarizedResult + { + $summary = null; + + return new SummarizedResult($summary, $maps); + } + + #[DataProvider('hasUploadsTargetingProvider')] + public function testHasUploadsTargeting(bool $uploadFound): void + { + [$service, , , , , , $cypherEntityManager] = $this->buildService(); + $client = $this->prophesize(ClientInterface::class); + $maps = $uploadFound ? [new CypherMap(['u.id' => UuidV4::uuid4()->toString()])] : []; + $client->runStatement(Argument::any())->willReturn($this->buildSummarizedResultOf(...$maps)); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $this->assertSame($uploadFound, $service->hasUploadsTargeting(UuidV4::uuid4())); + } + + /** + * @return array + */ + public static function hasUploadsTargetingProvider(): array + { + return ['upload found' => [true], 'no upload' => [false]]; + } + + public function testDeleteUploadsTargetingDoesNothingWhenNoUploadsFound(): void + { + $elementId = UuidV4::uuid4(); + + [$service, $elementManager, , , , , $cypherEntityManager] = $this->buildService(); + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->willReturn($this->buildSummarizedResultOf()); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $elementManager->getElement(Argument::any())->shouldNotBeCalled(); + + $service->deleteUploadsTargeting($elementId); + $this->addToAssertionCount(1); + } + + public function testDeleteUploadsTargetingSkipsUploadsAlreadyGone(): void + { + $elementId = UuidV4::uuid4(); + $uploadId = UuidV4::uuid4(); + + [$service, $elementManager, , , , , $cypherEntityManager] = $this->buildService(); + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->willReturn($this->buildSummarizedResultOf(new CypherMap(['u.id' => $uploadId->toString()]))); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $elementManager->getElement(Argument::that(fn (UuidInterface $id) => $id->equals($uploadId)))->willReturn(null); + $elementManager->delete(Argument::any())->shouldNotBeCalled(); + + $service->deleteUploadsTargeting($elementId); + $this->addToAssertionCount(1); + } + + public function testDeleteUploadsTargetingSkipsUploadsThatFailToParse(): void + { + $elementId = UuidV4::uuid4(); + $uploadId = UuidV4::uuid4(); + $uploadElement = (new NodeElement())->setId($uploadId)->setLabel('Upload'); + + [$service, $elementManager, , , , $uploadFactory, $cypherEntityManager] = $this->buildService(); + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->willReturn($this->buildSummarizedResultOf(new CypherMap(['u.id' => $uploadId->toString()]))); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $elementManager->getElement(Argument::that(fn (UuidInterface $id) => $id->equals($uploadId)))->willReturn($uploadElement); + $uploadFactory->createUploadFromElement($uploadElement)->willThrow(new Exception('bad upload')); + $elementManager->delete(Argument::any())->shouldNotBeCalled(); + + $service->deleteUploadsTargeting($elementId); + $this->addToAssertionCount(1); + } + + public function testDeleteUploadsTargetingDeletesFoundUploadsAndTheirChunks(): void + { + $elementId = UuidV4::uuid4(); + $uploadId = UuidV4::uuid4(); + $uploadElement = (new NodeElement())->setId($uploadId)->setLabel('Upload'); + $upload = $this->buildUpload(id: $uploadId, chunkIds: ['aaaaaaaaaaaaaaa1'])->reveal(); + + [$service, $elementManager, , $fileOperationFactory, $s3Service, $uploadFactory, $cypherEntityManager] = $this->buildService(); + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::any())->willReturn($this->buildSummarizedResultOf(new CypherMap(['u.id' => $uploadId->toString()]))); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $elementManager->getElement(Argument::that(fn (UuidInterface $id) => $id->equals($uploadId)))->willReturn($uploadElement); + $uploadFactory->createUploadFromElement($uploadElement)->willReturn($upload); + + $fileOperation = $this->prophesize(FileOperationInterface::class)->reveal(); + $fileOperationFactory->createFileOperationFromUpload($upload, 1, 'aaaaaaaaaaaaaaa1')->willReturn($fileOperation); + $s3Service->deleteFile($fileOperation)->shouldBeCalledOnce(); + + $elementManager->getElementOrFail($uploadId)->willReturn($uploadElement); + $elementManager->delete($uploadElement)->shouldBeCalledOnce()->willReturn($elementManager->reveal()); + + $service->deleteUploadsTargeting($elementId); + } + + /** + * @return array + */ + public static function appendChunkResultProvider(): array + { + return ['offset matches' => [true, true], 'concurrently modified' => [false, false]]; + } + + #[DataProvider('appendChunkResultProvider')] + public function testAppendChunkIfOffsetMatchesReportsWhetherUploadWasUpdated(bool $rowReturned, bool $expected): void + { + $id = UuidV4::uuid4(); + $expectedUpload = $this->buildUpload(id: $id, uploadOffset: 500, chunkIds: ['aaaaaaaaaaaaaaa1'])->reveal(); + $nextUpload = $this->buildUpload(id: $id, uploadOffset: 1000, uploadComplete: true, chunkIds: ['aaaaaaaaaaaaaaa1', 'aaaaaaaaaaaaaaa2'], hashState: 'state')->reveal(); + + [$service, $elementManager, , , , , $cypherEntityManager] = $this->buildService(); + // the list is written by a flushed merge, but only if the compare-and-set succeeded + $elementManager->getElement($id)->willReturn(null); + $elementManager->merge(Argument::that(fn (NodeElement $element) => ['aaaaaaaaaaaaaaa1', 'aaaaaaaaaaaaaaa2'] === $element->getProperty('chunkIds') && 'aaaaaaaaaaaaaaa2' === $element->getProperty('lastChunkId'))) + ->shouldBeCalledTimes($rowReturned ? 1 : 0)->willReturn($elementManager->reveal()); + $elementManager->flush()->shouldBeCalledTimes($rowReturned ? 1 : 0)->willReturn($elementManager->reveal()); + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::that(function (Statement $statement) use ($id) { + $parameters = $statement->getParameters(); + + return str_contains($statement->getText(), 'WHERE u.uploadOffset = $expectedOffset') + && str_contains($statement->getText(), 'coalesce(u.lastChunkId, "") = $expectedLastChunkId') + && $parameters['id'] === $id->toString() + && 500 === $parameters['expectedOffset'] + && 'aaaaaaaaaaaaaaa1' === $parameters['expectedLastChunkId'] + && 1000 === $parameters['uploadOffset'] + && true === $parameters['uploadComplete'] + && 'aaaaaaaaaaaaaaa2' === $parameters['lastChunkId'] + && 'state' === $parameters['hashState']; + }))->shouldBeCalledOnce()->willReturn( + $rowReturned ? $this->buildSummarizedResultOf(new CypherMap(['u.id' => $id->toString()])) : $this->buildSummarizedResultOf() + ); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $this->assertSame($expected, $service->appendChunkIfOffsetMatches($expectedUpload, $nextUpload)); + } + + #[DataProvider('appendChunkResultProvider')] + public function testMarkUploadAsUnfinalizedReportsWhetherUploadWasUpdated(bool $rowReturned, bool $expected): void + { + $id = UuidV4::uuid4(); + $upload = $this->buildUpload(id: $id, uploadOffset: 500, uploadComplete: true, chunkIds: ['aaaaaaaaaaaaaaa1'])->reveal(); + + [$service, $elementManager, , , , , $cypherEntityManager] = $this->buildService(); + // only the graph is touched, nothing of the entity manager + $elementManager->merge(Argument::any())->shouldNotBeCalled(); + $elementManager->flush()->shouldNotBeCalled(); + $client = $this->prophesize(ClientInterface::class); + $client->runStatement(Argument::that(function (Statement $statement) use ($id) { + $parameters = $statement->getParameters(); + + return str_contains($statement->getText(), 'WHERE u.uploadComplete = true') + && str_contains($statement->getText(), 'SET u.uploadComplete = false, u.hashState = $hashState') + && $parameters['id'] === $id->toString() + && 'final-state' === $parameters['hashState']; + }))->shouldBeCalledOnce()->willReturn( + $rowReturned ? $this->buildSummarizedResultOf(new CypherMap(['u.id' => $id->toString()])) : $this->buildSummarizedResultOf() + ); + $cypherEntityManager->getClient()->willReturn($client->reveal()); + + $this->assertSame($expected, $service->markUploadAsUnfinalized($upload, 'final-state')); + } +} diff --git a/tests/UnitTests/Type/ByteRangeTest.php b/tests/UnitTests/Type/ByteRangeTest.php new file mode 100644 index 00000000..bed97bc1 --- /dev/null +++ b/tests/UnitTests/Type/ByteRangeTest.php @@ -0,0 +1,45 @@ +assertSame(10, $range->getStart()); + $this->assertSame(20, $range->getEnd()); + $this->assertSame(100, $range->getTotalLength()); + } + + public function testLengthIsInclusive(): void + { + $range = new ByteRange(0, 9, 100); + + $this->assertSame(10, $range->getLength()); + } + + public function testLengthOfSingleByteRange(): void + { + $range = new ByteRange(5, 5, 100); + + $this->assertSame(1, $range->getLength()); + } + + public function testLengthCoveringFullResource(): void + { + $range = new ByteRange(0, 99, 100); + + $this->assertSame(100, $range->getLength()); + } +} diff --git a/tests/UnitTests/Type/FilePropertyTest.php b/tests/UnitTests/Type/FilePropertyTest.php new file mode 100644 index 00000000..3486f791 --- /dev/null +++ b/tests/UnitTests/Type/FilePropertyTest.php @@ -0,0 +1,47 @@ +assertSame(FileService::DEFAULT_EXTENSION, $fileProperty->getExtension()); + } + + public function testSetExtensionReturnsStaticAndUpdatesValue(): void + { + $fileProperty = new FileProperty(); + $result = $fileProperty->setExtension('png'); + + $this->assertSame($fileProperty, $result); + $this->assertSame('png', $fileProperty->getExtension()); + } + + public function testJsonSerialize(): void + { + $fileProperty = (new FileProperty())->setExtension('jpg'); + + $this->assertSame(['extension' => 'jpg'], $fileProperty->jsonSerialize()); + $this->assertSame('{"extension":"jpg"}', json_encode($fileProperty)); + } + + public function testJsonSerializeWithDefaultExtension(): void + { + $fileProperty = new FileProperty(); + + $this->assertSame(['extension' => FileService::DEFAULT_EXTENSION], $fileProperty->jsonSerialize()); + } +} diff --git a/tests/UnitTests/Type/Response/BinaryStreamResponseTest.php b/tests/UnitTests/Type/Response/BinaryStreamResponseTest.php new file mode 100644 index 00000000..f7f6edd2 --- /dev/null +++ b/tests/UnitTests/Type/Response/BinaryStreamResponseTest.php @@ -0,0 +1,158 @@ +prophesize(GetObjectOutput::class); + $body = $this->prophesize(ResultStream::class); + $body->getContentAsResource()->willReturn($this->buildStreamResource($content)); + $object->getBody()->willReturn($body->reveal()); + $object->getContentLength()->willReturn($contentLength); + + return $object->reveal(); + } + + public function testFullResponseHasStatus200AndFullContentLength(): void + { + $response = new BinaryStreamResponse($this->buildObject('hello world', 11), 'file.txt', 'file.txt'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('11', $response->headers->get('Content-Length')); + $this->assertSame('bytes', $response->headers->get('Accept-Ranges')); + $this->assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); + $this->assertFalse($response->headers->has('Content-Range')); + } + + public function testFullResponseDefaultsContentLengthToZeroWhenUnknown(): void + { + $response = new BinaryStreamResponse($this->buildObject('hello world', null), 'file.txt', 'file.txt'); + + $this->assertSame('0', $response->headers->get('Content-Length')); + } + + public function testRangeResponseHasStatus206AndContentRange(): void + { + $range = new ByteRange(2, 5, 11); + $response = new BinaryStreamResponse($this->buildObject('hello world'), 'file.txt', 'file.txt', $range); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame('4', $response->headers->get('Content-Length')); + $this->assertSame('bytes 2-5/11', $response->headers->get('Content-Range')); + $this->assertSame('bytes', $response->headers->get('Accept-Ranges')); + } + + public function testDefaultContentTypeIsOctetStream(): void + { + $response = new BinaryStreamResponse($this->buildObject('data'), 'file.txt', 'file.txt'); + + $this->assertSame('application/octet-stream', $response->headers->get('Content-Type')); + } + + public function testExplicitContentTypeIsUsed(): void + { + $response = new BinaryStreamResponse($this->buildObject('data'), 'file.txt', 'file.txt', null, null, 'image/png'); + + $this->assertSame('image/png', $response->headers->get('Content-Type')); + } + + public function testReprDigestHeaderIsSetWhenProvided(): void + { + $response = new BinaryStreamResponse($this->buildObject('data'), 'file.txt', 'file.txt', null, 'sha-256=:abc123:'); + + $this->assertSame('sha-256=:abc123:', $response->headers->get('Repr-Digest')); + } + + public function testReprDigestHeaderIsAbsentWhenNotProvided(): void + { + $response = new BinaryStreamResponse($this->buildObject('data'), 'file.txt', 'file.txt'); + + $this->assertFalse($response->headers->has('Repr-Digest')); + } + + public function testContentDispositionHeaderIsSetWithFileName(): void + { + $response = new BinaryStreamResponse($this->buildObject('data'), 'special file.txt', 'special-file.txt'); + + $disposition = $response->headers->get('Content-Disposition'); + $this->assertNotNull($disposition); + $this->assertStringContainsString('attachment', $disposition); + $this->assertStringContainsString('special-file.txt', $disposition); + } + + public function testSetEtagFromEtagInstanceSetsEtagHeader(): void + { + $response = new BinaryStreamResponse($this->buildObject('data'), 'file.txt', 'file.txt'); + $result = $response->setEtagFromEtagInstance(new Etag('"my-etag"')); + + $this->assertSame($response, $result); + $this->assertSame('"my-etag"', $response->getEtag()); + } + + public function testStreamsFullBodyContent(): void + { + $response = new BinaryStreamResponse($this->buildObject('the quick brown fox', 20), 'file.txt', 'file.txt'); + + ob_start(); + $response->sendContent(); + $output = ob_get_clean(); + + $this->assertSame('the quick brown fox', $output); + } + + public function testStreamsLargeContentAcrossMultipleChunks(): void + { + $content = str_repeat('x', BinaryStreamResponse::STREAM_CHUNK_SIZE + 100); + $response = new BinaryStreamResponse($this->buildObject($content, strlen($content)), 'file.txt', 'file.txt'); + + ob_start(); + $response->sendContent(); + $output = ob_get_clean(); + + $this->assertSame($content, $output); + } + + public function testStreamsEmptyContent(): void + { + $response = new BinaryStreamResponse($this->buildObject('', 0), 'file.txt', 'file.txt'); + + ob_start(); + $response->sendContent(); + $output = ob_get_clean(); + + $this->assertSame('', $output); + } +} diff --git a/tests/UnitTests/Response/CollectionResponseTest.php b/tests/UnitTests/Type/Response/CollectionResponseTest.php similarity index 97% rename from tests/UnitTests/Response/CollectionResponseTest.php rename to tests/UnitTests/Type/Response/CollectionResponseTest.php index 6291ded9..16156288 100644 --- a/tests/UnitTests/Response/CollectionResponseTest.php +++ b/tests/UnitTests/Type/Response/CollectionResponseTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\UnitTests\Response; +namespace App\Tests\UnitTests\Type\Response; use App\Type\Etag; use App\Type\Response\CollectionResponse; diff --git a/tests/UnitTests/Response/CreatedResponseTest.php b/tests/UnitTests/Type/Response/CreatedResponseTest.php similarity index 95% rename from tests/UnitTests/Response/CreatedResponseTest.php rename to tests/UnitTests/Type/Response/CreatedResponseTest.php index 564fc115..c79d9a9a 100644 --- a/tests/UnitTests/Response/CreatedResponseTest.php +++ b/tests/UnitTests/Type/Response/CreatedResponseTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\UnitTests\Response; +namespace App\Tests\UnitTests\Type\Response; use App\Type\Response\CreatedResponse; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/UnitTests/Response/ElementResponseTest.php b/tests/UnitTests/Type/Response/ElementResponseTest.php similarity index 96% rename from tests/UnitTests/Response/ElementResponseTest.php rename to tests/UnitTests/Type/Response/ElementResponseTest.php index 875f51ed..7c7a6433 100644 --- a/tests/UnitTests/Response/ElementResponseTest.php +++ b/tests/UnitTests/Type/Response/ElementResponseTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\UnitTests\Response; +namespace App\Tests\UnitTests\Type\Response; use App\Type\Etag; use App\Type\Response\ElementResponse; diff --git a/tests/UnitTests/Response/JsonResponseTest.php b/tests/UnitTests/Type/Response/JsonResponseTest.php similarity index 95% rename from tests/UnitTests/Response/JsonResponseTest.php rename to tests/UnitTests/Type/Response/JsonResponseTest.php index 27196d68..cc37f5b0 100644 --- a/tests/UnitTests/Response/JsonResponseTest.php +++ b/tests/UnitTests/Type/Response/JsonResponseTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\UnitTests\Response; +namespace App\Tests\UnitTests\Type\Response; use App\Type\Response\JsonResponse; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/UnitTests/Response/NoContentResponseTest.php b/tests/UnitTests/Type/Response/NoContentResponseTest.php similarity index 92% rename from tests/UnitTests/Response/NoContentResponseTest.php rename to tests/UnitTests/Type/Response/NoContentResponseTest.php index d1b11884..10a4b0e2 100644 --- a/tests/UnitTests/Response/NoContentResponseTest.php +++ b/tests/UnitTests/Type/Response/NoContentResponseTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\UnitTests\Response; +namespace App\Tests\UnitTests\Type\Response; use App\Type\Response\NoContentResponse; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/UnitTests/Response/NotModifiedResponseTest.php b/tests/UnitTests/Type/Response/NotModifiedResponseTest.php similarity index 95% rename from tests/UnitTests/Response/NotModifiedResponseTest.php rename to tests/UnitTests/Type/Response/NotModifiedResponseTest.php index 6c65cf06..d6fb44ad 100644 --- a/tests/UnitTests/Response/NotModifiedResponseTest.php +++ b/tests/UnitTests/Type/Response/NotModifiedResponseTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\UnitTests\Response; +namespace App\Tests\UnitTests\Type\Response; use App\Type\Etag; use App\Type\Response\NotModifiedResponse; diff --git a/tests/UnitTests/Response/ProblemJsonResponseTest.php b/tests/UnitTests/Type/Response/ProblemJsonResponseTest.php similarity index 96% rename from tests/UnitTests/Response/ProblemJsonResponseTest.php rename to tests/UnitTests/Type/Response/ProblemJsonResponseTest.php index deae7014..03e3bd22 100644 --- a/tests/UnitTests/Response/ProblemJsonResponseTest.php +++ b/tests/UnitTests/Type/Response/ProblemJsonResponseTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\UnitTests\Response; +namespace App\Tests\UnitTests\Type\Response; use App\Type\Response\ProblemJsonResponse; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/UnitTests/Response/TextResponseTest.php b/tests/UnitTests/Type/Response/TextResponseTest.php similarity index 95% rename from tests/UnitTests/Response/TextResponseTest.php rename to tests/UnitTests/Type/Response/TextResponseTest.php index afcf7615..42b43937 100644 --- a/tests/UnitTests/Response/TextResponseTest.php +++ b/tests/UnitTests/Type/Response/TextResponseTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\UnitTests\Response; +namespace App\Tests\UnitTests\Type\Response; use App\Type\Response\TextResponse; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/UnitTests/Type/UploadTest.php b/tests/UnitTests/Type/UploadTest.php index c353fa7b..f3b67961 100644 --- a/tests/UnitTests/Type/UploadTest.php +++ b/tests/UnitTests/Type/UploadTest.php @@ -24,10 +24,12 @@ public function testUpload(): void 321, false, Uuid::fromString('81fbe257-08e0-465b-aa32-8b002a7517c5'), - 1, + ['0123456789abcdef'], Uuid::fromString('beb42bb3-3b3d-40d2-895b-e54ffb7816dd'), 'bin', - $expires + $expires, + null, + true ); $this->assertSame('93257437-6f77-4b2d-87a4-3eea785d13ae', (string) $upload->getId()); @@ -36,8 +38,27 @@ public function testUpload(): void $this->assertSame(false, $upload->isUploadComplete()); $this->assertSame('81fbe257-08e0-465b-aa32-8b002a7517c5', (string) $upload->getUploadTarget()); $this->assertSame(1, $upload->getAlreadyUploadedChunks()); + $this->assertSame(['0123456789abcdef'], $upload->getChunkIds()); $this->assertSame('beb42bb3-3b3d-40d2-895b-e54ffb7816dd', (string) $upload->getUploadOwner()); $this->assertSame('bin', $upload->getExtension()); $this->assertSame($expires, $upload->getExpires()); + $this->assertTrue($upload->targetHadFileAtCreation()); + } + + public function testTargetHadFileAtCreationDefaultsToFalse(): void + { + $upload = new Upload( + Uuid::uuid4(), + null, + 0, + false, + Uuid::uuid4(), + [], + Uuid::uuid4(), + 'bin', + new DateTime() + ); + + $this->assertFalse($upload->targetHadFileAtCreation()); } } diff --git a/tests/UnitTests/Wrapper/ContentDispositionWrapperTest.php b/tests/UnitTests/Wrapper/ContentDispositionWrapperTest.php new file mode 100644 index 00000000..18542f1b --- /dev/null +++ b/tests/UnitTests/Wrapper/ContentDispositionWrapperTest.php @@ -0,0 +1,30 @@ +assertSame('report.pdf', (new ContentDispositionWrapper())->parseContentDisposition('attachment; filename="report.pdf"')); + } + + public function testExtendedFilenameIsPreferred(): void + { + $this->assertSame('€ rates.txt', (new ContentDispositionWrapper())->parseContentDisposition("attachment; filename=\"EURO rates.txt\"; filename*=UTF-8''%E2%82%AC%20rates.txt")); + } + + public function testMissingFilenameReturnsNull(): void + { + $this->assertNull((new ContentDispositionWrapper())->parseContentDisposition('attachment')); + } +} diff --git a/tests/phpunit.feature.xml.dist b/tests/phpunit.feature.xml.dist index 4f9a5bff..dbb9dc46 100644 --- a/tests/phpunit.feature.xml.dist +++ b/tests/phpunit.feature.xml.dist @@ -22,6 +22,7 @@ ../src + ../lib/EmberNexusBundle diff --git a/tests/phpunit.server.xml.dist b/tests/phpunit.server.xml.dist new file mode 100644 index 00000000..81f649e4 --- /dev/null +++ b/tests/phpunit.server.xml.dist @@ -0,0 +1,16 @@ + + + + + + + + + + + + + ServerTests + + + diff --git a/tests/phpunit.xml.dist b/tests/phpunit.xml.dist index 97c3f8e7..6e1a8196 100644 --- a/tests/phpunit.xml.dist +++ b/tests/phpunit.xml.dist @@ -30,6 +30,7 @@ ../src + ../lib/EmberNexusBundle diff --git a/tests/psalm.xml b/tests/psalm.xml index ab5100b0..6122ef91 100644 --- a/tests/psalm.xml +++ b/tests/psalm.xml @@ -19,6 +19,8 @@ + + diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml index 36675a7f..3803b9be 100644 --- a/tools/docker-compose.yml +++ b/tools/docker-compose.yml @@ -14,17 +14,6 @@ services: ports: - "80:80" - reference-dataset-server: - container_name: reference-dataset-server - image: caddy:latest - volumes: - - ../../reference-dataset-local-backup:/usr/share/caddy - ports: - - "8090:80" - # temporary, local-only static file server used to serve a locally modified copy of the - # reference-dataset backup to the `api` container, bypassing the GitHub release used by - # `bin/test-feature-prepare`. Not part of the regular dev stack. - docs: container_name: docs image: sujaykumarh/docsify:latest @@ -51,7 +40,7 @@ services: environment: - MONGO_INITDB_ROOT_USERNAME=mongodb - MONGO_INITDB_ROOT_PASSWORD=password - - GLIBC_TUNABLES="glibc.cpu.hwcaps=-SHSTK" # see https://github.com/docker-library/mongo/discussions/748#discussioncomment-15927486 + - GLIBC_TUNABLES=glibc.cpu.hwcaps=-SHSTK # see https://github.com/docker-library/mongo/discussions/748#discussioncomment-15927486 elasticsearch: container_name: elasticsearch @@ -99,7 +88,7 @@ services: minio: container_name: minio - image: quay.io/minio/minio:latest + image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z ports: - "9000:9000" - "9001:9001" @@ -111,7 +100,7 @@ services: command: server /data --console-address ":9001" minio-init: - image: quay.io/minio/mc:latest + image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z depends_on: - minio entrypoint: > From 6a78d692aebbd1e2b288bb0e5832bb7cab58d38d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Klein?= Date: Wed, 30 Sep 2026 13:13:49 +0200 Subject: [PATCH 5/7] optimize S3 throughput --- config/reference.php | 1 + docker/Dockerfile | 347 +++++++++++++++++- docker/php-embed/docker-php-entrypoint | 9 + docker/php-embed/docker-php-ext-configure | 69 ++++ docker/php-embed/docker-php-ext-enable | 121 ++++++ docker/php-embed/docker-php-ext-install | 143 ++++++++ docker/php-embed/docker-php-source | 34 ++ .../0001-hash-sha-cache-shani-cpu-check.patch | 21 ++ .../src/DependencyInjection/Configuration.php | 5 + .../src/Service/EmberNexusConfiguration.php | 23 ++ src/Factory/S3ClientFactory.php | 12 +- src/Service/S3Service.php | 88 ++++- src/Service/UploadAppendService.php | 12 + src/Service/UploadCreationService.php | 22 ++ .../Backup/BackupFilesTest.php | 18 +- tests/FeatureTests/BaseRequestTestCase.php | 18 +- .../DependencyInjection/ConfigurationTest.php | 5 + .../UnitTests/Factory/S3ClientFactoryTest.php | 50 ++- tests/UnitTests/Service/S3ServiceTest.php | 145 ++++++-- .../Service/UploadAppendServiceTest.php | 2 + .../Service/UploadCreationServiceTest.php | 2 + 21 files changed, 1100 insertions(+), 47 deletions(-) create mode 100755 docker/php-embed/docker-php-entrypoint create mode 100755 docker/php-embed/docker-php-ext-configure create mode 100755 docker/php-embed/docker-php-ext-enable create mode 100755 docker/php-embed/docker-php-ext-install create mode 100755 docker/php-embed/docker-php-source create mode 100644 docker/php-embed/patches/0001-hash-sha-cache-shani-cpu-check.patch diff --git a/config/reference.php b/config/reference.php index 5b1252c6..3ab00db3 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1072,6 +1072,7 @@ * S3StorageBucketLevelLength?: int|Param, // Number of characters used for each level (folder) in the S3 storage bucket. // Default: 2 * S3UploadBucketLevels?: int|Param, // Number of levels (folders) used within the S3 upload bucket. // Default: 2 * S3UploadBucketLevelLength?: int|Param, // Number of characters used for each level (folder) in the S3 upload bucket. // Default: 2 + * S3MaxHostConnections?: int|Param, // Maximum number of concurrent HTTP connections kept open to the S3 host. Used to parallelize otherwise sequential S3 operations, e.g. copying the parts of a large file during merge. Must be greater than 1. // Default: 16 * }, * } * @psalm-type ConfigType = array{ diff --git a/docker/Dockerfile b/docker/Dockerfile index cd9cb35d..2f5948ac 100755 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,21 +1,338 @@ +# PHP itself is compiled from source in the `php_base` stage below instead of reusing the prebuilt +# `dunglas/frankenphp:*-alpine` image, for control over the build (pinned/reproducible toolchain, SQL +# stripped out, etc.). Note this does NOT get ext/hash's SHA-NI (hardware-accelerated SHA-256) code path +# working on Alpine/musl either -- confirmed by building it here too (still ~330 MB/s, same as the prebuilt +# image); see ext/hash/php_hash_sha.h (PHP_HASH_INTRIN_SHA_RESOLVER) in php-src for why. +# +# `php_base`'s RUN steps are adapted from https://github.com/docker-library/php (MIT licensed), with all +# SQL-related configure flags/extensions removed (this project has no SQL dependency) and ZTS + embed kept +# enabled (required by FrankenPHP). The `frankenphp_builder` stage is adapted from FrankenPHP's own +# https://github.com/dunglas/frankenphp/blob/main/alpine.Dockerfile (MIT licensed), building FrankenPHP's +# Go binary via xcaddy/cgo against `php_base` instead of against docker-library's own PHP image. +# +# Helper scripts in ./php-embed/ (docker-php-source, docker-php-ext-*, docker-php-entrypoint) are copied +# verbatim from docker-library/php, unmodified. + +ARG ALPINE_VERSION='3.23' +ARG PHP_VERSION='8.5.11' ARG FRANKENPHP_VERSION='1.12.1' -ARG PHP_VERSION='8.5.4' +ARG WATCHER_VERSION='0.14.5' +ARG GO_VERSION='1.26' ARG CADDY_BUILDER_VERSION='2.11' + FROM caddy:${CADDY_BUILDER_VERSION}-builder-alpine AS caddy_builder -FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-builder-php${PHP_VERSION}-alpine AS frankenphp_builder + + +FROM golang:${GO_VERSION}-alpine AS golang_base + + +FROM alpine:${ALPINE_VERSION} AS frankenphp_source +ARG FRANKENPHP_VERSION +RUN apk add --no-cache curl tar +RUN set -eux; \ + mkdir -p /usr/local/src/frankenphp; \ + curl -fsSL "https://github.com/dunglas/frankenphp/archive/refs/tags/v${FRANKENPHP_VERSION}.tar.gz" -o /tmp/frankenphp.tar.gz; \ + tar -xzf /tmp/frankenphp.tar.gz -C /usr/local/src/frankenphp --strip-components=1; \ + rm /tmp/frankenphp.tar.gz + + +FROM alpine:${ALPINE_VERSION} AS php_base +LABEL org.opencontainers.image.source="https://github.com/docker-library/php" \ + org.opencontainers.image.title="Custom PHP, ZTS + embed, no SQL" \ + org.opencontainers.image.description="PHP compiled from source using the docker-library/php Alpine ZTS recipe (MIT licensed), with all SQL-related extensions/flags removed." + +ARG PHP_VERSION + +# dependencies required for running "phpize" +# these get automatically installed and removed by "docker-php-ext-*" (unless they're already installed) +ENV PHPIZE_DEPS="\ + autoconf \ + dpkg-dev dpkg \ + file \ + g++ \ + gcc \ + libc-dev \ + make \ + pkgconf \ + re2c" \ + PHP_INI_DIR="/usr/local/etc/php" \ +# Apply stack smash protection to functions using local buffers and alloca() +# Make PHP's main executable position-independent (improves ASLR security mechanism, and has no performance impact on x86_64) +# Enable optimization (-O2) +# Enable linker optimization (this sorts the hash buckets to improve cache locality, and is non-default) +# https://github.com/docker-library/php/issues/272 +# -D_LARGEFILE_SOURCE and -D_FILE_OFFSET_BITS=64 (https://www.php.net/manual/en/intro.filesystem.php) +# -DHAVE_FUNC_ATTRIBUTE_TARGET=1: works around ext/hash's SHA-256 (and zend_cpuinfo's other SIMD dispatch) +# never getting a hardware-accelerated code path on Alpine. That path is gated behind this exact macro (see +# ext/hash/php_hash_sha.h / Zend/zend_cpuinfo.h), which configure only sets via build/ax_gcc_func_attribute.m4 +# -- a probe that treats *any* compiler warning on a throwaway one-line test as "attribute unsupported", even +# though GCC only warns (never errors) for it; see https://github.com/php/php-src/issues/15384. GCC on Alpine +# does support `__attribute__((target(...)))` in the actual code (ordinary, well-supported syntax), so this +# just skips the flaky probe instead of trusting it; if that's wrong, `make` fails loudly on a real compiler +# error instead of silently keeping the slow path. Harmless on arm64: the attribute-gated code is additionally +# scoped to `__i386__`/`__x86_64__` at the point this macro is checked, so it has no effect there. + PHP_CFLAGS="-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64 -DHAVE_FUNC_ATTRIBUTE_TARGET=1" \ + PHP_CPPFLAGS="-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64 -DHAVE_FUNC_ATTRIBUTE_TARGET=1" \ + PHP_LDFLAGS="-Wl,-O1 -pie" \ + PHP_BUILD_PROVIDER="https://github.com/ember-nexus/api" \ + PHP_UNAME="Linux - Docker" \ + GPG_KEYS="1198C0117593497A5EC5C199286AF1F9897469DC 49D9AF6BC72A80D6691719C8AA23F5BE9C7097D4 D95C03BC702BE9515344AE3374E44BC9067701A5" \ + PHP_VERSION="${PHP_VERSION}" \ + PHP_URL="https://www.php.net/distributions/php-${PHP_VERSION}.tar.xz" \ + PHP_ASC_URL="https://www.php.net/distributions/php-${PHP_VERSION}.tar.xz.asc" \ +# pinned to PHP_VERSION 8.5.11; bump together when bumping the ARG above + PHP_SHA256="d9be75c08e8c316f4c8f4194d8fbe1750a15f6a6d9d4e3fe72082abeeb800360" + +# persistent / runtime deps +RUN apk add --no-cache \ + ca-certificates \ + curl \ + tar \ + xz + +# ensure www-data user exists +RUN set -eux; \ + adduser -u 82 -D -S -G www-data www-data +# 82 is the standard uid/gid for "www-data" in Alpine +# https://git.alpinelinux.org/aports/tree/main/apache2/apache2.pre-install?h=3.14-stable +# https://git.alpinelinux.org/aports/tree/main/lighttpd/lighttpd.pre-install?h=3.14-stable +# https://git.alpinelinux.org/aports/tree/main/nginx/nginx.pre-install?h=3.14-stable + +RUN set -eux; \ + mkdir -p "$PHP_INI_DIR/conf.d"; \ +# allow running as an arbitrary user (https://github.com/docker-library/php/issues/743) + [ ! -d /var/www/html ]; \ + mkdir -p /var/www/html; \ + chown www-data:www-data /var/www/html; \ + chmod 1777 /var/www/html + +RUN set -eux; \ + apk add --no-cache --virtual .fetch-deps gnupg; \ + mkdir -p /usr/src; \ + cd /usr/src; \ + curl -fsSL -o php.tar.xz "$PHP_URL"; \ + echo "$PHP_SHA256 *php.tar.xz" | sha256sum -c -; \ + curl -fsSL -o php.tar.xz.asc "$PHP_ASC_URL"; \ + export GNUPGHOME="$(mktemp -d)"; \ + for key in $GPG_KEYS; do \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key"; \ + done; \ + gpg --batch --verify php.tar.xz.asc php.tar.xz; \ + gpgconf --kill all; \ + rm -rf "$GNUPGHOME"; \ + apk del --no-network .fetch-deps + +COPY ./docker/php-embed/docker-php-source /usr/local/bin/ +COPY ./docker/php-embed/patches /usr/local/src/php-patches + +RUN set -eux; \ + apk add --no-cache --virtual .build-deps \ + $PHPIZE_DEPS \ + argon2-dev \ + coreutils \ + curl-dev \ + gnu-libiconv-dev \ + libsodium-dev \ + libxml2-dev \ + linux-headers \ + oniguruma-dev \ + openssl-dev \ + patch \ + readline-dev \ + sed \ + ; \ +# make sure musl's iconv doesn't get used (https://www.php.net/manual/en/intro.iconv.php) + rm -vf /usr/include/iconv.h; \ + export \ + CFLAGS="$PHP_CFLAGS" \ + CPPFLAGS="$PHP_CPPFLAGS" \ + LDFLAGS="$PHP_LDFLAGS" \ + ; \ + docker-php-source extract; \ + cd /usr/src/php; \ +# ext/hash's hash_update_stream() (used by IncrementalHashService for upload chunk hashing) reads the +# resource through php_stream_read() in a 1024-byte buffer, which for a large chunk means well over 100,000 +# small stream reads, each paying PHP's stream-wrapper dispatch overhead; that dominates once SHA-256 itself +# is SHA-NI-accelerated (see -DHAVE_FUNC_ATTRIBUTE_TARGET=1 above). Bump it to 100KiB: large enough to cut +# that call count by ~100x, small enough to be a total non-issue as a stack buffer. Only the buffer inside +# PHP_FUNCTION(hash_update_stream) is touched (matched by the `toread = 1024` line immediately following the +# declaration, which is unique to that function) -- hash()/hash_hmac()/hash_update_file()'s own `buf[1024]` +# (used for e.g. hashing a filename/path directly, not this project's upload path) are deliberately left as-is. + sed -i '/char buf\[1024\];/{N;s/char buf\[1024\];\n\([ \t]*\)zend_long toread = 1024;/char buf[102400];\n\1zend_long toread = 102400;/}' ext/hash/hash.c; \ + grep -q 'char buf\[102400\];' ext/hash/hash.c; \ + grep -q 'zend_long toread = 102400;' ext/hash/hash.c; \ + [ "$(grep -c 'char buf\[1024\];' ext/hash/hash.c)" = 3 ]; \ +# SHA256Transform() (the SHA-NI dispatch used by the hash above) re-checks zend_cpu_supports() -- a CPUID +# feature lookup -- on every single 64-byte block, i.e. over a million times for a large upload chunk. That +# lookup is a pure function of CPU feature bits read once at process start, so it is safe to cache instead of +# re-checking per block; see the patch file for the full reasoning kept next to the diff itself. + patch -p1 --forward < /usr/local/src/php-patches/0001-hash-sha-cache-shani-cpu-check.patch; \ + gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \ + test "$PHP_INI_DIR" != "${PHP_INI_DIR%/php}"; \ + ./configure \ + --build="$gnuArch" \ + --sysconfdir="${PHP_INI_DIR%/php}" \ + --with-config-file-path="$PHP_INI_DIR" \ + --with-config-file-scan-dir="$PHP_INI_DIR/conf.d" \ +# make sure invalid --configure-flags are fatal errors instead of just warnings + --enable-option-checking=fatal \ +# https://github.com/docker-library/php/issues/439 + --with-mhash \ +# https://github.com/docker-library/php/issues/822 + --with-pic \ +# --enable-mbstring is included here because otherwise there's no way to get pecl to use it properly (see https://github.com/docker-library/php/issues/195) + --enable-mbstring \ +# opcache is bundled and enabled by default since PHP 7.3, no configure flag needed/accepted anymore +# https://wiki.php.net/rfc/argon2_password_hash + --with-password-argon2 \ +# https://wiki.php.net/rfc/libsodium + --with-sodium=shared \ +# this project has no SQL dependency; PDO and all SQL drivers are fully disabled/removed instead of just unused + --disable-pdo \ + --without-sqlite3 \ + --without-pdo-sqlite \ + --with-curl \ + --with-iconv=/usr \ + --with-openssl \ + --with-readline \ + --with-zlib \ +# https://github.com/docker-library/php/pull/1259 + --enable-phpdbg \ + --enable-phpdbg-readline \ +# in PHP 7.4+, the pecl/pear installers are officially deprecated (requiring an explicit "--with-pear") + --with-pear \ +# https://github.com/docker-library/php/pull/939#issuecomment-730501748 + --enable-embed \ +# required by FrankenPHP + --enable-zts \ +# https://externals.io/message/118859 + --disable-zend-signals \ +# bundled pcre does not support JIT on s390x +# https://manpages.debian.org/bullseye/libpcre3-dev/pcrejit.3.en.html#AVAILABILITY_OF_JIT_SUPPORT + $(test "$gnuArch" = 's390x-linux-musl' && echo '--without-pcre-jit') \ + ; \ + make -j "$(nproc)"; \ + find -type f -name '*.a' -delete; \ + make install; \ + find \ + /usr/local \ + -type f \ + -perm '/0111' \ + -exec sh -euxc ' \ + strip --strip-all "$@" || : \ + ' -- '{}' + \ + ; \ + make clean; \ +# https://github.com/docker-library/php/issues/692 (copy default example "php.ini" files somewhere easily discoverable) + cp -v php.ini-* "$PHP_INI_DIR/"; \ + cd /; \ + docker-php-source delete; \ + runDeps="$( \ + scanelf --needed --nobanner --format '%n#p' --recursive /usr/local \ + | tr ',' '\n' \ + | sort -u \ + | awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \ + )"; \ + apk add --no-cache $runDeps; \ + apk del --no-network .build-deps; \ +# update pecl channel definitions https://github.com/docker-library/php/issues/443 + pecl update-channels; \ + rm -rf /tmp/pear ~/.pearrc; \ +# smoke test + php --version + +# informational only (never fails the build): reports whether ext/hash's SHA-256 actually got the SHA-NI +# hardware path this time, see the -DHAVE_FUNC_ATTRIBUTE_TARGET=1 comment above for why this was in doubt. +# the sample is generated in memory (random_bytes + str_repeat), nothing is read from or stored in the repo. +RUN if [ "$(uname -m)" = "x86_64" ]; then \ + php -r ' \ + $data = str_repeat(random_bytes(1024 * 1024), 32); \ + $t0 = microtime(true); \ + hash("sha256", $data); \ + $t1 = microtime(true); \ + $mbPerSec = (strlen($data) / 1024 / 1024) / ($t1 - $t0); \ + fwrite(STDERR, sprintf("[php_base] ext/hash SHA-256 throughput: %.0f MB/s -- %s\n", $mbPerSec, $mbPerSec > 700 ? "SHA-NI accelerated" : "generic/SSE2 fallback, no hardware acceleration")); \ + '; \ + fi + +COPY ./docker/php-embed/docker-php-ext-* ./docker/php-embed/docker-php-entrypoint /usr/local/bin/ +COPY ./docker/php-embed/php.prod.ini "$PHP_INI_DIR/php.ini" + +# sodium was built as a shared module (so that it can be replaced later if so desired), so let's enable it too (https://github.com/docker-library/php/issues/598) +RUN docker-php-ext-enable sodium \ + && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + + + +FROM php_base AS frankenphp_builder +ARG FRANKENPHP_VERSION +ARG WATCHER_VERSION +ARG GO_VERSION + COPY --from=caddy_builder /usr/bin/xcaddy /usr/bin/xcaddy +COPY --from=golang_base /usr/local/go /usr/local/go +ENV PATH="/usr/local/go/bin:${PATH}" \ + GOTOOLCHAIN=local \ + GOBIN=/usr/local/bin + +# same build deps as https://github.com/dunglas/frankenphp/blob/main/alpine.Dockerfile's "builder" stage, +# minus sqlite-dev (this project's php_base has no SQL support to link against) +RUN apk add --no-cache --virtual .frankenphp-build-deps \ + $PHPIZE_DEPS \ + argon2-dev \ + bash \ + brotli-dev \ + cmake \ + coreutils \ + curl-dev \ + git \ + gnu-libiconv-dev \ + libcap \ + libsodium-dev \ + libstdc++ \ + libxml2-dev \ + linux-headers \ + oniguruma-dev \ + openssl-dev \ + readline-dev \ + upx + +# e-dant/watcher powers FrankenPHP's file watcher; pinned to a release instead of "latest" for reproducible +# builds (bump alongside FRANKENPHP_VERSION if FrankenPHP requires a newer watcher ABI) +WORKDIR /usr/local/src/watcher +RUN set -eux; \ + curl -fsSL "https://github.com/e-dant/watcher/archive/refs/tags/${WATCHER_VERSION}.tar.gz" -o /tmp/watcher.tar.gz; \ + tar -xzf /tmp/watcher.tar.gz -C /usr/local/src/watcher --strip-components=1; \ + rm /tmp/watcher.tar.gz; \ + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release; \ + cmake --build build; \ + cmake --install build + +COPY --from=frankenphp_source /usr/local/src/frankenphp /go/src/app +WORKDIR /go/src/app +RUN go mod download +WORKDIR /go/src/app/caddy +RUN go mod download +WORKDIR /go/src/app + +# see also https://github.com/docker-library/php/blob/master/8.3/alpine3.20/zts/Dockerfile#L53-L55 and +# https://github.com/dunglas/frankenphp/blob/main/alpine.Dockerfile RUN CGO_ENABLED=1 \ XCADDY_SETCAP=1 \ - XCADDY_GO_BUILD_FLAGS="-tags=nobadger,nomysql,nopgx -ldflags='-w -s -extldflags \'-Wl,-z,stack-size=0x80000\''" \ - CGO_CFLAGS=$(php-config --includes) \ - CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ + XCADDY_GO_BUILD_FLAGS="-tags=nobadger,nomysql,nopgx -ldflags='-w -s -extldflags \'-Wl,-z,stack-size=0x80000\' -X \'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP ${FRANKENPHP_VERSION} PHP ${PHP_VERSION} Caddy\' -X \'github.com/caddyserver/caddy/v2/modules/caddyhttp.ServerHeader=FrankenPHP Caddy\''" \ + CGO_CFLAGS="-DFRANKENPHP_VERSION=${FRANKENPHP_VERSION} $(php-config --includes) $PHP_CFLAGS" \ + CGO_CPPFLAGS="$PHP_CPPFLAGS" \ + CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs) -lssl -lcrypto -lreadline -largon2 -lcurl -lonig -lz $PHP_LDFLAGS" \ xcaddy build \ --output /usr/local/bin/frankenphp \ --with github.com/dunglas/frankenphp=./ \ --with github.com/dunglas/frankenphp/caddy=./caddy/ \ --with github.com/dunglas/caddy-cbrotli \ - --with github.com/baldinof/caddy-supervisor + --with github.com/baldinof/caddy-supervisor \ + && setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp \ + && /usr/local/bin/frankenphp version \ + && /usr/local/bin/frankenphp build-info FROM golang:1.25-alpine AS go_builder @@ -27,7 +344,7 @@ RUN cd /build && \ upx --best --lzma expr-cli -FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}-alpine AS php +FROM php_base AS php # see also https://github.com/opencontainers/image-spec/blob/main/annotations.md LABEL org.opencontainers.image.vendor="Ember Nexus" \ org.opencontainers.image.authors="Sören Klein / Syndesi " \ @@ -42,19 +359,21 @@ ENV VERSION=${VERSION} ARG FRANKENPHP_VERSION ENV FRANKENPHP_VERSION=${FRANKENPHP_VERSION} -COPY --from=frankenphp_builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp RUN apk add --no-cache \ ca-certificates \ curl \ icu \ icu-data-full \ + libcap \ + libstdc++ \ libzip \ + mailcap \ # https://github.com/docker-library/php/issues/494 openssl \ pcre \ supercronic \ tar \ - xz \ + xz \ zlib \ && apk add --no-cache --virtual .build-deps \ ${PHPIZE_DEPS} \ @@ -73,14 +392,24 @@ RUN apk add --no-cache \ && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ && apk del .build-deps +COPY --from=frankenphp_builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp +COPY --from=frankenphp_builder /usr/local/lib/libwatcher* /usr/local/lib/ +RUN ldconfig /usr/local/lib || true + +ENV GODEBUG=cgocheck=0 \ + XDG_CONFIG_HOME=/config \ + XDG_DATA_HOME=/data + COPY ./docker/Caddyfile /etc/frankenphp/Caddyfile COPY ./docker/docker-entrypoint.sh /usr/local/bin/ COPY ./docker/supercronic /etc/crontabs/supercronic COPY --from=go_builder /build/expr-cli /usr/local/bin/expr-cli RUN set -x \ + && mkdir -p /config/caddy /data/caddy \ && chmod +x /usr/local/bin/docker-entrypoint.sh \ && adduser -D worker \ && setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp \ + && /usr/local/bin/frankenphp version \ && chown -R worker:worker /config/caddy /data/caddy /etc/crontabs/supercronic STOPSIGNAL SIGTERM diff --git a/docker/php-embed/docker-php-entrypoint b/docker/php-embed/docker-php-entrypoint new file mode 100755 index 00000000..88a016c1 --- /dev/null +++ b/docker/php-embed/docker-php-entrypoint @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +# first arg is `-f` or `--some-option` +if [ "${1#-}" != "$1" ]; then + set -- php "$@" +fi + +exec "$@" diff --git a/docker/php-embed/docker-php-ext-configure b/docker/php-embed/docker-php-ext-configure new file mode 100755 index 00000000..34fc1337 --- /dev/null +++ b/docker/php-embed/docker-php-ext-configure @@ -0,0 +1,69 @@ +#!/bin/sh +set -e + +# prefer user supplied CFLAGS, but default to our PHP_CFLAGS +: ${CFLAGS:=$PHP_CFLAGS} +: ${CPPFLAGS:=$PHP_CPPFLAGS} +: ${LDFLAGS:=$PHP_LDFLAGS} +export CFLAGS CPPFLAGS LDFLAGS + +srcExists= +if [ -d /usr/src/php ]; then + srcExists=1 +fi +docker-php-source extract +if [ -z "$srcExists" ]; then + touch /usr/src/php/.docker-delete-me +fi + +cd /usr/src/php/ext + +usage() { + echo "usage: $0 ext-name [configure flags]" + echo " ie: $0 gd --with-jpeg-dir=/usr/local/something" + echo + echo 'Possible values for ext-name:' + find . \ + -mindepth 2 \ + -maxdepth 2 \ + -type f \ + -name 'config.m4' \ + | xargs -n1 dirname \ + | xargs -n1 basename \ + | sort \ + | xargs + echo + echo 'Some of the above modules are already compiled into PHP; please check' + echo 'the output of "php -i" to see which modules are already loaded.' +} + +ext="$1" +if [ -z "$ext" ] || [ ! -d "$ext" ]; then + usage >&2 + exit 1 +fi +shift + +pm='unknown' +if [ -e /lib/apk/db/installed ]; then + pm='apk' +fi + +if [ "$pm" = 'apk' ]; then + if \ + [ -n "$PHPIZE_DEPS" ] \ + && ! apk info --installed .phpize-deps > /dev/null \ + && ! apk info --installed .phpize-deps-configure > /dev/null \ + ; then + apk add --no-cache --virtual .phpize-deps-configure $PHPIZE_DEPS + fi +fi + +if command -v dpkg-architecture > /dev/null; then + gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)" + set -- --build="$gnuArch" "$@" +fi + +cd "$ext" +phpize +./configure --enable-option-checking=fatal "$@" diff --git a/docker/php-embed/docker-php-ext-enable b/docker/php-embed/docker-php-ext-enable new file mode 100755 index 00000000..41d20bbe --- /dev/null +++ b/docker/php-embed/docker-php-ext-enable @@ -0,0 +1,121 @@ +#!/bin/sh +set -e + +extDir="$(php -d 'display_errors=stderr' -r 'echo ini_get("extension_dir");')" +cd "$extDir" + +usage() { + echo "usage: $0 [options] module-name [module-name ...]" + echo " ie: $0 gd mysqli" + echo " $0 pdo pdo_mysql" + echo " $0 --ini-name 0-apc.ini apcu apc" + echo + echo 'Possible values for module-name:' + find -maxdepth 1 \ + -type f \ + -name '*.so' \ + -exec basename '{}' ';' \ + | sort \ + | xargs + echo + echo 'Some of the above modules are already compiled into PHP; please check' + echo 'the output of "php -i" to see which modules are already loaded.' +} + +opts="$(getopt -o 'h?' --long 'help,ini-name:' -- "$@" || { usage >&2 && false; })" +eval set -- "$opts" + +iniName= +while true; do + flag="$1" + shift + case "$flag" in + --help|-h|'-?') usage && exit 0 ;; + --ini-name) iniName="$1" && shift ;; + --) break ;; + *) + { + echo "error: unknown flag: $flag" + usage + } >&2 + exit 1 + ;; + esac +done + +modules= +for module; do + if [ -z "$module" ]; then + continue + fi + if ! [ -f "$module" ] && ! [ -f "$module.so" ]; then + echo >&2 "error: '$module' does not exist" + echo >&2 + usage >&2 + exit 1 + fi + modules="$modules $module" +done + +if [ -z "$modules" ]; then + usage >&2 + exit 1 +fi + +pm='unknown' +if [ -e /lib/apk/db/installed ]; then + pm='apk' +fi + +apkDel= +if [ "$pm" = 'apk' ]; then + if \ + [ -n "$PHPIZE_DEPS" ] \ + && ! apk info --installed .phpize-deps > /dev/null \ + && ! apk info --installed .phpize-deps-configure > /dev/null \ + ; then + apk add --no-cache --virtual '.docker-php-ext-enable-deps' binutils + apkDel='.docker-php-ext-enable-deps' + fi +fi + +for module in $modules; do + moduleFile="$module" + if [ -f "$module.so" ] && ! [ -f "$module" ]; then + moduleFile="$module.so" + fi + if readelf --wide --syms "$moduleFile" | grep -q ' zend_extension_entry$'; then + # https://wiki.php.net/internals/extensions#loading_zend_extensions + line="zend_extension=$module" + else + line="extension=$module" + fi + + ext="$(basename "$module")" + ext="${ext%.*}" + if php -d 'display_errors=stderr' -r 'exit(extension_loaded("'"$ext"'") ? 0 : 1);'; then + # this isn't perfect, but it's better than nothing + # (for example, 'opcache.so' presents inside PHP as 'Zend OPcache', not 'opcache') + echo >&2 + echo >&2 "warning: $ext ($module) is already loaded!" + echo >&2 + continue + fi + + case "$iniName" in + /*) + # allow an absolute path + ini="$iniName" + ;; + *) + ini="$PHP_INI_DIR/conf.d/${iniName:-"docker-php-ext-$ext.ini"}" + ;; + esac + if ! grep -qFx -e "$line" -e "$line.so" "$ini" 2>/dev/null; then + echo "$line" >> "$ini" + fi +done + +if [ "$pm" = 'apk' ] && [ -n "$apkDel" ]; then + apk del --no-network $apkDel +fi diff --git a/docker/php-embed/docker-php-ext-install b/docker/php-embed/docker-php-ext-install new file mode 100755 index 00000000..fbf44849 --- /dev/null +++ b/docker/php-embed/docker-php-ext-install @@ -0,0 +1,143 @@ +#!/bin/sh +set -e + +# prefer user supplied CFLAGS, but default to our PHP_CFLAGS +: ${CFLAGS:=$PHP_CFLAGS} +: ${CPPFLAGS:=$PHP_CPPFLAGS} +: ${LDFLAGS:=$PHP_LDFLAGS} +export CFLAGS CPPFLAGS LDFLAGS + +srcExists= +if [ -d /usr/src/php ]; then + srcExists=1 +fi +docker-php-source extract +if [ -z "$srcExists" ]; then + touch /usr/src/php/.docker-delete-me +fi + +cd /usr/src/php/ext + +usage() { + echo "usage: $0 [-jN] [--ini-name file.ini] ext-name [ext-name ...]" + echo " ie: $0 gd mysqli" + echo " $0 pdo pdo_mysql" + echo " $0 -j5 gd mbstring mysqli pdo pdo_mysql shmop" + echo + echo 'if custom ./configure arguments are necessary, see docker-php-ext-configure' + echo + echo 'Possible values for ext-name:' + find . \ + -mindepth 2 \ + -maxdepth 2 \ + -type f \ + -name 'config.m4' \ + | xargs -n1 dirname \ + | xargs -n1 basename \ + | sort \ + | xargs + echo + echo 'Some of the above modules are already compiled into PHP; please check' + echo 'the output of "php -i" to see which modules are already loaded.' +} + +opts="$(getopt -o 'h?j:' --long 'help,ini-name:,jobs:' -- "$@" || { usage >&2 && false; })" +eval set -- "$opts" + +j=1 +iniName= +while true; do + flag="$1" + shift + case "$flag" in + --help|-h|'-?') usage && exit 0 ;; + --ini-name) iniName="$1" && shift ;; + --jobs|-j) j="$1" && shift ;; + --) break ;; + *) + { + echo "error: unknown flag: $flag" + usage + } >&2 + exit 1 + ;; + esac +done + +exts= +for ext; do + if [ -z "$ext" ]; then + continue + fi + if [ ! -d "$ext" ]; then + echo >&2 "error: $PWD/$ext does not exist" + echo >&2 + usage >&2 + exit 1 + fi + exts="$exts $ext" +done + +if [ -z "$exts" ]; then + usage >&2 + exit 1 +fi + +pm='unknown' +if [ -e /lib/apk/db/installed ]; then + pm='apk' +fi + +apkDel= +if [ "$pm" = 'apk' ]; then + if [ -n "$PHPIZE_DEPS" ]; then + if apk info --installed .phpize-deps-configure > /dev/null; then + apkDel='.phpize-deps-configure' + elif ! apk info --installed .phpize-deps > /dev/null; then + apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS + apkDel='.phpize-deps' + fi + fi +fi + +popDir="$PWD" +for ext in $exts; do + cd "$ext" + + [ -e Makefile ] || docker-php-ext-configure "$ext" + + make -j"$j" + + if ! php -n -d 'display_errors=stderr' -r 'exit(ZEND_DEBUG_BUILD ? 0 : 1);' > /dev/null; then + # only "strip" modules if we aren't using a debug build of PHP + # (none of our builds are debug builds, but PHP might be recompiled with "--enable-debug" configure option) + # https://github.com/docker-library/php/issues/1268 + + find modules \ + -maxdepth 1 \ + -name '*.so' \ + -exec sh -euxc ' \ + strip --strip-all "$@" || : + ' -- '{}' + + fi + + make -j"$j" install + + find modules \ + -maxdepth 1 \ + -name '*.so' \ + -exec basename '{}' '.so' ';' \ + | xargs -r docker-php-ext-enable ${iniName:+--ini-name "$iniName"} + + make -j"$j" clean + + cd "$popDir" +done + +if [ "$pm" = 'apk' ] && [ -n "$apkDel" ]; then + apk del --no-network $apkDel +fi + +if [ -e /usr/src/php/.docker-delete-me ]; then + docker-php-source delete +fi diff --git a/docker/php-embed/docker-php-source b/docker/php-embed/docker-php-source new file mode 100755 index 00000000..9033d243 --- /dev/null +++ b/docker/php-embed/docker-php-source @@ -0,0 +1,34 @@ +#!/bin/sh +set -e + +dir=/usr/src/php + +usage() { + echo "usage: $0 COMMAND" + echo + echo "Manage php source tarball lifecycle." + echo + echo "Commands:" + echo " extract extract php source tarball into directory $dir if not already done." + echo " delete delete extracted php source located into $dir if not already done." + echo +} + +case "$1" in + extract) + mkdir -p "$dir" + if [ ! -f "$dir/.docker-extracted" ]; then + tar -Jxf /usr/src/php.tar.xz -C "$dir" --strip-components=1 + touch "$dir/.docker-extracted" + fi + ;; + + delete) + rm -rf "$dir" + ;; + + *) + usage + exit 1 + ;; +esac diff --git a/docker/php-embed/patches/0001-hash-sha-cache-shani-cpu-check.patch b/docker/php-embed/patches/0001-hash-sha-cache-shani-cpu-check.patch new file mode 100644 index 00000000..292fc270 --- /dev/null +++ b/docker/php-embed/patches/0001-hash-sha-cache-shani-cpu-check.patch @@ -0,0 +1,21 @@ +--- a/ext/hash/hash_sha.c ++++ b/ext/hash/hash_sha.c +@@ -168,7 +168,17 @@ + SHA256_Transform_shani(state, block); + return; + #elif defined(PHP_HASH_INTRIN_SHA_RESOLVER) +- if (zend_cpu_supports(ZEND_CPU_FEATURE_SSSE3) && zend_cpu_supports(ZEND_CPU_FEATURE_SHA)) { ++ /* zend_cpu_supports() itself is cheap, but SHA256Transform() is called once per 64-byte block -- ++ * millions of times for a large upload chunk. Cache the resolved result instead of re-checking the ++ * CPUID-derived feature flags on every block. Safe to cache process-wide: zend_cpu_supports() is a ++ * pure function of the CPU feature bits zend_cpu_startup() reads once at process start, so every ++ * thread computes the same value; a benign race on first use (concurrent threads computing it at the ++ * same time) can only ever produce that same value. */ ++ static int has_shani = -1; ++ if (has_shani < 0) { ++ has_shani = zend_cpu_supports(ZEND_CPU_FEATURE_SSSE3) && zend_cpu_supports(ZEND_CPU_FEATURE_SHA); ++ } ++ if (has_shani) { + SHA256_Transform_shani(state, block); + return; + } diff --git a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php index 32cb880a..1a91fb21 100644 --- a/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php +++ b/lib/EmberNexusBundle/src/DependencyInjection/Configuration.php @@ -206,6 +206,11 @@ public function getConfigTreeBuilder(): TreeBuilder ->min(1) ->defaultValue(2) ->end() + ->integerNode(EmberNexusConfiguration::FILE_S3_MAX_HOST_CONNECTIONS) + ->info('Maximum number of concurrent HTTP connections kept open to the S3 host. Used to parallelize otherwise sequential S3 operations, e.g. copying the parts of a large file during merge. Must be greater than 1.') + ->min(2) + ->defaultValue(16) + ->end() ->end() ->end() diff --git a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php index 5d82e3ac..77785515 100644 --- a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php +++ b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php @@ -55,6 +55,7 @@ class EmberNexusConfiguration public const string FILE_S3_STORAGE_BUCKET_LEVEL_LENGTH = 'S3StorageBucketLevelLength'; public const string FILE_S3_UPLOAD_BUCKET_LEVELS = 'S3UploadBucketLevels'; public const string FILE_S3_UPLOAD_BUCKET_LEVEL_LENGTH = 'S3UploadBucketLevelLength'; + public const string FILE_S3_MAX_HOST_CONNECTIONS = 'S3MaxHostConnections'; private int $pageSizeMin; private int $pageSizeDefault; @@ -86,6 +87,7 @@ class EmberNexusConfiguration private int $fileS3StorageBucketLevelLength; private int $fileS3UploadBucketLevels; private int $fileS3UploadBucketLevelLength; + private int $fileS3MaxHostConnections; private static function getValueFromConfig(array $configuration, array $keyParts): mixed { @@ -376,6 +378,15 @@ public static function createFromConfiguration(array $configuration): static ); $emberNexusConfiguration->setFileS3UploadBucketLevelLength($value); + $value = (int) self::getValueFromConfig( + $configuration, + [ + self::FILE, + self::FILE_S3_MAX_HOST_CONNECTIONS, + ] + ); + $emberNexusConfiguration->setFileS3MaxHostConnections($value); + return $emberNexusConfiguration; } @@ -735,4 +746,16 @@ public function setFileS3UploadBucketLevelLength(int $fileS3UploadBucketLevelLen return $this; } + + public function getFileS3MaxHostConnections(): int + { + return $this->fileS3MaxHostConnections; + } + + public function setFileS3MaxHostConnections(int $fileS3MaxHostConnections): static + { + $this->fileS3MaxHostConnections = $fileS3MaxHostConnections; + + return $this; + } } diff --git a/src/Factory/S3ClientFactory.php b/src/Factory/S3ClientFactory.php index 19463c2a..b2fde9f6 100644 --- a/src/Factory/S3ClientFactory.php +++ b/src/Factory/S3ClientFactory.php @@ -6,6 +6,9 @@ use AsyncAws\Core\Configuration; use AsyncAws\S3\S3Client; +use EmberNexusBundle\Service\EmberNexusConfiguration; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpClient\HttpClient; /** * @codeCoverageIgnore @@ -16,6 +19,8 @@ public function __construct( private string $s3Endpoint, private string $s3AccessKeyId, private string $s3SecretAccessKey, + private LoggerInterface $logger, + private EmberNexusConfiguration $emberNexusConfiguration, ) { } @@ -28,6 +33,11 @@ public function createS3Client(): S3Client Configuration::OPTION_SECRET_ACCESS_KEY => $this->s3SecretAccessKey, ]); - return new S3Client($configuration); + // Symfony's HttpClient defaults to 6 concurrent connections per host, which throttles otherwise-parallel S3 + // operations, e.g. the concurrent `uploadPartCopy`/delete calls issued by S3Service while merging a large + // file's chunks. + $httpClient = HttpClient::create(maxHostConnections: $this->emberNexusConfiguration->getFileS3MaxHostConnections()); + + return new S3Client($configuration, httpClient: $httpClient, logger: $this->logger); } } diff --git a/src/Service/S3Service.php b/src/Service/S3Service.php index 38884d33..7974a4aa 100644 --- a/src/Service/S3Service.php +++ b/src/Service/S3Service.php @@ -18,6 +18,7 @@ use AsyncAws\S3\Result\GetObjectOutput; use AsyncAws\S3\S3Client; use Psr\Log\LoggerInterface; +use Symfony\Component\Stopwatch\Stopwatch; use Throwable; /** @@ -26,6 +27,9 @@ */ class S3Service { + private const string PROFILER_MERGE_FILE_CHUNKS_CHUNK_MERGE = 'S3Service:mergeFileChunks:chunkMerge'; + private const string PROFILER_MERGE_FILE_CHUNKS_TRANSFER = 'S3Service:mergeFileChunks:transfer'; + /** * Kept well below the backend's single PUT limit, as a failed single PUT has to be retried completely. */ @@ -46,6 +50,7 @@ public function __construct( private S3TechnicalLimitsInterface $s3TechnicalLimits, private LoggerInterface $logger, S3TechnicalLimitsValidator $s3TechnicalLimitsValidator, + private Stopwatch $stopwatch, private int $multipartUploadThresholdInBytes = self::MULTIPART_UPLOAD_THRESHOLD_IN_BYTES, private int $multipartUploadPartSizeInBytes = self::MULTIPART_UPLOAD_PART_SIZE_IN_BYTES, ) { @@ -79,16 +84,22 @@ public function createMultipartUploadFromMergeFileChunksOperation(MergeFileChunk */ public function createUploadPartsFromMergeFileChunksOperation(MergeFileChunksOperationInterface $mergeFileChunksOperation, string $multipartUploadId): array { - $parts = []; + // `uploadPartCopy` is a server-side copy: AsyncAws results are lazy and only block on the first call to a + // getter. All copies are issued here, before any of them is read, so the underlying HTTP client can run them + // concurrently instead of waiting for each part to finish before starting the next. + $copyResults = []; foreach ($mergeFileChunksOperation->getUploadKeys() as $i => $uploadKey) { - $copyResult = $this->s3Client->uploadPartCopy([ + $copyResults[$i] = $this->s3Client->uploadPartCopy([ 'Bucket' => $mergeFileChunksOperation->getStorageBucket(), 'Key' => $mergeFileChunksOperation->getStorageKey(), 'UploadId' => $multipartUploadId, 'PartNumber' => $i + 1, 'CopySource' => sprintf('%s/%s', $mergeFileChunksOperation->getUploadBucket(), $uploadKey), ]); + } + $parts = []; + foreach ($copyResults as $i => $copyResult) { $copyPartResult = $copyResult->getCopyPartResult(); if (null === $copyPartResult) { throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to read copy part result.'); @@ -112,7 +123,11 @@ public function mergeFileChunks(MergeFileChunksOperationInterface $mergeFileChun { $multipartUploadId = $this->createMultipartUploadFromMergeFileChunksOperation($mergeFileChunksOperation); try { + $this->stopwatch->start(self::PROFILER_MERGE_FILE_CHUNKS_CHUNK_MERGE); $parts = $this->createUploadPartsFromMergeFileChunksOperation($mergeFileChunksOperation, $multipartUploadId); + $this->stopwatch->stop(self::PROFILER_MERGE_FILE_CHUNKS_CHUNK_MERGE); + + $this->stopwatch->start(self::PROFILER_MERGE_FILE_CHUNKS_TRANSFER); $this->s3Client->completeMultipartUpload([ 'Bucket' => $mergeFileChunksOperation->getStorageBucket(), 'Key' => $mergeFileChunksOperation->getStorageKey(), @@ -121,6 +136,7 @@ public function mergeFileChunks(MergeFileChunksOperationInterface $mergeFileChun 'Parts' => $parts, ], ]); + $this->stopwatch->stop(self::PROFILER_MERGE_FILE_CHUNKS_TRANSFER); } catch (Throwable $e) { $this->tryAbortMultipartUpload($mergeFileChunksOperation->getStorageBucket(), $mergeFileChunksOperation->getStorageKey(), $multipartUploadId); @@ -159,11 +175,71 @@ public function getChunksContentLength(MergeFileChunksOperationInterface $mergeF public function deleteFileChunks(MergeFileChunksOperationInterface $mergeFileChunksOperation): void { + $fileOperations = []; foreach ($mergeFileChunksOperation->getUploadKeys() as $uploadKey) { - $this->deleteFile(new FileOperation( - $mergeFileChunksOperation->getUploadBucket(), - $uploadKey - )); + $fileOperations[] = new FileOperation($mergeFileChunksOperation->getUploadBucket(), $uploadKey); + } + + $this->deleteFiles($fileOperations); + } + + /** + * Same steps as {@see deleteFile()} for each operation, but all S3 calls of a step are issued for every + * operation before any of them is read, so the underlying HTTP client can run them concurrently (bounded by + * {@see EmberNexusConfiguration::FILE_S3_MAX_HOST_CONNECTIONS}) instead of resolving one file fully before + * starting the next. + * + * @param FileOperationInterface[] $fileOperations + */ + private function deleteFiles(array $fileOperations): void + { + if ([] === $fileOperations) { + return; + } + + $objectConfigs = []; + foreach ($fileOperations as $i => $fileOperation) { + $objectConfigs[$i] = [ + 'Bucket' => $fileOperation->getBucket(), + 'Key' => $fileOperation->getKey(), + ]; + } + + $existsWaiters = []; + foreach ($objectConfigs as $i => $objectConfig) { + $existsWaiters[$i] = $this->s3Client->objectExists($objectConfig); + } + + $objectConfigsToDelete = []; + foreach ($existsWaiters as $i => $existsWaiter) { + if ($this->s3ClientWrapper->getIsSuccessFromObjectExistsWaiter($existsWaiter)) { + $objectConfigsToDelete[$i] = $objectConfigs[$i]; + } + } + + if ([] === $objectConfigsToDelete) { + return; + } + + $deleteResults = []; + foreach ($objectConfigsToDelete as $i => $objectConfig) { + $deleteResults[$i] = $this->s3Client->deleteObject($objectConfig); + } + // every delete must actually be applied before the objects are checked again below, so all deletes (which run + // concurrently among themselves) are resolved here, before any confirmation waiter is issued + foreach ($deleteResults as $deleteResult) { + $deleteResult->resolve(); + } + + $confirmationWaiters = []; + foreach ($objectConfigsToDelete as $i => $objectConfig) { + $confirmationWaiters[$i] = $this->s3Client->objectExists($objectConfig); + } + + foreach ($confirmationWaiters as $confirmationWaiter) { + if ($this->s3ClientWrapper->getIsSuccessFromObjectExistsWaiter($confirmationWaiter)) { + throw $this->server500LogicErrorExceptionFactory->createFromTemplate('Unable to delete file.'); + } } } diff --git a/src/Service/UploadAppendService.php b/src/Service/UploadAppendService.php index f660b8af..16af04de 100644 --- a/src/Service/UploadAppendService.php +++ b/src/Service/UploadAppendService.php @@ -10,6 +10,7 @@ use App\Factory\Type\S3\S3OperationFactory; use App\Factory\Type\UploadFactory; use HashContext; +use Symfony\Component\Stopwatch\Stopwatch; use Throwable; /** @@ -18,6 +19,10 @@ */ class UploadAppendService { + private const string PROFILER_CHUNK_UPLOAD_LENGTH_CHECK = 'UploadAppendService:chunkUpload:lengthCheck'; + private const string PROFILER_CHUNK_UPLOAD_HASH_CALCULATION = 'UploadAppendService:chunkUpload:hashCalculation'; + private const string PROFILER_CHUNK_UPLOAD_S3_UPLOAD = 'UploadAppendService:chunkUpload:s3Upload'; + public function __construct( private UploadFactory $uploadFactory, private UploadService $uploadService, @@ -30,6 +35,7 @@ public function __construct( private Client409ConflictExceptionFactory $client409ConflictExceptionFactory, private ElementManager $elementManager, private ElementService $elementService, + private Stopwatch $stopwatch, ) { } @@ -70,16 +76,20 @@ public function append(UploadInterface $upload, PartialUploadRequestInterface $p // reject obviously invalid chunks before anything is sent to S3 if (null !== $declaredChunkLength) { + $this->stopwatch->start(self::PROFILER_CHUNK_UPLOAD_LENGTH_CHECK); $this->uploadChunkValidator->assertValidChunk($declaredChunkLength, $isFinalChunk, $upload->getUploadOffset(), $upload->getUploadLength()); + $this->stopwatch->stop(self::PROFILER_CHUNK_UPLOAD_LENGTH_CHECK); } // hashed before the upload to S3, see IncrementalHashService $resource = $partialUploadRequest->getContent(); $hashState = $upload->getHashState(); + $this->stopwatch->start(self::PROFILER_CHUNK_UPLOAD_HASH_CALCULATION); $hashContext = null !== $hashState ? $this->incrementalHashService->unserializeContextFromStorage($hashState) : $this->incrementalHashService->createContext(FileHashService::ALGORITHM); $this->incrementalHashService->updateFromResource($hashContext, $resource); + $this->stopwatch->stop(self::PROFILER_CHUNK_UPLOAD_HASH_CALCULATION); $chunkId = null; $chunkLength = 0; @@ -88,7 +98,9 @@ public function append(UploadInterface $upload, PartialUploadRequestInterface $p } else { // every attempt writes its own object, so that a concurrent attempt for the same chunk can not overwrite it $chunkId = $this->fileService->generateUploadChunkId(); + $this->stopwatch->start(self::PROFILER_CHUNK_UPLOAD_S3_UPLOAD); $chunkLength = $this->s3Service->uploadFileChunk($this->s3OperationFactory->createUploadFileChunkOperationFromPartialUploadRequest($partialUploadRequest, $upload, $chunkId)); + $this->stopwatch->stop(self::PROFILER_CHUNK_UPLOAD_S3_UPLOAD); $this->closeResource($resource); } diff --git a/src/Service/UploadCreationService.php b/src/Service/UploadCreationService.php index 4a9c0cf3..495964a5 100755 --- a/src/Service/UploadCreationService.php +++ b/src/Service/UploadCreationService.php @@ -23,6 +23,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -30,6 +31,13 @@ */ class UploadCreationService { + private const string PROFILER_DIRECT_UPLOAD_LENGTH_CHECK = 'UploadCreationService:directUpload:lengthCheck'; + private const string PROFILER_DIRECT_UPLOAD_HASH_CALCULATION = 'UploadCreationService:directUpload:hashCalculation'; + private const string PROFILER_DIRECT_UPLOAD_S3_UPLOAD = 'UploadCreationService:directUpload:s3Upload'; + private const string PROFILER_CHUNK_UPLOAD_LENGTH_CHECK = 'UploadCreationService:chunkUpload:lengthCheck'; + private const string PROFILER_CHUNK_UPLOAD_HASH_CALCULATION = 'UploadCreationService:chunkUpload:hashCalculation'; + private const string PROFILER_CHUNK_UPLOAD_S3_UPLOAD = 'UploadCreationService:chunkUpload:s3Upload'; + public function __construct( private AuthProvider $authProvider, private EmberNexusConfiguration $emberNexusConfiguration, @@ -50,6 +58,7 @@ public function __construct( private UploadChunkValidator $uploadChunkValidator, private ElementFileDeletionService $elementFileDeletionService, private ElementService $elementService, + private Stopwatch $stopwatch, ) { } @@ -84,18 +93,22 @@ private function setOrReplaceElementFileDirectly( ResumableUploadRequestInterface $resumableUploadRequest, array $requestDigestHeaderValues, ): Response { + $this->stopwatch->start(self::PROFILER_DIRECT_UPLOAD_LENGTH_CHECK); $contentLength = $resumableUploadRequest->getContentLength(); if (null !== $contentLength) { $this->fileSizeLimitService->assertWithinMaxFileSize($contentLength); } // a single request is bound by the maximum chunk size; the content itself is bound in the request factory $this->uploadBodyLimitService->assertDeclaredLengthWithinLimit($contentLength); + $this->stopwatch->stop(self::PROFILER_DIRECT_UPLOAD_LENGTH_CHECK); $resource = $resumableUploadRequest->getContent(); // hashed before the upload, so that a digest mismatch never replaces an existing file + $this->stopwatch->start(self::PROFILER_DIRECT_UPLOAD_HASH_CALCULATION); $hashContext = $this->incrementalHashService->createContext(FileHashService::ALGORITHM); $this->incrementalHashService->updateFromResource($hashContext, $resource); $hash = $this->incrementalHashService->finalize($hashContext); + $this->stopwatch->stop(self::PROFILER_DIRECT_UPLOAD_HASH_CALCULATION); $uploadFileOperation = $this->s3OperationFactory->createUploadFileOperationFromResumableUploadRequest($resumableUploadRequest); @@ -104,7 +117,9 @@ private function setOrReplaceElementFileDirectly( } // authoritative length, the Content-Length header is optional (e.g. chunked transfer encoding) + $this->stopwatch->start(self::PROFILER_DIRECT_UPLOAD_S3_UPLOAD); $uploadedContentLength = $this->s3Service->uploadFile($uploadFileOperation); + $this->stopwatch->stop(self::PROFILER_DIRECT_UPLOAD_S3_UPLOAD); // the S3 client may already have closed the resource while uploading it /** @psalm-suppress RedundantConditionGivenDocblockType */ if (is_resource($resource)) { @@ -165,13 +180,20 @@ private function createNewResumableUpload(ResumableUploadRequestInterface $resum } } else { // rejected before anything is sent to S3 + $this->stopwatch->start(self::PROFILER_CHUNK_UPLOAD_LENGTH_CHECK); $this->uploadChunkValidator->assertWithinDeclaredLength($contentLength, false, 0, $uploadLength); + $this->stopwatch->stop(self::PROFILER_CHUNK_UPLOAD_LENGTH_CHECK); + + $this->stopwatch->start(self::PROFILER_CHUNK_UPLOAD_HASH_CALCULATION); $hashContext = $this->incrementalHashService->createContext(FileHashService::ALGORITHM); $this->incrementalHashService->updateFromResource($hashContext, $resource); + $this->stopwatch->stop(self::PROFILER_CHUNK_UPLOAD_HASH_CALCULATION); $chunkId = $this->fileService->generateUploadChunkId(); $uploadFileChunkOperation = $this->s3OperationFactory->createUploadFileChunkOperationFromResumableUploadRequest($resumableUploadRequest, $uploadId, $chunkId); + $this->stopwatch->start(self::PROFILER_CHUNK_UPLOAD_S3_UPLOAD); $uploadOffset = $this->s3Service->uploadFileChunk($uploadFileChunkOperation); + $this->stopwatch->stop(self::PROFILER_CHUNK_UPLOAD_S3_UPLOAD); // the S3 client may already have closed the resource while uploading it /** @psalm-suppress RedundantConditionGivenDocblockType */ if (is_resource($resource)) { diff --git a/tests/ExampleGenerationCommand/Backup/BackupFilesTest.php b/tests/ExampleGenerationCommand/Backup/BackupFilesTest.php index 2aede4ed..1606425c 100644 --- a/tests/ExampleGenerationCommand/Backup/BackupFilesTest.php +++ b/tests/ExampleGenerationCommand/Backup/BackupFilesTest.php @@ -6,7 +6,10 @@ use App\Factory\S3ClientFactory; use App\Tests\ExampleGenerationCommand\BaseCommandTestCase; +use AsyncAws\S3\S3Client; +use EmberNexusBundle\Service\EmberNexusConfiguration; use FilesystemIterator; +use Psr\Log\NullLogger; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -111,9 +114,20 @@ public function testBackupCreateAndLoadRoundTripIncludesFilesWithoutExtension(): } } + private function createS3ClientForAssertions(): S3Client + { + return (new S3ClientFactory( + $_ENV['S3_ENDPOINT'], + $_ENV['S3_ACCESS_KEY_ID'], + $_ENV['S3_SECRET_ACCESS_KEY'], + new NullLogger(), + (new EmberNexusConfiguration())->setFileS3MaxHostConnections(16), + ))->createS3Client(); + } + private function isFileInStorageWithTrailingDot(string $elementId): bool { - $s3Client = (new S3ClientFactory($_ENV['S3_ENDPOINT'], $_ENV['S3_ACCESS_KEY_ID'], $_ENV['S3_SECRET_ACCESS_KEY']))->createS3Client(); + $s3Client = $this->createS3ClientForAssertions(); foreach ($s3Client->listObjectsV2(['Bucket' => 'api-storage']) as $object) { if (str_contains((string) $object->getKey(), $elementId) && str_ends_with((string) $object->getKey(), '.')) { return true; @@ -125,7 +139,7 @@ private function isFileInStorageWithTrailingDot(string $elementId): bool private function isFileInStorage(string $elementId): bool { - $s3Client = (new S3ClientFactory($_ENV['S3_ENDPOINT'], $_ENV['S3_ACCESS_KEY_ID'], $_ENV['S3_SECRET_ACCESS_KEY']))->createS3Client(); + $s3Client = $this->createS3ClientForAssertions(); foreach ($s3Client->listObjectsV2(['Bucket' => 'api-storage']) as $object) { if (str_contains((string) $object->getKey(), $elementId)) { return true; diff --git a/tests/FeatureTests/BaseRequestTestCase.php b/tests/FeatureTests/BaseRequestTestCase.php index 5f67b90a..f59070cf 100644 --- a/tests/FeatureTests/BaseRequestTestCase.php +++ b/tests/FeatureTests/BaseRequestTestCase.php @@ -5,10 +5,13 @@ namespace App\Tests\FeatureTests; use App\Factory\S3ClientFactory; +use AsyncAws\S3\S3Client; +use EmberNexusBundle\Service\EmberNexusConfiguration; use GuzzleHttp\Client; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; +use Psr\Log\NullLogger; /** * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) @@ -456,9 +459,20 @@ public function assertFileDoesNotExistInStorage(string $elementId): void $this->assertFalse($this->isFileInStorage($elementId), sprintf('Expected file of element %s to be removed from storage bucket.', $elementId)); } + private function createS3ClientForAssertions(): S3Client + { + return (new S3ClientFactory( + $_ENV['S3_ENDPOINT'], + $_ENV['S3_ACCESS_KEY_ID'], + $_ENV['S3_SECRET_ACCESS_KEY'], + new NullLogger(), + (new EmberNexusConfiguration())->setFileS3MaxHostConnections(16), + ))->createS3Client(); + } + private function isFileInStorage(string $elementId): bool { - $s3Client = (new S3ClientFactory($_ENV['S3_ENDPOINT'], $_ENV['S3_ACCESS_KEY_ID'], $_ENV['S3_SECRET_ACCESS_KEY']))->createS3Client(); + $s3Client = $this->createS3ClientForAssertions(); // the object key ends with the element id, see FileService::getStorageBucketKey() foreach ($s3Client->listObjectsV2(['Bucket' => 'api-storage']) as $object) { if (str_contains((string) $object->getKey(), $elementId)) { @@ -474,7 +488,7 @@ private function isFileInStorage(string $elementId): bool */ public function countUploadChunksInUploadBucket(string $uploadId): int { - $s3Client = (new S3ClientFactory($_ENV['S3_ENDPOINT'], $_ENV['S3_ACCESS_KEY_ID'], $_ENV['S3_SECRET_ACCESS_KEY']))->createS3Client(); + $s3Client = $this->createS3ClientForAssertions(); $count = 0; // the object key contains the upload id, see FileService::getUploadBucketKey() foreach ($s3Client->listObjectsV2(['Bucket' => 'api-upload']) as $object) { diff --git a/tests/UnitTests/EmberNexusBundle/DependencyInjection/ConfigurationTest.php b/tests/UnitTests/EmberNexusBundle/DependencyInjection/ConfigurationTest.php index 8a5f96dc..706afacf 100644 --- a/tests/UnitTests/EmberNexusBundle/DependencyInjection/ConfigurationTest.php +++ b/tests/UnitTests/EmberNexusBundle/DependencyInjection/ConfigurationTest.php @@ -44,6 +44,7 @@ public function testFileDefaults(): void EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVEL_LENGTH => 2, EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVELS => 2, EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVEL_LENGTH => 2, + EmberNexusConfiguration::FILE_S3_MAX_HOST_CONNECTIONS => 16, ], $config[EmberNexusConfiguration::FILE]); } @@ -54,12 +55,14 @@ public function testFileValuesCanBeOverwritten(): void EmberNexusConfiguration::FILE_MAX_FILE_SIZE_IN_BYTES => 1024, EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET => 'my-storage', EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET => 'my-upload', + EmberNexusConfiguration::FILE_S3_MAX_HOST_CONNECTIONS => 32, ], ]); $this->assertSame(1024, $config[EmberNexusConfiguration::FILE][EmberNexusConfiguration::FILE_MAX_FILE_SIZE_IN_BYTES]); $this->assertSame('my-storage', $config[EmberNexusConfiguration::FILE][EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET]); $this->assertSame('my-upload', $config[EmberNexusConfiguration::FILE][EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET]); + $this->assertSame(32, $config[EmberNexusConfiguration::FILE][EmberNexusConfiguration::FILE_S3_MAX_HOST_CONNECTIONS]); // untouched options keep their default $this->assertSame(4, $config[EmberNexusConfiguration::FILE][EmberNexusConfiguration::FILE_UPLOAD_CHUNK_DIGITS_LENGTH]); } @@ -80,6 +83,8 @@ public static function invalidFileValuesProvider(): array 'storage bucket level length of 0' => [EmberNexusConfiguration::FILE_S3_STORAGE_BUCKET_LEVEL_LENGTH, 0], 'upload bucket without levels' => [EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVELS, 0], 'upload bucket level length of 0' => [EmberNexusConfiguration::FILE_S3_UPLOAD_BUCKET_LEVEL_LENGTH, 0], + 'max host connections of 0' => [EmberNexusConfiguration::FILE_S3_MAX_HOST_CONNECTIONS, 0], + 'max host connections of 1' => [EmberNexusConfiguration::FILE_S3_MAX_HOST_CONNECTIONS, 1], ]; } diff --git a/tests/UnitTests/Factory/S3ClientFactoryTest.php b/tests/UnitTests/Factory/S3ClientFactoryTest.php index 28223a7c..d4e08c60 100644 --- a/tests/UnitTests/Factory/S3ClientFactoryTest.php +++ b/tests/UnitTests/Factory/S3ClientFactoryTest.php @@ -5,19 +5,65 @@ namespace App\Tests\UnitTests\Factory; use App\Factory\S3ClientFactory; +use AsyncAws\Core\AbstractApi; use AsyncAws\S3\S3Client; +use EmberNexusBundle\Service\EmberNexusConfiguration; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use ReflectionClass; +use Symfony\Component\HttpClient\CurlHttpClient; #[Small] #[CoversClass(S3ClientFactory::class)] class S3ClientFactoryTest extends TestCase { + private function buildFactory(int $maxHostConnections = 16): S3ClientFactory + { + return new S3ClientFactory( + 'http://s3.example.test:9000', + 'access-key', + 'secret-key', + new NullLogger(), + (new EmberNexusConfiguration())->setFileS3MaxHostConnections($maxHostConnections), + ); + } + public function testCreateS3ClientReturnsClient(): void { - $factory = new S3ClientFactory('http://s3.example.test:9000', 'access-key', 'secret-key'); + $this->assertInstanceOf(S3Client::class, $this->buildFactory()->createS3Client()); + } + + /** + * The configured value only takes effect deep inside the constructed AsyncAws/Symfony client graph, so + * reflection is used to assert it actually arrived where it needs to. + */ + private function readMaxHostConnections(S3Client $s3Client): int + { + $httpClientProperty = (new ReflectionClass(AbstractApi::class))->getProperty('httpClient'); + $httpClient = $httpClientProperty->getValue($s3Client); + $this->assertInstanceOf(CurlHttpClient::class, $httpClient); + + $multiProperty = (new ReflectionClass(CurlHttpClient::class))->getProperty('multi'); + $multi = $multiProperty->getValue($httpClient); + + $maxHostConnectionsProperty = (new ReflectionClass($multi))->getProperty('maxHostConnections'); + + return $maxHostConnectionsProperty->getValue($multi); + } + + public function testCreateS3ClientUsesConfiguredMaxHostConnections(): void + { + $s3Client = $this->buildFactory(4)->createS3Client(); + + $this->assertSame(4, $this->readMaxHostConnections($s3Client)); + } + + public function testCreateS3ClientUsesDifferentConfiguredMaxHostConnections(): void + { + $s3Client = $this->buildFactory(32)->createS3Client(); - $this->assertInstanceOf(S3Client::class, $factory->createS3Client()); + $this->assertSame(32, $this->readMaxHostConnections($s3Client)); } } diff --git a/tests/UnitTests/Service/S3ServiceTest.php b/tests/UnitTests/Service/S3ServiceTest.php index d87398f9..34a83e40 100644 --- a/tests/UnitTests/Service/S3ServiceTest.php +++ b/tests/UnitTests/Service/S3ServiceTest.php @@ -19,10 +19,12 @@ use App\Service\S3TechnicalLimitsValidator; use App\Type\S3\S3TechnicalLimits; use App\Wrapper\S3ClientWrapper; +use AsyncAws\Core\Response; use AsyncAws\Core\Stream\ResultStream; use AsyncAws\S3\Result\AbortMultipartUploadOutput; use AsyncAws\S3\Result\CopyObjectOutput; use AsyncAws\S3\Result\CreateMultipartUploadOutput; +use AsyncAws\S3\Result\DeleteObjectOutput; use AsyncAws\S3\Result\GetObjectOutput; use AsyncAws\S3\Result\HeadObjectOutput; use AsyncAws\S3\Result\ObjectExistsWaiter; @@ -38,6 +40,9 @@ use Prophecy\PhpUnit\ProphecyTrait; use Psr\Log\LoggerInterface; use RuntimeException; +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpClientResponseInterface; /** * @SuppressWarnings(PHPMD.ExcessiveClassLength) @@ -75,6 +80,7 @@ private function buildS3Service( $s3TechnicalLimits ?? new S3TechnicalLimits(), $logger ?? $this->prophesize(LoggerInterface::class)->reveal(), $s3TechnicalLimitsValidator, + new Stopwatch(), $multipartUploadThresholdInBytes ?? S3Service::MULTIPART_UPLOAD_THRESHOLD_IN_BYTES, $multipartUploadPartSizeInBytes ?? S3Service::MULTIPART_UPLOAD_PART_SIZE_IN_BYTES, ); @@ -764,6 +770,11 @@ public function testGetChunksContentLengthReturnsNullForMissingChunk(): void $this->assertNull($s3Service->getChunksContentLength($mergeFileChunksOperation->reveal())); } + /** + * Every S3 call of a given step (existence check, delete, confirmation check) is issued for all three chunks + * before any of them is read, so the underlying HTTP client can run them concurrently; hence `objectExists` and + * `deleteObject` are each expected exactly once per key rather than interleaved per chunk. + */ public function testDeleteFileChunks(): void { $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); @@ -771,48 +782,113 @@ public function testDeleteFileChunks(): void $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(3)->willReturn('upload-bucket'); $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); - $objectExistsWaiter1 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); - $objectExistsWaiter2 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); - $objectExistsWaiter3 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + $existsWaiters = []; + $confirmationWaiters = []; + $s3Client = $this->prophesize(S3Client::class); + foreach (['upload-key-0001', 'upload-key-0002', 'upload-key-0003'] as $uploadKey) { + $objectConfig = [ + 'Bucket' => 'upload-bucket', + 'Key' => $uploadKey, + ]; + + $existsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + $confirmationWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + $existsWaiters[] = $existsWaiter; + $confirmationWaiters[] = $confirmationWaiter; + + $s3Client->objectExists(Argument::is($objectConfig)) + ->shouldBeCalledTimes(2) + ->willReturn($existsWaiter, $confirmationWaiter); + + $s3Client->deleteObject(Argument::is($objectConfig))->shouldBeCalledOnce()->willReturn($this->buildDeleteObjectOutput()); + } + + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + foreach ($existsWaiters as $existsWaiter) { + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($existsWaiter))->shouldBeCalledOnce()->willReturn(true); + } + foreach ($confirmationWaiters as $confirmationWaiter) { + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($confirmationWaiter))->shouldBeCalledOnce()->willReturn(false); + } + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $s3Service->deleteFileChunks($mergeFileChunksOperation); + } + + public function testDeleteFileChunksSkipsMissingChunksAndDoesNotDeleteThem(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn(['upload-key-0001', 'upload-key-0002']); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledTimes(2)->willReturn('upload-bucket'); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $existsWaiter1 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + $existsWaiter2 = $this->prophesize(ObjectExistsWaiter::class)->reveal(); $s3Client = $this->prophesize(S3Client::class); $s3Client->objectExists(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key-0001', - ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter1); - $s3Client->deleteObject(Argument::is([ - 'Bucket' => 'upload-bucket', - 'Key' => 'upload-key-0001', - ]))->shouldBeCalledOnce(); - + ]))->shouldBeCalledOnce()->willReturn($existsWaiter1); $s3Client->objectExists(Argument::is([ 'Bucket' => 'upload-bucket', 'Key' => 'upload-key-0002', - ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter2); - $s3Client->deleteObject(Argument::is([ - 'Bucket' => 'upload-bucket', - 'Key' => 'upload-key-0002', - ]))->shouldBeCalledOnce(); + ]))->shouldBeCalledOnce()->willReturn($existsWaiter2); + $s3Client->deleteObject(Argument::any())->shouldNotBeCalled(); - $s3Client->objectExists(Argument::is([ - 'Bucket' => 'upload-bucket', - 'Key' => 'upload-key-0003', - ]))->shouldBeCalledTimes(2)->willReturn($objectExistsWaiter3); - $s3Client->deleteObject(Argument::is([ + $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($existsWaiter1))->shouldBeCalledOnce()->willReturn(false); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($existsWaiter2))->shouldBeCalledOnce()->willReturn(false); + + $s3Service = $this->buildS3Service( + s3Client: $s3Client->reveal(), + s3ClientWrapper: $s3ClientWrapper->reveal() + ); + + $s3Service->deleteFileChunks($mergeFileChunksOperation); + } + + public function testDeleteFileChunksThrowsWhenAChunkWasNotActuallyDeleted(): void + { + $mergeFileChunksOperation = $this->prophesize(MergeFileChunksOperationInterface::class); + $mergeFileChunksOperation->getUploadKeys()->shouldBeCalledOnce()->willReturn(['upload-key-0001']); + $mergeFileChunksOperation->getUploadBucket()->shouldBeCalledOnce()->willReturn('upload-bucket'); + $mergeFileChunksOperation = $mergeFileChunksOperation->reveal(); + + $existsWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + $confirmationWaiter = $this->prophesize(ObjectExistsWaiter::class)->reveal(); + + $objectConfig = [ 'Bucket' => 'upload-bucket', - 'Key' => 'upload-key-0003', - ]))->shouldBeCalledOnce(); + 'Key' => 'upload-key-0001', + ]; + + $s3Client = $this->prophesize(S3Client::class); + $s3Client->objectExists(Argument::is($objectConfig))->shouldBeCalledTimes(2)->willReturn($existsWaiter, $confirmationWaiter); + $s3Client->deleteObject(Argument::is($objectConfig))->shouldBeCalledOnce()->willReturn($this->buildDeleteObjectOutput()); $s3ClientWrapper = $this->prophesize(S3ClientWrapper::class); - $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter1))->shouldBeCalledTimes(2)->willReturn(true, false); - $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter2))->shouldBeCalledTimes(2)->willReturn(true, false); - $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($objectExistsWaiter3))->shouldBeCalledTimes(2)->willReturn(true, false); + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($existsWaiter))->shouldBeCalledOnce()->willReturn(true); + // the chunk still exists after the delete, which must be reported as a failure + $s3ClientWrapper->getIsSuccessFromObjectExistsWaiter(Argument::is($confirmationWaiter))->shouldBeCalledOnce()->willReturn(true); + + $exception = $this->prophesize(Server500LogicErrorException::class)->reveal(); + + $server500LogicErrorExceptionFactory = $this->prophesize(Server500LogicErrorExceptionFactory::class); + $server500LogicErrorExceptionFactory->createFromTemplate('Unable to delete file.')->shouldBeCalledOnce()->willReturn($exception); $s3Service = $this->buildS3Service( s3Client: $s3Client->reveal(), - s3ClientWrapper: $s3ClientWrapper->reveal() + s3ClientWrapper: $s3ClientWrapper->reveal(), + server500LogicErrorExceptionFactory: $server500LogicErrorExceptionFactory->reveal() ); + $this->expectException(Server500LogicErrorException::class); + $s3Service->deleteFileChunks($mergeFileChunksOperation); } @@ -1848,6 +1924,25 @@ private function buildMultipartUploadFileOperation($resource, int $contentLength return $uploadFileOperation->reveal(); } + /** + * {@see Result::resolve()} is final and can therefore not be doubled with Prophecy; a real {@see DeleteObjectOutput} + * wrapping a stubbed HTTP response is built instead, so calling `resolve()` on it behaves like a real, already + * succeeded S3 call. + */ + private function buildDeleteObjectOutput(): DeleteObjectOutput + { + $httpResponse = $this->prophesize(HttpClientResponseInterface::class); + $httpResponse->getStatusCode()->willReturn(204); + + $response = new Response( + $httpResponse->reveal(), + $this->prophesize(HttpClientInterface::class)->reveal(), + $this->prophesize(LoggerInterface::class)->reveal(), + ); + + return new DeleteObjectOutput($response); + } + private function buildUploadPartOutput(string $etag): UploadPartOutput { $uploadPartOutput = $this->prophesize(UploadPartOutput::class); diff --git a/tests/UnitTests/Service/UploadAppendServiceTest.php b/tests/UnitTests/Service/UploadAppendServiceTest.php index e7a4e813..30275494 100644 --- a/tests/UnitTests/Service/UploadAppendServiceTest.php +++ b/tests/UnitTests/Service/UploadAppendServiceTest.php @@ -32,6 +32,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Ramsey\Uuid\Uuid; use RuntimeException; +use Symfony\Component\Stopwatch\Stopwatch; #[Small] #[CoversClass(UploadAppendService::class)] @@ -148,6 +149,7 @@ private function createService( $client409ConflictExceptionFactory->reveal(), $this->elementManager->reveal(), $this->elementService->reveal(), + new Stopwatch(), ); } diff --git a/tests/UnitTests/Service/UploadCreationServiceTest.php b/tests/UnitTests/Service/UploadCreationServiceTest.php index 65973b7e..39eb8b31 100644 --- a/tests/UnitTests/Service/UploadCreationServiceTest.php +++ b/tests/UnitTests/Service/UploadCreationServiceTest.php @@ -43,6 +43,7 @@ use RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; #[Small] @@ -163,6 +164,7 @@ protected function setUp(): void new UploadChunkValidator($configuration, $fileSizeLimitService, $badContentFactory, $this->conflictFactory->reveal()), $this->elementFileDeletionService->reveal(), $this->elementService->reveal(), + new Stopwatch(), ); } From 9e2b0deb7b4ac93d484a6c2d864ce21d8904b07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Klein?= Date: Wed, 30 Sep 2026 23:42:15 +0200 Subject: [PATCH 6/7] add versitygw as s3 server target --- .github/workflows/ci-test.yml | 1 + .../docker-compose-s3-versitygw.yml | 129 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/FeatureTests/docker-compose-s3-versitygw.yml diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index a6e73594..59751a56 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -205,6 +205,7 @@ jobs: - "neo4j-5.7" - "neo4j-5.20" - "neo4j-latest" + - "s3-versitygw" steps: - uses: actions/checkout@v5 - run: mkdir -p /tmp/docker diff --git a/tests/FeatureTests/docker-compose-s3-versitygw.yml b/tests/FeatureTests/docker-compose-s3-versitygw.yml new file mode 100644 index 00000000..4a66e1a0 --- /dev/null +++ b/tests/FeatureTests/docker-compose-s3-versitygw.yml @@ -0,0 +1,129 @@ +# The production image only contains the application, the connections to the other services are configured through its +# environment (the repository is not mounted, so no `.env` file provides them). +# +# Explicitly exercises versitygw as the S3 backend instead of minio, to demonstrate and verify compatibility +# (gh-484). Otherwise same as docker-compose-neo4j-latest.yml. Buckets are just top-level directories under the +# posix backend's root, so minio-init only has to create those directories - no bucket/user API calls needed. +x-api-environment: &api-environment + CYPHER_AUTH: bolt://neo4j:password@neo4j + MONGO_AUTH: mongodb://mongodb:password@mongodb:27017 + ELASTIC_AUTH: elasticsearch:9200 + REDIS_AUTH: tcp://redis?password=redis-password + RABBITMQ_AUTH: amqp://user:password@rabbitmq:5672 + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY_ID: accessKey + S3_SECRET_ACCESS_KEY: secretKey + # cron commands would otherwise run whenever the scheduler triggers them during the test run; the tty-bypass + # rule still lets a manually-`docker exec -t`'d cron command run despite this + DISABLE_CRON: 'true' + +services: + + api-dev: + container_name: api-dev + image: api:dev-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + environment: + DISABLE_CRON: 'true' + volumes: + - ../../:/var/www/html + - ./well-known-security.txt:/well-known-security.txt + + api-prod: + container_name: api-prod + image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + environment: *api-environment + volumes: + # the repository is deliberately not mounted: the production image is tested as it is shipped + - ./well-known-security.txt:/well-known-security.txt + + neo4j: + container_name: neo4j + image: neo4j:latest + expose: + - "7474" + - "7687" + environment: + - NEO4J_AUTH=neo4j/password + + mongodb: + container_name: mongodb + image: mongo:8 + expose: + - "27017" + environment: + - MONGO_INITDB_ROOT_USERNAME=mongodb + - MONGO_INITDB_ROOT_PASSWORD=password + - GLIBC_TUNABLES=glibc.pthread.rseq=1 + + elasticsearch: + container_name: elasticsearch + image: elasticsearch:8.17.2 + expose: + - "9200" + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.security.enrollment.enabled=true + + redis: + container_name: redis + image: redis:7-alpine + expose: + - "6379" + command: redis-server --save 20 1 --loglevel warning --requirepass redis-password + + rabbitmq: + container_name: rabbitmq + image: rabbitmq:3-management + expose: + - "5672" + - "15672" + environment: + - RABBITMQ_DEFAULT_USER=user + - RABBITMQ_DEFAULT_PASS=password + + minio: + container_name: minio + image: ghcr.io/versity/versitygw:latest + expose: + - "9000" + - "9001" + depends_on: + minio-init: + condition: service_completed_successfully + volumes: + - versitygw-data:/data + environment: + - ROOT_ACCESS_KEY=accessKey + - ROOT_SECRET_KEY=secretKey + - VGW_BACKEND=posix + - VGW_BACKEND_ARGS=/data + - VGW_PORT=:9000 + - VGW_WEBUI_PORT=:9001 + + minio-init: + image: busybox + volumes: + - versitygw-data:/data + command: sh -c "mkdir -p /data/api-storage /data/api-upload" + +volumes: + versitygw-data: From 17e99c7d3ca4676770fa7c4509373da54b77e7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Klein?= Date: Thu, 1 Oct 2026 00:07:13 +0200 Subject: [PATCH 7/7] add seaweed fs as s3 server target --- .github/workflows/ci-test.yml | 3 +- .../docker-compose-s3-seaweed-fs.yml | 114 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/FeatureTests/docker-compose-s3-seaweed-fs.yml diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 59751a56..a2a3d205 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -26,7 +26,7 @@ jobs: build-docker-image: runs-on: gh-4vcpu-ubuntu-latest-amd name: 'Build Docker Image' - timeout-minutes: 20 + timeout-minutes: 40 needs: - yml-lint - markdown-lint @@ -206,6 +206,7 @@ jobs: - "neo4j-5.20" - "neo4j-latest" - "s3-versitygw" + - "s3-seaweed-fs" steps: - uses: actions/checkout@v5 - run: mkdir -p /tmp/docker diff --git a/tests/FeatureTests/docker-compose-s3-seaweed-fs.yml b/tests/FeatureTests/docker-compose-s3-seaweed-fs.yml new file mode 100644 index 00000000..ac3b22de --- /dev/null +++ b/tests/FeatureTests/docker-compose-s3-seaweed-fs.yml @@ -0,0 +1,114 @@ +# The production image only contains the application, the connections to the other services are configured through its +# environment (the repository is not mounted, so no `.env` file provides them). +# +# Explicitly exercises SeaweedFS as the S3 backend instead of minio, to demonstrate and verify compatibility +# (gh-484). Otherwise same as docker-compose-neo4j-latest.yml. "weed mini" is an all-in-one process +# (master+volume+filer+S3 gateway); its entrypoint reads AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/S3_BUCKET to +# statically provision credentials and buckets on boot - no init container needed. +x-api-environment: &api-environment + CYPHER_AUTH: bolt://neo4j:password@neo4j + MONGO_AUTH: mongodb://mongodb:password@mongodb:27017 + ELASTIC_AUTH: elasticsearch:9200 + REDIS_AUTH: tcp://redis?password=redis-password + RABBITMQ_AUTH: amqp://user:password@rabbitmq:5672 + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY_ID: accessKey + S3_SECRET_ACCESS_KEY: secretKey + # cron commands would otherwise run whenever the scheduler triggers them during the test run; the tty-bypass + # rule still lets a manually-`docker exec -t`'d cron command run despite this + DISABLE_CRON: 'true' + +services: + + api-dev: + container_name: api-dev + image: api:dev-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + environment: + DISABLE_CRON: 'true' + volumes: + - ../../:/var/www/html + - ./well-known-security.txt:/well-known-security.txt + + api-prod: + container_name: api-prod + image: api:prod-amd + depends_on: + - neo4j + - mongodb + - elasticsearch + - redis + - rabbitmq + - minio + expose: + - "80" + environment: *api-environment + volumes: + # the repository is deliberately not mounted: the production image is tested as it is shipped + - ./well-known-security.txt:/well-known-security.txt + + neo4j: + container_name: neo4j + image: neo4j:latest + expose: + - "7474" + - "7687" + environment: + - NEO4J_AUTH=neo4j/password + + mongodb: + container_name: mongodb + image: mongo:8 + expose: + - "27017" + environment: + - MONGO_INITDB_ROOT_USERNAME=mongodb + - MONGO_INITDB_ROOT_PASSWORD=password + - GLIBC_TUNABLES=glibc.pthread.rseq=1 + + elasticsearch: + container_name: elasticsearch + image: elasticsearch:8.17.2 + expose: + - "9200" + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.security.enrollment.enabled=true + + redis: + container_name: redis + image: redis:7-alpine + expose: + - "6379" + command: redis-server --save 20 1 --loglevel warning --requirepass redis-password + + rabbitmq: + container_name: rabbitmq + image: rabbitmq:3-management + expose: + - "5672" + - "15672" + environment: + - RABBITMQ_DEFAULT_USER=user + - RABBITMQ_DEFAULT_PASS=password + + minio: + container_name: minio + image: chrislusf/seaweedfs:latest + expose: + - "9000" + - "8888" + environment: + - AWS_ACCESS_KEY_ID=accessKey + - AWS_SECRET_ACCESS_KEY=secretKey + - S3_BUCKET=api-storage,api-upload + command: ["mini", "-dir=/data", "-s3.port=9000"]