diff --git a/app/Domains/Administrator/Requests/StoreAdministratorRequest.php b/app/Domains/Administrator/Requests/StoreAdministratorRequest.php index 529a181..9d49afd 100644 --- a/app/Domains/Administrator/Requests/StoreAdministratorRequest.php +++ b/app/Domains/Administrator/Requests/StoreAdministratorRequest.php @@ -31,7 +31,7 @@ class StoreAdministratorRequest extends FormRequest 'required', 'email', 'max:255', - Rule::unique('users', 'email')->whereNull('deleted_at'), + Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)->whereNull('deleted_at'), ], ]; } diff --git a/app/Domains/Administrator/Requests/UpdateAdministratorRequest.php b/app/Domains/Administrator/Requests/UpdateAdministratorRequest.php index 6750789..9b95a46 100644 --- a/app/Domains/Administrator/Requests/UpdateAdministratorRequest.php +++ b/app/Domains/Administrator/Requests/UpdateAdministratorRequest.php @@ -32,7 +32,7 @@ class UpdateAdministratorRequest extends FormRequest 'required', 'email', 'max:255', - Rule::unique('users', 'email') + Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value) ->whereNull('deleted_at') ->ignore($administratorId), ], diff --git a/app/Domains/Auth/Requests/RegisterUserRequest.php b/app/Domains/Auth/Requests/RegisterUserRequest.php index 5acfb09..155f7b8 100644 --- a/app/Domains/Auth/Requests/RegisterUserRequest.php +++ b/app/Domains/Auth/Requests/RegisterUserRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Auth\Requests; +use App\Domains\Authorization\Enums\RoleCode; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; @@ -13,9 +14,14 @@ class RegisterUserRequest extends FormRequest return true; } - /** - * @return array - */ + protected function prepareForValidation(): void + { + if (is_string($this->input('email'))) { + $this->merge(['email' => mb_strtolower(trim($this->input('email')))]); + } + } + + /** @return array */ public function rules(): array { return [ @@ -26,7 +32,7 @@ class RegisterUserRequest extends FormRequest 'string', 'email', 'max:255', - Rule::unique('users', 'email')->whereNull('deleted_at'), + Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::User->value)->whereNull('deleted_at'), ], 'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()], 'dni' => ['nullable', 'string', 'max:255'], diff --git a/app/Domains/Auth/Requests/UpdateProfileRequest.php b/app/Domains/Auth/Requests/UpdateProfileRequest.php index a4984d7..f0a88cb 100644 --- a/app/Domains/Auth/Requests/UpdateProfileRequest.php +++ b/app/Domains/Auth/Requests/UpdateProfileRequest.php @@ -13,6 +13,13 @@ class UpdateProfileRequest extends FormRequest return true; } + protected function prepareForValidation(): void + { + if (is_string($this->input('email'))) { + $this->merge(['email' => mb_strtolower(trim($this->input('email')))]); + } + } + public function rules(): array { return [ @@ -20,7 +27,7 @@ class UpdateProfileRequest extends FormRequest 'email' => [ 'required', 'email', - Rule::unique('users', 'email') + Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo) ->whereNull('deleted_at') ->ignore($this->user()->id), ], diff --git a/app/Domains/Staff/Requests/StoreStaffRequest.php b/app/Domains/Staff/Requests/StoreStaffRequest.php index b592e4a..4237318 100644 --- a/app/Domains/Staff/Requests/StoreStaffRequest.php +++ b/app/Domains/Staff/Requests/StoreStaffRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Staff\Requests; +use App\Domains\Authorization\Enums\RoleCode; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -12,6 +13,13 @@ class StoreStaffRequest extends FormRequest return true; } + protected function prepareForValidation(): void + { + if (is_string($this->input('email'))) { + $this->merge(['email' => mb_strtolower(trim($this->input('email')))]); + } + } + /** @return array */ public function rules(): array { @@ -27,7 +35,7 @@ class StoreStaffRequest extends FormRequest 'required', 'email', 'max:255', - Rule::unique('users', 'email')->whereNull('deleted_at'), + Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value)->whereNull('deleted_at'), ], 'category_ids' => $categoryRules, 'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')], diff --git a/app/Domains/Staff/Requests/UpdateStaffRequest.php b/app/Domains/Staff/Requests/UpdateStaffRequest.php index 19e1a94..fcf6e3f 100644 --- a/app/Domains/Staff/Requests/UpdateStaffRequest.php +++ b/app/Domains/Staff/Requests/UpdateStaffRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Staff\Requests; +use App\Domains\Authorization\Enums\RoleCode; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -12,6 +13,13 @@ class UpdateStaffRequest extends FormRequest return true; } + protected function prepareForValidation(): void + { + if (is_string($this->input('email'))) { + $this->merge(['email' => mb_strtolower(trim($this->input('email')))]); + } + } + /** @return array */ public function rules(): array { @@ -28,7 +36,7 @@ class UpdateStaffRequest extends FormRequest 'required', 'email', 'max:255', - Rule::unique('users', 'email') + Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value) ->whereNull('deleted_at') ->ignore($staffId), ], diff --git a/database/migrations/2026_09_04_000000_scope_active_email_unique_per_role.php b/database/migrations/2026_09_04_000000_scope_active_email_unique_per_role.php new file mode 100644 index 0000000..557a0f7 --- /dev/null +++ b/database/migrations/2026_09_04_000000_scope_active_email_unique_per_role.php @@ -0,0 +1,24 @@ +unique(['active_email', 'rol_codigo']); + $table->dropUnique(['active_email']); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->unique('active_email'); + $table->dropUnique(['active_email', 'rol_codigo']); + }); + } +}; diff --git a/tests/Feature/Administrator/AdministratorControllerTest.php b/tests/Feature/Administrator/AdministratorControllerTest.php index ddd160e..407592a 100644 --- a/tests/Feature/Administrator/AdministratorControllerTest.php +++ b/tests/Feature/Administrator/AdministratorControllerTest.php @@ -60,6 +60,17 @@ class AdministratorControllerTest extends TestCase return ['nombre_apellido' => 'Ada Lovelace', 'dni' => '12345678', 'email' => 'ada@example.test']; } + public function test_email_can_be_shared_with_customers_and_scanners(): void + { + Sanctum::actingAs($this->admin); + foreach (['user', 'scanner'] as $role) { + User::factory()->create(['email' => 'ada@example.test', 'rol_codigo' => $role]); + } + $response = $this->postJson(self::URL, $this->payload())->assertCreated(); + $this->putJson(self::URL.'/'.$response->json('data.id'), $this->payload())->assertOk(); + $this->postJson(self::URL, $this->payload())->assertUnprocessable()->assertJsonValidationErrors('email'); + } + public function test_crud_and_password_setup_and_token_revocation(): void { Sanctum::actingAs($this->admin); diff --git a/tests/Feature/Auth/EmailUniquenessPerRoleTest.php b/tests/Feature/Auth/EmailUniquenessPerRoleTest.php new file mode 100644 index 0000000..a4e9d8d --- /dev/null +++ b/tests/Feature/Auth/EmailUniquenessPerRoleTest.php @@ -0,0 +1,133 @@ +withoutMiddleware(ThrottleRequests::class); + $this->seed(AuthorizationSeeder::class); + Event::fake([PasswordResetRequested::class]); + } + + public function test_database_allows_different_roles_and_reuse_after_soft_delete(): void + { + foreach (RoleCode::cases() as $role) { + User::factory()->create(['email' => 'Shared@example.com', 'rol_codigo' => $role->value]); + } + $user = User::where('rol_codigo', 'user')->sole(); + $user->delete(); + $replacement = User::factory()->create(['email' => 'shared@example.com']); + $this->assertNotSame($user->id, $replacement->id); + $this->assertSame(4, User::where('active_email', 'shared@example.com')->count()); + } + + public function test_database_rejects_same_role_case_insensitively_across_tenants(): void + { + foreach (['one', 'two'] as $code) { + $this->createTenant($code); + } + User::factory()->create(['email' => 'Shared@example.com', 'tenant_codigo' => 'one']); + $this->expectException(UniqueConstraintViolationException::class); + User::factory()->create(['email' => 'shared@example.com', 'tenant_codigo' => 'two']); + } + + public function test_role_change_cannot_create_a_duplicate_active_identity(): void + { + User::factory()->create(['email' => 'shared@example.com']); + $admin = User::factory()->create(['email' => 'shared@example.com', 'rol_codigo' => 'adminapp']); + $this->expectException(UniqueConstraintViolationException::class); + $admin->update(['rol_codigo' => 'user']); + } + + public function test_registration_accepts_another_role_but_rejects_same_role(): void + { + User::factory()->create(['email' => 'SHARED@example.com', 'rol_codigo' => 'adminapp']); + $payload = ['nombre_apellido' => 'Shared', 'email' => ' Shared@Example.com ', + 'password' => 'Secret!123', 'password_confirmation' => 'Secret!123']; + $this->postJson('/api/register', $payload)->assertCreated()->assertJsonPath('data.email', 'shared@example.com'); + $this->postJson('/api/register', $payload)->assertUnprocessable()->assertJsonValidationErrors('email'); + } + + public function test_login_and_password_reset_select_the_role_from_each_application(): void + { + $tenant = $this->createTenant('acme'); + $users = []; + // Create staff first so an email-only lookup would select the wrong account. + foreach (['adminapp', 'scanner', 'user'] as $role) { + $users[$role] = User::factory()->create([ + 'email' => 'Shared@example.com', 'rol_codigo' => $role, + 'tenant_codigo' => $tenant->codigo, 'password' => 'Old!'.$role, + ]); + } + foreach (['user' => '/api', 'adminapp' => '/api/v1/adminapp', 'scanner' => '/api/v1/scanner'] as $role => $base) { + $this->postJson($base.'/login', [ + 'email' => 'SHARED@example.com', 'password' => 'Old!'.$role, 'tenant_codigo' => 'acme', + ])->assertOk()->assertJsonPath('user.id', $users[$role]->id); + $this->postJson($base.'/password/reset-attempts', [ + 'email' => 'shared@example.com', 'tenant_codigo' => 'acme', + ])->assertAccepted(); + } + // Identical codes across roles must still only change the intended account. + ResetPasswordAttempt::query()->update(['codigo' => '1234']); + foreach (['user' => '/api', 'adminapp' => '/api/v1/adminapp', 'scanner' => '/api/v1/scanner'] as $role => $base) { + $this->postJson($base.'/password/reset-attempts/validate', [ + 'email' => 'shared@example.com', 'codigo' => '1234', + ])->assertOk(); + $this->postJson($base.'/password/reset', [ + 'email' => 'shared@example.com', 'codigo' => '1234', + 'password' => 'New!'.$role, 'password_confirmation' => 'New!'.$role, + ])->assertOk(); + $this->assertTrue(Hash::check('New!'.$role, $users[$role]->fresh()->password)); + } + } + + 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', + ]); + } +} diff --git a/tests/Feature/Staff/StaffControllerTest.php b/tests/Feature/Staff/StaffControllerTest.php index 194cd35..9f4ebd0 100644 --- a/tests/Feature/Staff/StaffControllerTest.php +++ b/tests/Feature/Staff/StaffControllerTest.php @@ -56,6 +56,17 @@ class StaffControllerTest extends TestCase ]); } + public function test_staff_email_can_be_shared_with_adminapp_and_customers(): void + { + Sanctum::actingAs($this->admin); + User::factory()->create(['email' => $this->admin->email]); + $payload = ['nombre_apellido' => 'Shared', 'dni' => '12345678', + 'email' => strtoupper($this->admin->email), 'category_ids' => [$this->createCategory('Tickets')->id]]; + $response = $this->postJson('/api/v1/adminapp/tenant/staff', $payload)->assertSuccessful(); + $this->putJson('/api/v1/adminapp/tenant/staff/'.$response->json('data.id'), $payload)->assertOk(); + $this->postJson('/api/v1/adminapp/tenant/staff', $payload)->assertUnprocessable()->assertJsonValidationErrors('email'); + } + public function test_adminapp_can_create_update_list_and_delete_staff_with_categories(): void { Sanctum::actingAs($this->admin);