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
107 lines
3.4 KiB
PHP
107 lines
3.4 KiB
PHP
<?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);
|
|
}
|
|
}
|