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