diff --git a/RELEASE_INFO-6.7.md b/RELEASE_INFO-6.7.md index 63e777762e3..1845a9ef4ef 100644 --- a/RELEASE_INFO-6.7.md +++ b/RELEASE_INFO-6.7.md @@ -116,6 +116,36 @@ The Store API OpenAPI schema was corrected where it contradicted the real respon - `Country.addressFormat` and `currentFilters.navigationId` are no longer required, and `redirectUrl` can be `null`. - `POST /product/{productId}/review` and `GET /breadcrumb/{id}` document their `204` responses. +### Store API reads every field of the compressed `_criteria` parameter + +On every Store API `GET` route, `_criteria` is now a compressed form of the query string: its fields are read like query parameters. So a `GET` request with `_criteria` reads the same fields as the `POST` request with that body. Before, `_criteria` was read only for the criteria and, on the product listing routes, for listing parameters such as `order` or `p`. Routes ignored the other fields inside it, for example: + +| Route | Ignored fields | +|---|---| +| `/category/{navigationId}`, `/cms/{id}`, `/landing-page/{landingPageId}` | `limit`, `includes`, `excludes`, `slots` | +| `/search` | `limit` | +| `/search-suggest` | `search` | +| `/product/{productId}` | `slots` | +| `/navigation/{activeId}/{rootId}` | `depth`, `buildTree` | +| `/media` | `ids`, `includes`, `excludes` | +| `/product/{productId}/find-variant` | `options`, `switchedGroup`, `includes`, `excludes` | +| `/payment-method`, `/shipping-method` | `onlyAvailable` | +| `/checkout/cart` | `includes`, `excludes` | + +A field that a route reads from the `POST` body only is not read from `_criteria`, the same as from a plain query parameter. This concerns, for example, the filter flags of the listing routes, such as `manufacturer-filter` or `property-whitelist`. The values of the fields are read as strings, as in a query string, and a field set to `null` counts as not sent. App scripts of `/store-api/script/{hook}` find them in the query as well. + +Sending these fields as plain query parameters keeps working. If you send `_criteria`, check these changes: + +- A field in `_criteria` takes precedence over a query parameter of the same name. Before, a plain `limit` took precedence on the listing routes. +- On the product listing, search and suggest routes, listing filters of extensions got the fields of `_criteria` with their JSON types before, such as `true` or `5`. They now get strings, the same as from plain query parameters. +- Query parameters that are not part of `_criteria` are applied next to it. Before, the criteria were built from `_criteria` alone, so a plain `filter` next to it was ignored. This also applies to the `GET` list and detail routes of the Admin API. +- The `sw-include-search-info` header is respected together with `_criteria`, on the Admin API as well. +- An invalid `_criteria` value is answered with `400` on every Store API `GET` route. Before, routes without criteria, such as `/media` or `/checkout/cart`, ignored it. + +`includes` and `excludes` that are not an array are answered with `400` instead of `500`. On the Admin API this applies to the fields of a single entity in JSON:API responses, such as `includes[product]=name`. + +The OpenAPI schema now also declares `_criteria` for `readCategoryGet`, `readCmsGet`, `readLandingPageGet`, `searchPageGet`, `searchSuggestGet`, `readMediaGet`, `searchProductVariantIdsGet` and `readProductCrossSellingsGet`. `GET` operations without criteria parameters, such as `readCart`, accept it without declaring it. + ## Administration ### [Internal] Native `` names are isolated per component diff --git a/adr/2025-09-15-store-api-cache-strategy.md b/adr/2025-09-15-store-api-cache-strategy.md index 2923711e798..cd88890d1d2 100644 --- a/adr/2025-09-15-store-api-cache-strategy.md +++ b/adr/2025-09-15-store-api-cache-strategy.md @@ -109,3 +109,7 @@ Important details: - More complex OpenAPI schema, problem with array format differences between clients persists. - Transparent request - easier to debug and log. Rejected in favor of more compact representation and simpler OpenAPI schema. + +## Updates + +- 2026-09-29: `_criteria` is a compressed form of the query string on every Store API `GET` route. Besides the criteria, it can contain any other query parameter of the route, and a field in it takes precedence over a plain query parameter of the same name. So a `GET` request reads the same fields as the `POST` request with that body, which the automatic request method selection of the SDKs needs. Fields that a route reads from the `POST` body only, such as the filter flags of the listing routes, are the exception. It does not change which routes should be called with `GET`. diff --git a/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php b/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php index 9ffd661c182..bc0278a37be 100644 --- a/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php +++ b/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php @@ -8,12 +8,15 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\RequestCriteriaBuilder; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException; +use Shopware\Core\Framework\Routing\StoreApiRouteScope; +use Shopware\Core\PlatformRequest; use Shopware\Core\System\SalesChannel\SalesChannelContext; use Symfony\Component\HttpFoundation\Request; /** * This processor adds support of ProductListingCriteria fields passed in the compressed criteria payload. * It should run before any other filter/processor that relies on request parameters. + * Store API requests are not handled here, the CompressedCriteriaRequestListener already copied all fields of the payload. * * @internal */ @@ -43,6 +46,11 @@ public function prepare(Request $request, Criteria $criteria, SalesChannelContex return; } + // the listener copied the fields as query-string values, copying them again would overwrite them with their JSON types + if (\in_array(StoreApiRouteScope::ID, (array) $request->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, []), true)) { + return; + } + $payload = $this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria')); foreach ($payload as $param => $value) { if (!\in_array($param, RequestCriteriaBuilder::KNOWN_FIELDS, true)) { diff --git a/src/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandler.php b/src/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandler.php index 03e6c04efd1..203522ba0ec 100644 --- a/src/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandler.php +++ b/src/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandler.php @@ -52,7 +52,8 @@ public function __invoke(RefreshHttpCacheMessage $msg): void */ $request->setSession(new Session(new MockArraySessionStorage())); - $response = $this->kernel->handle($request, HttpKernelInterface::MAIN_REQUEST, false); + // the kernel gets a copy, like in the HttpCache, because listeners can change the query parameters the cache key is built from + $response = $this->kernel->handle(clone $request, HttpKernelInterface::MAIN_REQUEST, false); $this->store->write($request, $response); $this->cache->delete($msg->lockKey); diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json index 6b7cb946901..cf55cfb08b8 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json @@ -179,7 +179,7 @@ "CompressedCriteria": { "name": "_criteria", "in": "query", - "description": "Compressed and encoded criteria object. Format: base64url(gzip(json_encode(criteria))). This parameter allows passing complex criteria as a single encoded string instead of multiple query parameters. The criteria object should be JSON-encoded, then gzipped, and finally base64url-encoded. The criteria object structure is defined in the Criteria schema (see #/components/schemas/Criteria).", + "description": "Compressed and encoded criteria object. Format: base64url(gzip(json_encode(criteria))). This parameter allows passing complex criteria as a single encoded string instead of multiple query parameters. The criteria object should be JSON-encoded, then gzipped, and finally base64url-encoded. The criteria object structure is defined in the Criteria schema (see #/components/schemas/Criteria). Besides the criteria, the object can contain the other query parameters of the route, so it works as a compressed query string on every Store API GET route. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.", "required": false, "schema": { "type": "string", @@ -190,7 +190,7 @@ "CompressedNoneFieldsCriteria": { "name": "_criteria", "in": "query", - "description": "Compressed and encoded criteria object. Format: base64url(gzip(json_encode(criteria))). This parameter allows passing complex criteria as a single encoded string instead of multiple query parameters. The criteria object should be JSON-encoded, then gzipped, and finally base64url-encoded. The criteria object structure is defined in the NoneFieldsCriteria schema (see #/components/schemas/NoneFieldsCriteria).", + "description": "Compressed and encoded criteria object. Format: base64url(gzip(json_encode(criteria))). This parameter allows passing complex criteria as a single encoded string instead of multiple query parameters. The criteria object should be JSON-encoded, then gzipped, and finally base64url-encoded. The criteria object structure is defined in the NoneFieldsCriteria schema (see #/components/schemas/NoneFieldsCriteria). Besides the criteria, the object can contain the other query parameters of the route, so it works as a compressed query string on every Store API GET route. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.", "required": false, "schema": { "type": "string", diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/category.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/category.json index 5a419f251b6..6a529e6cd7d 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/category.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/category.json @@ -221,6 +221,9 @@ }, { "x-parameter-group": "productListingCriteria" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/cms.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/cms.json index 1ced0eb3495..541ceadb004 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/cms.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/cms.json @@ -114,6 +114,9 @@ }, { "x-parameter-group": "productListingCriteria" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/landing-page.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/landing-page.json index 8c695fc9a6a..6d0ab1f2ad0 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/landing-page.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/landing-page.json @@ -127,6 +127,9 @@ }, { "x-parameter-group": "productListingCriteria" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/media.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/media.json index ef4996d2348..1e7f4dcf8c0 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/media.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/media.json @@ -90,6 +90,9 @@ } } }, + { + "$ref": "#/components/parameters/CompressedCriteria" + }, { "$ref": "#/components/parameters/swLanguageId" } diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json index d9f6375843d..f12681290a8 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json @@ -85,6 +85,9 @@ "type": "boolean" } }, + { + "$ref": "#/components/parameters/CompressedCriteria" + }, { "$ref": "#/components/parameters/swLanguageId" } @@ -841,6 +844,9 @@ "type": "string" } }, + { + "$ref": "#/components/parameters/CompressedCriteria" + }, { "$ref": "#/components/parameters/swLanguageId" } diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search-suggest.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search-suggest.json index 5af7613b80c..243133ec84b 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search-suggest.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search-suggest.json @@ -115,6 +115,9 @@ }, { "x-parameter-group": "productListingFlags" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search.json index 92f8282e7e3..9665dd5cc5f 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search.json @@ -129,6 +129,9 @@ }, { "x-parameter-group": "productListingFlags" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php new file mode 100644 index 00000000000..e12ed01b90a --- /dev/null +++ b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php @@ -0,0 +1,89 @@ + [ + 'expandCompressedCriteria', + KernelListenerPriorities::KERNEL_CONTROLLER_EVENT_PRIORITY_AUTH_VALIDATE_POST, + ], + ]; + } + + public function expandCompressedCriteria(ControllerEvent $event): void + { + $request = $event->getRequest(); + + if (!$request->isMethod(Request::METHOD_GET) || !$request->query->has(self::PARAMETER)) { + return; + } + + $scopes = (array) $request->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, []); + if (!\in_array(StoreApiRouteScope::ID, $scopes, true)) { + return; + } + + $payload = $this->decoder->decode((string) $request->query->get(self::PARAMETER)); + + foreach ($payload as $field => $value) { + $field = (string) $field; + + if ($field === self::PARAMETER) { + continue; + } + + // a field without a value is not sent, as in a POST body, and also hides a plain query parameter of the same name + if ($value === null) { + $request->query->remove($field); + + continue; + } + + // the compressed criteria wins over a plain query parameter of the same name + $request->query->set($field, self::toQueryValue($value)); + } + } + + /** + * Values become strings, as in a query string, so readers of the query parameters get the types they expect. + * The criteria keep their JSON types, because the criteria builder decodes the parameter itself. + */ + private static function toQueryValue(mixed $value): mixed + { + return match (true) { + \is_array($value) => array_map(self::toQueryValue(...), $value), + \is_bool($value) => $value ? '1' : '0', + \is_scalar($value) => (string) $value, + default => '', + }; + } +} diff --git a/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php b/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php index afeab6bd588..54120315813 100644 --- a/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php +++ b/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php @@ -75,17 +75,7 @@ public function __construct( public function handleRequest(Request $request, Criteria $criteria, EntityDefinition $definition, Context $context): Criteria { - if ($request->isMethod(Request::METHOD_GET)) { - // Check for _criteria parameter first - if ($request->query->has('_criteria')) { - $payload = $this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria')); - - return $this->fromArray($payload, $criteria, $definition, $context); - } - $criteria = $this->fromArray($request->query->all(), $criteria, $definition, $context); - } else { - $criteria = $this->fromArray($request->request->all(), $criteria, $definition, $context); - } + $criteria = $this->fromArray($this->getPayload($request), $criteria, $definition, $context); // @deprecated tag:v6.8.0 - switch the default to 0 if ($request->headers->get(PlatformRequest::HEADER_INCLUDE_SEARCH_INFO, '1') === '0') { @@ -125,6 +115,32 @@ public function addTotalCountMode(string $totalCountMode, Criteria $criteria): v } } + /** + * @return array + */ + private function getPayload(Request $request): array + { + if (!$request->isMethod(Request::METHOD_GET)) { + return $request->request->all(); + } + + $payload = $request->query->all(); + + if (!$request->query->has('_criteria')) { + return $payload; + } + + // a field of the _criteria parameter replaces the individual query parameter of the same name as a whole, + // individual query parameters that are not part of it are kept + $payload = array_replace( + $payload, + $this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria')) + ); + unset($payload['_criteria']); + + return $payload; + } + /** * @param array $payload */ diff --git a/src/Core/Framework/DependencyInjection/api.php b/src/Core/Framework/DependencyInjection/api.php index af32828ef19..75b682d7658 100644 --- a/src/Core/Framework/DependencyInjection/api.php +++ b/src/Core/Framework/DependencyInjection/api.php @@ -52,6 +52,7 @@ use Shopware\Core\Framework\Api\EventListener\Authentication\ApiAuthenticationListener; use Shopware\Core\Framework\Api\EventListener\Authentication\SalesChannelAuthenticationListener; use Shopware\Core\Framework\Api\EventListener\Authentication\UserCredentialsChangedSubscriber; +use Shopware\Core\Framework\Api\EventListener\CompressedCriteriaRequestListener; use Shopware\Core\Framework\Api\EventListener\CorsListener; use Shopware\Core\Framework\Api\EventListener\ExpectationSubscriber; use Shopware\Core\Framework\Api\EventListener\JsonRequestTransformerListener; @@ -85,6 +86,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; use Shopware\Core\Framework\DataAbstractionLayer\EntityProtection\EntityProtectionValidator; use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexerRegistry; +use Shopware\Core\Framework\DataAbstractionLayer\Search\CompressedCriteriaDecoder; use Shopware\Core\Framework\Event\BusinessEventCollector; use Shopware\Core\Framework\Feature\FeatureFlagRegistry; use Shopware\Core\Framework\MessageQueue\Stats\StatsService; @@ -442,6 +444,12 @@ $services->set(JsonRequestTransformerListener::class) ->tag('kernel.event_subscriber'); + $services->set(CompressedCriteriaRequestListener::class) + ->args([ + service(CompressedCriteriaDecoder::class), + ]) + ->tag('kernel.event_subscriber'); + $services->set(ExpectationSubscriber::class) ->args([ param('kernel.shopware_version'), diff --git a/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php b/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php index 04f43cad494..2416db65040 100644 --- a/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php +++ b/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php @@ -5,7 +5,6 @@ use Psr\Log\LoggerInterface; use Shopware\Core\Framework\Adapter\Cache\CacheCompressor; use Shopware\Core\Framework\Adapter\Cache\Http\HttpCacheKeyGenerator; -use Shopware\Core\Framework\Adapter\Request\RequestParamHelper; use Shopware\Core\Framework\Feature; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Routing\StoreApiRouteScope; @@ -62,10 +61,7 @@ public function execute(string $hook, Request $request, SalesChannelContext $con // hook: store-api-{hook} $this->executor->execute($responseHook); - $fields = new ResponseFields( - RequestParamHelper::get($request, 'includes', []), - RequestParamHelper::get($request, 'excludes', []), - ); + $fields = ResponseFields::fromRequest($request); $symfonyResponse = $this->scriptResponseEncoder->encodeToSymfonyResponse( $responseHook->getScriptResponse(), diff --git a/src/Core/System/SalesChannel/Api/ResponseFields.php b/src/Core/System/SalesChannel/Api/ResponseFields.php index 7cd4bebe134..7c7f1941625 100644 --- a/src/Core/System/SalesChannel/Api/ResponseFields.php +++ b/src/Core/System/SalesChannel/Api/ResponseFields.php @@ -2,8 +2,10 @@ namespace Shopware\Core\System\SalesChannel\Api; +use Shopware\Core\Framework\Adapter\Request\RequestParamHelper; use Shopware\Core\Framework\Log\Package; use Shopware\Core\System\SalesChannel\SalesChannelException; +use Symfony\Component\HttpFoundation\Request; #[Package('framework')] class ResponseFields @@ -19,6 +21,17 @@ public function __construct( $this->validateFields(); } + /** + * Reads the `includes` and `excludes` parameters of the request. + */ + public static function fromRequest(Request $request): self + { + return new self( + self::getFieldsParameter($request, 'includes'), + self::getFieldsParameter($request, 'excludes'), + ); + } + public function isAllowed(string $type, string $property): bool { if (isset($this->excludes[$type]) && \in_array($property, $this->excludes[$type], true)) { @@ -87,4 +100,20 @@ protected function validateFields(): void } } } + + /** + * @return array>|null + */ + private static function getFieldsParameter(Request $request, string $name): ?array + { + $fields = RequestParamHelper::get($request, $name, []); + + if ($fields !== null && !\is_array($fields)) { + throw SalesChannelException::invalidType( + \sprintf('The %s must be of the type array, %s given', $name, \gettype($fields)) + ); + } + + return $fields; + } } diff --git a/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php b/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php index 6c8beaf3bcd..911716faa79 100644 --- a/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php +++ b/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php @@ -4,7 +4,6 @@ use Shopware\Core\Content\Media\MediaUrlPlaceholderHandlerInterface; use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface; -use Shopware\Core\Framework\Adapter\Request\RequestParamHelper; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; use Shopware\Core\PlatformRequest; @@ -56,10 +55,7 @@ public function encodeResponse(ResponseEvent $event): void if ($criteria instanceof Criteria) { $fields = new ResponseFields($criteria->getIncludes(), $criteria->getExcludes()); } else { - $fields = new ResponseFields( - RequestParamHelper::get($request, 'includes', []), - RequestParamHelper::get($request, 'excludes', []), - ); + $fields = ResponseFields::fromRequest($request); } $encoded = $this->encoder->encode($response->getObject(), $fields); diff --git a/src/Core/System/SalesChannel/SalesChannelException.php b/src/Core/System/SalesChannel/SalesChannelException.php index 575d4e4bc58..3fc8d600f2f 100644 --- a/src/Core/System/SalesChannel/SalesChannelException.php +++ b/src/Core/System/SalesChannel/SalesChannelException.php @@ -193,7 +193,7 @@ public static function salesChannelDomainInUse(?\Throwable $previous = null): Sh public static function invalidType(string $message): self { return new self( - Response::HTTP_INTERNAL_SERVER_ERROR, + Response::HTTP_BAD_REQUEST, self::INVALID_TYPE, $message ); diff --git a/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php b/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php index 8cc90f98baf..98f158515ef 100644 --- a/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php +++ b/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php @@ -23,6 +23,7 @@ use Shopware\Core\Framework\Rule\Rule; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\System\SalesChannel\Context\AbstractSalesChannelContextFactory; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory; use Shopware\Core\System\SalesChannel\SalesChannelContext; @@ -88,6 +89,19 @@ public function testEmptyCart(): void static::assertEmpty($response['errors']); } + public function testIncludesOfTheCompressedCriteriaAreApplied(): void + { + $compressed = gzencode(json_encode(['includes' => ['cart' => ['token']]], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', '/store-api/checkout/cart', ['_criteria' => Base64::urlEncode($compressed)]); + + $response = json_decode((string) $this->browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + static::assertArrayHasKey('token', $response); + static::assertArrayNotHasKey('price', $response, 'The includes of the compressed criteria are applied'); + } + /** * @param array>|null $ruleConditions */ diff --git a/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php b/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php index 40e1ac37532..22893284029 100644 --- a/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php +++ b/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php @@ -2,6 +2,7 @@ namespace Shopware\Tests\Integration\Core\Checkout\Order\SalesChannel; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice; @@ -17,6 +18,7 @@ use Shopware\Core\Checkout\Order\Aggregate\OrderDelivery\OrderDeliveryStates; use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates; use Shopware\Core\Checkout\Order\OrderCollection; +use Shopware\Core\Checkout\Order\OrderException; use Shopware\Core\Checkout\Order\OrderStates; use Shopware\Core\Checkout\Payment\PaymentMethodCollection; use Shopware\Core\Checkout\Payment\PaymentMethodEntity; @@ -32,6 +34,7 @@ use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\MailTemplateTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\PlatformRequest; use Shopware\Core\System\Country\CountryCollection; @@ -253,6 +256,70 @@ public function testGetOrderGuestWrongDeepLink(): void static::assertSame(Response::HTTP_FORBIDDEN, $this->browser->getResponse()->getStatusCode()); } + #[DataProvider('guestCredentialsProvider')] + public function testGuestCredentialsAreReadFromTheCompressedCriteria(string $zipcode, int $expectedStatus, ?string $expectedErrorCode): void + { + $this->browser->setServerParameter('HTTP_SW_CONTEXT_TOKEN', ''); + + $criteria = new Criteria([$this->orderId]); + $criteria->addAssociation('orderCustomer'); + + $order = $this->orderRepository->search($criteria, Context::createDefaultContext())->getEntities()->get($this->orderId); + + static::assertNotNull($order); + static::assertNotNull($order->getOrderCustomer()); + + $this->customerRepository->update([ + [ + 'id' => $order->getOrderCustomer()->getCustomerId(), + 'guest' => true, + ], + ], Context::createDefaultContext()); + + $criteria = new Criteria([$this->orderId]); + $criteria->addFilter(new EqualsFilter('deepLinkCode', $this->deepLinkCode)); + + $compressed = gzencode(json_encode( + \array_merge( + $this->requestCriteriaBuilder->toArray($criteria), + [ + 'email' => 'test@example.com', + 'zipcode' => $zipcode, + ] + ), + \JSON_THROW_ON_ERROR + )); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', '/store-api/order', ['_criteria' => Base64::urlEncode($compressed)]); + + $content = (string) $this->browser->getResponse()->getContent(); + static::assertSame($expectedStatus, $this->browser->getResponse()->getStatusCode(), $content); + + if ($expectedErrorCode !== null) { + $response = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + static::assertSame($expectedErrorCode, $response['errors'][0]['code']); + } + } + + /** + * @return iterable + */ + public static function guestCredentialsProvider(): iterable + { + yield 'the order is returned for the right credentials' => [ + 'zipcode' => '59438-0403', + 'expectedStatus' => Response::HTTP_OK, + 'expectedErrorCode' => null, + ]; + + yield 'a wrong zipcode is checked and rejected the same as a plain query parameter' => [ + 'zipcode' => '00000', + 'expectedStatus' => Response::HTTP_FORBIDDEN, + 'expectedErrorCode' => OrderException::CHECKOUT_GUEST_WRONG_CREDENTIALS, + ]; + } + public function testGetOrderGuestNoOrder(): void { $this->browser->setServerParameter('HTTP_SW_CONTEXT_TOKEN', ''); diff --git a/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php b/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php index b14ca0e8a17..cea7689fb6d 100644 --- a/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php +++ b/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php @@ -10,6 +10,7 @@ use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\QueueTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Test\Stub\Framework\IdsCollection; use Symfony\Bundle\FrameworkBundle\KernelBrowser; @@ -113,6 +114,26 @@ public function testIncludes(): void static::assertArrayNotHasKey('parentId', $response['elements'][0]); } + public function testQueryParametersAreAppliedNextToCompressedCriteria(): void + { + $compressed = gzencode(json_encode(['includes' => ['category' => ['id', 'name']]], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request( + 'GET', + '/store-api/category', + [ + 'limit' => 1, + '_criteria' => Base64::urlEncode($compressed), + ] + ); + + $response = json_decode((string) $this->browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + static::assertCount(1, $response['elements'], 'The limit of the query is applied'); + static::assertEqualsCanonicalizing(['id', 'name', 'apiAlias'], array_keys($response['elements'][0]), 'The includes of the compressed criteria are applied'); + } + private function createData(): void { $data = [ diff --git a/tests/integration/Core/Content/Category/SalesChannel/CategoryRouteTest.php b/tests/integration/Core/Content/Category/SalesChannel/CategoryRouteTest.php index da6aa66f2f2..7e5fc49b006 100644 --- a/tests/integration/Core/Content/Category/SalesChannel/CategoryRouteTest.php +++ b/tests/integration/Core/Content/Category/SalesChannel/CategoryRouteTest.php @@ -14,10 +14,13 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\System\SalesChannel\SalesChannelCollection; +use Shopware\Core\System\SalesChannel\SalesChannelException; use Shopware\Core\Test\Stub\Framework\IdsCollection; use Shopware\Tests\Integration\Core\Content\Category\SalesChannel\fixtures\CategoryRouteInheritanceFixtures; use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Component\HttpFoundation\Response; /** * @internal @@ -105,6 +108,63 @@ public function testIncludesConsidered(): void } } + /** + * @param array $parameters + */ + #[DataProvider('requestParameterProvider')] + public function testCompressedCriteriaIsReadLikeTheRequestBody(array $parameters): void + { + $this->createListingData(); + $url = '/store-api/category/' . $this->ids->get('category'); + + $this->browser->request('POST', $url); + $withoutParameters = $this->decodeResponse(); + + $this->browser->request('POST', $url, $parameters); + $expected = $this->decodeResponse(); + static::assertNotSame($withoutParameters, $expected, 'The parameters have to change the response'); + + $this->browser->request('GET', $url, ['_criteria' => self::compress($parameters)]); + + static::assertSame($expected, $this->decodeResponse()); + } + + /** + * @return iterable}> + */ + public static function requestParameterProvider(): iterable + { + yield 'limit of the product listing' => [ + 'parameters' => ['limit' => 1], + ]; + + yield 'included fields of the response' => [ + 'parameters' => ['includes' => ['category' => ['id', 'name']]], + ]; + + yield 'excluded fields of the response' => [ + 'parameters' => ['excludes' => ['category' => ['cmsPage']]], + ]; + + yield 'slots of the CMS page, no slot has the given id' => [ + 'parameters' => ['slots' => '0198c5a1f2d97c3e9b4a6d5e7f801234'], + ]; + } + + public function testIncludesThatAreNotAnArrayAreRejected(): void + { + $this->createListingData(); + + $this->browser->request( + 'GET', + '/store-api/category/' . $this->ids->get('category'), + ['includes' => '{"category":["id"]}'] + ); + + static::assertSame(Response::HTTP_BAD_REQUEST, $this->browser->getResponse()->getStatusCode()); + static::assertSame(SalesChannelException::INVALID_TYPE, $this->decodeResponse()['errors'][0]['code']); + } + public function testHome(): void { $this->createListingData(); @@ -360,6 +420,28 @@ private function createDomainsWithSystemDefaultParent(Context $context): void ]], $context); } + /** + * @return array + */ + private function decodeResponse(): array + { + $content = $this->browser->getResponse()->getContent(); + static::assertIsString($content); + + return json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + } + + /** + * @param array $parameters + */ + private static function compress(array $parameters): string + { + $compressed = gzencode(json_encode($parameters, \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + return Base64::urlEncode($compressed); + } + private function assertError(string $categoryId): void { static::assertIsString($this->browser->getResponse()->getContent()); diff --git a/tests/integration/Core/Content/LandingPage/SalesChannel/LandingPageRouteTest.php b/tests/integration/Core/Content/LandingPage/SalesChannel/LandingPageRouteTest.php index f569f77db3d..0f80553e094 100644 --- a/tests/integration/Core/Content/LandingPage/SalesChannel/LandingPageRouteTest.php +++ b/tests/integration/Core/Content/LandingPage/SalesChannel/LandingPageRouteTest.php @@ -3,6 +3,7 @@ namespace Shopware\Tests\Integration\Core\Content\LandingPage\SalesChannel; use Doctrine\DBAL\Connection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Shopware\Core\Content\Cms\CmsPageCollection; @@ -14,6 +15,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; @@ -81,6 +83,43 @@ public function testPostLandingPage(): void static::assertArrayHasKey('cmsPage', $response); } + /** + * @param array $parameters + */ + #[DataProvider('requestParameterProvider')] + public function testCompressedCriteriaIsReadLikeTheRequestBody(array $parameters): void + { + $url = '/store-api/landing-page/' . $this->ids->get('landing-page'); + + $this->browser->request('POST', $url); + $withoutParameters = $this->decodeResponse(); + + $this->browser->request('POST', $url, $parameters); + $expected = $this->decodeResponse(); + static::assertNotSame($withoutParameters, $expected, 'The parameters have to change the response'); + + $compressed = gzencode(json_encode($parameters, \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', $url, ['_criteria' => Base64::urlEncode($compressed)]); + + static::assertSame($expected, $this->decodeResponse()); + } + + /** + * @return iterable}> + */ + public static function requestParameterProvider(): iterable + { + yield 'included fields of the response' => [ + 'parameters' => ['includes' => ['landing_page' => ['id', 'name']]], + ]; + + yield 'slots of the CMS page, no slot has the given id' => [ + 'parameters' => ['slots' => '0198c5a1f2d97c3e9b4a6d5e7f801234'], + ]; + } + public function testLoadLandingPageCmsSlotConfigFromParentLanguageOverride(): void { $this->createLanguages(); @@ -230,6 +269,17 @@ private function createData(): void ], $context); } + /** + * @return array + */ + private function decodeResponse(): array + { + $content = $this->browser->getResponse()->getContent(); + static::assertIsString($content); + + return json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + } + private function createLanguages(): void { static::getContainer()->get('language.repository')->upsert([ diff --git a/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php b/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php index b456794e71e..275fcd0512b 100644 --- a/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php +++ b/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Shopware\Core\Content\Category\CategoryCollection; use Shopware\Core\Content\Product\Aggregate\ProductVisibility\ProductVisibilityDefinition; +use Shopware\Core\Content\Product\Events\ProductListingCriteriaEvent; use Shopware\Core\Content\Product\ProductCollection; use Shopware\Core\Content\Product\SalesChannel\Listing\ProductListingRoute; use Shopware\Core\Content\ProductStream\Aggregate\ProductStreamFilter\ProductStreamFilterCollection; @@ -20,8 +21,10 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Metric\StatsResult; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\EventDispatcherBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory; use Shopware\Core\Test\Stub\Framework\IdsCollection; @@ -36,6 +39,7 @@ #[Group('store-api')] class ProductListingRouteTest extends TestCase { + use EventDispatcherBehaviour; use IntegrationTestBehaviour; use SalesChannelApiTestBehaviour; @@ -109,6 +113,33 @@ public function testLoadProducts(): void static::assertSame('product', $response['elements'][0]['apiAlias']); } + public function testCustomListingParametersOfTheCompressedCriteriaAreReadLikeQueryParameters(): void + { + $this->createData(); + + $queries = []; + $this->addEventListener( + static::getContainer()->get('event_dispatcher'), + ProductListingCriteriaEvent::class, + static function (ProductListingCriteriaEvent $event) use (&$queries): void { + $query = $event->getRequest()->query->all(); + unset($query['_criteria']); + $queries[] = $query; + } + ); + + $url = '/store-api/product-listing/' . $this->ids->get('category'); + $this->browser->request('GET', $url, ['custom-filter' => '0', 'custom-flag' => '1', 'custom-number' => '5']); + + $compressed = gzencode(json_encode(['custom-filter' => false, 'custom-flag' => true, 'custom-number' => 5, 'custom-empty' => null], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + $this->browser->request('GET', $url, ['_criteria' => Base64::urlEncode($compressed)]); + + static::assertCount(2, $queries); + static::assertSame(['custom-filter' => '0', 'custom-flag' => '1', 'custom-number' => '5'], $queries[0]); + static::assertSame($queries[0], $queries[1], 'Listing processors of extensions read the same values as from plain query parameters'); + } + public function testReturnsHttpNotFoundWhenRequestedPageExceedsLastPage(): void { $this->createData(); diff --git a/tests/integration/Core/Content/Product/SalesChannel/ProductSearchRouteTest.php b/tests/integration/Core/Content/Product/SalesChannel/ProductSearchRouteTest.php index 41c2c000c74..4b787a33183 100644 --- a/tests/integration/Core/Content/Product/SalesChannel/ProductSearchRouteTest.php +++ b/tests/integration/Core/Content/Product/SalesChannel/ProductSearchRouteTest.php @@ -32,6 +32,7 @@ use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\KernelLifecycleManager; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\Migration\V6_7\Migration1775460999AddParentNameToProductSearchConfig; use Shopware\Core\PlatformRequest; @@ -155,6 +156,42 @@ public function testNotFindingProducts(): void static::assertCount(0, $response['elements']); } + public function testLimitOfTheCompressedCriteriaIsApplied(): void + { + $browser = self::$browser; + $this->productSearchConfigRepository->update([ + ['id' => $this->productSearchConfigId, 'andLogic' => false], + ], Context::createDefaultContext()); + + $browser->request('GET', '/store-api/search', [ + 'limit' => 10, + '_criteria' => self::compress(['search' => 'Test-Product', 'limit' => 2]), + ]); + + $response = \json_decode((string) $browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + static::assertSame(15, $response['total']); + static::assertCount(2, $response['elements'], 'The limit of the compressed criteria wins over the plain query parameter'); + } + + public function testSearchTermOfTheCompressedCriteriaIsReadBySuggest(): void + { + $browser = self::$browser; + $this->productSearchConfigRepository->update([ + ['id' => $this->productSearchConfigId, 'andLogic' => false], + ], Context::createDefaultContext()); + + $browser->request('GET', '/store-api/search-suggest', [ + '_criteria' => self::compress(['search' => 'Test-Product']), + ]); + + static::assertSame(200, $browser->getResponse()->getStatusCode(), (string) $browser->getResponse()->getContent()); + + $response = \json_decode((string) $browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + static::assertSame(15, $response['total']); + } + public function testMissingSearchTermWithFilter(): void { $browser = self::$browser; @@ -868,6 +905,17 @@ private function proceedTestSearch(KernelBrowser $browser, string $term, array $ static::assertSame($expected, $resultProductName); } + /** + * @param array $payload + */ + private static function compress(array $payload): string + { + $compressed = gzencode(json_encode($payload, \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + return Base64::urlEncode($compressed); + } + private function createNavigationCategory(IdsCollection $ids): void { $data = [ diff --git a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php index c773fa08624..5d6595d8e56 100644 --- a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php +++ b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php @@ -11,6 +11,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Test\AppSystemTestBehaviour; use Shopware\Core\Test\Stub\Framework\IdsCollection; use Symfony\Bundle\FrameworkBundle\KernelBrowser; @@ -77,6 +78,32 @@ public function testApiEndpointWithSlashInHookName(): void static::assertSame('store_api_simple_script_response', $response['apiAlias']); } + public function testIncludesThatAreNotAnArrayAreRejected(): void + { + $this->loadAppsFromDir(__DIR__ . '/_fixtures'); + + $this->browser->request('POST', '/store-api/script/simple-script', ['includes' => 'foo']); + + static::assertSame(Response::HTTP_BAD_REQUEST, $this->browser->getResponse()->getStatusCode(), (string) $this->browser->getResponse()->getContent()); + } + + public function testIncludesOfTheCompressedCriteriaAreApplied(): void + { + $this->loadAppsFromDir(__DIR__ . '/_fixtures'); + + $compressed = gzencode(json_encode(['includes' => ['store_api_simple_script_response' => ['apiAlias']]], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', '/store-api/script/simple-script', ['_criteria' => Base64::urlEncode($compressed)]); + + $content = (string) $this->browser->getResponse()->getContent(); + static::assertSame(Response::HTTP_OK, $this->browser->getResponse()->getStatusCode(), $content); + + $response = \json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + static::assertSame('store_api_simple_script_response', $response['apiAlias']); + static::assertArrayNotHasKey('foo', $response, 'The includes of the compressed criteria are applied'); + } + public function testRepositoryCall(): void { $this->loadAppsFromDir(__DIR__ . '/_fixtures'); diff --git a/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php b/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php index 08a2603feff..31db91e16a5 100644 --- a/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php +++ b/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php @@ -10,6 +10,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\Snippet\SnippetException; use Shopware\Core\Test\Stub\Framework\IdsCollection; @@ -123,6 +124,26 @@ public function testPrefixesLimitTheResultToWholeNamespaces(): void static::assertSame($set['snippets'], $setWithTrailingDot['snippets']); } + public function testPrefixesAreReadFromTheCompressedCriteria(): void + { + $this->createSnippet('myPrefix.inside.key', 'Inside'); + $this->createSnippet('myPrefixOther.key', 'Outside'); + + $this->browser->request('GET', '/store-api/snippet?prefixes=myPrefix'); + $expected = $this->getJsonResponse()['sets'][0]; + + $compressed = gzencode(json_encode(['prefixes' => 'myPrefix'], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', '/store-api/snippet', ['_criteria' => Base64::urlEncode($compressed)]); + + $set = $this->getJsonResponse()['sets'][0]; + + static::assertSame('Inside', $set['snippets']['myPrefix.inside.key'] ?? null); + static::assertArrayNotHasKey('myPrefixOther.key', $set['snippets']); + static::assertSame($expected['snippets'], $set['snippets'], 'The compressed criteria is read like the query string'); + } + public function testEtagRevalidationReturnsNotModified(): void { $this->browser->request('GET', '/store-api/snippet'); diff --git a/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php b/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php index 94689dfdd04..044a0fac647 100644 --- a/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php +++ b/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php @@ -9,6 +9,8 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\CompressedCriteriaDecoder; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\StoreApiRouteScope; +use Shopware\Core\PlatformRequest; use Shopware\Core\System\SalesChannel\SalesChannelContext; use Symfony\Component\HttpFoundation\Request; @@ -78,4 +80,17 @@ public function testPrepareExtractsNonCriteriaFields(): void static::assertFalse($request->query->has('limit'), 'Standard param "limit" should NOT be in query'); } + + public function testStoreApiRequestsAreLeftToTheListener(): void + { + $request = new Request(['_criteria' => 'encoded-payload', 'custom-flag' => '1']); + $request->setMethod(Request::METHOD_GET); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [StoreApiRouteScope::ID]); + + $this->decoder->expects($this->never())->method('decode'); + + $this->processor->prepare($request, new Criteria(), static::createStub(SalesChannelContext::class)); + + static::assertSame('1', $request->query->get('custom-flag'), 'The value the listener copied is not overwritten'); + } } diff --git a/tests/unit/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandlerTest.php b/tests/unit/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandlerTest.php index 7c3ec226933..2479b81ad0c 100644 --- a/tests/unit/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandlerTest.php +++ b/tests/unit/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandlerTest.php @@ -81,6 +81,32 @@ public function testInvokeHandlesMessage(): void ($this->handler)($message); } + public function testResponseIsStoredForTheRequestTheKernelDidNotChange(): void + { + $message = new RefreshHttpCacheMessage('lock-key', ['_criteria' => 'compressed']); + $response = new Response(); + + $this->kernel->expects($this->once()) + ->method('handle') + ->willReturnCallback(static function (Request $request) use ($response): Response { + // like the listener that copies the compressed criteria into the query parameters + $request->query->set('limit', '2'); + + return $response; + }); + + $this->store->expects($this->once()) + ->method('write') + ->with( + static::callback(static fn (Request $request): bool => $request->query->all() === ['_criteria' => 'compressed']), + static::identicalTo($response) + ); + + $this->cache->expects($this->once())->method('delete')->with('lock-key'); + + ($this->handler)($message); + } + public function testInvokeSetsTrustedProxies(): void { $originalTrustedIps = Request::getTrustedProxies(); diff --git a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php new file mode 100644 index 00000000000..c9adec48486 --- /dev/null +++ b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php @@ -0,0 +1,236 @@ +listener = new CompressedCriteriaRequestListener(new CompressedCriteriaDecoder()); + } + + public function testRunsAfterTheAuthenticationAndBeforeTheContextIsResolved(): void + { + static::assertSame( + [ + KernelEvents::CONTROLLER => [ + 'expandCompressedCriteria', + KernelListenerPriorities::KERNEL_CONTROLLER_EVENT_PRIORITY_AUTH_VALIDATE_POST, + ], + ], + CompressedCriteriaRequestListener::getSubscribedEvents() + ); + } + + public function testFieldsOfTheCompressedCriteriaAreReadableAsQueryParameters(): void + { + $request = self::createStoreApiRequest([ + '_criteria' => self::compress([ + 'limit' => 2, + 'slots' => 'slot-a|slot-b', + 'buildTree' => false, + 'includes' => ['category' => ['id', 'name']], + ]), + ]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame(2, $request->query->getInt('limit')); + static::assertSame('slot-a|slot-b', $request->query->get('slots')); + static::assertFalse($request->query->getBoolean('buildTree', true)); + static::assertSame(['category' => ['id', 'name']], $request->query->all('includes')); + } + + public function testValuesAreStringsAsInAQueryString(): void + { + $request = self::createStoreApiRequest([ + '_criteria' => self::compress([ + 'limit' => 2, + 'min-price' => 1.5, + 'buildTree' => false, + 'onlyAvailable' => true, + 'options' => ['group' => 5], + ]), + ]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + $query = $request->query->all(); + unset($query['_criteria']); + + static::assertSame( + [ + 'limit' => '2', + 'min-price' => '1.5', + 'buildTree' => '0', + 'onlyAvailable' => '1', + 'options' => ['group' => '5'], + ], + $query + ); + } + + public function testFieldWithoutValueIsNotSent(): void + { + $request = self::createStoreApiRequest([ + 'rating' => '3', + '_criteria' => self::compress(['rating' => null, 'reduce-aggregations' => null]), + ]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertFalse($request->query->has('rating'), 'The compressed criteria also wins over a plain query parameter here'); + static::assertFalse($request->query->has('reduce-aggregations')); + } + + public function testCompressedCriteriaWinsOverPlainQueryParameter(): void + { + $request = self::createStoreApiRequest([ + 'limit' => '10', + 'p' => '3', + '_criteria' => self::compress(['limit' => 2]), + ]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame(2, $request->query->getInt('limit')); + static::assertSame(3, $request->query->getInt('p'), 'A plain parameter that is not part of the compressed criteria is kept'); + } + + public function testCompressedParameterIsKeptForTheCriteriaBuilder(): void + { + $compressed = self::compress(['limit' => 2, '_criteria' => 'nested']); + + $request = self::createStoreApiRequest(['_criteria' => $compressed]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame($compressed, $request->query->get('_criteria')); + } + + public function testListPayloadDoesNotFail(): void + { + $request = self::createStoreApiRequest(['_criteria' => self::compress(['first', 'second'])]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame('first', $request->query->get('0')); + static::assertSame('second', $request->query->get('1')); + } + + /** + * @param array $query + * @param list $scopes + */ + #[DataProvider('untouchedRequestProvider')] + public function testRequestIsLeftUntouched(string $method, array $query, array $scopes): void + { + $request = new Request($query); + $request->setMethod($method); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, $scopes); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame($query, $request->query->all()); + } + + /** + * @return iterable, scopes: list}> + */ + public static function untouchedRequestProvider(): iterable + { + yield 'POST requests carry their parameters in the body' => [ + 'method' => Request::METHOD_POST, + 'query' => ['_criteria' => self::compress(['limit' => 2])], + 'scopes' => [StoreApiRouteScope::ID], + ]; + + yield 'GET request without compressed criteria' => [ + 'method' => Request::METHOD_GET, + 'query' => ['limit' => '2'], + 'scopes' => [StoreApiRouteScope::ID], + ]; + + yield 'only Store API routes are handled' => [ + 'method' => Request::METHOD_GET, + 'query' => ['_criteria' => self::compress(['limit' => 2])], + 'scopes' => [ApiRouteScope::ID], + ]; + + yield 'route without a scope' => [ + 'method' => Request::METHOD_GET, + 'query' => ['_criteria' => self::compress(['limit' => 2])], + 'scopes' => [], + ]; + } + + #[WithoutErrorHandler] + public function testInvalidCompressedCriteriaIsRejected(): void + { + $request = self::createStoreApiRequest(['_criteria' => 'not-base64!!']); + + $this->expectExceptionObject( + DataAbstractionLayerException::invalidCompressedCriteriaParameter('Failed to decode base64url data') + ); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + } + + /** + * @param array $query + */ + private static function createStoreApiRequest(array $query): Request + { + $request = new Request($query); + $request->setMethod(Request::METHOD_GET); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [StoreApiRouteScope::ID]); + + return $request; + } + + private static function createEvent(Request $request): ControllerEvent + { + return new ControllerEvent( + static::createStub(HttpKernelInterface::class), + static fn () => null, + $request, + HttpKernelInterface::MAIN_REQUEST + ); + } + + /** + * @param array $payload + */ + private static function compress(array $payload): string + { + $compressed = gzencode(json_encode($payload, \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + return Base64::urlEncode($compressed); + } +} diff --git a/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php b/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php index 432ff53911d..290dab11baa 100644 --- a/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php +++ b/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php @@ -1053,6 +1053,25 @@ public static function searchInfoHeaderProvider(): iterable ]; } + public function testIncludeSearchInfoHeaderIsReadWithCompressedCriteria(): void + { + $request = new Request([ + '_criteria' => self::gzipAndBase64UrlEncode(json_encode(['limit' => 5], \JSON_THROW_ON_ERROR)), + ]); + $request->setMethod(Request::METHOD_GET); + $request->headers->set(PlatformRequest::HEADER_INCLUDE_SEARCH_INFO, '0'); + + $criteria = $this->requestCriteriaBuilder->handleRequest( + $request, + new Criteria(), + $this->staticDefinitionRegistry->get(ProductDefinition::class), + Context::createDefaultContext() + ); + + static::assertSame(5, $criteria->getLimit()); + static::assertTrue($criteria->hasState(Criteria::STATE_DISABLE_SEARCH_INFO)); + } + public function testCompressedCriteriaParameter(): void { $criteriaData = [ @@ -1130,6 +1149,61 @@ public function testCompressedCriteriaParameterTakesPrecedenceOverIndividualPara static::assertSame(['product.active'], $filter->getFields()); } + public function testIndividualParametersAreKeptNextToCompressedCriteria(): void + { + $encodedCriteria = self::gzipAndBase64UrlEncode(json_encode([ + 'filter' => [ + ['type' => 'equals', 'field' => 'active', 'value' => true], + ], + ], \JSON_THROW_ON_ERROR)); + + $request = new Request([ + '_criteria' => $encodedCriteria, + 'limit' => '10', + 'page' => '2', + ]); + $request->setMethod(Request::METHOD_GET); + + $criteria = $this->requestCriteriaBuilder->handleRequest( + $request, + new Criteria(), + $this->staticDefinitionRegistry->get(ProductDefinition::class), + Context::createDefaultContext() + ); + + static::assertSame(10, $criteria->getLimit()); + static::assertSame(10, $criteria->getOffset()); + static::assertCount(1, $criteria->getFilters()); + } + + public function testCompressedCriteriaFieldReplacesTheIndividualParameterAsAWhole(): void + { + $encodedCriteria = self::gzipAndBase64UrlEncode(json_encode([ + 'filter' => [ + ['type' => 'equals', 'field' => 'active', 'value' => true], + ], + ], \JSON_THROW_ON_ERROR)); + + $request = new Request([ + '_criteria' => $encodedCriteria, + 'filter' => [ + ['type' => 'equals', 'field' => 'name', 'value' => 'test'], + ['type' => 'equals', 'field' => 'stock', 'value' => '1'], + ], + ]); + $request->setMethod(Request::METHOD_GET); + + $criteria = $this->requestCriteriaBuilder->handleRequest( + $request, + new Criteria(), + $this->staticDefinitionRegistry->get(ProductDefinition::class), + Context::createDefaultContext() + ); + + static::assertCount(1, $criteria->getFilters(), 'The filters of both sources must not be mixed'); + static::assertSame(['product.active'], $criteria->getFilters()[0]->getFields()); + } + #[WithoutErrorHandler] public function testInvalidCompressedCriteriaParameterThrowsException(): void { diff --git a/tests/unit/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php b/tests/unit/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php new file mode 100644 index 00000000000..cf3cd7da32e --- /dev/null +++ b/tests/unit/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php @@ -0,0 +1,76 @@ +encoder = $this->createMock(ScriptResponseEncoder::class); + + $this->route = new ScriptStoreApiRoute( + static::createStub(ScriptExecutor::class), + $this->encoder, + static::createStub(TagAwareAdapterInterface::class), + new NullLogger() + ); + } + + public function testIncludesOfTheRequestAreApplied(): void + { + $this->encoder->expects($this->once()) + ->method('encodeToSymfonyResponse') + ->with( + static::anything(), + static::equalTo(new ResponseFields(['product' => ['name']], [])), + 'store_api_my_hook_response' + ) + ->willReturn(new Response()); + + $this->route->execute('my-hook', self::createPostRequest(['includes' => ['product' => ['name']]]), Generator::generateSalesChannelContext()); + } + + public function testIncludesThatAreNotAnArrayAreRejected(): void + { + $this->encoder->expects($this->never())->method('encodeToSymfonyResponse'); + + $this->expectExceptionObject(SalesChannelException::invalidType('The includes must be of the type array, string given')); + + $this->route->execute('my-hook', self::createPostRequest(['includes' => 'product']), Generator::generateSalesChannelContext()); + } + + /** + * @param array $body + */ + private static function createPostRequest(array $body): Request + { + $request = new Request([], $body); + $request->setMethod(Request::METHOD_POST); + + return $request; + } +} diff --git a/tests/unit/Core/System/SalesChannel/Api/ResponseFieldsTest.php b/tests/unit/Core/System/SalesChannel/Api/ResponseFieldsTest.php index 58e02bd9897..1e58a843734 100644 --- a/tests/unit/Core/System/SalesChannel/Api/ResponseFieldsTest.php +++ b/tests/unit/Core/System/SalesChannel/Api/ResponseFieldsTest.php @@ -7,6 +7,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\System\SalesChannel\Api\ResponseFields; use Shopware\Core\System\SalesChannel\SalesChannelException; +use Symfony\Component\HttpFoundation\Request; /** * @internal @@ -60,4 +61,52 @@ public function testHasNestedReturnsFalseWhenPropertyDoesNotHavePrefix(): void $responseFields = new ResponseFields(['alias' => ['otherprefix.property']]); static::assertFalse($responseFields->hasNested('alias', 'prefix')); } + + public function testFromRequestReadsTheQueryOfAGetRequest(): void + { + $request = new Request([ + 'includes' => ['product' => ['id', 'name']], + 'excludes' => ['product' => ['name']], + ]); + + $responseFields = ResponseFields::fromRequest($request); + + static::assertTrue($responseFields->isAllowed('product', 'id')); + static::assertFalse($responseFields->isAllowed('product', 'name')); + static::assertFalse($responseFields->isAllowed('product', 'description')); + } + + public function testFromRequestReadsTheBodyOfAPostRequest(): void + { + $request = new Request(request: ['includes' => ['product' => ['id']]]); + $request->setMethod(Request::METHOD_POST); + + $responseFields = ResponseFields::fromRequest($request); + + static::assertTrue($responseFields->isAllowed('product', 'id')); + static::assertFalse($responseFields->isAllowed('product', 'name')); + } + + public function testFromRequestAllowsEverythingWithoutParameters(): void + { + $request = new Request(request: ['includes' => null, 'excludes' => null]); + $request->setMethod(Request::METHOD_POST); + + static::assertTrue(ResponseFields::fromRequest($request)->isAllowed('product', 'id')); + static::assertTrue(ResponseFields::fromRequest(new Request())->isAllowed('product', 'id')); + } + + public function testFromRequestRejectsIncludesThatAreNotAnArray(): void + { + $this->expectExceptionObject(SalesChannelException::invalidType('The includes must be of the type array, string given')); + + ResponseFields::fromRequest(new Request(['includes' => '{"product":["id"]}'])); + } + + public function testFromRequestRejectsExcludesThatAreNotAnArray(): void + { + $this->expectExceptionObject(SalesChannelException::invalidType('The excludes must be of the type array, string given')); + + ResponseFields::fromRequest(new Request(['excludes' => 'name'])); + } } diff --git a/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php b/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php index 4059a734196..fd72156b522 100644 --- a/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php +++ b/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php @@ -17,6 +17,7 @@ use Shopware\Core\System\SalesChannel\Api\StoreApiResponseListener; use Shopware\Core\System\SalesChannel\Api\StructEncoder; use Shopware\Core\System\SalesChannel\GenericStoreApiResponse; +use Shopware\Core\System\SalesChannel\SalesChannelException; use Shopware\Core\System\SalesChannel\StoreApiResponse; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\JsonResponse; @@ -175,4 +176,48 @@ public function testEncodeResponseUsesFieldsFromResolvedCriteria(): void $listener = new StoreApiResponseListener($encoder, new EventDispatcher(), $this->seoUrlPlaceholderHandler, $this->mediaUrlPlaceholderHandler); $listener->encodeResponse($event); } + + public function testEncodeResponseUsesFieldsFromRequestWithoutResolvedCriteria(): void + { + $request = new Request(['includes' => ['category' => ['id']]]); + + $encoder = $this->createMock(StructEncoder::class); + $encoder->expects($this->once()) + ->method('encode') + ->willReturnCallback(static function (Struct $struct, ResponseFields $fields): array { + static::assertTrue($fields->isAllowed('category', 'id')); + static::assertFalse($fields->isAllowed('category', 'name')); + + return ['encoded' => 'data']; + }); + + $event = new ResponseEvent( + static::createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + new GenericStoreApiResponse(200, new ArrayStruct()) + ); + + $listener = new StoreApiResponseListener($encoder, new EventDispatcher(), $this->seoUrlPlaceholderHandler, $this->mediaUrlPlaceholderHandler); + $listener->encodeResponse($event); + } + + public function testEncodeResponseRejectsIncludesThatAreNotAnArray(): void + { + $encoder = $this->createMock(StructEncoder::class); + $encoder->expects($this->never())->method('encode'); + + $event = new ResponseEvent( + static::createStub(HttpKernelInterface::class), + new Request(['includes' => '{"category":["id"]}']), + HttpKernelInterface::MAIN_REQUEST, + new GenericStoreApiResponse(200, new ArrayStruct()) + ); + + $listener = new StoreApiResponseListener($encoder, new EventDispatcher(), $this->seoUrlPlaceholderHandler, $this->mediaUrlPlaceholderHandler); + + $this->expectExceptionObject(SalesChannelException::invalidType('The includes must be of the type array, string given')); + + $listener->encodeResponse($event); + } } diff --git a/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php b/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php index 634ff801a2f..d0cad996435 100644 --- a/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php +++ b/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php @@ -107,4 +107,13 @@ public static function exceptionDataProvider(): iterable 'message' => 'The context token is not accessible in Twig rendering context, as the token should never be leaked in HTML content.', ]; } + + public function testWrongTypeInTheRequestIsAClientError(): void + { + $exception = SalesChannelException::invalidType('The includes must be of the type array, string given'); + + static::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode()); + static::assertSame(SalesChannelException::INVALID_TYPE, $exception->getErrorCode()); + static::assertSame('The includes must be of the type array, string given', $exception->getMessage()); + } }