corentin_wakdo/tests/Unit/Auth/PinThrottleTest.php
Imugiii 5a4897921e
Some checks failed
CI / php-lint (push) Successful in 19s
CI / secret-scan (pull_request) Successful in 8s
CI / secret-scan (push) Successful in 10s
CI / static-tests (push) Successful in 30s
CI / php-lint (pull_request) Successful in 19s
CI / auto-merge (push) Has been skipped
CI / static-tests (pull_request) Successful in 30s
CI / auto-merge (pull_request) Failing after 4s
feat(admin): throttle du PIN d'action sensible par acteur (RG-T22)
Ferme le finding HIGH de la revue Produits (#17) : le PIN d'action sensible
etait verifie sans limitation de tentatives. Conception via panel multi-agents
(3 lentilles + synthese + passe adversariale, holds=true) puis revue de
l'implementation (holds=true).

Dimension du throttle = UTILISATEUR AGISSANT (identite de session, RG-T02), pas
l'email cible (contournable par rotation) ni l'IP (collateral sur poste partage).
Table dediee pin_throttle (entite 22) STRICTEMENT SEPAREE des compteurs de login
(user.failed_login_attempts / login_throttle) : un echec de PIN n'incremente aucun
compteur de connexion (pas d'escalade DoS vers le login).

- db/migrations/0002_pin_throttle.sql : table cle sur actor_user_id (UNIQUE, FK
  -> user ON DELETE CASCADE), separee du login. Appliquee a la base dev.
- ThrottlePolicy : dimension 'pin' (bornes propres PIN_THROTTLE_*, 30s..300s, plus
  permissives que le login : controle de dissuasion, residuel Faible).
- PinThrottle (nouveau) : isLocked / recordFailure (upsert atomique + backoff, une
  transaction, miroir d'AuthService) / reset (UPDATE simple). N'ecrit jamais
  user/login_throttle/audit_log.
- PinVerifier::payTimingDecoy : parite de timing du chemin verrouille.
- ProductController update/destroy : gate AVANT verification (leurre + 422
  generique, pas de pin.failed sous verrou actif = borne anti-flood de l'audit) ;
  recordFailure sur PIN faux ; reset sur succes, cle sur l'acteur de SESSION.
- Docs Merise 21 -> 22 entites : RG-T22 (mlt), entite 22 pin_throttle
  (mcd/mld/dictionary), couverture MCT 22/22 (mct).
- .env.example + docker-compose : PIN_THROTTLE_THRESHOLD/BASE/MAX/WINDOW.
- Journal RNCP : docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md.

Tests : 188 verts (525 assertions), PHPStan L6 propre.
2026-06-15 22:03:07 +00:00

169 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\PinThrottle;
use App\Core\Config;
use App\Tests\Support\FakeDatabase;
/**
* Throttle du PIN d'action sensible (RG-T22) avec un FakeDatabase. Verrouille les
* deux invariants de securite : (1) la dimension est l'utilisateur AGISSANT, et
* (2) aucun ecrit ne touche les compteurs de connexion (user / login_throttle) —
* un echec de PIN ne doit jamais verrouiller une CONNEXION (escalade de DoS).
*/
final class PinThrottleTest extends TestCase
{
/** @var list<string> */
private array $touchedKeys = [];
private FakeDatabase $db;
protected function setUp(): void
{
$this->setEnv('PIN_THROTTLE_THRESHOLD', '5');
$this->setEnv('PIN_THROTTLE_BASE_SECONDS', '30');
$this->setEnv('PIN_THROTTLE_MAX_SECONDS', '300');
$this->setEnv('PIN_THROTTLE_WINDOW_SECONDS', '900');
$this->db = new FakeDatabase();
}
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);
}
private function throttle(): PinThrottle
{
return new PinThrottle($this->db, new Config());
}
/**
* @return array{sql: string, params: array<string|int, mixed>}|null
*/
private function find(string $needle): ?array
{
foreach ($this->db->writes as $write) {
if (str_contains($write['sql'], $needle)) {
return $write;
}
}
return null;
}
private function assertNoLoginCounterTouched(): void
{
// Invariant dur RG-T22 : un echec de PIN ne touche JAMAIS les compteurs de
// connexion. Retirer cette separation ferait virer ce test au rouge.
foreach ($this->db->writes as $write) {
self::assertStringNotContainsString('failed_login_attempts', $write['sql']);
self::assertStringNotContainsString('login_throttle', $write['sql']);
self::assertStringNotContainsString('audit_log', $write['sql']);
}
}
public function testIsLockedTrueWhenLockoutInFuture(): void
{
$now = 1_000_000;
$this->db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', $now + 60);
self::assertTrue($this->throttle()->isLocked(9, $now));
}
public function testIsLockedFalseWhenNoLockOrPast(): void
{
$now = 1_000_000;
$this->db->pinThrottleLockoutUntil = null;
self::assertFalse($this->throttle()->isLocked(9, $now));
$this->db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', $now - 1);
self::assertFalse($this->throttle()->isLocked(9, $now));
}
public function testIsLockedFalseWhenNoActor(): void
{
// actorUserId <= 0 (pas de session derriere guard()) : non verrouille, et
// aucune lecture inutile (defensif).
self::assertFalse($this->throttle()->isLocked(0));
self::assertSame([], $this->db->reads);
}
public function testRecordFailureOneTransactionUpsertThenLockNoLoginState(): void
{
// Au seuil : le compteur relu vaut 5 -> backoff 30s -> verrou pose.
$this->db->pinThrottleAttempts = 5;
$this->throttle()->recordFailure(9, 1_000_000);
// Une seule transaction (RG-T08).
self::assertSame(['begin', 'commit'], $this->db->transactionEvents);
$upsert = $this->find('INSERT INTO pin_throttle');
self::assertNotNull($upsert);
self::assertStringContainsString('ON DUPLICATE KEY UPDATE', $upsert['sql']);
self::assertSame(9, $upsert['params']['uid'] ?? null);
$lock = $this->find('UPDATE pin_throttle SET lockout_until');
self::assertNotNull($lock);
self::assertSame(date('Y-m-d H:i:s', 1_000_000 + 30), $lock['params']['lock'] ?? null);
self::assertSame(9, $lock['params']['uid'] ?? null);
$this->assertNoLoginCounterTouched();
}
public function testRecordFailureBelowThresholdSetsNoLock(): void
{
$this->db->pinThrottleAttempts = 1; // sous le seuil 5
$this->throttle()->recordFailure(9, 1_000_000);
$lock = $this->find('UPDATE pin_throttle SET lockout_until');
self::assertNotNull($lock);
self::assertArrayHasKey('lock', $lock['params']);
self::assertNull($lock['params']['lock']); // verrou null sous le seuil
$this->assertNoLoginCounterTouched();
}
public function testRecordFailureNoActorIsNoop(): void
{
$this->throttle()->recordFailure(0);
self::assertSame([], $this->db->writes);
self::assertSame([], $this->db->transactionEvents);
}
public function testResetClearsActorCounterNoLoginState(): void
{
$this->throttle()->reset(9, 1_000_000);
$reset = $this->find('UPDATE pin_throttle SET failed_attempts = 0');
self::assertNotNull($reset);
self::assertStringContainsString('lockout_until = NULL', $reset['sql']);
self::assertSame(9, $reset['params']['uid'] ?? null);
// reset = UPDATE simple, hors transaction propre (inclus dans l'effet controleur).
self::assertSame([], $this->db->transactionEvents);
$this->assertNoLoginCounterTouched();
}
public function testResetNoActorIsNoop(): void
{
$this->throttle()->reset(0);
self::assertSame([], $this->db->writes);
}
}