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); Mail::assertQueued(PasswordResetLink::class, function (PasswordResetLink $mail) use ($customer) { // Template seed bằng migration phải render được và chứa link reset, không chứa mật khẩu. $html = $mail->render(); return $mail->hasTo($customer->email) && str_contains($html, '/dat-lai-mat-khau?token=') && str_contains($html, '60 phút'); }); $token = Password::broker('users')->createToken($customer); $this->api()->postJson('/v1/client/reset-password', [ 'email' => $customer->email, 'token' => 'wrong', 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123', ])->assertStatus(422); $this->api()->postJson('/v1/client/reset-password', [ 'email' => $customer->email, 'token' => $token, 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123', ])->assertOk(); $this->assertTrue(Hash::check('NewPass123', $customer->fresh()->password)); $this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count()); } public function test_cccd_images_are_private_and_served_only_via_signed_url(): void { Storage::fake('local'); Storage::disk('local')->put('cccd/front.jpg', 'img'); $customer = Customer::factory()->create([ 'status' => 1, 'identity_number' => '079123456789', 'front_identity_number' => 'cccd/front.jpg', ]); $me = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')->assertOk(); $data = $me->json('data.client.customer'); $this->assertSame('********6789', $data['cmnd']); $this->assertStringStartsWith('/v1/client/cccd/' . $customer->id . '/front?expires=', $data['cmnd_before']); $this->api()->get('/v1/client/cccd/' . $customer->id . '/front')->assertStatus(403); $this->api()->get($data['cmnd_before'])->assertOk(); } 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_legacy_cccd_path_is_not_exposed(): void { $customer = Customer::factory()->create(['status' => 1, 'front_identity_number' => '/uploads/2023/11/06/abc.jpg']); $url = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me') ->json('data.client.customer.cmnd_before'); $this->assertStringNotContainsString('uploads', $url); $this->api()->get($url)->assertNotFound(); } }