Files
portal-api/tests/Feature/Api/Client/ClientTwoFactorTest.php
T
baodev0andClaude Opus 5.5 bf78f3b810 feat: hồ sơ khách (ngày sinh, tỉnh/phường theo mã), gộp menu + nút tắt, thông báo theo uuid
- PUT v1/client/update: gender theo enum ClientGender (0 Nam/1 Nữ), thêm
  birthday (cột mới), province_code/ward_code validate theo bảng provinces/wards
  (phường phải thuộc tỉnh). Migration nạp dữ liệu hành chính 2025 từ
  province_ward_import.sql (chỉ bảng trống), API GET v1/provinces[/{code}/wards].
- CustomerObserver/OrderObserver log giá trị cũ bằng getRawOriginal: getOriginal
  trả enum object, nối chuỗi gây 500 khi khách đổi giới tính.
- ClientResource: các cờ trả boolean, CCCD chỉ trả cccd_updated (không lộ ảnh/số).
- GET v1/setting/menu trả cả items + quick_links, bỏ v1/setting/quick-links.
- Thông báo admin/khách tra theo uuid thay vì id tự tăng.
- Chặn gửi template marketing cho khách đã tắt nhận email marketing.
- Xoá ProvinceWardSeeder và Customer::$updatable không dùng.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:03:18 +07:00

175 lines
6.8 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', 60);
$this->travel(91)->days();
$this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['two_factor']]);
}
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());
}
}