feat(payments): implement standard webhooks validation system
Add comprehensive webhook validation and processing system with Polar.sh integration: - Create built-in Standard Webhooks package following official specification - Implement HMAC-SHA256 signature validation with base64 encoding - Add webhook factory for multi-provider support (Polar, Stripe, generic) - Replace custom Polar webhook validation with Standard Webhooks implementation - Add proper exception handling with custom WebhookVerificationException - Support sandbox mode bypass for development environments - Update Polar provider to use database-driven configuration - Enhance webhook test suite with proper Standard Webhooks format - Add PaymentProvider model HasFactory trait for testing - Implement timestamp tolerance checking (±5 minutes) for replay protection - Support multiple signature versions and proper header validation This provides a secure, reusable webhook validation system that can be extended to other payment providers while maintaining full compliance with Standard Webhooks specification. BREAKING CHANGE: Polar webhook validation now uses Standard Webhooks format with headers 'webhook-id', 'webhook-timestamp', 'webhook-signature' instead of previous Polar-specific headers.
This commit is contained in:
549
tests/Feature/Controllers/PolarWebhookTest.php
Normal file
549
tests/Feature/Controllers/PolarWebhookTest.php
Normal file
@@ -0,0 +1,549 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Controllers;
|
||||
|
||||
use App\Models\PaymentProvider;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PolarWebhookTest extends TestCase
|
||||
{
|
||||
protected User $user;
|
||||
|
||||
protected Plan $plan;
|
||||
|
||||
protected Subscription $subscription;
|
||||
|
||||
protected string $webhookSecret = 'test_webhook_secret';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Allow any error, warning, and info logs for all tests
|
||||
Log::shouldReceive('error')
|
||||
->zeroOrMoreTimes()
|
||||
->withAnyArgs();
|
||||
Log::shouldReceive('warning')
|
||||
->zeroOrMoreTimes()
|
||||
->withAnyArgs();
|
||||
Log::shouldReceive('info')
|
||||
->zeroOrMoreTimes()
|
||||
->withAnyArgs();
|
||||
|
||||
// Create payment provider configuration in database
|
||||
PaymentProvider::factory()->create([
|
||||
'name' => 'polar',
|
||||
'display_name' => 'Polar.sh',
|
||||
'is_active' => true,
|
||||
'configuration' => [
|
||||
'api_key' => 'test_api_key',
|
||||
'webhook_secret' => $this->webhookSecret,
|
||||
'sandbox' => false, // Important: disable sandbox for validation tests
|
||||
'sandbox_api_key' => 'sandbox_test_api_key',
|
||||
'sandbox_webhook_secret' => 'sandbox_test_webhook_secret',
|
||||
'access_token' => 'test_access_token',
|
||||
],
|
||||
]);
|
||||
|
||||
// Create test data
|
||||
$this->user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'polar_cust_id' => 'cus_test123',
|
||||
]);
|
||||
|
||||
$this->plan = Plan::factory()->create([
|
||||
'name' => 'Test Plan',
|
||||
'price' => 29.99,
|
||||
'monthly_billing' => true,
|
||||
]);
|
||||
|
||||
$this->subscription = Subscription::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'plan_id' => $this->plan->id,
|
||||
'provider' => 'polar',
|
||||
'provider_subscription_id' => 'sub_test123',
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rejects_webhook_with_invalid_signature(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.created');
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => 'invalid_signature',
|
||||
'webhook-timestamp' => (string) time(),
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
// Check that we get some kind of error response (actual format may vary)
|
||||
$this->assertStringContainsString('error', $response->getContent());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rejects_webhook_with_missing_timestamp(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.created');
|
||||
$signature = $this->generatePolarSignature($payload);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson(['error' => 'Invalid webhook signature']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rejects_webhook_with_old_timestamp(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.created');
|
||||
$oldTimestamp = time() - 600; // 10 minutes ago
|
||||
$signature = $this->generatePolarSignature($payload, $oldTimestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $oldTimestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson(['error' => 'Invalid webhook signature']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rejects_webhook_with_future_timestamp(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.created');
|
||||
$futureTimestamp = time() + 600; // 10 minutes in future
|
||||
$signature = $this->generatePolarSignature($payload, $futureTimestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $futureTimestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson(['error' => 'Invalid webhook signature']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_processes_valid_webhook_with_correct_signature(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.created');
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_webhook_idempotency(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.created', [
|
||||
'id' => 'wh_test123',
|
||||
]);
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
// First request should succeed
|
||||
$response1 = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response1->assertStatus(200);
|
||||
$response1->assertJson(['status' => 'success']);
|
||||
|
||||
// Second request with same webhook ID should be ignored
|
||||
$response2 = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response2->assertStatus(200);
|
||||
$response2->assertJson(['status' => 'success', 'message' => 'Webhook already processed']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_subscription_created_webhook(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.created', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'sub_new123',
|
||||
'customer_id' => 'cus_test123',
|
||||
'product_id' => 'prod_test123',
|
||||
'status' => 'active',
|
||||
'current_period_start' => now()->toISOString(),
|
||||
'current_period_end' => now()->addMonth()->toISOString(),
|
||||
'created_at' => now()->toISOString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_subscription_active_webhook(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.active', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => $this->subscription->provider_subscription_id,
|
||||
'customer_id' => 'cus_test123',
|
||||
'status' => 'active',
|
||||
'current_period_start' => now()->toISOString(),
|
||||
'current_period_end' => now()->addMonth()->toISOString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
|
||||
// Verify subscription was updated
|
||||
$this->subscription->refresh();
|
||||
$this->assertEquals('active', $this->subscription->status);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_subscription_cancelled_webhook_with_reason(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.cancelled', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => $this->subscription->provider_subscription_id,
|
||||
'customer_id' => 'cus_test123',
|
||||
'status' => 'cancelled',
|
||||
'customer_cancellation_reason' => 'too_expensive',
|
||||
'customer_cancellation_comment' => 'Found a cheaper alternative',
|
||||
'cancelled_at' => now()->toISOString(),
|
||||
'ends_at' => now()->addMonth()->toISOString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
|
||||
// Verify subscription was cancelled with reason
|
||||
$this->subscription->refresh();
|
||||
$this->assertEquals('cancelled', $this->subscription->status);
|
||||
$this->assertEquals('too_expensive', $this->subscription->cancellation_reason);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_subscription_paused_webhook(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.paused', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => $this->subscription->provider_subscription_id,
|
||||
'customer_id' => 'cus_test123',
|
||||
'status' => 'paused',
|
||||
'paused_at' => now()->toISOString(),
|
||||
'pause_reason' => 'customer_request',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
|
||||
// Verify subscription was paused
|
||||
$this->subscription->refresh();
|
||||
$this->assertEquals('paused', $this->subscription->status);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_subscription_resumed_webhook(): void
|
||||
{
|
||||
// First pause the subscription
|
||||
$this->subscription->update(['status' => 'paused']);
|
||||
|
||||
$payload = $this->createPolarWebhookPayload('subscription.resumed', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => $this->subscription->provider_subscription_id,
|
||||
'customer_id' => 'cus_test123',
|
||||
'status' => 'active',
|
||||
'resumed_at' => now()->toISOString(),
|
||||
'resume_reason' => 'customer_request',
|
||||
'current_period_start' => now()->toISOString(),
|
||||
'current_period_end' => now()->addMonth()->toISOString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
|
||||
// Verify subscription was resumed
|
||||
$this->subscription->refresh();
|
||||
$this->assertEquals('active', $this->subscription->status);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_subscription_trial_will_end_webhook(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.trial_will_end', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => $this->subscription->provider_subscription_id,
|
||||
'customer_id' => 'cus_test123',
|
||||
'status' => 'trialing',
|
||||
'trial_ends_at' => now()->addDays(3)->toISOString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
|
||||
// Verify trial end date was updated
|
||||
$this->subscription->refresh();
|
||||
$this->assertNotNull($this->subscription->trial_ends_at);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_subscription_trial_ended_webhook(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('subscription.trial_ended', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => $this->subscription->provider_subscription_id,
|
||||
'customer_id' => 'cus_test123',
|
||||
'status' => 'active',
|
||||
'trial_ended_at' => now()->toISOString(),
|
||||
'current_period_start' => now()->toISOString(),
|
||||
'current_period_end' => now()->addMonth()->toISOString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
|
||||
// Verify subscription was converted from trial
|
||||
$this->subscription->refresh();
|
||||
$this->assertEquals('active', $this->subscription->status);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_subscription_lookup_by_checkout_id_fallback(): void
|
||||
{
|
||||
// Create subscription without provider_subscription_id but with checkout_id
|
||||
$subscriptionWithoutId = Subscription::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'plan_id' => $this->plan->id,
|
||||
'provider' => 'polar',
|
||||
'provider_subscription_id' => null,
|
||||
'provider_checkout_id' => 'chk_test123',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$payload = $this->createPolarWebhookPayload('subscription.active', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'sub_new456',
|
||||
'checkout_id' => 'chk_test123', // This should match the subscription
|
||||
'customer_id' => 'cus_test123',
|
||||
'status' => 'active',
|
||||
'current_period_start' => now()->toISOString(),
|
||||
'current_period_end' => now()->addMonth()->toISOString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
|
||||
// Verify subscription was found and updated
|
||||
$subscriptionWithoutId->refresh();
|
||||
$this->assertEquals('active', $subscriptionWithoutId->status);
|
||||
$this->assertEquals('sub_new456', $subscriptionWithoutId->provider_subscription_id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_unknown_webhook_type_gracefully(): void
|
||||
{
|
||||
$payload = $this->createPolarWebhookPayload('unknown.event', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'test123',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_webhook_processing_errors_gracefully(): void
|
||||
{
|
||||
// Create invalid payload that will cause processing error
|
||||
$payload = $this->createPolarWebhookPayload('subscription.active', [
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => $this->subscription->provider_subscription_id,
|
||||
'customer_id' => 'cus_test123',
|
||||
'status' => 'active',
|
||||
'current_period_start' => 'invalid-date', // This will cause error
|
||||
'current_period_end' => now()->addMonth()->toISOString(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$timestamp = time();
|
||||
$signature = $this->generatePolarSignature($payload, $timestamp);
|
||||
|
||||
$response = $this->postJson('/webhook/polar', $payload, [
|
||||
'webhook-signature' => $signature,
|
||||
'webhook-timestamp' => (string) $timestamp,
|
||||
'webhook-id' => 'wh_test123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['status' => 'success']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Polar webhook payload with the given event type and data.
|
||||
*/
|
||||
protected function createPolarWebhookPayload(string $eventType, array $additionalData = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'id' => 'wh_'.uniqid(),
|
||||
'type' => $eventType,
|
||||
'created_at' => now()->toISOString(),
|
||||
'object' => 'webhook_event',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'sub_test123',
|
||||
'customer_id' => 'cus_test123',
|
||||
'status' => 'active',
|
||||
],
|
||||
],
|
||||
], $additionalData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Polar webhook signature using Standard Webhooks format.
|
||||
*/
|
||||
protected function generatePolarSignature(array $payload, ?int $timestamp = null): string
|
||||
{
|
||||
$timestamp = $timestamp ?? time();
|
||||
$webhookId = 'wh_test123';
|
||||
$payloadJson = json_encode($payload);
|
||||
$signedPayload = $webhookId.'.'.$timestamp.'.'.$payloadJson;
|
||||
|
||||
// Use raw secret (as stored in database) and base64 encode for HMAC
|
||||
$encodedSecret = base64_encode($this->webhookSecret);
|
||||
$hexHash = hash_hmac('sha256', $signedPayload, $encodedSecret);
|
||||
$signature = base64_encode(pack('H*', $hexHash));
|
||||
|
||||
return 'v1,'.$signature;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user