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:
idevakk
2025-12-06 22:49:54 -08:00
parent 15e018eb88
commit 289baa1286
13 changed files with 1955 additions and 66 deletions

View 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;
}
}