From 7c06b61c6ea8ab6b34c2de081f865d7a0ce5729b Mon Sep 17 00:00:00 2001 From: Ghaith Olabi Date: Mon, 15 Jun 2026 17:44:54 +0200 Subject: [PATCH 1/2] feat: respect keepUserData on app uninstall Shopware's app.deleted webhook carries a keepUserData flag. Honor it: keep the shop in the repository when true and delete it otherwise, forward the flag to BeforeShopDeletionEvent/ShopDeletedEvent via a keepUserData() accessor, and record it on the uninstall log line. Mirrors shopware/shopware#17466 (retain the app secret iff keepUserData). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AppLifecycle.php | 29 ++++++++++++++++++--- src/Event/BeforeShopDeletionEvent.php | 19 ++++++++++++++ src/Event/ShopDeletedEvent.php | 19 ++++++++++++++ tests/AppLifecycleTest.php | 36 ++++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/AppLifecycle.php b/src/AppLifecycle.php index edc7f1d..ed9aa6f 100644 --- a/src/AppLifecycle.php +++ b/src/AppLifecycle.php @@ -60,7 +60,11 @@ public function deactivate(RequestInterface $request): ResponseInterface } /** - * Handles the 'app.deleted' Hook to remove the shop from the repository + * Handles the 'app.deleted' Hook to remove the shop from the repository. + * + * When the merchant opts to keep the shop's data on uninstall, Shopware sends + * `keepUserData: true` in the webhook payload; the shop is then left untouched + * so a later re-registration can restore it. */ public function delete(RequestInterface $request): ResponseInterface { @@ -73,20 +77,37 @@ public function delete(RequestInterface $request): ResponseInterface return $response; } - $this->eventDispatcher?->dispatch(new BeforeShopDeletionEvent($request, $shop)); + $keepUserData = $this->shouldKeepUserData($request); - $this->shopRepository->deleteShop($shop->getShopId()); + $this->eventDispatcher?->dispatch(new BeforeShopDeletionEvent($request, $shop, $keepUserData)); - $this->eventDispatcher?->dispatch(new ShopDeletedEvent($request, $shop)); + if (!$keepUserData) { + $this->shopRepository->deleteShop($shop->getShopId()); + } + + $this->eventDispatcher?->dispatch(new ShopDeletedEvent($request, $shop, $keepUserData)); $this->logger->info('Shop uninstalled', [ 'shop-id' => $shop->getShopId(), 'shop-url' => $shop->getShopUrl(), + 'keep-user-data' => $keepUserData, ]); return $response; } + /** + * Reads the `keepUserData` flag from the app.deleted webhook payload, defaulting + * to false so the shop is removed unless Shopware explicitly asks to keep it. + */ + private function shouldKeepUserData(RequestInterface $request): bool + { + $body = \json_decode($request->getBody()->getContents(), true); + $request->getBody()->rewind(); + + return \is_array($body) && ($body['data']['payload']['keepUserData'] ?? false) === true; + } + private function findShop(RequestInterface $request): ?ShopInterface { try { diff --git a/src/Event/BeforeShopDeletionEvent.php b/src/Event/BeforeShopDeletionEvent.php index 9353b35..393f8bd 100644 --- a/src/Event/BeforeShopDeletionEvent.php +++ b/src/Event/BeforeShopDeletionEvent.php @@ -4,6 +4,25 @@ namespace Shopware\App\SDK\Event; +use Psr\Http\Message\RequestInterface; +use Shopware\App\SDK\Shop\ShopInterface; + class BeforeShopDeletionEvent extends AbstractAppLifecycleEvent { + public function __construct( + RequestInterface $request, + ShopInterface $shop, + private readonly bool $keepUserData = false, + ) { + parent::__construct($request, $shop); + } + + /** + * Whether the merchant chose to keep the shop's data when uninstalling the app. + * When true, the SDK leaves the shop in the repository instead of deleting it. + */ + public function keepUserData(): bool + { + return $this->keepUserData; + } } diff --git a/src/Event/ShopDeletedEvent.php b/src/Event/ShopDeletedEvent.php index ce8a317..9710ed9 100644 --- a/src/Event/ShopDeletedEvent.php +++ b/src/Event/ShopDeletedEvent.php @@ -4,6 +4,25 @@ namespace Shopware\App\SDK\Event; +use Psr\Http\Message\RequestInterface; +use Shopware\App\SDK\Shop\ShopInterface; + class ShopDeletedEvent extends AbstractAppLifecycleEvent { + public function __construct( + RequestInterface $request, + ShopInterface $shop, + private readonly bool $keepUserData = false, + ) { + parent::__construct($request, $shop); + } + + /** + * Whether the merchant chose to keep the shop's data when uninstalling the app. + * When true, the shop was left in the repository instead of being deleted. + */ + public function keepUserData(): bool + { + return $this->keepUserData; + } } diff --git a/tests/AppLifecycleTest.php b/tests/AppLifecycleTest.php index 0beb73b..81cf631 100644 --- a/tests/AppLifecycleTest.php +++ b/tests/AppLifecycleTest.php @@ -107,6 +107,40 @@ public function testUninstall(): void static::assertInstanceOf(ShopDeletedEvent::class, $this->events[1]); } + public function testUninstallKeepsShopWhenKeepUserDataIsTrue(): void + { + $this->shopRepository->createShop(new MockShop('123', 'https://foo.com', '1234567890')); + + $body = (string) \json_encode(['data' => ['payload' => ['keepUserData' => true]]], JSON_THROW_ON_ERROR); + $response = $this->appLifecycle->delete(new Request("POST", '/?shop-id=123', [], $body)); + static::assertSame(204, $response->getStatusCode()); + + static::assertNotNull($this->shopRepository->getShopFromId('123')); + + static::assertCount(2, $this->events); + static::assertInstanceOf(BeforeShopDeletionEvent::class, $this->events[0]); + static::assertTrue($this->events[0]->keepUserData()); + static::assertInstanceOf(ShopDeletedEvent::class, $this->events[1]); + static::assertTrue($this->events[1]->keepUserData()); + } + + public function testUninstallDeletesShopWhenKeepUserDataIsFalse(): void + { + $this->shopRepository->createShop(new MockShop('123', 'https://foo.com', '1234567890')); + + $body = (string) \json_encode(['data' => ['payload' => ['keepUserData' => false]]], JSON_THROW_ON_ERROR); + $response = $this->appLifecycle->delete(new Request("POST", '/?shop-id=123', [], $body)); + static::assertSame(204, $response->getStatusCode()); + + static::assertNull($this->shopRepository->getShopFromId('123')); + + static::assertCount(2, $this->events); + static::assertInstanceOf(BeforeShopDeletionEvent::class, $this->events[0]); + static::assertFalse($this->events[0]->keepUserData()); + static::assertInstanceOf(ShopDeletedEvent::class, $this->events[1]); + static::assertFalse($this->events[1]->keepUserData()); + } + public function testUninstallNotExisting(): void { $response = $this->appLifecycle->delete(new Request("POST", '/?shop-id=123', [], '{}')); @@ -142,7 +176,7 @@ public function testUninstallLogs(): void $logger ->expects(static::once()) ->method('info') - ->with('Shop uninstalled', ['shop-id' => '123', 'shop-url' => 'https://foo.com']); + ->with('Shop uninstalled', ['shop-id' => '123', 'shop-url' => 'https://foo.com', 'keep-user-data' => false]); $appLifeCycle = new AppLifecycle( $this->createMock(RegistrationService::class), From 50b9a22bd0546aa338e8c227f6530a27a058a123 Mon Sep 17 00:00:00 2001 From: Ghaith Olabi Date: Tue, 16 Jun 2026 10:11:43 +0200 Subject: [PATCH 2/2] fix: reject corrupt app.deleted body instead of silently deleting shouldKeepUserData() decoded without JSON_THROW_ON_ERROR, so an invalid/empty payload became null, fell through to keepUserData=false, and deleted the shop. A JSON parse failure means the webhook body is corrupt: decode with JSON_THROW_ON_ERROR and rethrow as MalformedWebhookBodyException (the SDK's malformed-webhook error, as used throughout ContextResolver) instead of dropping the shop. A valid payload missing the key still defaults to false. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AppLifecycle.php | 12 +++++++++++- tests/AppLifecycleTest.php | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/AppLifecycle.php b/src/AppLifecycle.php index ed9aa6f..5cab82a 100644 --- a/src/AppLifecycle.php +++ b/src/AppLifecycle.php @@ -16,6 +16,7 @@ use Shopware\App\SDK\Event\ShopActivatedEvent; use Shopware\App\SDK\Event\ShopDeactivatedEvent; use Shopware\App\SDK\Event\ShopDeletedEvent; +use Shopware\App\SDK\Exception\MalformedWebhookBodyException; use Shopware\App\SDK\Exception\ShopNotFoundException; use Shopware\App\SDK\Registration\RegistrationService; use Shopware\App\SDK\Shop\ShopInterface; @@ -99,10 +100,19 @@ public function delete(RequestInterface $request): ResponseInterface /** * Reads the `keepUserData` flag from the app.deleted webhook payload, defaulting * to false so the shop is removed unless Shopware explicitly asks to keep it. + * + * Invalid JSON means the webhook body itself is corrupt; it is rejected as a + * malformed body rather than silently treated as keepUserData=false. + * + * @throws MalformedWebhookBodyException when the request body is not valid JSON */ private function shouldKeepUserData(RequestInterface $request): bool { - $body = \json_decode($request->getBody()->getContents(), true); + try { + $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw new MalformedWebhookBodyException(); + } $request->getBody()->rewind(); return \is_array($body) && ($body['data']['payload']['keepUserData'] ?? false) === true; diff --git a/tests/AppLifecycleTest.php b/tests/AppLifecycleTest.php index 81cf631..4d36746 100644 --- a/tests/AppLifecycleTest.php +++ b/tests/AppLifecycleTest.php @@ -19,6 +19,7 @@ use Shopware\App\SDK\Event\ShopActivatedEvent; use Shopware\App\SDK\Event\ShopDeactivatedEvent; use Shopware\App\SDK\Event\ShopDeletedEvent; +use Shopware\App\SDK\Exception\MalformedWebhookBodyException; use Shopware\App\SDK\Registration\RegistrationService; use Shopware\App\SDK\Shop\ShopResolver; use Shopware\App\SDK\Test\MockShop; @@ -141,6 +142,21 @@ public function testUninstallDeletesShopWhenKeepUserDataIsFalse(): void static::assertFalse($this->events[1]->keepUserData()); } + public function testUninstallThrowsOnMalformedBodyAndKeepsShop(): void + { + $this->shopRepository->createShop(new MockShop('123', 'https://foo.com', '1234567890')); + + try { + $this->appLifecycle->delete(new Request("POST", '/?shop-id=123', [], 'not-json')); + static::fail('Expected a MalformedWebhookBodyException on a corrupt body'); + } catch (MalformedWebhookBodyException) { + // expected: a corrupt body must not be treated as keepUserData=false + } + + static::assertNotNull($this->shopRepository->getShopFromId('123')); + static::assertCount(0, $this->events); + } + public function testUninstallNotExisting(): void { $response = $this->appLifecycle->delete(new Request("POST", '/?shop-id=123', [], '{}'));