- 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>
345 lines
15 KiB
PHP
345 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api\Client;
|
|
|
|
use App\Mail\Customer\PasswordResetLink;
|
|
use App\Models\Customer;
|
|
use App\Models\RefreshToken;
|
|
use App\Services\Api\Client\ClientAuthService;
|
|
use App\Support\PasswordResetToken;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Tests\TestCase;
|
|
|
|
class ClientAuthSecurityTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
|
|
private function api(): static
|
|
{
|
|
return $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
|
|
}
|
|
|
|
public function test_access_token_older_than_skew_window_is_rejected(): void
|
|
{
|
|
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken(time() - 3600)])
|
|
->getJson('/v1/banks')
|
|
->assertStatus(403);
|
|
}
|
|
|
|
public function test_verify_email_code_is_invalidated_after_too_many_wrong_attempts(): void
|
|
{
|
|
$customer = Customer::factory()->create([
|
|
'status' => 0,
|
|
'email_verify_code' => '123456',
|
|
'email_verify_code_expired_at' => now()->addMinutes(5),
|
|
]);
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$this->api()->postJson('/v1/client/verify-email', ['email' => $customer->email, 'code' => '000000'])
|
|
->assertStatus(422);
|
|
}
|
|
|
|
// Lần sai thứ 5 huỷ mã: mã đúng cũng vô dụng, phải gửi lại.
|
|
$this->assertNull($customer->fresh()->email_verify_code);
|
|
$this->assertSame(0, (int) $customer->fresh()->status);
|
|
|
|
// throttle:client-auth khoá theo email: lần thứ 6 trong phút bị chặn, email khác vẫn qua.
|
|
$this->api()->postJson('/v1/client/verify-email', ['email' => $customer->email, 'code' => '123456'])
|
|
->assertStatus(429);
|
|
$this->api()->postJson('/v1/client/verify-email', ['email' => 'other@example.com', 'code' => '123456'])
|
|
->assertStatus(422);
|
|
}
|
|
|
|
public function test_forgot_password_sends_link_without_changing_password_and_reset_revokes_sessions(): void
|
|
{
|
|
Mail::fake();
|
|
$customer = Customer::factory()->create(['status' => 1]);
|
|
$oldHash = $customer->password;
|
|
RefreshToken::create(['customer_id' => $customer->id, 'token' => 'x', 'expires_at' => now()->addDay()]);
|
|
|
|
$this->api()->postJson('/v1/client/forgot-password', ['email' => strtoupper($customer->email)])->assertOk();
|
|
|
|
$this->assertSame($oldHash, $customer->fresh()->password);
|
|
$token = null;
|
|
Mail::assertQueued(PasswordResetLink::class, function (PasswordResetLink $mail) use ($customer, &$token) {
|
|
$html = $mail->render();
|
|
preg_match('#/dat-lai-mat-khau\?t=([0-9a-z]+)#', $html, $match);
|
|
$token = $match[1] ?? null;
|
|
|
|
return $mail->hasTo($customer->email)
|
|
&& !str_contains($html, 'email=')
|
|
&& str_contains($html, '60 phút');
|
|
});
|
|
|
|
$this->assertNotNull($token);
|
|
$this->assertSame(PasswordResetToken::LENGTH, strlen($token));
|
|
$this->assertTrue(PasswordResetToken::isWellFormed($token));
|
|
|
|
$this->api()->getJson('/v1/client/reset-password/' . $token)
|
|
->assertOk()
|
|
->assertJsonPath('data.email', $customer->email);
|
|
|
|
$this->api()->postJson('/v1/client/reset-password', [
|
|
'token' => $token, 'password' => 'NewPass1234', 'password_confirmation' => 'NewPass1234',
|
|
])->assertOk();
|
|
|
|
$this->assertTrue(Hash::check('NewPass1234', $customer->fresh()->password));
|
|
$this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count());
|
|
|
|
$this->api()->getJson('/v1/client/reset-password/' . $token)->assertNotFound();
|
|
$this->api()->postJson('/v1/client/reset-password', [
|
|
'token' => $token, 'password' => 'Other12345', 'password_confirmation' => 'Other12345',
|
|
])->assertStatus(422);
|
|
}
|
|
|
|
public function test_reset_token_rejects_tampered_unknown_expired_and_superseded_tokens(): void
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 1]);
|
|
$token = PasswordResetToken::issue($customer);
|
|
|
|
$lastChar = $token[PasswordResetToken::LENGTH - 1];
|
|
$tampered = substr($token, 0, -1) . ($lastChar === 'a' ? 'b' : 'a');
|
|
$this->assertFalse(PasswordResetToken::isWellFormed($tampered));
|
|
$this->assertFalse(PasswordResetToken::isWellFormed(substr($token, 0, -1)));
|
|
$this->assertFalse(PasswordResetToken::isWellFormed(strtoupper($token)));
|
|
$this->api()->getJson('/v1/client/reset-password/' . $tampered)->assertNotFound();
|
|
|
|
$this->assertNull(PasswordResetToken::issue($customer), 'throttled within 60s');
|
|
|
|
$this->travel(61)->seconds();
|
|
$newer = PasswordResetToken::issue($customer);
|
|
$this->assertNotNull($newer);
|
|
$this->assertNull(PasswordResetToken::findCustomer($token), 'old link revoked when a new one is issued');
|
|
$this->assertSame($customer->id, PasswordResetToken::findCustomer($newer)?->id);
|
|
|
|
$other = Customer::factory()->create(['status' => 1]);
|
|
$otherToken = PasswordResetToken::issue($other);
|
|
$this->assertSame($other->id, PasswordResetToken::findCustomer($otherToken)?->id);
|
|
|
|
$this->travel(61)->minutes();
|
|
$this->assertNull(PasswordResetToken::findCustomer($newer), 'expired after 60 minutes');
|
|
$this->api()->postJson('/v1/client/reset-password', [
|
|
'token' => $newer, 'password' => 'NewPass1234', 'password_confirmation' => 'NewPass1234',
|
|
])->assertStatus(422);
|
|
}
|
|
|
|
public function test_locked_account_loses_api_access_even_with_unexpired_jwt(): void
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 1]);
|
|
$token = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer);
|
|
|
|
$this->api()->withHeaders(['Authorization' => "Bearer {$token}"])->getJson('/v1/client/me')->assertOk();
|
|
|
|
$customer->update(['status' => 3]);
|
|
$this->app['auth']->forgetGuards();
|
|
|
|
$this->api()->withHeaders(['Authorization' => "Bearer {$token}"])->getJson('/v1/client/me')->assertUnauthorized();
|
|
}
|
|
|
|
public function test_cccd_details_are_not_exposed_to_client(): void
|
|
{
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('cccd/front.jpg', 'img');
|
|
Storage::disk('local')->put('cccd/back.jpg', 'img');
|
|
$customer = Customer::factory()->create([
|
|
'status' => 1,
|
|
'identity_number' => '079123456789',
|
|
'front_identity_number' => 'cccd/front.jpg',
|
|
'back_identity_number' => 'cccd/back.jpg',
|
|
]);
|
|
|
|
$me = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')->assertOk();
|
|
$data = $me->json('data.client.customer');
|
|
|
|
$this->assertTrue($data['cccd_updated']);
|
|
$this->assertArrayNotHasKey('cmnd', $data);
|
|
$this->assertArrayNotHasKey('cmnd_before', $data);
|
|
$this->assertArrayNotHasKey('cmnd_after', $data);
|
|
$this->api()->get("/v1/client/cccd/{$customer->id}/front")->assertNotFound();
|
|
}
|
|
|
|
public function test_phone_is_masked_to_last_three_digits_in_client_response(): void
|
|
{
|
|
$customer = Customer::factory()->create([
|
|
'status' => 1,
|
|
'phone' => '0929768789',
|
|
]);
|
|
|
|
$this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')
|
|
->assertOk()
|
|
->assertJsonPath('data.client.customer.phone', '*******789');
|
|
|
|
$this->actingAs($customer->fresh(), 'client')->api()->putJson('/v1/client/update', [
|
|
'fullname' => 'Bảo',
|
|
'company' => 'Công ty mới',
|
|
])->assertOk();
|
|
|
|
$this->assertSame('0929768789', $customer->fresh()->phone);
|
|
}
|
|
|
|
public function test_access_token_is_revoked_when_password_changes(): void
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 1, 'password' => Hash::make('OldPass123')]);
|
|
$jwt = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer);
|
|
$call = fn () => $this->api()->withHeader('Authorization', "Bearer {$jwt}")->getJson('/v1/client/me');
|
|
|
|
$call()->assertOk();
|
|
|
|
$this->api()->withHeader('Authorization', "Bearer {$jwt}")->putJson('/v1/client/change-password', [
|
|
'current_password' => 'OldPass123', 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123',
|
|
])->assertOk();
|
|
|
|
$this->app['auth']->forgetGuards();
|
|
$call()->assertStatus(401);
|
|
}
|
|
|
|
public function test_throttle_uses_client_ip_from_encrypted_payload_not_next_server_ip(): void
|
|
{
|
|
// Cùng REMOTE_ADDR (Next server) nhưng IP khách khác nhau → không bị đếm chung.
|
|
foreach (['203.0.113.1', '203.0.113.2'] as $ip) {
|
|
for ($i = 0; $i < 60; $i++) {
|
|
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken(null, $ip)])
|
|
->getJson('/v1/banks')->assertOk();
|
|
}
|
|
}
|
|
|
|
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken(null, '203.0.113.1')])
|
|
->getJson('/v1/banks')->assertStatus(429);
|
|
}
|
|
|
|
public function test_logout_revokes_refresh_token_so_it_cannot_be_used_again(): void
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 1]);
|
|
$jwt = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer);
|
|
$rawRefreshToken = 'raw-refresh-token';
|
|
RefreshToken::create([
|
|
'customer_id' => $customer->id,
|
|
'token' => hash('sha256', $rawRefreshToken),
|
|
'expires_at' => now()->addDays(30),
|
|
]);
|
|
|
|
$this->api()->withHeader('Authorization', "Bearer {$jwt}")
|
|
->postJson('/v1/client/logout', ['refresh_token' => $rawRefreshToken])
|
|
->assertOk();
|
|
|
|
$this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count());
|
|
|
|
$this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])
|
|
->assertStatus(401);
|
|
}
|
|
|
|
public function test_reused_rotated_refresh_token_revokes_all_tokens_of_customer(): void
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 1]);
|
|
$rawRefreshToken = 'raw-refresh-token';
|
|
RefreshToken::create([
|
|
'customer_id' => $customer->id,
|
|
'token' => hash('sha256', $rawRefreshToken),
|
|
'expires_at' => now()->addDays(30),
|
|
]);
|
|
|
|
// Rotation hợp lệ lần đầu: đánh dấu token cũ đã dùng, cấp token mới.
|
|
$first = $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])->assertOk();
|
|
$newRawRefreshToken = $first->json('data.refresh_token');
|
|
$this->assertNotSame($rawRefreshToken, $newRawRefreshToken);
|
|
// Token cũ vẫn còn nhưng đã đánh dấu revoked_at, cộng token mới vừa cấp.
|
|
$this->assertSame(2, RefreshToken::where('customer_id', $customer->id)->count());
|
|
|
|
$this->travel(ClientAuthService::REFRESH_TOKEN_REUSE_GRACE_SECONDS + 1)->seconds();
|
|
|
|
// Dùng lại token cũ (đã rotate): coi như bị lộ, thu hồi toàn bộ token của khách.
|
|
$this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])
|
|
->assertStatus(401);
|
|
|
|
$this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count());
|
|
|
|
// Token mới (hợp lệ, chưa dùng) cũng đã bị thu hồi theo.
|
|
$this->api()->postJson('/v1/client/refresh', ['refresh_token' => $newRawRefreshToken])
|
|
->assertStatus(401);
|
|
}
|
|
|
|
public function test_concurrent_refresh_within_grace_window_does_not_revoke_sessions(): void
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 1]);
|
|
$rawRefreshToken = 'raw-refresh-token';
|
|
RefreshToken::create([
|
|
'customer_id' => $customer->id,
|
|
'token' => hash('sha256', $rawRefreshToken),
|
|
'expires_at' => now()->addDays(30),
|
|
]);
|
|
|
|
$first = $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])->assertOk();
|
|
$second = $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])->assertOk();
|
|
|
|
foreach ([$first, $second] as $response) {
|
|
$this->api()->postJson('/v1/client/refresh', ['refresh_token' => $response->json('data.refresh_token')])
|
|
->assertOk();
|
|
}
|
|
}
|
|
|
|
public function test_incomplete_legacy_cccd_is_reported_only_as_not_updated(): void
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 1, 'front_identity_number' => '/uploads/2023/11/06/abc.jpg']);
|
|
|
|
$data = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')
|
|
->assertOk()
|
|
->json('data.client.customer');
|
|
|
|
$this->assertFalse($data['cccd_updated']);
|
|
$this->assertArrayNotHasKey('cmnd_before', $data);
|
|
}
|
|
|
|
public function test_profile_update_accepts_gender_enum_values_only(): void
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 1, 'gender' => 0])->fresh();
|
|
$payload = ['fullname' => 'Bảo', 'phone' => '0929768789'];
|
|
|
|
$this->actingAs($customer, 'client')->api()
|
|
->putJson('/v1/client/update', $payload + ['gender' => 1])
|
|
->assertOk()
|
|
->assertJsonPath('data.client.customer.gender', 1)
|
|
->assertJsonPath('data.client.customer.gender_label', 'Nữ');
|
|
|
|
$this->actingAs($customer->fresh(), 'client')->api()
|
|
->putJson('/v1/client/update', $payload + ['gender' => 2])
|
|
->assertStatus(422);
|
|
}
|
|
|
|
public function test_profile_update_saves_birthday_and_validates_ward_belongs_to_province(): void
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 1])->fresh();
|
|
$payload = ['fullname' => 'Bảo', 'phone' => '0929768789'];
|
|
|
|
$this->actingAs($customer, 'client')->api()
|
|
->putJson('/v1/client/update', $payload + [
|
|
'birthday' => '1995-06-20',
|
|
'province_code' => '79',
|
|
'ward_code' => '27448',
|
|
])
|
|
->assertOk()
|
|
->assertJsonPath('data.client.customer.birthday', '1995-06-20')
|
|
->assertJsonPath('data.client.customer.province_code', '79')
|
|
->assertJsonPath('data.client.customer.province', 'Hồ Chí Minh')
|
|
->assertJsonPath('data.client.customer.ward', 'Bình Trị Đông');
|
|
|
|
$this->actingAs($customer->fresh(), 'client')->api()
|
|
->putJson('/v1/client/update', $payload + ['province_code' => '01', 'ward_code' => '27448'])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['ward_code']);
|
|
|
|
$this->actingAs($customer->fresh(), 'client')->api()
|
|
->putJson('/v1/client/update', $payload + ['birthday' => now()->addDay()->toDateString()])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['birthday']);
|
|
|
|
$this->api()->getJson('/v1/provinces')->assertOk()->assertJsonCount(34, 'data');
|
|
$this->api()->getJson('/v1/provinces/79/wards')->assertOk()->assertJsonFragment(['code' => '27448']);
|
|
}
|
|
}
|