feat(core): accesseurs form/IP du Request, Database::transaction + DatabaseInterface, getters Response

Request::formBody() decode un POST urlencode (le login back-office est un
formulaire, pas du JSON) ; Request::clientIp() resout l'IP client reelle derriere
Traefik (dernier hop X-Forwarded-For valide, repli REMOTE_ADDR). Database::transaction()
enveloppe un jeu d'ecritures dans un begin/commit atomique avec rollback sur exception
(RG-T08) ; DatabaseInterface extrait le seam d'acces aux donnees pour rendre les services
testables avec un double. Response gagne des accesseurs en lecture (body/header/headers)
pour les tests de controleur. Tout est additif et retro-compatible.
This commit is contained in:
Imugiii 2026-06-15 18:15:32 +00:00
parent 8fb4fdf743
commit a499607add
5 changed files with 286 additions and 1 deletions

View file

@ -6,6 +6,7 @@ namespace App\Core;
use PDO; use PDO;
use PDOStatement; use PDOStatement;
use Throwable;
/** /**
* Enveloppe PDO MariaDB, requetes preparees exclusivement (anti-SQLi, Cr 4.e.1). * Enveloppe PDO MariaDB, requetes preparees exclusivement (anti-SQLi, Cr 4.e.1).
@ -14,7 +15,7 @@ use PDOStatement;
* routes sans BDD (ex : la home back-office) fonctionnent meme si la base est * routes sans BDD (ex : la home back-office) fonctionnent meme si la base est
* indisponible. * indisponible.
*/ */
final class Database final class Database implements DatabaseInterface
{ {
private ?PDO $pdo = null; private ?PDO $pdo = null;
@ -91,4 +92,30 @@ final class Database
{ {
return $this->query($sql, $params)->rowCount(); return $this->query($sql, $params)->rowCount();
} }
/**
* Execute $fn dans une transaction atomique (RG-T08) : begin -> $fn -> commit.
* Tout Throwable declenche un rollback complet puis est repropage : jamais
* d'ecriture partielle, jamais d'echec silencieux. Le retour est void (et non
* mixed) pour rester strictement type sous PHPStan ; $fn ecrit via le $this
* qui lui est passe (memes requetes preparees, meme connexion).
*
* @param callable(DatabaseInterface): void $fn
*/
public function transaction(callable $fn): void
{
$pdo = $this->pdo();
$pdo->beginTransaction();
try {
$fn($this);
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
}
} }

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Contrat d'acces aux donnees consomme par les services applicatifs (auth...).
* Il expose uniquement les operations dont ils ont besoin (lecture, ecriture,
* transaction atomique) sans la primitive bas niveau query()/PDOStatement.
*
* Raison d'etre : permettre aux services securite-critiques (AuthService,
* PasswordResetService, SessionGuard) d'etre testes unitairement avec un double
* en memoire, tout en gardant la classe Database concrete `final`. Le seul autre
* implementeur est ce double de test : interface justifiee, pas speculative.
*/
interface DatabaseInterface
{
/**
* @param array<string|int, mixed> $params
* @return array<string, mixed>|null
*/
public function fetch(string $sql, array $params = []): ?array;
/**
* @param array<string|int, mixed> $params
* @return array<int, array<string, mixed>>
*/
public function fetchAll(string $sql, array $params = []): array;
/**
* @param array<string|int, mixed> $params
*/
public function execute(string $sql, array $params = []): int;
/**
* Execute $fn dans une transaction atomique : commit si succes, rollback
* complet sur tout Throwable (puis repropagation).
*
* @param callable(DatabaseInterface): void $fn
*/
public function transaction(callable $fn): void;
}

View file

@ -22,6 +22,10 @@ final class Request
private readonly array $query, private readonly array $query,
private readonly array $headers, private readonly array $headers,
private readonly string $rawBody, private readonly string $rawBody,
// Adresse de la connexion TCP entrante (le proxy Traefik en frontal).
// Defaut vide pour conserver la compatibilite des appels a 5 arguments
// (tests existants). clientIp() s'en sert comme repli derriere X-Forwarded-For.
private readonly string $remoteAddr = '',
) { ) {
} }
@ -44,6 +48,7 @@ final class Request
$query, $query,
self::extractHeaders(), self::extractHeaders(),
(string) file_get_contents('php://input'), (string) file_get_contents('php://input'),
(string) ($_SERVER['REMOTE_ADDR'] ?? ''),
); );
} }
@ -142,4 +147,68 @@ final class Request
return is_array($decoded) ? $decoded : []; return is_array($decoded) ? $decoded : [];
} }
/**
* Decode un corps application/x-www-form-urlencoded en map cle => valeur.
* Symetrique de json() : renvoie [] si le content-type n'est pas un
* formulaire urlencode, pour laisser la validation metier decider (pas de
* fatale ici). Le back-office se connecte par formulaire POST, pas par JSON.
*
* @return array<string, string>
*/
public function formBody(): array
{
$contentType = $this->header('content-type') ?? '';
if (!str_starts_with($contentType, 'application/x-www-form-urlencoded')) {
return [];
}
parse_str($this->rawBody, $parsed);
// parse_str peut produire des valeurs tableau (cle[]=...) ; on ne retient
// que les scalaires convertis en chaine pour tenir le contrat strict
// array<string, string> (et neutraliser une cle de type "champ[]").
$form = [];
foreach ($parsed as $key => $value) {
if (is_scalar($value)) {
$form[(string) $key] = (string) $value;
}
}
return $form;
}
/**
* IP client reelle derriere le reverse proxy Traefik. REMOTE_ADDR est ici
* toujours l'adresse du proxy, donc on lit X-Forwarded-For et on retient le
* DERNIER hop : c'est celui ajoute par Traefik (proxy de confiance), tandis
* que les entrees de gauche sont fournies par le client et donc falsifiables.
* La valeur est validee par FILTER_VALIDATE_IP et bornee a 45 caracteres
* (taille de login_throttle.ip_address). Repli sur REMOTE_ADDR si l'en-tete
* est absent ou invalide ; sentinelle 0.0.0.0 en dernier recours.
*
* Hypothese de deploiement : un unique proxy de confiance (Traefik) est
* toujours en frontal. Sans lui, X-Forwarded-For serait falsifiable ; le
* verrou par compte (failed_login_attempts) reste alors le garde-fou.
*/
public function clientIp(): string
{
$forwarded = $this->header('x-forwarded-for');
if ($forwarded !== null && $forwarded !== '') {
$hops = explode(',', $forwarded);
$candidate = trim((string) end($hops));
if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) {
return substr($candidate, 0, 45);
}
}
if ($this->remoteAddr !== '' && filter_var($this->remoteAddr, FILTER_VALIDATE_IP) !== false) {
return substr($this->remoteAddr, 0, 45);
}
return '0.0.0.0';
}
} }

