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