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
12 changes: 11 additions & 1 deletion resources/js/components/WebhookStatus.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<ui-table-column>Topic</ui-table-column>
<ui-table-column>Callback URL</ui-table-column>
<ui-table-column>Status</ui-table-column>
<ui-table-column>Last received</ui-table-column>
</ui-table-columns>
<ui-table-rows>
<ui-table-row v-for="webhook in webhooks" :key="webhook.id">
Expand All @@ -19,13 +20,15 @@
<ui-badge v-if="webhook.expected" color="green" icon="check" text="Registered" />
<ui-badge v-else color="amber" icon="warning" text="Unknown URL" />
</ui-table-cell>
<ui-table-cell class="text-xs">{{ formatLastReceived(webhook.last_received_at) }}</ui-table-cell>
</ui-table-row>
<ui-table-row v-for="topic in missingTopics" :key="topic">
<ui-table-cell class="font-mono text-xs">{{ topic }}</ui-table-cell>
<ui-table-cell class="font-mono text-xs text-gray-400">{{ expected[topic] }}</ui-table-cell>
<ui-table-cell class="font-mono text-xs">{{ expected[topic] }}</ui-table-cell>
<ui-table-cell>
<ui-badge color="red" icon="warning-diamond" text="Not registered" />
</ui-table-cell>
<ui-table-cell class="text-xs">—</ui-table-cell>
</ui-table-row>
</ui-table-rows>
</ui-table>
Expand Down Expand Up @@ -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 => {
Expand Down
11 changes: 11 additions & 0 deletions src/Http/Controllers/CP/WebhooksStatusController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand All @@ -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'
Expand Down
21 changes: 21 additions & 0 deletions src/Http/Middleware/VerifyShopifyHeaders.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use StatamicRadPack\Shopify\Support\StoreConfig;

class VerifyShopifyHeaders
Expand Down Expand Up @@ -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
*/
Expand Down
206 changes: 206 additions & 0 deletions tests/Unit/WebhookLastReceivedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<?php

namespace StatamicRadPack\Shopify\Tests\Unit;

use Illuminate\Support\Facades\Cache;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\Test;
use Shopify\Clients\Graphql;
use Shopify\Clients\HttpResponse;
use Statamic\Facades\User;
use StatamicRadPack\Shopify\Tests\TestCase;

class WebhookLastReceivedTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

config()->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'));
}
}
Loading