diff --git a/src/AppLifecycle.php b/src/AppLifecycle.php index 5cab82a..8acec0c 100644 --- a/src/AppLifecycle.php +++ b/src/AppLifecycle.php @@ -18,6 +18,7 @@ use Shopware\App\SDK\Event\ShopDeletedEvent; use Shopware\App\SDK\Exception\MalformedWebhookBodyException; use Shopware\App\SDK\Exception\ShopNotFoundException; +use Shopware\App\SDK\Framework\RequestBodyParser; use Shopware\App\SDK\Registration\RegistrationService; use Shopware\App\SDK\Shop\ShopInterface; use Shopware\App\SDK\Shop\ShopRepositoryInterface; @@ -109,13 +110,15 @@ public function delete(RequestInterface $request): ResponseInterface private function shouldKeepUserData(RequestInterface $request): bool { try { - $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); + $body = RequestBodyParser::parse($request); } catch (\JsonException) { throw new MalformedWebhookBodyException(); } - $request->getBody()->rewind(); - return \is_array($body) && ($body['data']['payload']['keepUserData'] ?? false) === true; + return \is_array($body) + && \is_array($body['data'] ?? null) + && \is_array($body['data']['payload'] ?? null) + && ($body['data']['payload']['keepUserData'] ?? false) === true; } private function findShop(RequestInterface $request): ?ShopInterface diff --git a/src/Context/ContextResolver.php b/src/Context/ContextResolver.php index 83da909..4edb9f5 100644 --- a/src/Context/ContextResolver.php +++ b/src/Context/ContextResolver.php @@ -6,7 +6,6 @@ use DateTimeImmutable; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ServerRequestInterface; use Shopware\App\SDK\Context\ActionButton\ActionButtonAction; use Shopware\App\SDK\Context\Cart\Cart; use Shopware\App\SDK\Context\Gateway\Checkout\CheckoutGatewayAction; @@ -31,6 +30,7 @@ use Shopware\App\SDK\Context\Webhook\WebhookAction; use Shopware\App\SDK\Exception\MalformedWebhookBodyException; use Shopware\App\SDK\Framework\Collection; +use Shopware\App\SDK\Framework\RequestBodyParser; use Shopware\App\SDK\Shop\ShopInterface; /** @@ -48,7 +48,7 @@ public function __construct( */ public function assembleWebhook(RequestInterface $request, ShopInterface $shop): WebhookAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -65,7 +65,7 @@ public function assembleWebhook(RequestInterface $request, ShopInterface $shop): public function assembleActionButton(RequestInterface $request, ShopInterface $shop): ActionButtonAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -109,7 +109,7 @@ public function assembleModule(RequestInterface $request, ShopInterface $shop): public function assembleTaxProvider(RequestInterface $request, ShopInterface $shop): TaxProviderAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -125,7 +125,7 @@ public function assembleTaxProvider(RequestInterface $request, ShopInterface $sh public function assemblePaymentPay(RequestInterface $request, ShopInterface $shop): PaymentPayAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -144,7 +144,7 @@ public function assemblePaymentPay(RequestInterface $request, ShopInterface $sho public function assemblePaymentFinalize(RequestInterface $request, ShopInterface $shop): PaymentFinalizeAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -162,7 +162,7 @@ public function assemblePaymentFinalize(RequestInterface $request, ShopInterface public function assemblePaymentCapture(RequestInterface $request, ShopInterface $shop): PaymentCaptureAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -180,7 +180,7 @@ public function assemblePaymentCapture(RequestInterface $request, ShopInterface public function assemblePaymentRecurringCapture(RequestInterface $request, ShopInterface $shop): PaymentRecurringAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -197,7 +197,7 @@ public function assemblePaymentRecurringCapture(RequestInterface $request, ShopI public function assemblePaymentValidate(RequestInterface $request, ShopInterface $shop): PaymentValidateAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -214,7 +214,7 @@ public function assemblePaymentValidate(RequestInterface $request, ShopInterface public function assemblePaymentRefund(RequestInterface $request, ShopInterface $shop): RefundAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -267,7 +267,7 @@ public function assembleStorefrontRequest(RequestInterface $request, ShopInterfa public function assembleCheckoutGatewayRequest(RequestInterface $request, ShopInterface $shop): CheckoutGatewayAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -285,7 +285,7 @@ public function assembleCheckoutGatewayRequest(RequestInterface $request, ShopIn public function assembleContextGatewayRequest(RequestInterface $request, ShopInterface $shop): ContextGatewayAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source']) || !isset($body['data']) || !\is_array($body['data'])) { throw new MalformedWebhookBodyException(); @@ -302,7 +302,7 @@ public function assembleContextGatewayRequest(RequestInterface $request, ShopInt public function assembleInAppPurchasesFilterRequest(RequestInterface $request, ShopInterface $shop): FilterAction { - $body = $this->getBody($request); + $body = RequestBodyParser::parse($request); if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { throw new MalformedWebhookBodyException(); @@ -319,25 +319,6 @@ public function assembleInAppPurchasesFilterRequest(RequestInterface $request, S ); } - /** - * @throws \JsonException - */ - private function getBody(RequestInterface $request): mixed - { - if ($request instanceof ServerRequestInterface) { - $body = $request->getParsedBody(); - - if ($body !== null) { - return $body; - } - } - - $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); - $request->getBody()->rewind(); - - return $body; - } - /** * @param array $source */ diff --git a/src/Framework/RequestBodyParser.php b/src/Framework/RequestBodyParser.php new file mode 100644 index 0000000..444289b --- /dev/null +++ b/src/Framework/RequestBodyParser.php @@ -0,0 +1,40 @@ +|null + * + * @throws \JsonException when the body is not valid JSON + */ + public static function parse(RequestInterface $request): ?array + { + if ($request instanceof ServerRequestInterface) { + $body = $request->getParsedBody(); + + if (\is_array($body)) { + return $body; + } + } + + $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); + $request->getBody()->rewind(); + + return \is_array($body) ? $body : null; + } +} diff --git a/src/Registration/RegistrationService.php b/src/Registration/RegistrationService.php index bfc6b0b..0caa03c 100644 --- a/src/Registration/RegistrationService.php +++ b/src/Registration/RegistrationService.php @@ -22,6 +22,7 @@ use Shopware\App\SDK\Exception\ShopNotFoundException; use Shopware\App\SDK\Exception\SignatureInvalidException; use Shopware\App\SDK\Exception\SignatureNotFoundException; +use Shopware\App\SDK\Framework\RequestBodyParser; use Shopware\App\SDK\Shop\ShopInterface; use Shopware\App\SDK\Shop\ShopRepositoryInterface; @@ -141,8 +142,7 @@ public function register(RequestInterface $request): ResponseInterface */ public function registerConfirm(RequestInterface $request): ResponseInterface { - /** @var array $requestContent */ - $requestContent = \json_decode($request->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + $requestContent = RequestBodyParser::parse($request); if ( empty($requestContent['shopId']) || @@ -166,8 +166,6 @@ public function registerConfirm(RequestInterface $request): ResponseInterface $this->registrationLogContext($request, $requestContent['shopId'], $shop->getShopUrl(), $shop) ); - $request->getBody()->rewind(); - // Use dual signature verifier for registration confirmation try { $this->dualSignatureVerifier->authenticateRegistrationConfirmation($request, $shop, $this->appConfiguration); diff --git a/src/Shop/ShopResolver.php b/src/Shop/ShopResolver.php index de1f70b..7b5d495 100644 --- a/src/Shop/ShopResolver.php +++ b/src/Shop/ShopResolver.php @@ -9,6 +9,7 @@ use Shopware\App\SDK\Exception\MissingShopParameterException; use Shopware\App\SDK\Exception\ShopNotFoundException; use Shopware\App\SDK\Exception\SignatureInvalidException; +use Shopware\App\SDK\Framework\RequestBodyParser; /** * Resolve and verify a request to a shop @@ -45,8 +46,7 @@ public function resolveShop(RequestInterface $request): ShopInterface */ private function resolveFromSource(RequestInterface $request): ShopInterface { - $body = \json_decode($request->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); - $request->getBody()->rewind(); + $body = RequestBodyParser::parse($request); if (!is_array($body) || !isset($body['source']) || !isset($body['source']['shopId']) || !is_string($body['source']['shopId'])) { throw new MissingShopParameterException(); diff --git a/tests/AppLifecycleTest.php b/tests/AppLifecycleTest.php index 4d36746..306518e 100644 --- a/tests/AppLifecycleTest.php +++ b/tests/AppLifecycleTest.php @@ -6,6 +6,7 @@ use Nyholm\Psr7\Request; use Nyholm\Psr7\Response; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\Attributes\CoversClass; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -125,6 +126,20 @@ public function testUninstallKeepsShopWhenKeepUserDataIsTrue(): void static::assertTrue($this->events[1]->keepUserData()); } + public function testUninstallUsesTheParsedBody(): void + { + $this->shopRepository->createShop(new MockShop('123', 'https://foo.com', '1234567890')); + + // an empty body stream cannot be decoded, so keeping the shop proves the parsed body was used + $request = (new ServerRequest('POST', '/?shop-id=123', [], '')) + ->withParsedBody(['data' => ['payload' => ['keepUserData' => true]]]); + + $response = $this->appLifecycle->delete($request); + + static::assertSame(204, $response->getStatusCode()); + static::assertNotNull($this->shopRepository->getShopFromId('123')); + } + public function testUninstallDeletesShopWhenKeepUserDataIsFalse(): void { $this->shopRepository->createShop(new MockShop('123', 'https://foo.com', '1234567890')); diff --git a/tests/Framework/RequestBodyParserTest.php b/tests/Framework/RequestBodyParserTest.php new file mode 100644 index 0000000..a2ded25 --- /dev/null +++ b/tests/Framework/RequestBodyParserTest.php @@ -0,0 +1,71 @@ + 'bar'], RequestBodyParser::parse($request)); + } + + public function testLeavesTheBodyReadableForTheNextReader(): void + { + $request = new Request('POST', 'https://example.com', [], '{"foo": "bar"}'); + + RequestBodyParser::parse($request); + + static::assertSame('{"foo": "bar"}', $request->getBody()->getContents()); + } + + public function testThrowsOnAnInvalidBody(): void + { + $request = new Request('POST', 'https://example.com', [], 'not-json'); + + static::expectException(\JsonException::class); + RequestBodyParser::parse($request); + } + + public function testReturnsNullForABodyThatIsNotAJsonObject(): void + { + $request = new Request('POST', 'https://example.com', [], '"foo"'); + + static::assertNull(RequestBodyParser::parse($request)); + } + + public function testReusesTheParsedBodyOfAServerRequest(): void + { + $request = static::createMock(ServerRequestInterface::class); + $request->expects(static::once())->method('getParsedBody')->willReturn(['foo' => 'bar']); + $request->expects(static::never())->method('getBody'); + + static::assertSame(['foo' => 'bar'], RequestBodyParser::parse($request)); + } + + public function testFallsBackToTheBodyWhenAServerRequestWasNotParsed(): void + { + $request = new ServerRequest('POST', 'https://example.com', [], '{"foo": "bar"}'); + + static::assertSame(['foo' => 'bar'], RequestBodyParser::parse($request)); + } + + public function testFallsBackToTheBodyWhenTheParsedBodyIsNotAnArray(): void + { + $request = (new ServerRequest('POST', 'https://example.com', [], '{"foo": "bar"}')) + ->withParsedBody(new \stdClass()); + + static::assertSame(['foo' => 'bar'], RequestBodyParser::parse($request)); + } +} diff --git a/tests/Registration/RegistrationServiceTest.php b/tests/Registration/RegistrationServiceTest.php index 198b2f3..902e6b7 100644 --- a/tests/Registration/RegistrationServiceTest.php +++ b/tests/Registration/RegistrationServiceTest.php @@ -5,6 +5,7 @@ namespace Shopware\App\SDK\Tests\Registration; use Nyholm\Psr7\Request; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -819,6 +820,33 @@ public function testRegisterConfirmRequestIsAuthenticated(): void $registrationService->registerConfirm($request); } + public function testRegisterConfirmUsesTheParsedBody(): void + { + $shop = new MockShop('123', 'https://foo.com', '1234567890'); + $shop->setPendingShopSecret('1234567890'); + $shop->setPendingShopUrl('https://my-shop.com'); + $this->shopRepository->createShop($shop); + + // an empty body stream cannot be decoded, so confirming the shop proves the parsed body was used + $request = (new ServerRequest('POST', 'http://localhost', [], '')) + ->withParsedBody(['shopId' => '123', 'apiKey' => '1', 'secretKey' => '2']); + + $this->registerService->registerConfirm($request); + + $confirmedShop = $this->shopRepository->getShopFromId('123'); + static::assertNotNull($confirmedShop); + static::assertSame('1', $confirmedShop->getShopClientId()); + static::assertSame('2', $confirmedShop->getShopClientSecret()); + } + + public function testRegisterConfirmRejectsABodyThatIsNotAJsonObject(): void + { + $request = new Request('POST', 'http://localhost', [], '"foo"'); + + static::expectException(MissingShopParameterException::class); + $this->registerService->registerConfirm($request); + } + public function testBodyRewindIsCalled(): void { $body = static::createMock(StreamInterface::class); diff --git a/tests/Shop/ShopResolverTest.php b/tests/Shop/ShopResolverTest.php index 79bd446..15c310e 100644 --- a/tests/Shop/ShopResolverTest.php +++ b/tests/Shop/ShopResolverTest.php @@ -5,6 +5,7 @@ namespace Shopware\App\SDK\Tests\Shop; use Nyholm\Psr7\Request; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\Attributes\CoversClass; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamInterface; @@ -100,6 +101,19 @@ public function testResolveFromQueryStringRequestIsAuthenticated(): void $resolver->resolveShop($request); } + public function testResolveSourceUsesTheParsedBody(): void + { + $this->shopRepository->createShop(new MockShop('1', 'test.de', 'asd')); + + // an empty body stream cannot be decoded, so resolving the shop proves the parsed body was used + $request = (new ServerRequest('POST', 'https://example.com', ['Content-Type' => 'application/json'], '')) + ->withParsedBody(['source' => ['shopId' => '1']]); + + $shop = $this->shopResolver->resolveShop($request); + + static::assertSame('1', $shop->getShopId()); + } + public function testRequestRewindIsCalled(): void { $this->shopRepository->createShop(new MockShop('1', 'test.de', 'asd'));