corentin_wakdo/tests/Integration/PinThrottleDbTest.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

123 lines
4.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Auth\PasswordHasher;
use App\Auth\PinThrottle;
use App\Core\Config;
use App\Core\Database;
/**
* Throttle du PIN (RG-T22) contre une vraie MariaDB. Prouve, sur le schema reel :
* - l'upsert atomique + backoff sur pin_throttle (cle = utilisateur agissant) ;
* - l'ISOLATION dure vis-a-vis du login : les echecs de PIN ne touchent NI
* user.failed_login_attempts / user.lockout_until, NI login_throttle.
*
* Auto-skip si WAKDO_DB_TESTS != 1 ou base injoignable. Cree un user jetable
* (email .invalid), supprime en tearDown (la FK ON DELETE CASCADE purge la ligne
* pin_throttle associee).
*/
final class PinThrottleDbTest extends TestCase
{
private Database $db;
private Config $config;
private int $userId = 0;
protected function setUp(): void
{
if (getenv('WAKDO_DB_TESTS') !== '1') {
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).');
}
putenv('PIN_THROTTLE_THRESHOLD=5');
putenv('PIN_THROTTLE_BASE_SECONDS=30');
putenv('PIN_THROTTLE_MAX_SECONDS=300');
putenv('PIN_THROTTLE_WINDOW_SECONDS=900');
$this->config = new Config();
$this->db = new Database($this->config);
try {
$this->db->fetch('SELECT 1');
} catch (Throwable $exception) {
self::markTestSkipped('Base injoignable: ' . $exception->getMessage());
}
$roleRow = $this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1');
$roleId = (int) ($roleRow['id'] ?? 0);
self::assertGreaterThan(0, $roleId, 'role seede attendu');
$hasher = new PasswordHasher($this->config);
$this->db->execute(
'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) '
. 'VALUES (:email, :pwd, :fn, :ln, :role, 1)',
[
'email' => 'it-pinthr-' . bin2hex(random_bytes(6)) . '@wakdo.invalid',
'pwd' => $hasher->hash('IntegrationPass1'),
'fn' => 'Integration',
'ln' => 'PinThrottle',
'role' => $roleId,
],
);
$this->userId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
}
protected function tearDown(): void
{
if ($this->userId === 0) {
return;
}
// FK ON DELETE CASCADE : la ligne pin_throttle de cet acteur part avec lui.
$this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]);
$this->userId = 0;
}
private function throttle(): PinThrottle
{
return new PinThrottle($this->db, $this->config);
}
public function testRecordFailureIncrementsAndLocksWithoutTouchingLoginCounters(): void
{
$now = time();
$throttle = $this->throttle();
for ($i = 0; $i < 5; $i++) {
$throttle->recordFailure($this->userId, $now);
}
$row = $this->db->fetch('SELECT failed_attempts, lockout_until FROM pin_throttle WHERE actor_user_id = :id', ['id' => $this->userId]);
self::assertNotNull($row);
self::assertSame(5, (int) ($row['failed_attempts'] ?? 0));
self::assertNotNull($row['lockout_until'] ?? null, 'verrou pose au seuil');
self::assertTrue(strtotime((string) $row['lockout_until']) > $now, 'verrou dans le futur');
self::assertTrue($throttle->isLocked($this->userId, $now));
// ISOLATION : aucun compteur de connexion touche par les echecs de PIN.
$userRow = $this->db->fetch('SELECT failed_login_attempts, lockout_until FROM user WHERE id = :id', ['id' => $this->userId]);
self::assertSame(0, (int) ($userRow['failed_login_attempts'] ?? -1));
self::assertNull($userRow['lockout_until'] ?? null);
}
public function testResetClearsTheActorRow(): void
{
$now = time();
$throttle = $this->throttle();
for ($i = 0; $i < 5; $i++) {
$throttle->recordFailure($this->userId, $now);
}
$throttle->reset($this->userId, $now);
$row = $this->db->fetch('SELECT failed_attempts, lockout_until FROM pin_throttle WHERE actor_user_id = :id', ['id' => $this->userId]);
self::assertSame(0, (int) ($row['failed_attempts'] ?? -1));
self::assertNull($row['lockout_until'] ?? null);
self::assertFalse($throttle->isLocked($this->userId, $now));
}
}