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,207 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserCrudTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolesAndPermissionsSeeder::class);
$this->admin = User::factory()->approved()->create();
$this->admin->assignRole('admin');
}
public function test_admin_can_list_approved_users(): void
{
$other = User::factory()->approved()->create();
User::factory()->create(['approved_at' => null]);
$response = $this->actingAs($this->admin)->getJson('/web-api/admin/users');
$response->assertOk();
$emails = collect($response->json())->pluck('email');
$this->assertTrue($emails->contains($this->admin->email));
$this->assertTrue($emails->contains($other->email));
}
public function test_non_admin_cannot_list_users(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)->getJson('/web-api/admin/users')->assertForbidden();
}
public function test_admin_can_create_a_user(): void
{
$response = $this->actingAs($this->admin)->postJson('/web-api/admin/users', [
'name' => 'New Person',
'email' => 'newperson@example.com',
'password' => 'password123',
'role' => 'user',
]);
$response->assertCreated();
$response->assertJsonPath('email', 'newperson@example.com');
$response->assertJsonPath('roles.0', 'user');
$created = User::where('email', 'newperson@example.com')->firstOrFail();
$this->assertNotNull($created->approved_at, 'un usuario creado por un admin queda aprobado automáticamente');
$this->assertDatabaseHas('audit_logs', ['action' => 'user.created', 'subject_id' => (string) $created->id]);
}
public function test_create_validates_unique_email_and_known_role(): void
{
$this->actingAs($this->admin)->postJson('/web-api/admin/users', [
'name' => 'Dup',
'email' => $this->admin->email,
'password' => 'password123',
'role' => 'user',
])->assertUnprocessable()->assertJsonValidationErrors('email');
$this->actingAs($this->admin)->postJson('/web-api/admin/users', [
'name' => 'Bad Role',
'email' => 'badrole@example.com',
'password' => 'password123',
'role' => 'superadmin',
])->assertUnprocessable()->assertJsonValidationErrors('role');
}
public function test_admin_can_update_a_users_name_email_role_and_active_state(): void
{
$target = User::factory()->approved()->create(['is_active' => true]);
$target->assignRole('user');
$response = $this->actingAs($this->admin)->putJson("/web-api/admin/users/{$target->id}", [
'name' => 'Renamed',
'email' => 'renamed@example.com',
'is_active' => false,
'role' => 'admin',
]);
$response->assertOk();
$response->assertJsonPath('name', 'Renamed');
$response->assertJsonPath('is_active', false);
$response->assertJsonPath('roles.0', 'admin');
$this->assertDatabaseHas('audit_logs', ['action' => 'user.updated', 'subject_id' => (string) $target->id]);
}
public function test_deactivating_a_user_dispatches_the_user_deactivated_event(): void
{
\Illuminate\Support\Facades\Event::fake([\App\Events\UserDeactivated::class]);
$target = User::factory()->approved()->create(['is_active' => true]);
$target->assignRole('user');
$this->actingAs($this->admin)->putJson("/web-api/admin/users/{$target->id}", [
'name' => $target->name,
'email' => $target->email,
'is_active' => false,
'role' => 'user',
])->assertOk();
\Illuminate\Support\Facades\Event::assertDispatched(\App\Events\UserDeactivated::class);
}
public function test_admin_cannot_remove_their_own_admin_role(): void
{
$response = $this->actingAs($this->admin)->putJson("/web-api/admin/users/{$this->admin->id}", [
'name' => $this->admin->name,
'email' => $this->admin->email,
'role' => 'user',
]);
$response->assertStatus(422);
$this->assertTrue($this->admin->fresh()->hasRole('admin'));
}
public function test_admin_cannot_delete_themselves(): void
{
$this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$this->admin->id}")
->assertStatus(422);
$this->assertNull($this->admin->fresh()->deleted_at);
}
public function test_cannot_delete_the_last_admin(): void
{
$secondAdmin = User::factory()->approved()->create();
$secondAdmin->assignRole('admin');
// Con dos admins, borrar uno debe andar.
$this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$secondAdmin->id}")
->assertNoContent();
// Ahora $this->admin es el único admin — ni siquiera otro usuario con
// permiso users.delete (pero sin rol admin) puede borrarlo.
$viewer = User::factory()->approved()->create();
$viewer->givePermissionTo('users.delete');
$this->actingAs($viewer)
->deleteJson("/web-api/admin/users/{$this->admin->id}")
->assertStatus(422);
$this->assertNull($this->admin->fresh()->deleted_at);
}
public function test_admin_can_soft_delete_restore_and_force_delete_a_user(): void
{
$target = User::factory()->approved()->create();
$this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$target->id}")
->assertNoContent();
$this->assertSoftDeleted('users', ['id' => $target->id]);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.deleted', 'subject_id' => (string) $target->id]);
$this->actingAs($this->admin)
->getJson('/web-api/admin/users/trashed')
->assertOk()
->assertJsonFragment(['email' => $target->email]);
$this->actingAs($this->admin)
->postJson("/web-api/admin/users/{$target->id}/restore")
->assertOk();
$this->assertDatabaseHas('users', ['id' => $target->id, 'deleted_at' => null]);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.restored', 'subject_id' => (string) $target->id]);
$this->actingAs($this->admin)->deleteJson("/web-api/admin/users/{$target->id}")->assertNoContent();
$this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$target->id}/force")
->assertNoContent();
$this->assertDatabaseMissing('users', ['id' => $target->id]);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.force_deleted', 'subject_id' => (string) $target->id]);
}
public function test_admin_can_list_and_approve_pending_registrations(): void
{
$pending = User::factory()->create(['approved_at' => null]);
$this->actingAs($this->admin)
->getJson('/web-api/admin/users/pending')
->assertOk()
->assertJsonFragment(['email' => $pending->email]);
$this->actingAs($this->admin)
->postJson("/web-api/admin/users/{$pending->id}/approve")
->assertOk()
->assertJsonPath('email', $pending->email);
$this->assertNotNull($pending->fresh()->approved_at);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.approved', 'subject_id' => (string) $pending->id]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Laravel\Passport\Client;
use Laravel\Passport\Token;
use Tests\TestCase;
class UserSessionTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private User $target;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolesAndPermissionsSeeder::class);
$this->admin = User::factory()->approved()->create();
$this->admin->assignRole('admin');
$this->target = User::factory()->approved()->create();
}
public function test_admin_can_list_a_users_active_sessions(): void
{
$token = $this->createActiveToken($this->target, 'Demo App');
$response = $this->actingAs($this->admin)->getJson("/web-api/admin/users/{$this->target->id}/sessions");
$response->assertOk();
$response->assertJsonFragment(['id' => $token->id, 'client_name' => 'Demo App']);
}
public function test_revoked_and_expired_tokens_are_not_listed(): void
{
$this->createActiveToken($this->target, 'Revoked App', ['revoked' => true]);
$this->createActiveToken($this->target, 'Expired App', ['expires_at' => now()->subDay()]);
$active = $this->createActiveToken($this->target, 'Active App');
$response = $this->actingAs($this->admin)->getJson("/web-api/admin/users/{$this->target->id}/sessions");
$response->assertOk()->assertJsonCount(1);
$response->assertJsonFragment(['id' => $active->id]);
}
public function test_non_admin_cannot_list_sessions(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)
->getJson("/web-api/admin/users/{$this->target->id}/sessions")
->assertForbidden();
}
public function test_admin_can_revoke_a_single_session(): void
{
$token = $this->createActiveToken($this->target);
$response = $this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$this->target->id}/sessions/{$token->id}");
$response->assertNoContent();
$this->assertTrue($token->fresh()->revoked);
$this->assertDatabaseHas('audit_logs', [
'action' => 'user.session_revoked',
'subject_id' => (string) $this->target->id,
]);
}
public function test_revoking_nonexistent_session_returns_404(): void
{
$this->actingAs($this->admin)
->deleteJson('/web-api/admin/users/'.$this->target->id.'/sessions/'.Str::random(80))
->assertNotFound();
}
public function test_admin_can_revoke_all_sessions(): void
{
$this->createActiveToken($this->target, 'App One');
$this->createActiveToken($this->target, 'App Two');
$response = $this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$this->target->id}/sessions");
$response->assertNoContent();
$this->assertSame(0, Token::where('user_id', $this->target->id)->where('revoked', false)->count());
$this->assertDatabaseHas('audit_logs', [
'action' => 'user.sessions_revoked_all',
'subject_id' => (string) $this->target->id,
]);
}
public function test_non_admin_cannot_revoke_sessions(): void
{
$user = User::factory()->approved()->create();
$token = $this->createActiveToken($this->target);
$this->actingAs($user)
->deleteJson("/web-api/admin/users/{$this->target->id}/sessions/{$token->id}")
->assertForbidden();
$this->assertFalse($token->fresh()->revoked);
}
private function createActiveToken(User $user, string $clientName = 'Test Client', array $overrides = []): Token
{
$client = Client::create([
'name' => $clientName,
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['authorization_code', 'refresh_token'],
'revoked' => false,
]);
return Token::create(array_merge([
'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),
], $overrides));
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace Tests\Feature\Api;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Passport\Client;
use Laravel\Passport\Passport;
use Tests\TestCase;
class ClientCrudTest extends TestCase
{
use RefreshDatabase;
private User $owner;
protected function setUp(): void
{
parent::setUp();
$this->owner = User::factory()->approved()->create();
}
public function test_can_create_a_client_with_account_write_scope(): void
{
Passport::actingAs($this->owner, ['account:write']);
$response = $this->postJson('/api/clients', [
'name' => 'API Created Client',
'grant_type' => 'client_credentials',
]);
$response->assertCreated();
$this->assertNotNull($response->json('plain_secret'));
$this->assertDatabaseHas('oauth_clients', ['name' => 'API Created Client', 'owner_id' => $this->owner->id]);
$this->assertDatabaseHas('audit_logs', ['action' => 'client.created']);
}
public function test_creating_a_client_without_account_write_scope_is_forbidden(): void
{
Passport::actingAs($this->owner, ['account:read']);
$this->postJson('/api/clients', [
'name' => 'Should Fail',
'grant_type' => 'client_credentials',
])->assertForbidden();
}
public function test_can_list_only_own_clients_with_account_read_scope(): void
{
$ownClient = Client::create([
'owner_id' => $this->owner->id,
'owner_type' => User::class,
'name' => 'Mine',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
$stranger = User::factory()->approved()->create();
Client::create([
'owner_id' => $stranger->id,
'owner_type' => User::class,
'name' => 'Not Mine',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
Passport::actingAs($this->owner, ['account:read']);
$response = $this->getJson('/api/clients');
$response->assertOk();
$names = collect($response->json())->pluck('name');
$this->assertTrue($names->contains('Mine'));
$this->assertFalse($names->contains('Not Mine'));
$this->assertTrue(collect($response->json())->every(fn ($c) => $c['id'] !== null));
$this->assertSame($ownClient->id, $response->json('0.id'));
}
public function test_can_update_own_client(): void
{
$client = Client::create([
'owner_id' => $this->owner->id,
'owner_type' => User::class,
'name' => 'Old Name',
'secret' => null,
'provider' => null,
'redirect_uris' => ['https://example.test/callback'],
'grant_types' => ['authorization_code', 'refresh_token'],
'revoked' => false,
]);
Passport::actingAs($this->owner, ['account:write']);
$response = $this->putJson("/api/clients/{$client->id}", [
'name' => 'New Name',
'redirect_uris' => ['https://example.test/new'],
]);
$response->assertOk();
$response->assertJsonPath('name', 'New Name');
$this->assertSame('New Name', $client->fresh()->name);
}
public function test_cannot_update_another_users_client(): void
{
$stranger = User::factory()->approved()->create();
$client = Client::create([
'owner_id' => $stranger->id,
'owner_type' => User::class,
'name' => 'Not Yours',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
Passport::actingAs($this->owner, ['account:write']);
$this->putJson("/api/clients/{$client->id}", ['name' => 'Hijacked'])->assertNotFound();
}
public function test_client_credentials_token_cannot_use_self_service_client_endpoints(): void
{
$serviceClient = Client::create([
'owner_id' => $this->owner->id,
'owner_type' => User::class,
'name' => 'Service Client',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
Passport::actingAsClient($serviceClient, ['account:write']);
$this->postJson('/api/clients', [
'name' => 'Should Fail, No User',
'grant_type' => 'client_credentials',
])->assertStatus(422);
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\LoginEvent;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class LoginTest extends TestCase
{
use RefreshDatabase;
public function test_active_approved_user_can_log_in(): void
{
$user = User::factory()->approved()->create(['password' => Hash::make('password123')]);
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertOk()->assertJsonPath('user.email', $user->email);
$this->assertAuthenticatedAs($user);
$this->assertDatabaseHas('login_events', [
'user_id' => $user->id,
'type' => 'session',
'outcome' => 'success',
]);
}
public function test_invalid_credentials_are_rejected_and_recorded_as_failure(): void
{
$user = User::factory()->approved()->create(['password' => Hash::make('password123')]);
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('email');
$this->assertGuest();
$failure = LoginEvent::where('outcome', 'failure')->first();
$this->assertNotNull($failure);
$this->assertNull($failure->user_id);
}
public function test_deactivated_account_cannot_log_in(): void
{
$user = User::factory()->approved()->create([
'password' => Hash::make('password123'),
'is_active' => false,
]);
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('email');
$this->assertGuest();
}
public function test_pending_account_cannot_log_in(): void
{
$user = User::factory()->create(['password' => Hash::make('password123')]);
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('email');
$this->assertGuest();
}
public function test_login_with_two_factor_enabled_requires_second_step(): void
{
$user = $this->createUserWithTwoFactor();
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertOk()->assertJson(['two_factor' => true]);
$this->assertGuest();
}
public function test_two_factor_challenge_completes_login_with_valid_totp_code(): void
{
$user = $this->createUserWithTwoFactor();
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
])->assertOk();
$code = (new Google2FA())->getCurrentOtp($user->two_factor_secret);
$response = $this->postJson('/web-api/login/2fa', ['code' => $code]);
$response->assertOk()->assertJsonPath('user.email', $user->email);
$this->assertAuthenticatedAs($user);
}
public function test_two_factor_challenge_rejects_invalid_code(): void
{
$user = $this->createUserWithTwoFactor();
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
])->assertOk();
$response = $this->postJson('/web-api/login/2fa', ['code' => '000000']);
$response->assertUnprocessable()->assertJsonValidationErrors('code');
$this->assertGuest();
}
public function test_two_factor_challenge_accepts_recovery_code_only_once(): void
{
$user = $this->createUserWithTwoFactor();
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
])->assertOk();
$this->postJson('/web-api/login/2fa', ['code' => 'AAAA-1111'])->assertOk();
$this->assertAuthenticatedAs($user);
$this->postJson('/web-api/logout')->assertNoContent();
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
])->assertOk();
$reuse = $this->postJson('/web-api/login/2fa', ['code' => 'AAAA-1111']);
$reuse->assertUnprocessable()->assertJsonValidationErrors('code');
$this->assertGuest();
}
public function test_two_factor_challenge_without_pending_session_fails(): void
{
$response = $this->postJson('/web-api/login/2fa', ['code' => '123456']);
$response->assertUnprocessable()->assertJsonValidationErrors('code');
$this->assertGuest();
}
private function createUserWithTwoFactor(): User
{
$secret = (new Google2FA())->generateSecretKey();
return User::factory()->approved()->create([
'password' => Hash::make('password123'),
'two_factor_secret' => $secret,
'two_factor_recovery_codes' => ['AAAA-1111', 'BBBB-2222'],
'two_factor_confirmed_at' => now(),
]);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class PasswordGrantTwoFactorTest extends TestCase
{
use RefreshDatabase;
private string $clientId;
private string $clientSecret;
protected function setUp(): void
{
parent::setUp();
$owner = User::factory()->approved()->create();
$response = $this->actingAs($owner)->postJson('/web-api/clients', [
'name' => 'Password Grant Test Client',
'grant_type' => 'password',
]);
$response->assertCreated();
$this->clientId = $response->json('client.id');
$this->clientSecret = $response->json('plain_secret');
}
public function test_password_grant_still_works_for_users_without_two_factor(): void
{
User::factory()->approved()->create([
'email' => 'no2fa@example.com',
'password' => Hash::make('password123'),
]);
$this->requestToken('no2fa@example.com', 'password123')
->assertOk()->assertJsonStructure(['access_token']);
}
public function test_password_grant_is_rejected_without_otp_when_two_factor_is_enabled(): void
{
User::factory()->approved()->create([
'email' => 'has2fa@example.com',
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
$this->requestToken('has2fa@example.com', 'password123')->assertStatus(400);
}
public function test_password_grant_is_rejected_with_wrong_otp(): void
{
User::factory()->approved()->create([
'email' => 'has2fa@example.com',
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
$this->requestToken('has2fa@example.com', 'password123', '000000')->assertStatus(400);
}
public function test_password_grant_succeeds_with_valid_totp_code(): void
{
$secret = (new Google2FA())->generateSecretKey();
User::factory()->approved()->create([
'email' => 'has2fa@example.com',
'password' => Hash::make('password123'),
'two_factor_secret' => $secret,
'two_factor_confirmed_at' => now(),
]);
$otp = (new Google2FA())->getCurrentOtp($secret);
$this->requestToken('has2fa@example.com', 'password123', $otp)
->assertOk()->assertJsonStructure(['access_token']);
}
public function test_password_grant_succeeds_with_a_recovery_code_and_consumes_it(): void
{
$user = User::factory()->approved()->create([
'email' => 'has2fa@example.com',
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_recovery_codes' => ['AAAA-1111', 'BBBB-2222'],
'two_factor_confirmed_at' => now(),
]);
$this->requestToken('has2fa@example.com', 'password123', 'AAAA-1111')->assertOk();
$this->assertNotContains('AAAA-1111', $user->fresh()->two_factor_recovery_codes);
// El mismo código de recuperación no sirve una segunda vez.
$this->requestToken('has2fa@example.com', 'password123', 'AAAA-1111')->assertStatus(400);
}
private function requestToken(string $email, string $password, ?string $otp = null)
{
return $this->postJson('/oauth/token', array_filter([
'grant_type' => 'password',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'username' => $email,
'password' => $password,
'otp' => $otp,
'scope' => 'users:read',
], fn ($v) => $v !== null));
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use App\Notifications\ResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Laravel\Passport\Client;
use Laravel\Passport\Token;
use Tests\TestCase;
// NOTA: Notification::fake() nunca ejecuta toMail() (solo registra que se
// "envió"), así que un error real ahí adentro (como pasó acá: llamar a un
// método que no existe en esta versión de MailMessage) no lo detecta ningún
// test de arriba — solo se vio al procesar la cola de verdad en un browser
// check manual. test_the_mail_actually_renders_without_throwing cubre
// justamente ese hueco.
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
public function test_requesting_a_reset_link_for_an_existing_email_sends_the_notification(): void
{
Notification::fake();
$user = User::factory()->approved()->create();
$response = $this->postJson('/web-api/forgot-password', ['email' => $user->email]);
$response->assertOk();
Notification::assertSentTo($user, ResetPasswordNotification::class);
}
public function test_requesting_a_reset_link_for_an_unknown_email_returns_the_same_generic_response(): void
{
Notification::fake();
$known = $this->postJson('/web-api/forgot-password', ['email' => 'known@example.com']);
$unknown = $this->postJson('/web-api/forgot-password', ['email' => 'nobody@example.com']);
$known->assertOk();
$unknown->assertOk();
$this->assertSame($known->json('message'), $unknown->json('message'));
Notification::assertNothingSent();
}
public function test_forgot_password_validates_email_format(): void
{
$this->postJson('/web-api/forgot-password', ['email' => 'not-an-email'])
->assertUnprocessable()
->assertJsonValidationErrors('email');
}
public function test_can_reset_password_with_a_valid_token(): void
{
Notification::fake();
$user = User::factory()->approved()->create(['password' => Hash::make('old-password')]);
$this->postJson('/web-api/forgot-password', ['email' => $user->email])->assertOk();
$token = null;
Notification::assertSentTo($user, ResetPasswordNotification::class, function ($notification) use (&$token) {
$token = $notification->token;
return true;
});
$this->assertNotNull($token);
$response = $this->postJson('/web-api/reset-password', [
'token' => $token,
'email' => $user->email,
'password' => 'brand-new-password',
'password_confirmation' => 'brand-new-password',
]);
$response->assertNoContent();
$this->assertTrue(Hash::check('brand-new-password', $user->fresh()->password));
$this->assertDatabaseHas('audit_logs', [
'action' => 'user.password_reset',
'subject_id' => (string) $user->id,
]);
}
public function test_resetting_the_password_revokes_all_active_sessions(): void
{
Notification::fake();
$user = User::factory()->approved()->create(['password' => Hash::make('old-password')]);
$client = Client::create([
'owner_id' => $user->id,
'owner_type' => User::class,
'name' => 'Some App',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['authorization_code', 'refresh_token'],
'revoked' => false,
]);
$token = Token::create([
'id' => \Illuminate\Support\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),
]);
$this->postJson('/web-api/forgot-password', ['email' => $user->email]);
$resetToken = null;
Notification::assertSentTo($user, ResetPasswordNotification::class, function ($notification) use (&$resetToken) {
$resetToken = $notification->token;
return true;
});
$this->postJson('/web-api/reset-password', [
'token' => $resetToken,
'email' => $user->email,
'password' => 'brand-new-password',
'password_confirmation' => 'brand-new-password',
])->assertNoContent();
$this->assertTrue($token->fresh()->revoked);
}
public function test_reset_fails_with_an_invalid_token(): void
{
$user = User::factory()->approved()->create(['password' => Hash::make('old-password')]);
$response = $this->postJson('/web-api/reset-password', [
'token' => 'not-a-real-token',
'email' => $user->email,
'password' => 'brand-new-password',
'password_confirmation' => 'brand-new-password',
]);
$response->assertUnprocessable();
$this->assertTrue(Hash::check('old-password', $user->fresh()->password));
}
public function test_reset_validates_password_confirmation_and_minimum_length(): void
{
$user = User::factory()->approved()->create();
$this->postJson('/web-api/reset-password', [
'token' => 'irrelevant',
'email' => $user->email,
'password' => 'short',
'password_confirmation' => 'short',
])->assertUnprocessable()->assertJsonValidationErrors('password');
$this->postJson('/web-api/reset-password', [
'token' => 'irrelevant',
'email' => $user->email,
'password' => 'a-long-enough-password',
'password_confirmation' => 'does-not-match',
])->assertUnprocessable()->assertJsonValidationErrors('password');
}
public function test_the_mail_actually_renders_without_throwing(): void
{
$user = User::factory()->approved()->create();
$mail = (new ResetPasswordNotification('a-token', 'es'))->toMail($user);
$rendered = $mail->render();
$this->assertStringContainsString('reset-password?token=a-token', $rendered);
$this->assertStringContainsString(urlencode($user->email), $rendered);
$this->assertStringContainsString('Restablecer contraseña', $rendered);
}
public function test_forgot_password_is_throttled(): void
{
Notification::fake();
for ($i = 0; $i < 5; $i++) {
$this->postJson('/web-api/forgot-password', ['email' => 'someone@example.com'])->assertOk();
}
$this->postJson('/web-api/forgot-password', ['email' => 'someone@example.com'])->assertStatus(429);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_first_user_bootstraps_as_admin_and_is_logged_in(): void
{
$response = $this->postJson('/web-api/register', [
'name' => 'Admin One',
'email' => 'admin@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertOk()->assertJson(['bootstrap' => true]);
$user = User::where('email', 'admin@example.com')->firstOrFail();
$this->assertTrue($user->hasRole('admin'));
$this->assertNotNull($user->approved_at);
$this->assertAuthenticatedAs($user);
}
public function test_second_user_registration_is_pending_and_not_logged_in(): void
{
$this->postJson('/web-api/register', [
'name' => 'Admin One',
'email' => 'admin@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
])->assertOk();
$this->postJson('/web-api/logout');
$response = $this->postJson('/web-api/register', [
'name' => 'Regular User',
'email' => 'user@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertCreated()->assertJson(['pending' => true]);
$user = User::where('email', 'user@example.com')->firstOrFail();
$this->assertTrue($user->hasRole('user'));
$this->assertNull($user->approved_at);
$this->assertGuest();
}
public function test_registration_requires_matching_password_confirmation(): void
{
$response = $this->postJson('/web-api/register', [
'name' => 'Someone',
'email' => 'someone@example.com',
'password' => 'password123',
'password_confirmation' => 'different',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('password');
}
public function test_registration_requires_unique_email(): void
{
User::factory()->create(['email' => 'taken@example.com']);
$response = $this->postJson('/web-api/register', [
'name' => 'Someone',
'email' => 'taken@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('email');
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class TwoFactorTest extends TestCase
{
use RefreshDatabase;
public function test_setup_generates_a_pending_secret_and_qr_url(): void
{
$user = User::factory()->approved()->create();
$response = $this->actingAs($user)->postJson('/web-api/2fa/setup');
$response->assertOk()->assertJsonStructure(['secret', 'otpauth_url']);
$user->refresh();
$this->assertNotNull($user->two_factor_secret);
$this->assertFalse($user->hasTwoFactorEnabled());
}
public function test_setup_is_blocked_when_already_enabled(): void
{
$user = User::factory()->approved()->create([
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
$this->actingAs($user)->postJson('/web-api/2fa/setup')->assertStatus(422);
}
public function test_confirm_enables_two_factor_and_returns_recovery_codes(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)->postJson('/web-api/2fa/setup')->assertOk();
$user->refresh();
$code = (new Google2FA())->getCurrentOtp($user->two_factor_secret);
$response = $this->actingAs($user)->postJson('/web-api/2fa/confirm', ['code' => $code]);
$response->assertOk();
$this->assertCount(8, $response->json('recovery_codes'));
$user->refresh();
$this->assertTrue($user->hasTwoFactorEnabled());
$this->assertDatabaseHas('audit_logs', [
'action' => 'user.2fa_enabled',
'subject_id' => (string) $user->id,
]);
}
public function test_confirm_rejects_invalid_code(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)->postJson('/web-api/2fa/setup')->assertOk();
$response = $this->actingAs($user)->postJson('/web-api/2fa/confirm', ['code' => '000000']);
$response->assertUnprocessable()->assertJsonValidationErrors('code');
$this->assertFalse($user->fresh()->hasTwoFactorEnabled());
}
public function test_confirm_without_pending_setup_fails(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)->postJson('/web-api/2fa/confirm', ['code' => '123456'])
->assertStatus(422);
}
public function test_disable_requires_correct_password(): void
{
$user = User::factory()->approved()->create([
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_recovery_codes' => ['AAAA-1111'],
'two_factor_confirmed_at' => now(),
]);
$this->actingAs($user)->postJson('/web-api/2fa/disable', ['password' => 'nope'])
->assertUnprocessable()->assertJsonValidationErrors('password');
$this->assertTrue($user->fresh()->hasTwoFactorEnabled());
$this->actingAs($user)->postJson('/web-api/2fa/disable', ['password' => 'password123'])
->assertNoContent();
$user->refresh();
$this->assertFalse($user->hasTwoFactorEnabled());
$this->assertNull($user->two_factor_secret);
$this->assertNull($user->two_factor_recovery_codes);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.2fa_disabled']);
}
public function test_regenerate_recovery_codes_requires_enabled_two_factor(): void
{
$user = User::factory()->approved()->create(['password' => Hash::make('password123')]);
$this->actingAs($user)->postJson('/web-api/2fa/recovery-codes', ['password' => 'password123'])
->assertStatus(422);
}
public function test_regenerate_recovery_codes_replaces_old_codes(): void
{
$user = User::factory()->approved()->create([
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_recovery_codes' => ['OLD1-CODE', 'OLD2-CODE'],
'two_factor_confirmed_at' => now(),
]);
$response = $this->actingAs($user)->postJson('/web-api/2fa/recovery-codes', ['password' => 'password123']);
$response->assertOk();
$newCodes = $response->json('recovery_codes');
$this->assertCount(8, $newCodes);
$this->assertNotContains('OLD1-CODE', $newCodes);
$user->refresh();
$this->assertEquals($newCodes, $user->two_factor_recovery_codes);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.2fa_recovery_regenerated']);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Passport\Token;
use Tests\TestCase;
class ClientCrudTest extends TestCase
{
use RefreshDatabase;
private User $owner;
protected function setUp(): void
{
parent::setUp();
$this->owner = User::factory()->approved()->create();
}
public function test_owner_can_list_only_their_own_non_revoked_clients(): void
{
$this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'My Client',
'grant_type' => 'client_credentials',
])->assertCreated();
$stranger = User::factory()->approved()->create();
$this->actingAs($stranger)->postJson('/web-api/clients', [
'name' => 'Someone Elses Client',
'grant_type' => 'client_credentials',
])->assertCreated();
$response = $this->actingAs($this->owner)->getJson('/web-api/clients');
$response->assertOk();
$names = collect($response->json())->pluck('name');
$this->assertTrue($names->contains('My Client'));
$this->assertFalse($names->contains('Someone Elses Client'));
}
public function test_creating_a_client_credentials_client_is_always_confidential(): void
{
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'CC Client',
'grant_type' => 'client_credentials',
'confidential' => false,
]);
$response->assertCreated();
$this->assertNotNull($response->json('plain_secret'));
$this->assertDatabaseHas('audit_logs', ['action' => 'client.created']);
}
public function test_creating_a_public_authorization_code_client_has_no_secret(): void
{
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Public SPA',
'grant_type' => 'authorization_code',
'redirect_uris' => ['https://example.test/callback'],
'confidential' => false,
]);
$response->assertCreated();
$this->assertNull($response->json('plain_secret'));
}
public function test_authorization_code_requires_redirect_uris(): void
{
$this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'No Redirect',
'grant_type' => 'authorization_code',
])->assertUnprocessable()->assertJsonValidationErrors('redirect_uris');
}
public function test_invalid_grant_type_is_rejected(): void
{
$this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Bad Grant',
'grant_type' => 'not-a-real-grant',
])->assertUnprocessable()->assertJsonValidationErrors('grant_type');
}
public function test_owner_can_update_name_and_redirect_uris(): void
{
$created = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Original Name',
'grant_type' => 'authorization_code',
'redirect_uris' => ['https://example.test/callback'],
])->json('client');
$response = $this->actingAs($this->owner)->putJson("/web-api/clients/{$created['id']}", [
'name' => 'Updated Name',
'redirect_uris' => ['https://example.test/new-callback'],
]);
$response->assertOk();
$response->assertJsonPath('name', 'Updated Name');
$response->assertJsonPath('redirect_uris.0', 'https://example.test/new-callback');
}
public function test_non_owner_cannot_update_or_revoke_another_users_client(): void
{
$created = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Owned Client',
'grant_type' => 'client_credentials',
])->json('client');
$stranger = User::factory()->approved()->create();
$this->actingAs($stranger)
->putJson("/web-api/clients/{$created['id']}", ['name' => 'Hijacked'])
->assertNotFound();
$this->actingAs($stranger)
->deleteJson("/web-api/clients/{$created['id']}")
->assertNotFound();
}
public function test_revoking_a_client_also_revokes_its_active_tokens(): void
{
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'To Revoke',
'grant_type' => 'client_credentials',
]);
$clientId = $response->json('client.id');
$token = Token::create([
'id' => \Illuminate\Support\Str::random(80),
'user_id' => $this->owner->id,
'client_id' => $clientId,
'name' => null,
'scopes' => ['users:read'],
'revoked' => false,
'created_at' => now(),
'updated_at' => now(),
'expires_at' => now()->addDays(15),
]);
$this->actingAs($this->owner)
->deleteJson("/web-api/clients/{$clientId}")
->assertNoContent();
$this->assertDatabaseHas('oauth_clients', ['id' => $clientId, 'revoked' => true]);
$this->assertDatabaseHas('oauth_access_tokens', ['id' => $token->id, 'revoked' => true]);
$this->assertDatabaseHas('audit_logs', ['action' => 'client.revoked', 'subject_id' => $clientId]);
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Laravel\Passport\Client;
use Tests\TestCase;
class ClientSecretRotationTest extends TestCase
{
use RefreshDatabase;
private User $owner;
private Client $client;
private string $originalSecret;
protected function setUp(): void
{
parent::setUp();
$this->owner = User::factory()->approved()->create();
// Se crea vía el endpoint real para tener un secret en texto plano
// válido y un cliente client_credentials real, igual que en producción.
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Rotation Test Client',
'grant_type' => 'client_credentials',
]);
$response->assertCreated();
$this->originalSecret = $response->json('plain_secret');
$this->client = Client::find($response->json('client.id'));
}
public function test_original_secret_works_before_rotation(): void
{
$this->requestToken($this->originalSecret)->assertOk()->assertJsonStructure(['access_token']);
}
public function test_rotating_returns_a_new_secret_and_grace_period(): void
{
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$response->assertOk()->assertJsonStructure(['plain_secret', 'previous_secret_expires_at']);
$this->assertNotEmpty($response->json('plain_secret'));
$this->assertNotSame($this->originalSecret, $response->json('plain_secret'));
$this->assertDatabaseHas('audit_logs', [
'action' => 'client.secret_rotated',
'subject_id' => $this->client->id,
]);
}
public function test_both_old_and_new_secret_work_during_the_grace_period(): void
{
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$newSecret = $response->json('plain_secret');
$this->requestToken($newSecret)->assertOk();
$this->requestToken($this->originalSecret)->assertOk();
}
public function test_old_secret_stops_working_after_the_grace_period_expires(): void
{
$this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$this->client->forceFill(['previous_secret_expires_at' => now()->subMinute()])->save();
$this->requestToken($this->originalSecret)->assertStatus(401);
}
public function test_rotating_again_invalidates_the_first_old_secret_immediately(): void
{
// Solo se guarda UN secret anterior — rotar de nuevo pisa el que
// había, no acumula un historial de secrets viejos válidos.
$firstResponse = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$secondSecret = $firstResponse->json('plain_secret');
$this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$this->requestToken($this->originalSecret)->assertStatus(401);
$this->requestToken($secondSecret)->assertOk();
}
public function test_non_owner_cannot_rotate_another_users_client_secret(): void
{
$stranger = User::factory()->approved()->create();
$this->actingAs($stranger)
->postJson("/web-api/clients/{$this->client->id}/rotate-secret")
->assertNotFound();
$this->requestToken($this->originalSecret)->assertOk();
}
public function test_cannot_rotate_a_public_clients_secret(): void
{
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Public Client',
'grant_type' => 'authorization_code',
'redirect_uris' => ['https://example.test/callback'],
'confidential' => false,
]);
$response->assertCreated();
$publicClient = Client::find($response->json('client.id'));
$this->actingAs($this->owner)
->postJson("/web-api/clients/{$publicClient->id}/rotate-secret")
->assertStatus(422);
}
private function requestToken(string $secret): TestResponse
{
return $this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $this->client->id,
'client_secret' => $secret,
'scope' => 'users:read',
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Passport\Client;
use Laravel\Passport\Events\AccessTokenCreated;
use Tests\TestCase;
/**
* Regresión: los listeners bajo app/Listeners se auto-descubren por su
* handle() tipado (Laravel 11+). Registrarlos TAMBIÉN a mano con
* Event::listen() en un ServiceProvider los duplica — cada evento real
* termina disparando el listener dos veces (visto con "php artisan
* event:list" listando el mismo listener dos veces por evento). Pasó acá:
* cada login_event/webhook se creaba duplicado. Este test evita que vuelva.
*/
class EventRegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_access_token_created_records_exactly_one_login_event(): void
{
$user = User::factory()->approved()->create();
$client = Client::create([
'name' => 'Regression Test Client',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
event(new AccessTokenCreated('fake-token-id', (string) $user->id, $client->id));
$this->assertDatabaseCount('login_events', 1);
$this->assertDatabaseHas('login_events', [
'user_id' => $user->id,
'client_id' => $client->id,
'type' => 'oauth',
'outcome' => 'success',
]);
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RateLimitingTest extends TestCase
{
use RefreshDatabase;
public function test_login_is_throttled_after_five_attempts(): void
{
$user = User::factory()->approved()->create(['password' => bcrypt('correct-password')]);
for ($i = 0; $i < 5; $i++) {
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'wrong-password',
])->assertUnprocessable();
}
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'wrong-password',
])->assertStatus(429);
// El throttle es por (email, ip): otro email desde la misma IP no está afectado.
$other = User::factory()->approved()->create(['password' => bcrypt('correct-password')]);
$this->postJson('/web-api/login', [
'email' => $other->email,
'password' => 'correct-password',
])->assertOk();
}
public function test_register_is_throttled_after_five_attempts(): void
{
// El primer registro de una instalación vacía toma un camino distinto
// (bootstrap admin, 200 en vez de 201, y siembra roles/permisos) — se
// crea un usuario previo y se siembran los roles a mano para que
// todos los intentos del loop pasen por el camino normal.
(new RolesAndPermissionsSeeder)->run();
User::factory()->approved()->create();
for ($i = 0; $i < 5; $i++) {
$this->postJson('/web-api/register', [
'name' => "User {$i}",
'email' => "user{$i}@example.com",
'password' => 'password123',
'password_confirmation' => 'password123',
])->assertCreated();
}
$this->postJson('/web-api/register', [
'name' => 'One Too Many',
'email' => 'onetoomany@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
])->assertStatus(429);
}
public function test_oauth_token_endpoint_is_throttled_per_client(): void
{
$owner = User::factory()->approved()->create();
$response = $this->actingAs($owner)->postJson('/web-api/clients', [
'name' => 'Throttle Test Client',
'grant_type' => 'client_credentials',
]);
$response->assertCreated();
$clientId = $response->json('client.id');
for ($i = 0; $i < 20; $i++) {
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => 'wrong-secret',
])->assertStatus(401);
}
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => 'wrong-secret',
])->assertStatus(429);
}
public function test_oauth_token_throttle_is_scoped_per_client_not_globally(): void
{
$owner = User::factory()->approved()->create();
$clientA = $this->actingAs($owner)->postJson('/web-api/clients', [
'name' => 'Client A',
'grant_type' => 'client_credentials',
])->json('client.id');
for ($i = 0; $i < 20; $i++) {
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientA,
'client_secret' => 'wrong-secret',
]);
}
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientA,
'client_secret' => 'wrong-secret',
])->assertStatus(429);
$clientBResponse = $this->actingAs($owner)->postJson('/web-api/clients', [
'name' => 'Client B',
'grant_type' => 'client_credentials',
]);
$clientBSecret = $clientBResponse->json('plain_secret');
$clientBId = $clientBResponse->json('client.id');
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientBId,
'client_secret' => $clientBSecret,
'scope' => 'users:read',
])->assertOk();
}
}

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