diff --git a/resources/js/components/WebhookStatus.vue b/resources/js/components/WebhookStatus.vue index cbc3dad..5866fbe 100644 --- a/resources/js/components/WebhookStatus.vue +++ b/resources/js/components/WebhookStatus.vue @@ -10,6 +10,7 @@ Topic Callback URL Status + Last received @@ -19,13 +20,15 @@ + {{ formatLastReceived(webhook.last_received_at) }} {{ topic }} - {{ expected[topic] }} + {{ expected[topic] }} + @@ -67,6 +70,13 @@ export default { }, }, + methods: { + formatLastReceived(timestamp) { + if (!timestamp) return 'Never'; + return new Date(timestamp).toLocaleString(); + }, + }, + mounted() { axios.get(this.url) .then(res => { diff --git a/src/Http/Controllers/CP/WebhooksStatusController.php b/src/Http/Controllers/CP/WebhooksStatusController.php index 5601962..1be3cf6 100644 --- a/src/Http/Controllers/CP/WebhooksStatusController.php +++ b/src/Http/Controllers/CP/WebhooksStatusController.php @@ -3,6 +3,7 @@ namespace StatamicRadPack\Shopify\Http\Controllers\CP; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Shopify\Clients\Graphql; use Statamic\Http\Controllers\CP\CpController; use Statamic\Support\Arr; @@ -39,6 +40,7 @@ public function index(Request $request) $webhooks = collect($registered)->map(fn ($webhook) => array_merge($webhook, [ 'expected' => isset($expected[$webhook['topic']]) && $webhook['callbackUrl'] === $expected[$webhook['topic']], + 'last_received_at' => $this->getLastReceivedAt($webhook['topic'], $storeHandle), ]))->all(); return response()->json([ @@ -47,6 +49,15 @@ public function index(Request $request) ]); } + private function getLastReceivedAt(string $topic, ?string $storeHandle): ?string + { + $cacheKey = $storeHandle + ? "shopify::webhook_last_received::{$storeHandle}::{$topic}" + : "shopify::webhook_last_received::{$topic}"; + + return Cache::get($cacheKey); + } + private function fetchWebhooks(Graphql $graphql): array { $query = <<<'QUERY' diff --git a/src/Http/Middleware/VerifyShopifyHeaders.php b/src/Http/Middleware/VerifyShopifyHeaders.php index 8568397..b131d5c 100644 --- a/src/Http/Middleware/VerifyShopifyHeaders.php +++ b/src/Http/Middleware/VerifyShopifyHeaders.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use StatamicRadPack\Shopify\Support\StoreConfig; class VerifyShopifyHeaders @@ -34,9 +35,29 @@ public function handle(Request $request, Closure $next) return response()->json(['error' => true], 403); } + $this->recordLastReceived($request); + return $next($request); } + protected function recordLastReceived(Request $request): void + { + $rawTopic = $request->header('X-Shopify-Topic'); + + if (! $rawTopic) { + return; + } + + $topic = str_replace('/', '_', strtoupper($rawTopic)); + $storeHandle = $request->attributes->get('shopify_store_handle'); + + $cacheKey = $storeHandle + ? "shopify::webhook_last_received::{$storeHandle}::{$topic}" + : "shopify::webhook_last_received::{$topic}"; + + Cache::forever($cacheKey, now()->toIso8601String()); + } + /** * Verify integrity */ diff --git a/tests/Unit/WebhookLastReceivedTest.php b/tests/Unit/WebhookLastReceivedTest.php new file mode 100644 index 0000000..5d8b000 --- /dev/null +++ b/tests/Unit/WebhookLastReceivedTest.php @@ -0,0 +1,206 @@ +set('shopify.ignore_webhook_integrity_check', true); + } + + private function actingAsSuperUser() + { + $user = User::make()->email('admin@example.com')->makeSuper()->save(); + + return $this->actingAs($user); + } + + private function shopifyWebhooksResponse(array $nodes = []): string + { + return json_encode([ + 'data' => [ + 'webhookSubscriptions' => [ + 'nodes' => $nodes, + ], + ], + ]); + } + + #[Test] + public function records_last_received_timestamp_on_webhook_request() + { + Cache::flush(); + + $this->mock(Graphql::class, function (MockInterface $mock) { + $mock->shouldReceive('query')->andReturn(new HttpResponse(status: 200, body: '{}')); + }); + + $this->postJson('/!/shopify/webhook/product/create', ['id' => 1], [ + 'X-Shopify-Topic' => 'products/create', + ]); + + $this->assertNotNull(Cache::get('shopify::webhook_last_received::PRODUCTS_CREATE')); + } + + #[Test] + public function normalises_topic_header_to_enum_format() + { + Cache::flush(); + + $this->mock(Graphql::class, function (MockInterface $mock) { + $mock->shouldReceive('query')->andReturn(new HttpResponse(status: 200, body: '{}')); + }); + + $this->postJson('/!/shopify/webhook/collection/update', ['id' => 1], [ + 'X-Shopify-Topic' => 'collections/update', + ]); + + $this->assertNotNull(Cache::get('shopify::webhook_last_received::COLLECTIONS_UPDATE')); + } + + #[Test] + public function does_not_record_timestamp_when_verification_fails() + { + Cache::flush(); + config()->set('shopify.ignore_webhook_integrity_check', false); + config()->set('shopify.webhook_secret', 'secret'); + + $this->postJson('/!/shopify/webhook/product/create', ['id' => 1], [ + 'X-Shopify-Topic' => 'products/create', + 'X-Shopify-Hmac-Sha256' => 'invalid', + ]); + + $this->assertNull(Cache::get('shopify::webhook_last_received::PRODUCTS_CREATE')); + } + + #[Test] + public function records_timestamp_per_store_in_multi_store_mode() + { + Cache::flush(); + + config(['shopify.multi_store' => [ + 'enabled' => true, + 'mode' => 'unified', + 'stores' => [ + 'uk' => [ + 'url' => 'uk.myshopify.com', + 'webhook_secret' => 'secret', + ], + ], + ]]); + + $this->mock(Graphql::class, function (MockInterface $mock) { + $mock->shouldReceive('query')->andReturn(new HttpResponse(status: 200, body: '{}')); + }); + + $this->postJson('/!/shopify/webhook/product/update', ['id' => 1], [ + 'X-Shopify-Topic' => 'products/update', + 'X-Shopify-Shop-Domain' => 'uk.myshopify.com', + ]); + + $this->assertNotNull(Cache::get('shopify::webhook_last_received::uk::PRODUCTS_UPDATE')); + $this->assertNull(Cache::get('shopify::webhook_last_received::PRODUCTS_UPDATE')); + } + + #[Test] + public function status_controller_includes_last_received_at_for_each_webhook() + { + $callbackUrl = route('statamic.shopify.webhook.product.create', [], true); + + Cache::forever('shopify::webhook_last_received::PRODUCTS_CREATE', '2025-06-01T12:00:00+00:00'); + + $this->mock(Graphql::class, function (MockInterface $mock) use ($callbackUrl) { + $mock->shouldReceive('query') + ->once() + ->andReturn(new HttpResponse(status: 200, body: $this->shopifyWebhooksResponse([ + [ + 'id' => 'gid://shopify/WebhookSubscription/1', + 'topic' => 'PRODUCTS_CREATE', + 'endpoint' => ['callbackUrl' => $callbackUrl], + 'createdAt' => '2025-01-01T00:00:00Z', + ], + ]))); + }); + + $response = $this->actingAsSuperUser() + ->getJson(cp_route('shopify.webhooks.status')); + + $response->assertOk(); + $this->assertSame('2025-06-01T12:00:00+00:00', $response->json('webhooks.0.last_received_at')); + } + + #[Test] + public function status_controller_returns_null_last_received_at_when_never_fired() + { + Cache::flush(); + + $callbackUrl = route('statamic.shopify.webhook.product.create', [], true); + + $this->mock(Graphql::class, function (MockInterface $mock) use ($callbackUrl) { + $mock->shouldReceive('query') + ->once() + ->andReturn(new HttpResponse(status: 200, body: $this->shopifyWebhooksResponse([ + [ + 'id' => 'gid://shopify/WebhookSubscription/1', + 'topic' => 'PRODUCTS_CREATE', + 'endpoint' => ['callbackUrl' => $callbackUrl], + 'createdAt' => '2025-01-01T00:00:00Z', + ], + ]))); + }); + + $response = $this->actingAsSuperUser() + ->getJson(cp_route('shopify.webhooks.status')); + + $response->assertOk(); + $this->assertNull($response->json('webhooks.0.last_received_at')); + } + + #[Test] + public function status_controller_uses_store_scoped_cache_key_in_multi_store_mode() + { + Cache::flush(); + + config(['shopify.multi_store' => [ + 'enabled' => true, + 'mode' => 'unified', + 'stores' => [ + 'uk' => ['url' => 'uk.myshopify.com', 'admin_token' => 'tok'], + ], + ]]); + + $callbackUrl = route('statamic.shopify.webhook.product.create', [], true); + + Cache::forever('shopify::webhook_last_received::uk::PRODUCTS_CREATE', '2025-06-01T12:00:00+00:00'); + + $this->app->instance('shopify.graphql.uk', tap($this->mock(Graphql::class), function (MockInterface $mock) use ($callbackUrl) { + $mock->shouldReceive('query') + ->once() + ->andReturn(new HttpResponse(status: 200, body: $this->shopifyWebhooksResponse([ + [ + 'id' => 'gid://shopify/WebhookSubscription/1', + 'topic' => 'PRODUCTS_CREATE', + 'endpoint' => ['callbackUrl' => $callbackUrl], + 'createdAt' => '2025-01-01T00:00:00Z', + ], + ]))); + })); + + $response = $this->actingAsSuperUser() + ->getJson(cp_route('shopify.webhooks.status').'?store=uk'); + + $response->assertOk(); + $this->assertSame('2025-06-01T12:00:00+00:00', $response->json('webhooks.0.last_received_at')); + } +}