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
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.
341 lines
10 KiB
PHP
341 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Support;
|
|
|
|
use App\Core\DatabaseInterface;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Double de test de DatabaseInterface : aucune connexion reelle. Les lectures
|
|
* sont scriptees par des "boutons" types (userRow, ipLockoutUntil,
|
|
* ipFailedAttempts), les ecritures sont enregistrees pour assertion, et les
|
|
* transactions tracent begin/commit/rollback. Permet de tester les branches de
|
|
* securite d'AuthService / PasswordResetService sans base de donnees.
|
|
*/
|
|
final class FakeDatabase implements DatabaseInterface
|
|
{
|
|
/**
|
|
* Reponse de la recherche utilisateur (RG-1) ; null = email inconnu.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $userRow = null;
|
|
|
|
/** lockout_until renvoye pour la porte de throttling IP ; null = pas de verrou. */
|
|
public ?string $ipLockoutUntil = null;
|
|
|
|
/**
|
|
* Compteur login_throttle relu apres l'upsert atomique (sert au calcul du
|
|
* backoff IP en PHP) ; null => 1 par defaut cote service.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $throttleRow = null;
|
|
|
|
/**
|
|
* Reponse de la recherche par token de reinitialisation (12.3) ; null = aucun.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $resetUserRow = null;
|
|
|
|
/**
|
|
* Reponse de la recherche par email (phase demande de reinitialisation) ; null = inconnu.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $emailLookupRow = null;
|
|
|
|
/**
|
|
* Reponse de la verification is_active du SessionGuard (RG-T02) ; null = absent.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $guardUserRow = null;
|
|
|
|
/** Resultat de Authorizer::can() (true = permission accordee). */
|
|
public bool $canResult = false;
|
|
|
|
/** Etat role.is_active modelise pour can()/permissionsFor() ; false => rien accorde. */
|
|
public bool $roleActive = true;
|
|
|
|
/**
|
|
* Trace des lectures (fetch/fetchAll) pour asserter les parametres lies
|
|
* (ex. liaison par code de permission, RG-T03), pendant que $writes trace les ecritures.
|
|
*
|
|
* @var list<array{sql: string, params: array<string|int, mixed>}>
|
|
*/
|
|
public array $reads = [];
|
|
|
|
/**
|
|
* Codes de permission renvoyes par Authorizer::permissionsFor().
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public array $permissionCodes = [];
|
|
|
|
/**
|
|
* Ligne role renvoyee pour la lecture du code de role (/api/me) ; null = absent.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $roleRow = null;
|
|
|
|
/**
|
|
* Ligne user renvoyee pour la verification du PIN (RG-T13) ; null = absent/inactif.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $pinUserRow = null;
|
|
|
|
/**
|
|
* Ligne renvoyee pour UserDirectory::displayInfo (nom + libelle role) ; null = absent.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $userDisplayRow = null;
|
|
|
|
/**
|
|
* Lignes renvoyees par CategoryRepository::all().
|
|
*
|
|
* @var list<array<string, mixed>>
|
|
*/
|
|
public array $categoriesRows = [];
|
|
|
|
/**
|
|
* Ligne renvoyee par CategoryRepository::find() ; null = introuvable.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $categoryRow = null;
|
|
|
|
/** Resultat de CategoryRepository::nameExists(). */
|
|
public bool $categoryNameTaken = false;
|
|
|
|
/** Resultat de CategoryRepository::slugExists(). */
|
|
public bool $categorySlugTaken = false;
|
|
|
|
/** Resultat de UserRepository::pinIsSet() (true = un PIN est defini). */
|
|
public bool $userPinSet = false;
|
|
|
|
/**
|
|
* Lignes renvoyees par ProductRepository::all().
|
|
*
|
|
* @var list<array<string, mixed>>
|
|
*/
|
|
public array $productsRows = [];
|
|
|
|
/**
|
|
* Ligne renvoyee par ProductRepository::find() ; null = introuvable.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $productRow = null;
|
|
|
|
/**
|
|
* Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ;
|
|
* null = email inconnu/inactif.
|
|
*
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $actingUserRow = null;
|
|
|
|
/**
|
|
* lockout_until renvoye pour la porte du throttle PIN (RG-T22, PinThrottle::isLocked) ;
|
|
* null = pas de verrou.
|
|
*/
|
|
public ?string $pinThrottleLockoutUntil = null;
|
|
|
|
/** Compteur pin_throttle relu apres l'upsert (PinThrottle::recordFailure) ; 1 par defaut. */
|
|
public int $pinThrottleAttempts = 1;
|
|
|
|
/** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */
|
|
public ?Throwable $failOnExecute = null;
|
|
|
|
/** Nombre de lignes affectees renvoye par execute() (1 par defaut). */
|
|
public int $executeRowCount = 1;
|
|
|
|
/** @var list<array{sql: string, params: array<string|int, mixed>}> */
|
|
public array $writes = [];
|
|
|
|
/** @var list<string> */
|
|
public array $transactionEvents = [];
|
|
|
|
/**
|
|
* Journal ordonne entrelacant ecritures et bornes de transaction, pour
|
|
* verifier qu'une ecriture (ex. audit_log) tombe bien ENTRE begin et commit
|
|
* (atomicite RG-T08), ce que deux listes disjointes ne prouvent pas.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public array $eventLog = [];
|
|
|
|
public function fetch(string $sql, array $params = []): ?array
|
|
{
|
|
$this->reads[] = ['sql' => $sql, 'params' => $params];
|
|
|
|
// Doit passer AVANT le lookup auth : la requete displayInfo contient aussi
|
|
// 'FROM user u JOIN role' mais selectionne 'AS role_label'.
|
|
if (str_contains($sql, 'AS role_label')) {
|
|
return $this->userDisplayRow;
|
|
}
|
|
|
|
if (str_contains($sql, 'FROM user u JOIN role')) {
|
|
return $this->userRow;
|
|
}
|
|
|
|
if (str_contains($sql, 'password_reset_token_hash')) {
|
|
return $this->resetUserRow;
|
|
}
|
|
|
|
if (str_contains($sql, 'SELECT id FROM user WHERE email')) {
|
|
return $this->emailLookupRow;
|
|
}
|
|
|
|
if (str_contains($sql, 'SELECT is_active FROM user WHERE id')) {
|
|
return $this->guardUserRow;
|
|
}
|
|
|
|
if (str_contains($sql, 'SELECT 1 AS granted FROM role_permission')) {
|
|
return ($this->canResult && $this->roleActive) ? ['granted' => 1] : null;
|
|
}
|
|
|
|
if (str_contains($sql, 'FROM role r WHERE r.id')) {
|
|
return $this->roleRow;
|
|
}
|
|
|
|
// Exige le predicat is_active = 1 : si la production le retirait, le double
|
|
// renverrait null et le test verify-true virerait au rouge (garde RG-T13).
|
|
if (str_contains($sql, 'SELECT pin_hash FROM user WHERE id') && str_contains($sql, 'is_active = 1')) {
|
|
return $this->pinUserRow;
|
|
}
|
|
|
|
if (str_contains($sql, 'FROM user WHERE id = :id AND pin_hash IS NOT NULL')) {
|
|
return $this->userPinSet ? ['id' => 1] : null;
|
|
}
|
|
|
|
// Exige is_active = 1 (garde RG-T13) : retirer le predicat en production
|
|
// ferait virer au rouge les tests de resolveActingUser.
|
|
if (str_contains($sql, 'pin_hash FROM user WHERE email') && str_contains($sql, 'is_active = 1')) {
|
|
return $this->actingUserRow;
|
|
}
|
|
|
|
if (str_contains($sql, 'FROM product WHERE id = :id')) {
|
|
return $this->productRow;
|
|
}
|
|
|
|
if (str_contains($sql, 'FROM category WHERE id = :id')) {
|
|
return $this->categoryRow;
|
|
}
|
|
|
|
if (str_contains($sql, 'FROM category WHERE name = :name')) {
|
|
return $this->categoryNameTaken ? ['id' => 1] : null;
|
|
}
|
|
|
|
if (str_contains($sql, 'FROM category WHERE slug = :slug')) {
|
|
return $this->categorySlugTaken ? ['id' => 1] : null;
|
|
}
|
|
|
|
if (str_contains($sql, 'lockout_until FROM pin_throttle')) {
|
|
return ['lockout_until' => $this->pinThrottleLockoutUntil];
|
|
}
|
|
|
|
if (str_contains($sql, 'failed_attempts FROM pin_throttle')) {
|
|
return ['failed_attempts' => $this->pinThrottleAttempts];
|
|
}
|
|
|
|
if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) {
|
|
return ['lockout_until' => $this->ipLockoutUntil];
|
|
}
|
|
|
|
if (str_contains($sql, 'SELECT failed_attempts FROM login_throttle')) {
|
|
return $this->throttleRow;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function fetchAll(string $sql, array $params = []): array
|
|
{
|
|
$this->reads[] = ['sql' => $sql, 'params' => $params];
|
|
|
|
if (str_contains($sql, 'FROM category ORDER BY')) {
|
|
return $this->categoriesRows;
|
|
}
|
|
|
|
if (str_contains($sql, 'FROM product p JOIN category')) {
|
|
return $this->productsRows;
|
|
}
|
|
|
|
if (str_contains($sql, 'SELECT p.code FROM role_permission')) {
|
|
if (!$this->roleActive) {
|
|
return [];
|
|
}
|
|
|
|
return array_map(static fn (string $code): array => ['code' => $code], $this->permissionCodes);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
public function execute(string $sql, array $params = []): int
|
|
{
|
|
if ($this->failOnExecute !== null) {
|
|
throw $this->failOnExecute;
|
|
}
|
|
|
|
$this->writes[] = ['sql' => $sql, 'params' => $params];
|
|
$this->eventLog[] = 'write:' . substr($sql, 0, 24);
|
|
|
|
return $this->executeRowCount;
|
|
}
|
|
|
|
public function transaction(callable $fn): void
|
|
{
|
|
$this->transactionEvents[] = 'begin';
|
|
$this->eventLog[] = 'begin';
|
|
|
|
try {
|
|
$fn($this);
|
|
$this->transactionEvents[] = 'commit';
|
|
$this->eventLog[] = 'commit';
|
|
} catch (\Throwable $exception) {
|
|
$this->transactionEvents[] = 'rollback';
|
|
$this->eventLog[] = 'rollback';
|
|
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
public function wrote(string $needle): bool
|
|
{
|
|
foreach ($this->writes as $write) {
|
|
if (str_contains($write['sql'], $needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Codes d'action audit_log inseres (dans l'ordre).
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public function auditActions(): array
|
|
{
|
|
$codes = [];
|
|
|
|
foreach ($this->writes as $write) {
|
|
if (str_contains($write['sql'], 'INSERT INTO audit_log')) {
|
|
$code = $write['params']['code'] ?? null;
|
|
$codes[] = is_string($code) ? $code : '';
|
|
}
|
|
}
|
|
|
|
return $codes;
|
|
}
|
|
}
|