Files
portal-api/tests/Feature/Api/Client/ClientTwoFactorTest.php
T
baodev0andClaude Sonnet 5.5 55a834a2f8 feat: nhắc gia hạn QR theo dịch vụ, failover server hosting, CCCD admin, log cấp phát dịch vụ
- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services)
- Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết
- Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ
- Đánh dấu mẫu email dịch vụ là non-marketing

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 18:36:41 +07:00

196 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Client;
use App\Mail\Customer\TwoFactorCode;
use App\Models\Customer;
use App\Models\CustomerTrustedDevice;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class ClientTwoFactorTest extends TestCase
{
use RefreshDatabase;
private const PASSWORD = 'Secret12345';
private function api(): static
{
return $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
}
private function customer(bool $twoFactor = false): Customer
{
return Customer::factory()->create([
'status' => 1,
'password' => Hash::make(self::PASSWORD),
'two_factor_enabled' => $twoFactor,
]);
}
private function login(Customer $customer, ?string $deviceToken = null)
{
return $this->api()->postJson('/v1/client/login', array_filter([
'email' => $customer->email,
'password' => self::PASSWORD,
'device_token' => $deviceToken,
]));
}
private function lastCode(): string
{
$code = null;
Mail::assertQueued(TwoFactorCode::class, function (TwoFactorCode $mail) use (&$code) {
$code = $mail->data['code'];
return true;
});
return $code;
}
public function test_login_without_two_factor_returns_tokens_directly(): void
{
$this->login($this->customer())
->assertOk()
->assertJsonStructure(['data' => ['token', 'refresh_token']])
->assertJsonMissingPath('data.two_factor');
}
public function test_customer_enables_two_factor_with_emailed_code(): void
{
Mail::fake();
$customer = $this->customer();
$this->actingAs($customer, 'client')->api()->postJson('/v1/client/two-factor/setup-code')->assertOk();
$code = $this->lastCode();
$this->actingAs($customer, 'client')->api()
->postJson('/v1/client/two-factor/setup-code')
->assertStatus(422);
$this->actingAs($customer, 'client')->api()
->postJson('/v1/client/two-factor/enable', ['code' => $code === '000000' ? '111111' : '000000'])
->assertStatus(422);
$this->assertFalse($customer->fresh()->two_factor_enabled);
$this->actingAs($customer, 'client')->api()
->postJson('/v1/client/two-factor/enable', ['code' => $code])
->assertOk();
$this->assertTrue($customer->fresh()->two_factor_enabled);
$this->actingAs($customer->fresh(), 'client')->api()
->getJson('/v1/client/me')
->assertJsonPath('data.client.customer.active_2fa', true);
}
public function test_login_requires_emailed_code_and_can_remember_device(): void
{
Mail::fake();
$customer = $this->customer(twoFactor: true);
$challenge = $this->login($customer)
->assertOk()
->assertJsonMissingPath('data.token')
->json('data.two_factor.challenge');
$this->assertSame(64, strlen($challenge));
$code = $this->lastCode();
$this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code === '000000' ? '111111' : '000000'])
->assertStatus(422);
$response = $this->api()->postJson('/v1/client/login/two-factor', [
'challenge' => $challenge, 'code' => $code, 'remember_device' => true,
])->assertOk()->assertJsonStructure(['data' => ['token', 'refresh_token', 'device_token', 'device_expires_in']]);
$this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code])
->assertStatus(422);
$deviceToken = $response->json('data.device_token');
$device = CustomerTrustedDevice::where('customer_id', $customer->id)->sole();
$this->assertNotSame($deviceToken, $device->token_hash);
$this->assertTrue($device->expires_at->between(now()->addDays(89), now()->addDays(91)));
$this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['token']]);
$this->login($customer, str_repeat('x', 64))->assertOk()->assertJsonPath('data.two_factor.resend_in', 300);
$this->travel(91)->days();
$this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['two_factor']]);
}
public function test_repeated_logins_from_same_client_reuse_code_until_it_expires(): void
{
Mail::fake();
$customer = $this->customer(twoFactor: true);
$first = $this->login($customer)->json('data.two_factor.challenge');
$this->travel(2)->minutes();
$second = $this->login($customer)->assertJsonPath('data.two_factor.resend_in', 180)->json('data.two_factor.challenge');
$this->assertSame($first, $second);
Mail::assertQueuedCount(1);
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])->login($customer);
Mail::assertQueuedCount(2);
$this->travel(301)->seconds();
$third = $this->login($customer)->json('data.two_factor.challenge');
$this->assertNotSame($first, $third);
Mail::assertQueuedCount(3);
}
public function test_challenge_is_destroyed_after_five_wrong_codes(): void
{
Mail::fake();
$customer = $this->customer(twoFactor: true);
$challenge = $this->login($customer)->json('data.two_factor.challenge');
$code = $this->lastCode();
$wrong = $code === '000000' ? '111111' : '000000';
for ($i = 0; $i < 5; $i++) {
$this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $wrong])->assertStatus(422);
}
$this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code])->assertStatus(429);
$this->travel(61)->seconds();
$this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code])
->assertStatus(422)
->assertJsonPath('errors.code.0', 'Phiên xác thực đã hết hạn, vui lòng đăng nhập lại.');
}
public function test_disabling_requires_password_and_revokes_devices_password_change_also_revokes(): void
{
$customer = $this->customer(twoFactor: true);
$twoFactor = app(\App\Services\Api\Client\ClientTwoFactorService::class);
$twoFactor->rememberDevice($customer, 'UA', '1.1.1.1');
$this->actingAs($customer, 'client')->api()->getJson('/v1/client/two-factor')
->assertOk()
->assertJsonPath('data.enabled', true)
->assertJsonCount(1, 'data.devices')
->assertJsonMissingPath('data.devices.0.token_hash');
$this->actingAs($customer, 'client')->api()
->postJson('/v1/client/two-factor/disable', ['password' => 'wrong-password'])
->assertStatus(422);
$this->assertTrue($customer->fresh()->two_factor_enabled);
$this->actingAs($customer, 'client')->api()
->postJson('/v1/client/two-factor/disable', ['password' => self::PASSWORD])
->assertOk();
$this->assertFalse($customer->fresh()->two_factor_enabled);
$this->assertSame(0, CustomerTrustedDevice::count());
$twoFactor->rememberDevice($customer, 'UA', '1.1.1.1');
$this->actingAs($customer, 'client')->api()->putJson('/v1/client/change-password', [
'current_password' => self::PASSWORD, 'password' => 'NewSecret123', 'password_confirmation' => 'NewSecret123',
])->assertOk();
$this->assertSame(0, CustomerTrustedDevice::count());
}
}