- 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>
196 lines
7.6 KiB
PHP
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());
|
|
}
|
|
}
|