feat: Réinitialisation de mot de passe avec tokens sécurisés
Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5): Backend: - Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique - Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP) - Endpoint POST /api/password/reset avec validation token - Templates email (demande + confirmation) - Repository Redis avec TTL 2h pour distinguer expiré/invalide Frontend: - Page /mot-de-passe-oublie avec message générique (anti-énumération) - Page /reset-password/[token] avec validation temps réel des critères - Gestion erreurs: token invalide, expiré, déjà utilisé Tests: - 14 tests unitaires PasswordResetToken - 7 tests unitaires RequestPasswordResetHandler - 7 tests unitaires ResetPasswordHandler - Tests E2E Playwright pour le flux complet
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Administration\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* Tests that password reset endpoints are accessible without authentication.
|
||||
*
|
||||
* These endpoints MUST be public because users who forgot their password
|
||||
* cannot authenticate to request a reset.
|
||||
*/
|
||||
final class PasswordResetEndpointsTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* Opt-in for API Platform 5.0 behavior where kernel boot is explicit.
|
||||
*
|
||||
* @see https://github.com/api-platform/core/issues/6971
|
||||
*/
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
#[Test]
|
||||
public function passwordForgotEndpointIsAccessibleWithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$response = $client->request('POST', '/api/password/forgot', [
|
||||
'json' => ['email' => 'test@example.com'],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// Should NOT return 401 Unauthorized
|
||||
// It should return 200 (success) or 429 (rate limited), but never 401
|
||||
self::assertNotEquals(401, $response->getStatusCode(), 'Password forgot endpoint should be accessible without JWT');
|
||||
|
||||
// The endpoint always returns success to prevent email enumeration
|
||||
// Even for non-existent emails
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function passwordResetEndpointIsAccessibleWithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$response = $client->request('POST', '/api/password/reset', [
|
||||
'json' => [
|
||||
'token' => 'invalid-token-for-test',
|
||||
'password' => 'NewSecurePassword123!',
|
||||
],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// Should NOT return 401 Unauthorized
|
||||
// It should return 400 (invalid token) or 410 (expired), but never 401
|
||||
self::assertNotEquals(401, $response->getStatusCode(), 'Password reset endpoint should be accessible without JWT');
|
||||
|
||||
// With an invalid token, we expect a 400 Bad Request
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function passwordForgotValidatesEmailFormat(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$response = $client->request('POST', '/api/password/forgot', [
|
||||
'json' => ['email' => 'not-an-email'],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// Invalid email format returns validation error (422)
|
||||
// This is acceptable because it doesn't reveal user existence
|
||||
// (format validation happens before checking if user exists)
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function passwordResetValidatesPasswordRequirements(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// Password too short
|
||||
$response = $client->request('POST', '/api/password/reset', [
|
||||
'json' => [
|
||||
'token' => 'some-token',
|
||||
'password' => 'short',
|
||||
],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// Should return 422 Unprocessable Entity for validation errors
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user