create([ 'two_factor_secret' => (new Google2FA)->generateSecretKey(), 'two_factor_confirmed_at' => now(), ]); $otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); $this->assertTrue($user->verifyTotp($otp), 'first use of a fresh code must succeed'); $this->assertFalse($user->fresh()->verifyTotp($otp), 'the same code must be rejected on replay'); } public function test_per_account_login_throttle_blocks_even_a_correct_password(): void { User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]); for ($i = 0; $i < 5; $i++) { Livewire::test(Login::class) ->set('email', 'admin@clusev.local')->set('password', 'wrong')->call('authenticate'); } // 6th attempt with the RIGHT password must still be refused (per-(email+IP) cap = 5). Livewire::test(Login::class) ->set('email', 'admin@clusev.local')->set('password', self::PW)->call('authenticate') ->assertHasErrors('email'); $this->assertGuest(); } public function test_per_ip_login_throttle_blocks_a_flood_across_distinct_emails(): void { User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]); // 20 failures spread over distinct emails — the per-(email+IP) bucket never trips (1 each), // but the per-IP bucket (20/min) does. for ($i = 0; $i < 20; $i++) { Livewire::test(Login::class) ->set('email', "flood{$i}@clusev.local")->set('password', 'wrong')->call('authenticate'); } // A correct login for the real admin from the same IP is now blocked by the per-IP cap. Livewire::test(Login::class) ->set('email', 'admin@clusev.local')->set('password', self::PW)->call('authenticate') ->assertHasErrors('email'); $this->assertGuest(); } public function test_unknown_and_known_wrong_email_are_rejected_identically(): void { User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]); // Both a known-wrong-password and a non-existent address fail on the SAME field with the // SAME generic auth.invalid_credentials message (by construction), and neither logs in — // no account-existence signal in the response. (The matching bcrypt cost on the unknown // branch, via DUMMY_HASH, closes the timing oracle; that is not asserted here because the // test env uses BCRYPT_ROUNDS=4 while DUMMY_HASH is cost-12.) foreach (['admin@clusev.local', 'ghost@clusev.local'] as $email) { Livewire::test(Login::class) ->set('email', $email)->set('password', 'wrong')->call('authenticate') ->assertHasErrors('email'); } $this->assertGuest(); } public function test_password_reauth_is_throttled_against_a_hijacked_session(): void { $user = User::factory()->create([ 'password' => Hash::make(self::PW), 'must_change_password' => true, ]); $this->actingAs($user); for ($i = 0; $i < 5; $i++) { Livewire::test(PasswordChange::class) ->set('current', 'wrong')->set('password', self::NEW_PW)->set('password_confirmation', self::NEW_PW) ->call('update'); } // 6th try with the CORRECT current password is still refused (reauth bucket tripped). Livewire::test(PasswordChange::class) ->set('current', self::PW)->set('password', self::NEW_PW)->set('password_confirmation', self::NEW_PW) ->call('update') ->assertHasErrors('current'); // Password unchanged. $this->assertTrue(Hash::check(self::PW, $user->fresh()->password)); } }