View file

@ -47,6 +47,24 @@ final class Response
return $this; return $this;
} }
public function body(): string
{
return $this->body;
}
public function header(string $name): ?string
{
return $this->headers[$name] ?? null;
}
/**
* @return array<string, string>
*/
public function headers(): array
{
return $this->headers;
}
/** /**
* @param array<string, string> $headers * @param array<string, string> $headers
*/ */

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Core;
use PHPUnit\Framework\TestCase;
use App\Core\Request;
/**
* Couvre les deux accesseurs ajoutes au Request pour l'auth back-office :
* formBody() (login = formulaire POST urlencode, pas JSON) et clientIp()
* (IP reelle derriere Traefik pour la cle de throttling par IP).
*/
final class RequestFormBodyTest extends TestCase
{
/**
* @param array<string, string> $headers
*/
private function request(string $method, string $rawBody, array $headers = [], string $remoteAddr = ''): Request
{
return new Request($method, '/login', [], $headers, $rawBody, $remoteAddr);
}
public function testFormBodyParsesUrlencodedBody(): void
{
$request = $this->request(
'POST',
'email=admin%40wakdo.local&password=secret+pass',
['content-type' => 'application/x-www-form-urlencoded'],
);
self::assertSame(
['email' => 'admin@wakdo.local', 'password' => 'secret pass'],
$request->formBody(),
);
}
public function testFormBodyToleratesCharsetSuffixOnContentType(): void
{
$request = $this->request(
'POST',
'a=1',
['content-type' => 'application/x-www-form-urlencoded; charset=UTF-8'],
);
self::assertSame(['a' => '1'], $request->formBody());
}
public function testFormBodyReturnsEmptyForJsonContentType(): void
{
$request = $this->request('POST', '{"email":"x"}', ['content-type' => 'application/json']);
self::assertSame([], $request->formBody());
}
public function testFormBodyReturnsEmptyWhenContentTypeAbsent(): void
{
$request = $this->request('POST', 'email=x');
self::assertSame([], $request->formBody());
}
public function testFormBodyDropsArrayShapedValues(): void
{
// parse_str transforme "tags[]=a&tags[]=b" en tableau : on ne garde que
// les scalaires pour tenir le contrat array<string, string>.
$request = $this->request(
'POST',
'name=ok&tags%5B%5D=a&tags%5B%5D=b',
['content-type' => 'application/x-www-form-urlencoded'],
);
self::assertSame(['name' => 'ok'], $request->formBody());
}
public function testClientIpUsesLastForwardedHop(): void
{
// Seul le dernier hop (ajoute par Traefik) est de confiance ; les entrees
// de gauche sont fournies par le client et donc falsifiables.
$request = $this->request(
'POST',
'',
['x-forwarded-for' => '10.0.0.9, 203.0.113.7'],
'172.18.0.2',
);
self::assertSame('203.0.113.7', $request->clientIp());
}
public function testClientIpFallsBackToRemoteAddrWhenNoForwardedHeader(): void
{
$request = $this->request('POST', '', [], '198.51.100.4');
self::assertSame('198.51.100.4', $request->clientIp());
}
public function testClientIpFallsBackWhenForwardedHopIsMalformed(): void
{
$request = $this->request(
'POST',
'',
['x-forwarded-for' => 'not-an-ip'],
'198.51.100.4',
);
self::assertSame('198.51.100.4', $request->clientIp());
}
public function testClientIpAcceptsIpv6(): void
{
$request = $this->request(
'POST',
'',
['x-forwarded-for' => '2001:db8::1'],
'172.18.0.2',
);
self::assertSame('2001:db8::1', $request->clientIp());
}
public function testClientIpReturnsSentinelWhenNothingResolvable(): void
{
$request = $this->request('POST', '', [], '');
self::assertSame('0.0.0.0', $request->clientIp());
}
}