new funcionality

This commit is contained in:
2026-07-27 17:20:02 +02:00
parent c2b5291a74
commit c5ce8a7a36
101 changed files with 6889 additions and 176 deletions

View 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),
]);
}
}