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