Files
Classeo/backend/tests/Functional/Administration/Api/PasswordResetEndpointsTest.php
Mathias STRASSER affad287f9 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
2026-02-02 09:45:15 +01:00

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