feat(scanner): implement password reset attempt functionality for scanner users

This commit is contained in:
2026-08-13 16:44:27 -03:00
parent e33ebf0af1
commit 630b49cad7
8 changed files with 253 additions and 3 deletions

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ScannerCreateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class CreateScannerResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
public function __invoke(ScannerCreateResetPasswordAttemptRequest $request): JsonResponse
{
$this->resetPasswordAttemptService->createForScannerEmail(
$request->validated('email'),
);
return response()->json([
'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class ScannerCreateResetPasswordAttemptRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge(['email' => Str::lower(trim($email))]);
}
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
];
}
}

View File

@@ -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);

View File

@@ -1,11 +1,20 @@
<?php
use App\Domains\Auth\Controllers\CreateScannerResetPasswordAttemptController;
use App\Domains\Auth\Controllers\ResetPasswordController;
use App\Domains\Auth\Controllers\ScannerLoginController;
use App\Domains\Auth\Controllers\ScannerMeController;
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/scanner')->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);
});

View File

@@ -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,

View File

@@ -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);

View File

@@ -0,0 +1,100 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class ScannerResetPasswordControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_an_attempt_for_a_tenant_bound_scanner_user(): 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' => ' 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));
}
}

View File

@@ -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([