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