424 lines
15 KiB
PHP
424 lines
15 KiB
PHP
<?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),
|
|
]);
|
|
}
|
|
}
|