new funcionality
This commit is contained in:
169
tests/Feature/Auth/LoginTest.php
Normal file
169
tests/Feature/Auth/LoginTest.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
115
tests/Feature/Auth/PasswordGrantTwoFactorTest.php
Normal file
115
tests/Feature/Auth/PasswordGrantTwoFactorTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
190
tests/Feature/Auth/PasswordResetTest.php
Normal file
190
tests/Feature/Auth/PasswordResetTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
83
tests/Feature/Auth/RegistrationTest.php
Normal file
83
tests/Feature/Auth/RegistrationTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
129
tests/Feature/Auth/TwoFactorTest.php
Normal file
129
tests/Feature/Auth/TwoFactorTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user