new funcionality
This commit is contained in:
423
tests/Feature/WebhookTest.php
Normal file
423
tests/Feature/WebhookTest.php
Normal file
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookDelivery;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use App\Support\TokenRevocation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Client;
|
||||
use Laravel\Passport\Token;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebhookTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $owner;
|
||||
private Client $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->owner = User::factory()->approved()->create();
|
||||
$this->client = Client::create([
|
||||
'owner_id' => $this->owner->id,
|
||||
'owner_type' => User::class,
|
||||
'name' => 'My App',
|
||||
'secret' => null,
|
||||
'provider' => null,
|
||||
'redirect_uris' => [],
|
||||
'grant_types' => ['authorization_code', 'refresh_token'],
|
||||
'revoked' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_owner_can_create_a_webhook_and_sees_the_secret_once(): void
|
||||
{
|
||||
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/webhooks", [
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'events' => ['token.revoked'],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonStructure(['id', 'url', 'events', 'active', 'secret']);
|
||||
$this->assertNotEmpty($response->json('secret'));
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', ['action' => 'client.webhook_created']);
|
||||
}
|
||||
|
||||
public function test_listing_webhooks_never_returns_the_secret(): void
|
||||
{
|
||||
WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => 'super-secret',
|
||||
'events' => ['token.revoked'],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->owner)->getJson("/web-api/clients/{$this->client->id}/webhooks");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringNotContainsString('super-secret', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_create_validates_url_and_events(): void
|
||||
{
|
||||
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/webhooks", [
|
||||
'url' => 'not-a-url',
|
||||
'events' => ['not.a.real.event'],
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()->assertJsonValidationErrors(['url', 'events.0']);
|
||||
}
|
||||
|
||||
public function test_non_owner_cannot_manage_another_users_client_webhooks(): void
|
||||
{
|
||||
$stranger = User::factory()->approved()->create();
|
||||
|
||||
$this->actingAs($stranger)
|
||||
->postJson("/web-api/clients/{$this->client->id}/webhooks", [
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'events' => ['token.revoked'],
|
||||
])
|
||||
->assertNotFound();
|
||||
|
||||
$this->actingAs($stranger)
|
||||
->getJson("/web-api/clients/{$this->client->id}/webhooks")
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_owner_can_pause_and_delete_a_webhook(): void
|
||||
{
|
||||
$endpoint = WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => 'super-secret',
|
||||
'events' => ['token.revoked'],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->putJson("/web-api/clients/{$this->client->id}/webhooks/{$endpoint->id}", ['active' => false])
|
||||
->assertOk()
|
||||
->assertJsonPath('active', false);
|
||||
|
||||
$this->actingAs($this->owner)
|
||||
->deleteJson("/web-api/clients/{$this->client->id}/webhooks/{$endpoint->id}")
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertDatabaseMissing('webhook_endpoints', ['id' => $endpoint->id]);
|
||||
$this->assertDatabaseHas('audit_logs', ['action' => 'client.webhook_deleted']);
|
||||
}
|
||||
|
||||
public function test_revoking_a_token_dispatches_a_signed_webhook_for_its_client(): void
|
||||
{
|
||||
Http::fake(['http://1.1.1.1/hook' => Http::response('ok', 200)]);
|
||||
|
||||
$secret = 'shh-its-a-secret';
|
||||
$endpoint = WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => $secret,
|
||||
'events' => ['token.revoked'],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$token = $this->createToken($this->client, $this->owner);
|
||||
|
||||
TokenRevocation::revoke($token);
|
||||
|
||||
// Con assertSentCount (no solo assertSent) para que un listener
|
||||
// registrado dos veces (auto-discovery + Event::listen manual) haga
|
||||
// fallar el test — ya nos pasó una vez en desarrollo.
|
||||
Http::assertSentCount(1);
|
||||
Http::assertSent(function ($request) use ($secret) {
|
||||
$expectedSignature = 'sha256='.hash_hmac('sha256', $request->body(), $secret);
|
||||
|
||||
return $request->url() === 'http://1.1.1.1/hook'
|
||||
&& $request->hasHeader('X-Webhook-Event', 'token.revoked')
|
||||
&& $request->header('X-Webhook-Signature')[0] === $expectedSignature;
|
||||
});
|
||||
|
||||
$this->assertDatabaseCount('webhook_deliveries', 1);
|
||||
$this->assertDatabaseHas('webhook_deliveries', [
|
||||
'webhook_endpoint_id' => $endpoint->id,
|
||||
'event' => 'token.revoked',
|
||||
'response_status' => 200,
|
||||
]);
|
||||
|
||||
$delivery = WebhookDelivery::where('webhook_endpoint_id', $endpoint->id)->first();
|
||||
$this->assertNotNull($delivery->delivered_at);
|
||||
$this->assertSame($token->id, $delivery->payload['token_id']);
|
||||
}
|
||||
|
||||
public function test_revoking_a_token_does_not_notify_endpoints_subscribed_to_a_different_event(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => 'shh',
|
||||
'events' => ['user.deactivated'],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$token = $this->createToken($this->client, $this->owner);
|
||||
|
||||
TokenRevocation::revoke($token);
|
||||
|
||||
Http::assertNothingSent();
|
||||
$this->assertDatabaseCount('webhook_deliveries', 0);
|
||||
}
|
||||
|
||||
public function test_deactivating_a_user_notifies_clients_with_an_active_token_for_that_user(): void
|
||||
{
|
||||
Http::fake(['http://1.1.1.1/hook' => Http::response('ok', 200)]);
|
||||
|
||||
WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => 'shh',
|
||||
'events' => ['user.deactivated'],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->createToken($this->client, $this->owner);
|
||||
|
||||
\App\Events\UserDeactivated::dispatch($this->owner);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
Http::assertSent(fn ($request) => $request->url() === 'http://1.1.1.1/hook');
|
||||
$this->assertDatabaseCount('webhook_deliveries', 1);
|
||||
$this->assertDatabaseHas('webhook_deliveries', ['event' => 'user.deactivated']);
|
||||
}
|
||||
|
||||
public function test_deactivating_a_user_does_not_notify_clients_without_an_active_token_for_that_user(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => 'shh',
|
||||
'events' => ['user.deactivated'],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// No token created for $this->owner against $this->client.
|
||||
\App\Events\UserDeactivated::dispatch($this->owner);
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[DataProvider('unsafeWebhookUrls')]
|
||||
public function test_cannot_register_a_webhook_pointing_to_a_private_or_reserved_address(string $url): void
|
||||
{
|
||||
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/webhooks", [
|
||||
'url' => $url,
|
||||
'events' => ['token.revoked'],
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()->assertJsonValidationErrors('url');
|
||||
$this->assertDatabaseCount('webhook_endpoints', 0);
|
||||
}
|
||||
|
||||
public static function unsafeWebhookUrls(): array
|
||||
{
|
||||
return [
|
||||
'loopback' => ['http://127.0.0.1/hook'],
|
||||
'loopback alt' => ['http://127.0.0.53/hook'],
|
||||
'private 10/8' => ['http://10.0.0.5/hook'],
|
||||
'private 192.168/16' => ['http://192.168.1.10/hook'],
|
||||
'private 172.16/12' => ['http://172.16.5.5/hook'],
|
||||
'link-local / cloud metadata' => ['http://169.254.169.254/latest/meta-data/'],
|
||||
'literal localhost' => ['http://localhost/hook'],
|
||||
'non-http scheme' => ['ftp://1.1.1.1/hook'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_can_register_a_webhook_pointing_to_a_public_ip(): void
|
||||
{
|
||||
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/webhooks", [
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'events' => ['token.revoked'],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
}
|
||||
|
||||
public function test_update_also_rejects_a_private_url(): void
|
||||
{
|
||||
$endpoint = WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => 'shh',
|
||||
'events' => ['token.revoked'],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->owner)
|
||||
->putJson("/web-api/clients/{$this->client->id}/webhooks/{$endpoint->id}", [
|
||||
'url' => 'http://169.254.169.254/latest/meta-data/',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()->assertJsonValidationErrors('url');
|
||||
$this->assertSame('http://1.1.1.1/hook', $endpoint->fresh()->url);
|
||||
}
|
||||
|
||||
public function test_delivery_is_blocked_if_the_url_is_no_longer_safe_by_send_time(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
// Salteamos el controller (que ya bloquearía esto) para simular el
|
||||
// caso real que nos importa: un dominio público al registrarse que
|
||||
// más tarde resuelve a una IP interna (DNS rebinding). Guardamos
|
||||
// directo en el modelo una URL que apunta a loopback.
|
||||
$endpoint = WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://127.0.0.1:9999/hook',
|
||||
'secret' => 'shh',
|
||||
'events' => ['token.revoked'],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$token = $this->createToken($this->client, $this->owner);
|
||||
|
||||
TokenRevocation::revoke($token);
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$delivery = WebhookDelivery::where('webhook_endpoint_id', $endpoint->id)->first();
|
||||
$this->assertNotNull($delivery);
|
||||
$this->assertNull($delivery->delivered_at);
|
||||
$this->assertNull($delivery->response_status);
|
||||
$this->assertStringContainsString('Bloqueado', $delivery->response_body);
|
||||
}
|
||||
|
||||
public function test_owner_can_list_delivery_history_for_a_webhook(): void
|
||||
{
|
||||
$endpoint = WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => 'shh',
|
||||
'events' => ['token.revoked'],
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
WebhookDelivery::create([
|
||||
'webhook_endpoint_id' => $endpoint->id,
|
||||
'event' => 'token.revoked',
|
||||
'payload' => ['token_id' => 'abc'],
|
||||
'response_status' => 500,
|
||||
'response_body' => 'server error',
|
||||
'attempts' => 3,
|
||||
'delivered_at' => null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
WebhookDelivery::create([
|
||||
'webhook_endpoint_id' => $endpoint->id,
|
||||
'event' => 'token.revoked',
|
||||
'payload' => ['token_id' => 'def'],
|
||||
'response_status' => 200,
|
||||
'response_body' => 'ok',
|
||||
'attempts' => 1,
|
||||
'delivered_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->owner)
|
||||
->getJson("/web-api/clients/{$this->client->id}/webhooks/{$endpoint->id}/deliveries");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(2, $response->json('data'));
|
||||
$this->assertSame(2, $response->json('total'));
|
||||
}
|
||||
|
||||
public function test_non_owner_cannot_see_or_retry_another_users_webhook_deliveries(): void
|
||||
{
|
||||
$endpoint = WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => 'shh',
|
||||
'events' => ['token.revoked'],
|
||||
'active' => true,
|
||||
]);
|
||||
$delivery = WebhookDelivery::create([
|
||||
'webhook_endpoint_id' => $endpoint->id,
|
||||
'event' => 'token.revoked',
|
||||
'payload' => [],
|
||||
'response_status' => 500,
|
||||
'attempts' => 1,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$stranger = User::factory()->approved()->create();
|
||||
|
||||
$this->actingAs($stranger)
|
||||
->getJson("/web-api/clients/{$this->client->id}/webhooks/{$endpoint->id}/deliveries")
|
||||
->assertNotFound();
|
||||
|
||||
$this->actingAs($stranger)
|
||||
->postJson("/web-api/clients/{$this->client->id}/webhooks/{$endpoint->id}/deliveries/{$delivery->id}/retry")
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_owner_can_manually_retry_a_failed_delivery(): void
|
||||
{
|
||||
Http::fake(['http://1.1.1.1/hook' => Http::response('ok', 200)]);
|
||||
|
||||
$secret = 'shh-its-a-secret';
|
||||
$endpoint = WebhookEndpoint::create([
|
||||
'client_id' => $this->client->id,
|
||||
'url' => 'http://1.1.1.1/hook',
|
||||
'secret' => $secret,
|
||||
'events' => ['token.revoked'],
|
||||
'active' => true,
|
||||
]);
|
||||
$delivery = WebhookDelivery::create([
|
||||
'webhook_endpoint_id' => $endpoint->id,
|
||||
'event' => 'token.revoked',
|
||||
'payload' => ['token_id' => 'abc'],
|
||||
'response_status' => 500,
|
||||
'response_body' => 'server error',
|
||||
'attempts' => 1,
|
||||
'delivered_at' => null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->owner)
|
||||
->postJson("/web-api/clients/{$this->client->id}/webhooks/{$endpoint->id}/deliveries/{$delivery->id}/retry");
|
||||
|
||||
$response->assertOk()->assertJson(['queued' => true]);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
|
||||
$delivery->refresh();
|
||||
$this->assertSame(200, $delivery->response_status);
|
||||
$this->assertNotNull($delivery->delivered_at);
|
||||
$this->assertSame(2, $delivery->attempts);
|
||||
}
|
||||
|
||||
private function createToken(Client $client, User $user): Token
|
||||
{
|
||||
return Token::create([
|
||||
'id' => Str::random(80),
|
||||
'user_id' => $user->id,
|
||||
'client_id' => $client->id,
|
||||
'name' => null,
|
||||
'scopes' => ['users:read'],
|
||||
'revoked' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'expires_at' => now()->addDays(15),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user