create([ 'codigo' => RoleCode::Scanner->value, 'nombre' => 'Scanner', ]); $permission = Permission::query()->create([ 'codigo' => PermissionCode::ScanTickets->value, 'nombre' => 'Escanear tickets', ]); $role->permissions()->attach($permission->codigo); $tenant = $this->createTenant(); $user = User::factory()->create([ 'email' => 'scanner@example.com', 'password' => Hash::make('secret123'), 'rol_codigo' => $role->codigo, 'tenant_codigo' => $tenant->codigo, ]); $response = $this->postJson('/api/v1/scanner/login', [ 'email' => ' SCANNER@EXAMPLE.COM ', 'password' => 'secret123', ]); $response ->assertOk() ->assertJsonPath('user.id', $user->id) ->assertJsonPath('user.rol_codigo', RoleCode::Scanner->value) ->assertJsonPath('token_type', 'Bearer'); $this->assertSame(['scanner'], $user->tokens()->sole()->abilities); } public function test_it_rejects_adminapp_credentials_even_when_the_role_has_scan_permission(): void { $role = Role::query()->create([ 'codigo' => RoleCode::AdminApp->value, 'nombre' => 'Administrador', ]); $permission = Permission::query()->create([ 'codigo' => PermissionCode::ScanTickets->value, 'nombre' => 'Escanear tickets', ]); $role->permissions()->attach($permission->codigo); $tenant = $this->createTenant(); $adminApp = User::factory()->create([ 'email' => 'shared@example.com', 'password' => Hash::make('admin-password'), 'rol_codigo' => $role->codigo, 'tenant_codigo' => $tenant->codigo, ]); $this->postJson('/api/v1/scanner/login', [ 'email' => $adminApp->email, 'password' => 'admin-password', 'rol_codigo' => RoleCode::AdminApp->value, ])->assertUnprocessable()->assertJsonValidationErrors(['email']); $this->assertDatabaseCount('personal_access_tokens', 0); } public function test_shared_email_authenticates_the_scanner_identity_only(): void { $scannerRole = Role::query()->create([ 'codigo' => RoleCode::Scanner->value, 'nombre' => 'Scanner', ]); $adminAppRole = Role::query()->create([ 'codigo' => RoleCode::AdminApp->value, 'nombre' => 'Administrador', ]); $permission = Permission::query()->create([ 'codigo' => PermissionCode::ScanTickets->value, 'nombre' => 'Escanear tickets', ]); $scannerRole->permissions()->attach($permission->codigo); $tenant = $this->createTenant(); User::factory()->create([ 'email' => 'shared@example.com', 'password' => Hash::make('admin-password'), 'rol_codigo' => $adminAppRole->codigo, 'tenant_codigo' => $tenant->codigo, ]); $scanner = User::factory()->create([ 'email' => 'shared@example.com', 'password' => Hash::make('scanner-password'), 'rol_codigo' => $scannerRole->codigo, 'tenant_codigo' => $tenant->codigo, ]); $this->postJson('/api/v1/scanner/login', [ 'email' => 'shared@example.com', 'password' => 'scanner-password', ]) ->assertOk() ->assertJsonPath('user.id', $scanner->id) ->assertJsonPath('user.rol_codigo', RoleCode::Scanner->value); } public function test_it_rejects_a_user_without_scan_permission(): void { $role = Role::query()->create([ 'codigo' => RoleCode::Scanner->value, 'nombre' => 'Scanner sin permiso', ]); $tenant = $this->createTenant(); $user = User::factory()->create([ 'email' => 'customer@example.com', 'password' => Hash::make('secret123'), 'rol_codigo' => $role->codigo, 'tenant_codigo' => $tenant->codigo, ]); $this->postJson('/api/v1/scanner/login', [ 'email' => $user->email, 'password' => 'secret123', ])->assertUnprocessable()->assertJsonValidationErrors(['email']); } public function test_an_invalid_password_is_recorded_with_the_users_tenant(): void { $role = Role::query()->create([ 'codigo' => RoleCode::Scanner->value, 'nombre' => 'Scanner', ]); $permission = Permission::query()->create([ 'codigo' => PermissionCode::ScanTickets->value, 'nombre' => 'Escanear tickets', ]); $role->permissions()->attach($permission->codigo); $tenant = $this->createTenant(); $user = User::factory()->create([ 'email' => 'scanner@example.com', 'password' => Hash::make('correct-password'), 'rol_codigo' => $role->codigo, 'tenant_codigo' => $tenant->codigo, ]); $this->postJson('/api/v1/scanner/login', [ 'email' => $user->email, 'password' => 'wrong-password', ])->assertUnprocessable()->assertJsonValidationErrors(['email']); $this->assertSame(1, $user->refresh()->failed_login_attempts); $this->assertDatabaseHas('login_attempts', [ 'user_id' => $user->id, 'tenant_codigo' => $tenant->codigo, 'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, ]); } public function test_locking_a_scanner_sends_the_scanner_password_reset_flow(): void { Event::fake([PasswordResetRequested::class]); config([ 'login-security.max_attempts' => 3, 'login-security.rate_limit_per_minute' => 100, 'login-security.ip_rate_limit_per_minute' => 100, ]); $role = Role::query()->create([ 'codigo' => RoleCode::Scanner->value, 'nombre' => 'Scanner', ]); $permission = Permission::query()->create([ 'codigo' => PermissionCode::ScanTickets->value, 'nombre' => 'Escanear tickets', ]); $role->permissions()->attach($permission->codigo); $tenant = $this->createTenant(); $user = User::factory()->create([ 'email' => 'scanner@example.com', 'password' => Hash::make('correct-password'), 'rol_codigo' => $role->codigo, 'tenant_codigo' => $tenant->codigo, ]); $payload = [ 'email' => $user->email, 'password' => 'wrong-password', ]; $this->postJson('/api/v1/scanner/login', $payload)->assertUnprocessable(); $this->postJson('/api/v1/scanner/login', $payload)->assertUnprocessable(); $this->postJson('/api/v1/scanner/login', $payload)->assertTooManyRequests(); $attempt = $user->resetPasswordAttempts()->sole(); $this->assertSame(ResetPasswordAttempt::REASON_ACCOUNT_LOCKED, $attempt->reason); Event::assertDispatched( PasswordResetRequested::class, fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id && $event->tenantCode === $tenant->codigo && $event->channel === PasswordResetRequested::CHANNEL_SCANNER, ); } private function createTenant(): Tenant { $logo = Attachment::query()->create([ 'key' => (string) Str::uuid(), 'path' => 'tests/scanner-login-logo.png', 'filename' => 'scanner-login-logo.png', 'type' => 'image', 'mime_type' => 'image/png', 'extension' => 'png', 'size' => 1, ]); return Tenant::query()->create([ 'codigo' => 'acme', 'nombre' => 'Acme', 'dominio' => 'acme.test', 'primary_color' => '#000000', 'secondary_color' => '#000000', 'danger_color' => '#000000', 'header_bg_color' => '#000000', 'footer_bg_color' => '#000000', 'header_logo_id' => $logo->id, 'footer_logo_id' => $logo->id, ]); } }