workspace = Workspace::create([ 'name' => 'Test Workspace', 'slug' => 'test-workspace', 'domain' => 'test.host.uk.com', 'type' => 'cms', 'is_active' => true, 'sort_order' => 1, ]); $this->service = app(EntitlementWebhookService::class); } // ------------------------------------------------------------------------- // Helper // ------------------------------------------------------------------------- protected function createWebhook(array $overrides = []): EntitlementWebhook { return EntitlementWebhook::create(array_merge([ 'workspace_id' => $this->workspace->id, 'name' => 'Test Webhook', 'url' => 'https://example.com/webhook', 'secret' => 'test-secret', 'events' => ['limit_warning'], 'is_active' => true, 'max_attempts' => 3, ], $overrides)); } protected function createFeature(): Feature { return Feature::create([ 'code' => 'test.feature.'.Str::random(6), 'name' => 'Test Feature', 'type' => Feature::TYPE_LIMIT, 'reset_type' => Feature::RESET_MONTHLY, 'is_active' => true, 'sort_order' => 1, ]); } protected function createDelivery(EntitlementWebhook $webhook, array $overrides = []): EntitlementWebhookDelivery { return $webhook->deliveries()->create(array_merge([ 'uuid' => Str::uuid(), 'event' => 'limit_warning', 'attempts' => 1, 'status' => WebhookDeliveryStatus::FAILED, 'http_status' => 500, 'payload' => ['event' => 'limit_warning', 'data' => ['test' => true]], 'response' => ['error' => 'Server Error'], 'created_at' => now(), ], $overrides)); } // ------------------------------------------------------------------------- // Webhook Registration // ------------------------------------------------------------------------- public function test_it_registers_a_webhook_with_valid_https_url(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'Test Webhook', url: 'https://example.com/webhook', events: ['limit_warning', 'limit_reached'], ); $this->assertInstanceOf(EntitlementWebhook::class, $webhook); $this->assertSame('Test Webhook', $webhook->name); $this->assertSame('https://example.com/webhook', $webhook->url); $this->assertSame(['limit_warning', 'limit_reached'], $webhook->events); $this->assertTrue($webhook->is_active); $this->assertSame(3, $webhook->max_attempts); $this->assertSame($this->workspace->id, $webhook->workspace_id); $this->assertDatabaseHas('entitlement_webhooks', [ 'workspace_id' => $this->workspace->id, 'name' => 'Test Webhook', 'url' => 'https://example.com/webhook', 'is_active' => true, ]); } public function test_it_generates_a_secret_when_none_is_provided(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'Auto Secret Webhook', url: 'https://example.com/webhook', events: ['limit_warning'], ); // Secret should be a 64-character hex string (32 bytes) $this->assertNotNull($webhook->secret); $this->assertSame(64, strlen($webhook->secret)); } public function test_it_uses_the_provided_secret_when_given(): void { $secret = 'my-custom-secret-key'; $webhook = $this->service->register( workspace: $this->workspace, name: 'Custom Secret Webhook', url: 'https://example.com/webhook', events: ['limit_warning'], secret: $secret, ); $this->assertSame($secret, $webhook->secret); } public function test_it_filters_events_to_only_allowed_values_during_registration(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'Filtered Events Webhook', url: 'https://example.com/webhook', events: ['limit_warning', 'invalid_event', 'boost_activated', 'nonexistent'], ); $this->assertEquals(['limit_warning', 'boost_activated'], array_values($webhook->events)); } public function test_it_stores_metadata_during_registration(): void { $metadata = ['source' => 'api', 'version' => '1.0']; $webhook = $this->service->register( workspace: $this->workspace, name: 'Metadata Webhook', url: 'https://example.com/webhook', events: ['limit_warning'], metadata: $metadata, ); $this->assertSame($metadata, $webhook->metadata); } // ------------------------------------------------------------------------- // Webhook Unregistration // ------------------------------------------------------------------------- public function test_it_unregisters_a_webhook(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'To Delete', url: 'https://example.com/webhook', events: ['limit_warning'], ); $result = $this->service->unregister($webhook); $this->assertTrue($result); $this->assertDatabaseMissing('entitlement_webhooks', ['id' => $webhook->id]); } // ------------------------------------------------------------------------- // Webhook Updates // ------------------------------------------------------------------------- public function test_it_updates_webhook_attributes(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'Original Name', url: 'https://example.com/webhook', events: ['limit_warning'], ); $updated = $this->service->update($webhook, [ 'name' => 'Updated Name', 'events' => ['limit_reached', 'boost_activated'], ]); $this->assertSame('Updated Name', $updated->name); $this->assertSame(['limit_reached', 'boost_activated'], $updated->events); } public function test_it_validates_the_url_when_updating_to_a_new_url(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'URL Change Test', url: 'https://example.com/webhook', events: ['limit_warning'], ); $this->expectException(InvalidWebhookUrlException::class); $this->service->update($webhook, [ 'url' => 'http://localhost/evil', ]); } public function test_it_skips_ssrf_validation_when_url_has_not_changed(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'Same URL Test', url: 'https://example.com/webhook', events: ['limit_warning'], ); $updated = $this->service->update($webhook, [ 'name' => 'Renamed', 'url' => 'https://example.com/webhook', ]); $this->assertSame('Renamed', $updated->name); $this->assertSame('https://example.com/webhook', $updated->url); } public function test_it_filters_events_to_only_allowed_values_during_update(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'Event Filter Test', url: 'https://example.com/webhook', events: ['limit_warning'], ); $updated = $this->service->update($webhook, [ 'events' => ['boost_expired', 'fake_event', 'package_changed'], ]); $this->assertEquals(['boost_expired', 'package_changed'], array_values($updated->events)); } // ------------------------------------------------------------------------- // Signature Verification // ------------------------------------------------------------------------- public function test_it_signs_a_payload_with_hmac_sha256(): void { $payload = ['event' => 'test', 'data' => ['key' => 'value']]; $secret = 'test-secret'; $signature = $this->service->sign($payload, $secret); $expected = hash_hmac('sha256', json_encode($payload), $secret); $this->assertSame($expected, $signature); } public function test_it_verifies_a_valid_signature(): void { $payload = ['event' => 'test', 'data' => ['key' => 'value']]; $secret = 'test-secret'; $signature = $this->service->sign($payload, $secret); $result = $this->service->verifySignature($payload, $signature, $secret); $this->assertTrue($result); } public function test_it_rejects_an_invalid_signature(): void { $payload = ['event' => 'test', 'data' => ['key' => 'value']]; $secret = 'test-secret'; $result = $this->service->verifySignature($payload, 'invalid-signature', $secret); $this->assertFalse($result); } public function test_it_rejects_a_signature_computed_with_a_different_secret(): void { $payload = ['event' => 'test', 'data' => ['key' => 'value']]; $signature = $this->service->sign($payload, 'secret-a'); $result = $this->service->verifySignature($payload, $signature, 'secret-b'); $this->assertFalse($result); } public function test_it_rejects_a_signature_computed_from_a_different_payload(): void { $secret = 'test-secret'; $signature = $this->service->sign(['event' => 'original'], $secret); $result = $this->service->verifySignature(['event' => 'tampered'], $signature, $secret); $this->assertFalse($result); } // ------------------------------------------------------------------------- // Dispatch (async) // ------------------------------------------------------------------------- public function test_it_dispatches_webhooks_asynchronously_via_job_queue(): void { Bus::fake([DispatchEntitlementWebhook::class]); $feature = $this->createFeature(); $webhook = $this->createWebhook(); $event = new LimitWarningEvent( workspace: $this->workspace, feature: $feature, used: 80, limit: 100, ); $results = $this->service->dispatch($this->workspace, $event, async: true); $this->assertCount(1, $results); $this->assertSame($webhook->id, $results[0]['webhook_id']); $this->assertTrue($results[0]['success']); $this->assertTrue($results[0]['queued']); Bus::assertDispatched(DispatchEntitlementWebhook::class); } public function test_it_dispatches_webhooks_synchronously_when_async_is_false(): void { Http::fake([ 'https://example.com/webhook' => Http::response(['ok' => true], 200), ]); $feature = $this->createFeature(); $webhook = $this->createWebhook(); $event = new LimitWarningEvent( workspace: $this->workspace, feature: $feature, used: 80, limit: 100, ); $results = $this->service->dispatch($this->workspace, $event, async: false); $this->assertCount(1, $results); $this->assertSame($webhook->id, $results[0]['webhook_id']); $this->assertTrue($results[0]['success']); $this->assertNotNull($results[0]['delivery_id']); } public function test_it_only_dispatches_to_active_webhooks_subscribed_to_the_event(): void { Bus::fake([DispatchEntitlementWebhook::class]); $feature = $this->createFeature(); // Active and subscribed $this->createWebhook(['name' => 'Active Subscribed', 'url' => 'https://example.com/webhook-1']); // Active but not subscribed to this event $this->createWebhook([ 'name' => 'Active Unsubscribed', 'url' => 'https://example.com/webhook-2', 'events' => ['boost_activated'], ]); // Inactive but subscribed $this->createWebhook([ 'name' => 'Inactive Subscribed', 'url' => 'https://example.com/webhook-3', 'is_active' => false, ]); $event = new LimitWarningEvent( workspace: $this->workspace, feature: $feature, used: 80, limit: 100, ); $results = $this->service->dispatch($this->workspace, $event, async: true); $this->assertCount(1, $results); Bus::assertDispatchedTimes(DispatchEntitlementWebhook::class, 1); } public function test_it_returns_empty_results_when_no_webhooks_match(): void { Bus::fake([DispatchEntitlementWebhook::class]); $feature = $this->createFeature(); $event = new LimitWarningEvent( workspace: $this->workspace, feature: $feature, used: 80, limit: 100, ); $results = $this->service->dispatch($this->workspace, $event); $this->assertEmpty($results); Bus::assertNotDispatched(DispatchEntitlementWebhook::class); } public function test_it_handles_synchronous_dispatch_failure_gracefully(): void { Http::fake([ 'https://example.com/webhook' => Http::response(['error' => 'Server Error'], 500), ]); $feature = $this->createFeature(); $webhook = $this->createWebhook(); $event = new LimitWarningEvent( workspace: $this->workspace, feature: $feature, used: 80, limit: 100, ); $results = $this->service->dispatch($this->workspace, $event, async: false); $this->assertCount(1, $results); $this->assertSame($webhook->id, $results[0]['webhook_id']); $this->assertFalse($results[0]['success']); } // ------------------------------------------------------------------------- // Test Webhook // ------------------------------------------------------------------------- public function test_it_sends_a_test_webhook_delivery(): void { Http::fake([ 'https://example.com/webhook' => Http::response(['received' => true], 200), ]); $webhook = $this->createWebhook(); $delivery = $this->service->testWebhook($webhook); $this->assertInstanceOf(EntitlementWebhookDelivery::class, $delivery); $this->assertSame('test', $delivery->event); $this->assertSame(WebhookDeliveryStatus::SUCCESS, $delivery->status); $this->assertSame(200, $delivery->http_status); $this->assertSame('test', $delivery->payload['event']); $this->assertSame($webhook->id, $delivery->payload['data']['webhook_id']); Http::assertSent(function ($request) { return $request->hasHeader('X-Signature') && $request->hasHeader('X-Test-Webhook'); }); } public function test_it_records_failed_test_webhook_delivery(): void { Http::fake([ 'https://example.com/webhook' => Http::response(['error' => 'Bad'], 500), ]); $webhook = $this->createWebhook(); $delivery = $this->service->testWebhook($webhook); $this->assertSame(WebhookDeliveryStatus::FAILED, $delivery->status); $this->assertSame(500, $delivery->http_status); } public function test_it_records_delivery_when_test_webhook_throws_exception(): void { Http::fake(fn () => throw new \RuntimeException('Connection timed out')); $webhook = $this->createWebhook(); $delivery = $this->service->testWebhook($webhook); $this->assertSame(WebhookDeliveryStatus::FAILED, $delivery->status); $this->assertArrayHasKey('error', $delivery->response); } // ------------------------------------------------------------------------- // Retry Delivery // ------------------------------------------------------------------------- public function test_it_retries_a_failed_delivery_successfully(): void { Http::fake([ 'https://example.com/webhook' => Http::response(['ok' => true], 200), ]); $webhook = $this->createWebhook(['failure_count' => 1]); $delivery = $this->createDelivery($webhook); $retried = $this->service->retryDelivery($delivery); $this->assertSame(WebhookDeliveryStatus::SUCCESS, $retried->status); $this->assertSame(2, $retried->attempts); $this->assertTrue($retried->resent_manually); $this->assertSame(200, $retried->http_status); // Failure count should be reset on success $webhook->refresh(); $this->assertSame(0, $webhook->failure_count); } public function test_it_increments_failure_count_when_retry_fails(): void { Http::fake([ 'https://example.com/webhook' => Http::response(['error' => 'Still failing'], 502), ]); $webhook = $this->createWebhook(['failure_count' => 1]); $delivery = $this->createDelivery($webhook); $retried = $this->service->retryDelivery($delivery); $this->assertSame(WebhookDeliveryStatus::FAILED, $retried->status); $this->assertSame(2, $retried->attempts); $this->assertTrue($retried->resent_manually); $webhook->refresh(); $this->assertSame(2, $webhook->failure_count); } public function test_it_throws_when_retrying_delivery_for_inactive_webhook(): void { $webhook = $this->createWebhook(['is_active' => false]); $delivery = $this->createDelivery($webhook); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Cannot retry delivery for inactive webhook'); $this->service->retryDelivery($delivery); } public function test_it_handles_exception_during_retry_gracefully(): void { Http::fake(fn () => throw new \RuntimeException('Connection timed out')); $webhook = $this->createWebhook(['failure_count' => 0]); $delivery = $this->createDelivery($webhook); $retried = $this->service->retryDelivery($delivery); $this->assertSame(WebhookDeliveryStatus::FAILED, $retried->status); $this->assertSame(2, $retried->attempts); $this->assertTrue($retried->resent_manually); $this->assertArrayHasKey('error', $retried->response); $webhook->refresh(); $this->assertSame(1, $webhook->failure_count); } // ------------------------------------------------------------------------- // Circuit Breaker // ------------------------------------------------------------------------- public function test_it_auto_disables_webhook_after_reaching_max_failures(): void { $webhook = $this->createWebhook([ 'failure_count' => EntitlementWebhook::MAX_FAILURES - 1, ]); // One more failure should trigger the circuit breaker $webhook->incrementFailureCount(); $webhook->refresh(); $this->assertFalse($webhook->is_active); $this->assertTrue($webhook->isCircuitBroken()); $this->assertSame(EntitlementWebhook::MAX_FAILURES, $webhook->failure_count); } public function test_it_resets_the_circuit_breaker(): void { $webhook = $this->createWebhook([ 'is_active' => false, 'failure_count' => EntitlementWebhook::MAX_FAILURES, ]); $this->assertTrue($webhook->isCircuitBroken()); $this->service->resetCircuitBreaker($webhook); $webhook->refresh(); $this->assertTrue($webhook->is_active); $this->assertSame(0, $webhook->failure_count); $this->assertFalse($webhook->isCircuitBroken()); } public function test_it_resets_failure_count_on_successful_delivery(): void { Http::fake([ 'https://example.com/webhook' => Http::response(['ok' => true], 200), ]); $webhook = $this->createWebhook(['failure_count' => 3]); $delivery = $this->createDelivery($webhook); $retried = $this->service->retryDelivery($delivery); $this->assertSame(WebhookDeliveryStatus::SUCCESS, $retried->status); $webhook->refresh(); $this->assertSame(0, $webhook->failure_count); } // ------------------------------------------------------------------------- // SSRF Protection // ------------------------------------------------------------------------- public function test_it_blocks_webhook_registration_with_http_url(): void { $this->expectException(InvalidWebhookUrlException::class); $this->service->register( workspace: $this->workspace, name: 'HTTP Webhook', url: 'http://example.com/webhook', events: ['limit_warning'], ); } public function test_it_blocks_webhook_registration_with_localhost_url(): void { $this->expectException(InvalidWebhookUrlException::class); $this->service->register( workspace: $this->workspace, name: 'Localhost Webhook', url: 'https://localhost/webhook', events: ['limit_warning'], ); } public function test_it_blocks_webhook_registration_with_private_ip_url(): void { $this->expectException(InvalidWebhookUrlException::class); $this->service->register( workspace: $this->workspace, name: 'Private IP Webhook', url: 'https://192.168.1.1/webhook', events: ['limit_warning'], ); } public function test_it_blocks_webhook_registration_with_10_range_url(): void { $this->expectException(InvalidWebhookUrlException::class); $this->service->register( workspace: $this->workspace, name: 'Private 10 Webhook', url: 'https://10.0.0.1/webhook', events: ['limit_warning'], ); } public function test_it_blocks_webhook_registration_with_loopback_ip_url(): void { $this->expectException(InvalidWebhookUrlException::class); $this->service->register( workspace: $this->workspace, name: 'Loopback Webhook', url: 'https://127.0.0.1/webhook', events: ['limit_warning'], ); } public function test_it_blocks_webhook_registration_with_local_domain(): void { $this->expectException(InvalidWebhookUrlException::class); $this->service->register( workspace: $this->workspace, name: 'Local Domain Webhook', url: 'https://myserver.local/webhook', events: ['limit_warning'], ); } public function test_it_blocks_webhook_registration_with_internal_domain(): void { $this->expectException(InvalidWebhookUrlException::class); $this->service->register( workspace: $this->workspace, name: 'Internal Domain Webhook', url: 'https://api.internal/webhook', events: ['limit_warning'], ); } public function test_it_blocks_webhook_registration_with_localhost_tld(): void { $this->expectException(InvalidWebhookUrlException::class); $this->service->register( workspace: $this->workspace, name: 'Localhost TLD Webhook', url: 'https://app.localhost/webhook', events: ['limit_warning'], ); } public function test_it_blocks_webhook_update_with_ssrf_url(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'SSRF Update Test', url: 'https://example.com/webhook', events: ['limit_warning'], ); $this->expectException(InvalidWebhookUrlException::class); $this->service->update($webhook, [ 'url' => 'https://192.168.0.1/evil', ]); } public function test_it_blocks_test_webhook_with_ssrf_url(): void { // Directly create a webhook with a private URL to test the SSRF guard $webhook = $this->createWebhook(['url' => 'https://10.0.0.1/webhook']); $this->expectException(InvalidWebhookUrlException::class); $this->service->testWebhook($webhook); } public function test_it_blocks_retry_with_ssrf_url(): void { $webhook = $this->createWebhook(['url' => 'https://127.0.0.1/webhook']); $delivery = $this->createDelivery($webhook); $this->expectException(InvalidWebhookUrlException::class); $this->service->retryDelivery($delivery); } public function test_it_allows_trusted_webhook_domains(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'Discord Webhook', url: 'https://discord.com/api/webhooks/123/abc', events: ['limit_warning'], ); $this->assertSame('https://discord.com/api/webhooks/123/abc', $webhook->url); } public function test_it_allows_trusted_subdomain_of_webhook_domains(): void { $webhook = $this->service->register( workspace: $this->workspace, name: 'Slack Webhook', url: 'https://hooks.slack.com/services/T00/B00/xxx', events: ['limit_warning'], ); $this->assertSame('https://hooks.slack.com/services/T00/B00/xxx', $webhook->url); } // ------------------------------------------------------------------------- // Available Events // ------------------------------------------------------------------------- public function test_it_returns_all_available_webhook_event_types(): void { $events = $this->service->getAvailableEvents(); $this->assertArrayHasKey('limit_warning', $events); $this->assertArrayHasKey('limit_reached', $events); $this->assertArrayHasKey('package_changed', $events); $this->assertArrayHasKey('boost_activated', $events); $this->assertArrayHasKey('boost_expired', $events); $this->assertArrayHasKey('name', $events['limit_warning']); $this->assertArrayHasKey('description', $events['limit_warning']); $this->assertArrayHasKey('class', $events['limit_warning']); $this->assertSame(LimitWarningEvent::class, $events['limit_warning']['class']); } public function test_it_returns_event_options_as_key_value_pairs(): void { $options = $this->service->getEventOptions(); $this->assertIsArray($options); $this->assertCount(5, $options); $this->assertArrayHasKey('limit_warning', $options); $this->assertArrayHasKey('limit_reached', $options); $this->assertArrayHasKey('package_changed', $options); $this->assertArrayHasKey('boost_activated', $options); $this->assertArrayHasKey('boost_expired', $options); } // ------------------------------------------------------------------------- // Workspace Queries // ------------------------------------------------------------------------- public function test_it_retrieves_webhooks_for_a_workspace(): void { $this->createWebhook(['name' => 'Webhook A', 'url' => 'https://example.com/a']); $this->createWebhook(['name' => 'Webhook B', 'url' => 'https://example.com/b', 'events' => ['boost_activated']]); // Webhook for a different workspace $otherWorkspace = Workspace::create([ 'name' => 'Other Workspace', 'slug' => 'other-workspace', 'type' => 'cms', 'is_active' => true, 'sort_order' => 2, ]); EntitlementWebhook::create([ 'workspace_id' => $otherWorkspace->id, 'name' => 'Other Workspace Webhook', 'url' => 'https://example.com/other', 'secret' => 'secret', 'events' => ['limit_warning'], 'is_active' => true, 'max_attempts' => 3, ]); $webhooks = $this->service->getWebhooksForWorkspace($this->workspace); $this->assertCount(2, $webhooks); $names = $webhooks->pluck('name')->toArray(); $this->assertContains('Webhook A', $names); $this->assertContains('Webhook B', $names); $this->assertNotContains('Other Workspace Webhook', $names); } public function test_it_retrieves_delivery_history_for_a_webhook(): void { $webhook = $this->createWebhook(); for ($i = 0; $i < 5; $i++) { $webhook->deliveries()->create([ 'uuid' => Str::uuid(), 'event' => 'limit_warning', 'status' => $i % 2 === 0 ? WebhookDeliveryStatus::SUCCESS : WebhookDeliveryStatus::FAILED, 'payload' => ['event' => 'limit_warning', 'data' => ['index' => $i]], 'created_at' => now()->subMinutes(5 - $i), ]); } $history = $this->service->getDeliveryHistory($webhook); $this->assertCount(5, $history); } public function test_it_limits_delivery_history_results(): void { $webhook = $this->createWebhook(); for ($i = 0; $i < 10; $i++) { $webhook->deliveries()->create([ 'uuid' => Str::uuid(), 'event' => 'limit_warning', 'status' => WebhookDeliveryStatus::SUCCESS, 'payload' => ['event' => 'limit_warning', 'data' => ['index' => $i]], 'created_at' => now()->subMinutes(10 - $i), ]); } $history = $this->service->getDeliveryHistory($webhook, limit: 3); $this->assertCount(3, $history); } }