From 630b49cad7c7b9942598304d2873adb4c70fb275 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 13 Aug 2026 16:44:27 -0300 Subject: [PATCH] feat(scanner): implement password reset attempt functionality for scanner users --- ...eScannerResetPasswordAttemptController.php | 29 +++++ ...annerCreateResetPasswordAttemptRequest.php | 31 ++++++ .../Services/ResetPasswordAttemptService.php | 46 ++++++++ app/Domains/Auth/routes/scanner.php | 9 ++ .../Events/PasswordResetRequested.php | 2 + .../Services/NotificationMailService.php | 8 +- .../ScannerResetPasswordControllerTest.php | 100 ++++++++++++++++++ .../NotificationMailServiceTest.php | 31 ++++++ 8 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 app/Domains/Auth/Controllers/CreateScannerResetPasswordAttemptController.php create mode 100644 app/Domains/Auth/Requests/ScannerCreateResetPasswordAttemptRequest.php create mode 100644 tests/Feature/Auth/ScannerResetPasswordControllerTest.php diff --git a/app/Domains/Auth/Controllers/CreateScannerResetPasswordAttemptController.php b/app/Domains/Auth/Controllers/CreateScannerResetPasswordAttemptController.php new file mode 100644 index 0000000..c6ad46e --- /dev/null +++ b/app/Domains/Auth/Controllers/CreateScannerResetPasswordAttemptController.php @@ -0,0 +1,29 @@ +resetPasswordAttemptService->createForScannerEmail( + $request->validated('email'), + ); + + return response()->json([ + 'code' => 'auth.password_reset_requested', + 'message' => __('api.auth.password_reset_requested'), + 'status' => ResetPasswordAttempt::STATUS_PENDING, + ], 202); + } +} diff --git a/app/Domains/Auth/Requests/ScannerCreateResetPasswordAttemptRequest.php b/app/Domains/Auth/Requests/ScannerCreateResetPasswordAttemptRequest.php new file mode 100644 index 0000000..16ce44d --- /dev/null +++ b/app/Domains/Auth/Requests/ScannerCreateResetPasswordAttemptRequest.php @@ -0,0 +1,31 @@ +input('email'); + + if (is_string($email)) { + $this->merge(['email' => Str::lower(trim($email))]); + } + } + + /** @return array */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + ]; + } +} diff --git a/app/Domains/Auth/Services/ResetPasswordAttemptService.php b/app/Domains/Auth/Services/ResetPasswordAttemptService.php index 6d9e8bb..816f64d 100644 --- a/app/Domains/Auth/Services/ResetPasswordAttemptService.php +++ b/app/Domains/Auth/Services/ResetPasswordAttemptService.php @@ -93,6 +93,52 @@ class ResetPasswordAttemptService ); } + public function createForScannerEmail(string $email, string $reason = 'manual'): void + { + $emailFingerprint = $this->emailFingerprint($email); + + try { + $result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array { + $user = User::query() + ->where('email', $email) + ->where('rol_codigo', RoleCode::Scanner->value) + ->whereNotNull('tenant_codigo') + ->lockForUpdate() + ->first(); + + $attemptId = $this->createAttemptForUser( + $user, + $reason, + $emailFingerprint, + 'Scanner password reset attempt was not created because the user was not found.', + ); + + if ($user === null || $attemptId === null) { + return null; + } + + return [ + 'attempt_id' => $attemptId, + 'tenant_code' => $user->tenant_codigo, + ]; + }); + } catch (Throwable $exception) { + Log::error('Failed to create Scanner password reset attempt.', [ + 'email_fingerprint' => $emailFingerprint, + 'exception' => $exception, + ]); + + throw $exception; + } + + $this->dispatchPasswordResetRequested( + $result['attempt_id'] ?? null, + $result['tenant_code'] ?? null, + PasswordResetRequested::CHANNEL_SCANNER, + $emailFingerprint, + ); + } + public function validateCode(string $email, string $code): bool { $emailFingerprint = $this->emailFingerprint($email); diff --git a/app/Domains/Auth/routes/scanner.php b/app/Domains/Auth/routes/scanner.php index d4894eb..2688eb0 100644 --- a/app/Domains/Auth/routes/scanner.php +++ b/app/Domains/Auth/routes/scanner.php @@ -1,11 +1,20 @@ group(function (): void { Route::post('login', ScannerLoginController::class)->middleware('throttle:login'); + Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class) + ->middleware('throttle:5,1'); + Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class) + ->middleware('throttle:10,1'); + Route::post('password/reset', ResetPasswordController::class) + ->middleware('throttle:5,1'); Route::middleware(['auth:sanctum', 'scanner.tenant']) ->get('me', ScannerMeController::class); }); diff --git a/app/Domains/Notification/Events/PasswordResetRequested.php b/app/Domains/Notification/Events/PasswordResetRequested.php index a9d9e5f..3a22e15 100644 --- a/app/Domains/Notification/Events/PasswordResetRequested.php +++ b/app/Domains/Notification/Events/PasswordResetRequested.php @@ -13,6 +13,8 @@ class PasswordResetRequested public const CHANNEL_ADMINAPP = 'adminapp'; + public const CHANNEL_SCANNER = 'scanner'; + public function __construct( public readonly int $attemptId, public readonly string $tenantCode, diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index e958705..f0c06d1 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -56,9 +56,11 @@ class NotificationMailService return; } - $recoveryDomain = $channel === PasswordResetRequested::CHANNEL_ADMINAPP - ? $tenant->websiteType?->dominio - : $tenant->dominio; + $recoveryDomain = match ($channel) { + PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, + PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, + default => $tenant->dominio, + }; $recoveryUrl = $recoveryDomain === null ? null : 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?email='.urlencode($attempt->user->email); diff --git a/tests/Feature/Auth/ScannerResetPasswordControllerTest.php b/tests/Feature/Auth/ScannerResetPasswordControllerTest.php new file mode 100644 index 0000000..a8e80d6 --- /dev/null +++ b/tests/Feature/Auth/ScannerResetPasswordControllerTest.php @@ -0,0 +1,100 @@ +create([ + 'codigo' => RoleCode::Scanner->value, + 'nombre' => 'Scanner', + ]); + $tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'store.acme.test', + ]); + $user = User::factory()->create([ + 'email' => 'scanner@example.com', + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $tenant->codigo, + ]); + + $this->postJson('/api/v1/scanner/password/reset-attempts', [ + 'email' => ' SCANNER@EXAMPLE.COM ', + ])->assertAccepted()->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING); + + $attempt = $user->resetPasswordAttempts()->sole(); + Event::assertDispatched( + PasswordResetRequested::class, + fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id + && $event->tenantCode === $tenant->codigo + && $event->channel === PasswordResetRequested::CHANNEL_SCANNER, + ); + } + + public function test_it_does_not_create_an_attempt_for_a_non_scanner_user(): void + { + Event::fake([PasswordResetRequested::class]); + + $this->postJson('/api/v1/scanner/password/reset-attempts', [ + 'email' => User::factory()->create()->email, + ])->assertAccepted()->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING); + + $this->assertDatabaseCount('reset_password_attempts', 0); + Event::assertNotDispatched(PasswordResetRequested::class); + } + + public function test_scanner_can_complete_the_password_reset_flow(): void + { + Event::fake([PasswordResetRequested::class]); + Role::query()->create([ + 'codigo' => RoleCode::Scanner->value, + 'nombre' => 'Scanner', + ]); + $tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'store.acme.test', + ]); + $user = User::factory()->create([ + 'email' => 'scanner@example.com', + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $tenant->codigo, + ]); + + $this->postJson('/api/v1/scanner/password/reset-attempts', [ + 'email' => $user->email, + ])->assertAccepted(); + + $attempt = $user->resetPasswordAttempts()->sole(); + $this->postJson('/api/v1/scanner/password/reset-attempts/validate', [ + 'email' => $user->email, + 'codigo' => $attempt->codigo, + ])->assertOk()->assertJsonPath('status', ResetPasswordAttempt::STATUS_VALIDATED); + + $this->postJson('/api/v1/scanner/password/reset', [ + 'email' => $user->email, + 'codigo' => $attempt->codigo, + 'password' => 'NewScanner!123', + 'password_confirmation' => 'NewScanner!123', + ])->assertOk()->assertJsonPath('status', ResetPasswordAttempt::STATUS_USED); + + $this->assertTrue(Hash::check('NewScanner!123', $user->fresh()->password)); + } +} diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index fdf699f..c79905b 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -7,9 +7,11 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Integration\Models\Integration; +use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Mail\Mailable; @@ -103,6 +105,35 @@ class NotificationMailServiceTest extends TestCase }); } + public function test_it_links_scanner_password_resets_to_the_scanner_domain(): void + { + $websiteType = WebsiteType::query()->create([ + 'codigo' => 'scanner-mail', + 'nombre' => 'Scanner Mail', + 'dominio' => 'admin.mail.local', + 'scanner_domain' => 'scanner.mail.local', + ]); + $this->tenant->update(['website_type_code' => $websiteType->codigo]); + $attempt = $this->user->resetPasswordAttempts()->create([ + 'codigo' => '0123', + ]); + + app(NotificationMailService::class)->sendPasswordResetCode( + $attempt->id, + $this->tenant->codigo, + PasswordResetRequested::CHANNEL_SCANNER, + ); + + Mail::assertSent(Mailable::class, function (Mailable $mail): bool { + $rendered = $mail->render(); + + return str_contains( + $rendered, + 'https://scanner.mail.local/recuperar-contrasena/codigo?email=ada%40example.com', + ); + }); + } + public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void { $purchase = Purchase::query()->create([