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.
550 lines
19 KiB
PHP
550 lines
19 KiB
PHP
<?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;
|
|
}
|
|
}
|