createTenant('acme'); $user = User::query()->create([ 'nombre_apellido' => 'Grace Hopper', 'email' => 'grace@example.com', 'password' => Hash::make('secret123'), ]); $response = $this->postJson('/api/login', [ 'email' => 'grace@example.com', 'password' => 'secret123', 'tenant_codigo' => $tenant->codigo, ]); $response ->assertOk() ->assertHeader('Content-Language', 'es') ->assertJsonPath('code', 'auth.login_success') ->assertJsonPath('message', 'Sesión iniciada correctamente.') ->assertJsonPath('token_type', 'Bearer') ->assertJsonPath('user.id', $user->id) ->assertJsonPath('user.nombre_apellido', 'Grace Hopper') ->assertJsonPath('user.email', 'grace@example.com'); $this->assertIsString($response->json('token')); $this->assertNotEmpty($response->json('token')); $accessToken = $user->tokens()->sole(); $this->assertTrue( $accessToken->expires_at->equalTo( $accessToken->created_at->copy()->addMinutes(config('sanctum.expiration')) ) ); $this->withHeader('Authorization', 'Bearer '.$response->json('token')) ->getJson('/api/me') ->assertOk() ->assertJsonPath('id', $user->id) ->assertJsonPath('email', 'grace@example.com'); } public function test_personal_access_tokens_expire_after_twelve_hours(): void { $this->travelTo(now()->startOfSecond()); $user = User::factory()->create(); $token = $user->createToken('api-token')->plainTextToken; $this->travel(12)->hours(); $this->travel(1)->minutes(); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/me') ->assertUnauthorized(); } public function test_it_rejects_access_to_the_current_user_endpoint_without_a_token(): void { $this->getJson('/api/me')->assertUnauthorized(); } public function test_it_logs_out_the_current_token(): void { $user = User::factory()->create(); $token = $user->createToken('api-token')->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/logout') ->assertOk() ->assertJsonPath('code', 'auth.logout_success') ->assertJsonPath('message', 'Sesión cerrada correctamente.'); $this->assertDatabaseCount('personal_access_tokens', 0); } public function test_it_returns_a_validation_error_for_invalid_credentials(): void { $tenant = $this->createTenant('acme'); User::query()->create([ 'nombre_apellido' => 'Grace Hopper', 'email' => 'grace@example.com', 'password' => Hash::make('secret123'), ]); $this->withHeader('User-Agent', 'Shopit login test') ->postJson('/api/login', [ 'email' => 'grace@example.com', 'password' => 'wrong-password', 'tenant_codigo' => $tenant->codigo, ])->assertUnprocessable()->assertJsonValidationErrors(['email']); $user = User::query()->where('email', 'grace@example.com')->sole(); $this->assertSame(1, $user->failed_login_attempts); $this->assertNotNull($user->last_failed_login_at); $this->assertDatabaseHas('login_attempts', [ 'user_id' => $user->id, 'tenant_codigo' => $tenant->codigo, 'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'ip_address' => '127.0.0.1', 'user_agent' => 'Shopit login test', ]); } public function test_it_locks_an_account_after_the_maximum_failed_attempts(): void { config([ 'login-security.max_attempts' => 3, 'login-security.lock_minutes' => 15, 'login-security.rate_limit_per_minute' => 100, 'login-security.ip_rate_limit_per_minute' => 100, ]); $this->travelTo(now()->startOfSecond()); $tenant = $this->createTenant('locked'); $user = User::factory()->create([ 'email' => 'locked@example.com', 'password' => Hash::make('secret123'), ]); $payload = [ 'email' => $user->email, 'password' => 'wrong-password', 'tenant_codigo' => $tenant->codigo, ]; for ($attempt = 0; $attempt < 2; $attempt++) { $this->postJson('/api/login', $payload)->assertUnprocessable(); } $this->postJson('/api/login', $payload) ->assertTooManyRequests() ->assertJsonPath('code', 'auth.account_locked'); $user->refresh(); $this->assertSame(3, $user->failed_login_attempts); $this->assertTrue($user->locked_until->equalTo(now()->addMinutes(15))); $this->postJson('/api/login', [ ...$payload, 'password' => 'secret123', ]) ->assertTooManyRequests() ->assertHeader('Retry-After', '900') ->assertJsonPath('code', 'auth.account_locked') ->assertJsonPath('retry_after', 900); $this->assertDatabaseCount('login_attempts', 4); $this->assertDatabaseHas('login_attempts', [ 'user_id' => $user->id, 'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED, ]); } public function test_a_successful_login_resets_failures_and_is_audited(): void { $tenant = $this->createTenant('successful'); $user = User::factory()->create([ 'email' => 'successful@example.com', 'password' => Hash::make('secret123'), ]); $user->forceFill([ 'failed_login_attempts' => 2, 'last_failed_login_at' => now()->subMinute(), ])->save(); $this->postJson('/api/login', [ 'email' => $user->email, 'password' => 'secret123', 'tenant_codigo' => $tenant->codigo, ])->assertOk(); $user->refresh(); $this->assertSame(0, $user->failed_login_attempts); $this->assertNull($user->last_failed_login_at); $this->assertNull($user->locked_until); $this->assertDatabaseHas('login_attempts', [ 'user_id' => $user->id, 'outcome' => LoginAttempt::OUTCOME_SUCCESS, ]); } public function test_an_expired_lock_allows_login_again(): void { $tenant = $this->createTenant('expired-lock'); $user = User::factory()->create([ 'email' => 'expired@example.com', 'password' => Hash::make('secret123'), ]); $user->forceFill([ 'failed_login_attempts' => 5, 'last_failed_login_at' => now()->subMinutes(20), 'locked_until' => now()->subMinute(), ])->save(); $this->postJson('/api/login', [ 'email' => $user->email, 'password' => 'secret123', 'tenant_codigo' => $tenant->codigo, ])->assertOk(); $user->refresh(); $this->assertSame(0, $user->failed_login_attempts); $this->assertNull($user->locked_until); } public function test_failures_outside_the_attempt_window_start_a_new_count(): void { config(['login-security.attempt_window_minutes' => 30]); $tenant = $this->createTenant('attempt-window'); $user = User::factory()->create([ 'email' => 'window@example.com', 'password' => Hash::make('secret123'), ]); $user->forceFill([ 'failed_login_attempts' => 4, 'last_failed_login_at' => now()->subMinutes(31), ])->save(); $this->postJson('/api/login', [ 'email' => $user->email, 'password' => 'wrong-password', 'tenant_codigo' => $tenant->codigo, ])->assertUnprocessable(); $this->assertSame(1, $user->refresh()->failed_login_attempts); $this->assertNull($user->locked_until); } public function test_unknown_emails_are_audited_without_storing_the_email(): void { $tenant = $this->createTenant('unknown'); $this->postJson('/api/login', [ 'email' => 'missing@example.com', 'password' => 'wrong-password', 'tenant_codigo' => $tenant->codigo, ])->assertUnprocessable(); $attempt = LoginAttempt::query()->sole(); $this->assertNull($attempt->user_id); $this->assertSame(LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $attempt->outcome); $this->assertSame(64, strlen($attempt->email_fingerprint)); $this->assertStringNotContainsString('missing@example.com', $attempt->email_fingerprint); } public function test_login_is_rate_limited_by_email_and_ip(): void { config([ 'login-security.max_attempts' => 100, 'login-security.rate_limit_per_minute' => 2, 'login-security.ip_rate_limit_per_minute' => 100, ]); $tenant = $this->createTenant('rate-limit'); $payload = [ 'email' => 'rate-limited@example.com', 'password' => 'wrong-password', 'tenant_codigo' => $tenant->codigo, ]; $this->postJson('/api/login', $payload)->assertUnprocessable(); $this->postJson('/api/login', $payload)->assertUnprocessable(); $this->postJson('/api/login', $payload) ->assertTooManyRequests() ->assertHeader('Retry-After'); $this->assertDatabaseCount('login_attempts', 2); } public function test_it_validates_required_login_fields(): void { $this->postJson('/api/login', [ 'email' => 'invalid-email', 'password' => '', ])->assertUnprocessable()->assertJsonValidationErrors([ 'email', 'password', 'tenant_codigo', ]); } public function test_it_localizes_success_and_validation_responses_from_accept_language(): void { $tenant = $this->createTenant('acme'); User::query()->create([ 'nombre_apellido' => 'Grace Hopper', 'email' => 'grace@example.com', 'password' => Hash::make('secret123'), ]); $this->withHeader('Accept-Language', 'en-US,en;q=0.9') ->postJson('/api/login', [ 'email' => 'grace@example.com', 'password' => 'secret123', 'tenant_codigo' => $tenant->codigo, ]) ->assertOk() ->assertHeader('Content-Language', 'en') ->assertJsonPath('code', 'auth.login_success') ->assertJsonPath('message', 'Signed in successfully.'); $this->withHeader('Accept-Language', 'en') ->postJson('/api/login', []) ->assertUnprocessable() ->assertHeader('Content-Language', 'en') ->assertJsonPath('errors.email.0', 'The email field is required.') ->assertJsonPath('errors.password.0', 'The password field is required.'); } public function test_it_replaces_the_active_user_cart_with_the_guest_cart_on_login(): void { $tenant = $this->createTenant('acme'); $user = User::factory()->create([ 'email' => 'grace@example.com', 'password' => Hash::make('secret123'), ]); $catalogItem = $this->createCatalogItem($tenant); $userCart = Cart::query()->create([ 'tenant_codigo' => $tenant->codigo, 'user_id' => $user->id, 'status' => 'active', ]); $guestCart = Cart::query()->create([ 'tenant_codigo' => $tenant->codigo, 'guest_token' => 'guest-cart-token', 'status' => 'active', ]); $guestCart->items()->create([ 'catalog_item_id' => $catalogItem->id, 'variant_id' => null, 'cantidad' => 2, ]); $this->withCookie('guest_token', 'guest-cart-token') ->postJson('/api/login', [ 'email' => 'grace@example.com', 'password' => 'secret123', 'tenant_codigo' => $tenant->codigo, ]) ->assertOk() ->assertCookieExpired('guest_token'); $this->assertSoftDeleted('carritos', [ 'id' => $userCart->id, 'status' => 'converted', ]); $this->assertDatabaseHas('carritos', [ 'id' => $guestCart->id, 'user_id' => $user->id, 'guest_token' => null, 'status' => 'active', ]); } private function createCatalogItem(Tenant $tenant): CatalogItem { $inventory = Inventory::query()->create(['real_stock' => 10]); return CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, 'inventory_id' => $inventory->id, 'slug' => 'login-item-'.$tenant->codigo, 'nombre' => 'Login item', 'precio' => '10.00', 'inventory_policy' => InventoryPolicy::Tracked, ]); } private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header"); $footerLogo = $this->createAttachment("{$code}-footer"); return Tenant::query()->create([ 'codigo' => $code, 'nombre' => ucfirst($code), 'dominio' => "{$code}.local", 'primary_color' => '#000000', 'secondary_color' => '#000000', 'danger_color' => '#000000', 'success_color' => '#000000', 'header_bg_color' => '#000000', 'footer_bg_color' => '#000000', 'header_logo_id' => $headerLogo->id, 'footer_logo_id' => $footerLogo->id, ]); } private function createAttachment(string $name): Attachment { return Attachment::query()->create([ 'path' => "test/{$name}.png", 'filename' => "{$name}.png", 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); } }