Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions src/AppLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,7 +61,11 @@
}

/**
* 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
{
Expand All @@ -73,20 +78,46 @@
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.
*
* 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
{
try {
$body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new MalformedWebhookBodyException();
}
$request->getBody()->rewind();

Check warning on line 116 in src/AppLifecycle.php

View workflow job for this annotation

GitHub Actions / unit

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ } catch (\JsonException) { throw new MalformedWebhookBodyException(); } - $request->getBody()->rewind(); + return \is_array($body) && ($body['data']['payload']['keepUserData'] ?? false) === true; } private function findShop(RequestInterface $request): ?ShopInterface

return \is_array($body) && ($body['data']['payload']['keepUserData'] ?? false) === true;
}

private function findShop(RequestInterface $request): ?ShopInterface
{
try {
Expand Down
19 changes: 19 additions & 0 deletions src/Event/BeforeShopDeletionEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
19 changes: 19 additions & 0 deletions src/Event/ShopDeletedEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
52 changes: 51 additions & 1 deletion tests/AppLifecycleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -107,6 +108,55 @@ 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 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', [], '{}'));
Expand Down Expand Up @@ -142,7 +192,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),
Expand Down
Loading