corentin_wakdo/tests/Unit/Auth/PasswordResetControllerTest.php
Imugiii 5835be0e66 feat(auth): authentification back-office (login, logout, reinitialisation mot de passe)
Implemente mlt.md section 12 : AUTHENTICATE_USER (12.1), LOGOUT_USER (12.2),
RESET_PASSWORD (12.3). Sessions PHP + argon2id, regeneration d'ID a la connexion,
idle 4h / absolu 10h via SessionGuard (cable en P3), jeton CSRF synchroniseur, backoff
degressif anti brute-force par compte et par IP source (login_throttle), audit_log
append-only (login_success/failed, password_reset), defenses anti-enumeration d'email
(timing + profil d'ecritures identique), fail-closed sur erreur base. Vues login/forgot/reset
rendues serveur. Routes posees sur le vhost admin (pas de prefixe /admin : docroot =
public/admin). PHPUnit sans Composer (unit + integration DB auto-skippee sans base) et
PHPStan L6 restent verts.
2026-06-15 18:15:32 +00:00

175 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\Csrf;
use App\Auth\PasswordHasher;
use App\Auth\PasswordResetService;
use App\Auth\SessionManager;
use App\Controllers\PasswordResetController;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
use App\Tests\Support\FakeDatabase;
use App\Tests\Support\SpyMailer;
/**
* Sous-classe de test : injecte session test, FakeDatabase et SpyMailer dans le
* controleur de reinitialisation.
*/
final class TestPasswordResetController extends PasswordResetController
{
public function __construct(
Request $request,
Config $config,
Database $database,
private readonly SessionManager $testSession,
private readonly FakeDatabase $fakeDb,
private readonly SpyMailer $spyMailer,
) {
parent::__construct($request, $config, $database);
}
protected function sessionManager(): SessionManager
{
return $this->testSession;
}
protected function resetService(): PasswordResetService
{
return new PasswordResetService($this->fakeDb, $this->config, new PasswordHasher($this->config), $this->spyMailer);
}
}
final class PasswordResetControllerTest extends TestCase
{
/** @var list<string> */
private array $touchedKeys = [];
protected function setUp(): void
{
$this->setEnv('PASSWORD_RESET_TTL', '3600');
$this->setEnv('APP_URL_ADMIN', 'https://admin.wakdo.test');
$this->setEnv('ARGON2_MEMORY_COST', '1024');
$this->setEnv('ARGON2_TIME_COST', '1');
$this->setEnv('ARGON2_THREADS', '1');
}
protected function tearDown(): void
{
foreach ($this->touchedKeys as $key) {
putenv($key);
}
$this->touchedKeys = [];
}
private function setEnv(string $key, string $value): void
{
$this->touchedKeys[] = $key;
putenv($key . '=' . $value);
}
/**
* @param array<string, string> $form
*/
private function post(array $form, string $path): Request
{
return new Request(
'POST',
$path,
[],
['content-type' => 'application/x-www-form-urlencoded'],
http_build_query($form),
'203.0.113.5',
);
}
private function controller(
Request $request,
SessionManager $session,
FakeDatabase $db,
SpyMailer $mailer,
): TestPasswordResetController {
return new TestPasswordResetController($request, new Config(), new Database(new Config()), $session, $db, $mailer);
}
public function testShowRequestRendersCsrfField(): void
{
$session = new SessionManager(new Config(), true);
$request = new Request('GET', '/forgot_password', [], [], '', '203.0.113.5');
$response = $this->controller($request, $session, new FakeDatabase(), new SpyMailer())->showRequest();
self::assertSame(200, $response->status());
self::assertStringContainsString('name="_csrf"', $response->body());
}
public function testSubmitRequestRejectsInvalidCsrf(): void
{
$session = new SessionManager(new Config(), true);
Csrf::token($session);
$mailer = new SpyMailer();
$request = $this->post(['_csrf' => 'wrong', 'email' => 'admin@wakdo.local'], '/forgot_password');
$response = $this->controller($request, $session, new FakeDatabase(), $mailer)->submitRequest();
self::assertSame(403, $response->status());
self::assertSame([], $mailer->sent);
}
public function testSubmitRequestUnknownEmailIsNeutralAndSilent(): void
{
$session = new SessionManager(new Config(), true);
$token = Csrf::token($session);
$db = new FakeDatabase();
$db->emailLookupRow = null;
$mailer = new SpyMailer();
$request = $this->post(['_csrf' => $token, 'email' => 'ghost@wakdo.local'], '/forgot_password');
$response = $this->controller($request, $session, $db, $mailer)->submitRequest();
self::assertSame(200, $response->status());
self::assertStringContainsString('Si un compte', $response->body());
self::assertSame([], $mailer->sent);
self::assertSame([], $db->writes);
}
public function testSubmitConfirmPasswordMismatchRendersError(): void
{
$session = new SessionManager(new Config(), true);
$token = Csrf::token($session);
$request = $this->post([
'_csrf' => $token,
'token' => 'raw-token',
'password' => 'longenough1',
'password_confirm' => 'different01',
], '/reset_password');
$response = $this->controller($request, $session, new FakeDatabase(), new SpyMailer())->submitConfirm();
self::assertSame(200, $response->status());
self::assertStringContainsString('ne correspondent pas', $response->body());
}
public function testSubmitConfirmValidTokenRedirectsToLogin(): void
{
$session = new SessionManager(new Config(), true);
$token = Csrf::token($session);
$db = new FakeDatabase();
$db->resetUserRow = ['id' => 7, 'role_id' => 3, 'password_reset_token_hash' => hash('sha256', 'raw-token')];
$request = $this->post([
'_csrf' => $token,
'token' => 'raw-token',
'password' => 'brandnewpassword',
'password_confirm' => 'brandnewpassword',
], '/reset_password');
$response = $this->controller($request, $session, $db, new SpyMailer())->submitConfirm();
self::assertSame(302, $response->status());
self::assertSame('/login?reset=ok', $response->header('Location'));
}
}