Adds 48 tests (113 assertions) covering webhook registration, dispatch, signature verification, circuit breaker behaviour, retry logic, SSRF protection, and workspace queries. Fixes #16. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
896 lines
30 KiB
PHP
896 lines
30 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Tenant\Tests\Feature;
|
|
|
|
use Core\Tenant\Enums\WebhookDeliveryStatus;
|
|
use Core\Tenant\Events\Webhook\LimitWarningEvent;
|
|
use Core\Tenant\Exceptions\InvalidWebhookUrlException;
|
|
use Core\Tenant\Jobs\DispatchEntitlementWebhook;
|
|
use Core\Tenant\Models\EntitlementWebhook;
|
|
use Core\Tenant\Models\EntitlementWebhookDelivery;
|
|
use Core\Tenant\Models\Feature;
|
|
use Core\Tenant\Models\Workspace;
|
|
use Core\Tenant\Services\EntitlementWebhookService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
class EntitlementWebhookServiceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected EntitlementWebhookService $service;
|
|
|
|
protected Workspace $workspace;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Cache::flush();
|
|
|
|
$this->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);
|
|
}
|
|
}
|