feat: PIN self-service P3 (/admin/profile/pin) (#16)
This commit is contained in:
parent
8290ceabc4
commit
f63ac9873c
7 changed files with 515 additions and 1 deletions
37
src/app/Auth/UserRepository.php
Normal file
37
src/app/Auth/UserRepository.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Core\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ecritures sur l'entite user necessaires hors du flux d'authentification
|
||||||
|
* (definition du PIN en self-service ici ; la gestion complete des comptes
|
||||||
|
* arrive avec le CRUD Users). Lecture seule d'affichage = UserDirectory.
|
||||||
|
*/
|
||||||
|
final class UserRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly DatabaseInterface $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le nombre de lignes affectees (1 attendu). Le hash argon2id
|
||||||
|
* change a chaque appel (sel aleatoire), donc une cible existante donne
|
||||||
|
* toujours 1 ; 0 revele une cible inexistante (defense en profondeur).
|
||||||
|
*/
|
||||||
|
public function setPinHash(int $userId, string $hash): int
|
||||||
|
{
|
||||||
|
return $this->db->execute('UPDATE user SET pin_hash = :hash WHERE id = :id', ['hash' => $hash, 'id' => $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pinIsSet(int $userId): bool
|
||||||
|
{
|
||||||
|
return $this->db->fetch(
|
||||||
|
'SELECT id FROM user WHERE id = :id AND pin_hash IS NOT NULL',
|
||||||
|
['id' => $userId],
|
||||||
|
) !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/app/Controllers/ProfileController.php
Normal file
116
src/app/Controllers/ProfileController.php
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\GuardResult;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\PinVerifier;
|
||||||
|
use App\Auth\UserRepository;
|
||||||
|
use App\Core\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profil self-service : definition / changement du PIN d'action sensible de
|
||||||
|
* l'utilisateur connecte (prerequis au modele "identifiant equipier + PIN" des
|
||||||
|
* actions sensibles, RG-T13). Accessible a tout utilisateur authentifie ; aucune
|
||||||
|
* permission specifique (on n'agit que sur son propre compte = session userId).
|
||||||
|
*
|
||||||
|
* Non `final` : les tests sous-classent pour injecter des doubles.
|
||||||
|
*/
|
||||||
|
class ProfileController extends AdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function showPin(array $params = []): Response
|
||||||
|
{
|
||||||
|
$guard = $this->guard();
|
||||||
|
if ($guard instanceof Response) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $guard->userId;
|
||||||
|
if ($userId === null) {
|
||||||
|
return Response::make('', 302, ['Location' => '/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->adminView('admin/profile/pin', [
|
||||||
|
'title' => 'Mon PIN - Wakdo Admin',
|
||||||
|
'activeNav' => '',
|
||||||
|
'pinIsSet' => $this->userRepository()->pinIsSet($userId),
|
||||||
|
'error' => null,
|
||||||
|
], $guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function updatePin(array $params = []): Response
|
||||||
|
{
|
||||||
|
$guard = $this->guard();
|
||||||
|
if ($guard instanceof Response) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $guard->userId;
|
||||||
|
if ($userId === null) {
|
||||||
|
return Response::make('', 302, ['Location' => '/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->request->formBody();
|
||||||
|
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
||||||
|
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pin = $form['pin'] ?? '';
|
||||||
|
$confirm = $form['pin_confirm'] ?? '';
|
||||||
|
$error = null;
|
||||||
|
|
||||||
|
if (!$this->pinVerifier()->meetsLengthPolicy($pin)) {
|
||||||
|
$error = 'Le PIN doit etre uniquement numerique et respecter la longueur requise.';
|
||||||
|
} elseif ($pin !== $confirm) {
|
||||||
|
$error = 'Les PIN ne correspondent pas.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error !== null) {
|
||||||
|
return $this->renderPinForm($guard, $userId, $error, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate sur 1 ligne affectee : une cible inexistante (0 ligne) ne doit pas
|
||||||
|
// produire un faux "PIN enregistre" (defense en profondeur).
|
||||||
|
if ($this->userRepository()->setPinHash($userId, $this->passwordHasher()->hash($pin)) !== 1) {
|
||||||
|
return $this->renderPinForm($guard, $userId, 'Echec de l enregistrement du PIN.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setFlash('PIN enregistre.');
|
||||||
|
|
||||||
|
return Response::make('', 302, ['Location' => '/admin/profile/pin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderPinForm(GuardResult $guard, int $userId, ?string $error, int $status): Response
|
||||||
|
{
|
||||||
|
return $this->adminView('admin/profile/pin', [
|
||||||
|
'title' => 'Mon PIN - Wakdo Admin',
|
||||||
|
'activeNav' => '',
|
||||||
|
'pinIsSet' => $this->userRepository()->pinIsSet($userId),
|
||||||
|
'error' => $error,
|
||||||
|
], $guard, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function userRepository(): UserRepository
|
||||||
|
{
|
||||||
|
return new UserRepository($this->database);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function pinVerifier(): PinVerifier
|
||||||
|
{
|
||||||
|
return new PinVerifier($this->database, $this->config, $this->passwordHasher());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function passwordHasher(): PasswordHasher
|
||||||
|
{
|
||||||
|
return new PasswordHasher($this->config);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/Views/admin/profile/pin.php
Normal file
49
src/app/Views/admin/profile/pin.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formulaire de definition / changement du PIN de l'utilisateur connecte.
|
||||||
|
* Injecte dans admin/layout.php. Le PIN sert a re-autoriser les actions sensibles.
|
||||||
|
*
|
||||||
|
* @var string $csrfToken
|
||||||
|
* @var bool $pinIsSet
|
||||||
|
* @var string|null $error
|
||||||
|
*/
|
||||||
|
|
||||||
|
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$alreadySet = isset($pinIsSet) && $pinIsSet === true;
|
||||||
|
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||||
|
?>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Mon PIN</h1>
|
||||||
|
<p class="page-subtitle">PIN de confirmation des actions sensibles (annulation, prix, suppressions...)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<p><small>Statut : <?= $alreadySet ? 'un PIN est defini.' : 'aucun PIN defini pour l instant.' ?></small></p>
|
||||||
|
|
||||||
|
<?php if ($errorMessage !== null): ?>
|
||||||
|
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/profile/pin" class="form-card">
|
||||||
|
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="pin">Nouveau PIN</label>
|
||||||
|
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="pin_confirm">Confirmer le PIN</label>
|
||||||
|
<input class="form-input" type="password" id="pin_confirm" name="pin_confirm" inputmode="numeric" autocomplete="off" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
@ -18,6 +18,7 @@ use App\Controllers\HealthController;
|
||||||
use App\Controllers\HomeController;
|
use App\Controllers\HomeController;
|
||||||
use App\Controllers\MeController;
|
use App\Controllers\MeController;
|
||||||
use App\Controllers\PasswordResetController;
|
use App\Controllers\PasswordResetController;
|
||||||
|
use App\Controllers\ProfileController;
|
||||||
use App\Core\Autoloader;
|
use App\Core\Autoloader;
|
||||||
use App\Core\Config;
|
use App\Core\Config;
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
|
@ -74,6 +75,10 @@ try {
|
||||||
$router->add('POST', '/admin/categories/{id}', [CategoryController::class, 'update']);
|
$router->add('POST', '/admin/categories/{id}', [CategoryController::class, 'update']);
|
||||||
$router->add('POST', '/admin/categories/{id}/toggle', [CategoryController::class, 'toggle']);
|
$router->add('POST', '/admin/categories/{id}/toggle', [CategoryController::class, 'toggle']);
|
||||||
|
|
||||||
|
// Profil self-service : definition du PIN d'action sensible (RG-T13).
|
||||||
|
$router->add('GET', '/admin/profile/pin', [ProfileController::class, 'showPin']);
|
||||||
|
$router->add('POST', '/admin/profile/pin', [ProfileController::class, 'updatePin']);
|
||||||
|
|
||||||
$response = $router->dispatch(Request::fromGlobals());
|
$response = $router->dispatch(Request::fromGlobals());
|
||||||
$response->send();
|
$response->send();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
|
|
||||||
80
tests/Integration/UserRepositoryDbTest.php
Normal file
80
tests/Integration/UserRepositoryDbTest.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Integration;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Throwable;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\UserRepository;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ecriture du PIN (UserRepository) contre une vraie MariaDB. Auto-skip si
|
||||||
|
* WAKDO_DB_TESTS != 1. User jetable (.invalid), supprime en tearDown.
|
||||||
|
*/
|
||||||
|
final class UserRepositoryDbTest 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_*).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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());
|
||||||
|
}
|
||||||
|
|
||||||
|
$roleId = (int) ($this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1')['id'] ?? 0);
|
||||||
|
$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-userrepo-' . bin2hex(random_bytes(6)) . '@wakdo.invalid',
|
||||||
|
'pwd' => $hasher->hash('IntegrationPass1'),
|
||||||
|
'fn' => 'Integration',
|
||||||
|
'ln' => 'UserRepo',
|
||||||
|
'role' => $roleId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$this->userId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if ($this->userId !== 0) {
|
||||||
|
$this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]);
|
||||||
|
$this->userId = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetPinHashAndPinIsSet(): void
|
||||||
|
{
|
||||||
|
$repo = new UserRepository($this->db);
|
||||||
|
$hasher = new PasswordHasher($this->config);
|
||||||
|
|
||||||
|
// Aucun PIN au depart.
|
||||||
|
self::assertFalse($repo->pinIsSet($this->userId));
|
||||||
|
|
||||||
|
$repo->setPinHash($this->userId, $hasher->hash('4729'));
|
||||||
|
|
||||||
|
self::assertTrue($repo->pinIsSet($this->userId));
|
||||||
|
|
||||||
|
// Le hash stocke est verifiable et n'est pas le PIN en clair.
|
||||||
|
$stored = (string) ($this->db->fetch('SELECT pin_hash FROM user WHERE id = :id', ['id' => $this->userId])['pin_hash'] ?? '');
|
||||||
|
self::assertNotSame('4729', $stored);
|
||||||
|
self::assertTrue($hasher->verify('4729', $stored));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -117,9 +117,15 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
/** Resultat de CategoryRepository::slugExists(). */
|
/** Resultat de CategoryRepository::slugExists(). */
|
||||||
public bool $categorySlugTaken = false;
|
public bool $categorySlugTaken = false;
|
||||||
|
|
||||||
|
/** Resultat de UserRepository::pinIsSet() (true = un PIN est defini). */
|
||||||
|
public bool $userPinSet = false;
|
||||||
|
|
||||||
/** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */
|
/** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */
|
||||||
public ?Throwable $failOnExecute = null;
|
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>}> */
|
/** @var list<array{sql: string, params: array<string|int, mixed>}> */
|
||||||
public array $writes = [];
|
public array $writes = [];
|
||||||
|
|
||||||
|
|
@ -166,6 +172,10 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
return $this->pinUserRow;
|
return $this->pinUserRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_contains($sql, 'FROM user WHERE id = :id AND pin_hash IS NOT NULL')) {
|
||||||
|
return $this->userPinSet ? ['id' => 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
if (str_contains($sql, 'FROM category WHERE id = :id')) {
|
if (str_contains($sql, 'FROM category WHERE id = :id')) {
|
||||||
return $this->categoryRow;
|
return $this->categoryRow;
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +226,7 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
|
|
||||||
$this->writes[] = ['sql' => $sql, 'params' => $params];
|
$this->writes[] = ['sql' => $sql, 'params' => $params];
|
||||||
|
|
||||||
return 1;
|
return $this->executeRowCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transaction(callable $fn): void
|
public function transaction(callable $fn): void
|
||||||
|
|
|
||||||
217
tests/Unit/Admin/ProfileControllerTest.php
Normal file
217
tests/Unit/Admin/ProfileControllerTest.php
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\Authorizer;
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\SessionGuard;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Auth\UserDirectory;
|
||||||
|
use App\Auth\UserRepository;
|
||||||
|
use App\Controllers\ProfileController;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Tests\Support\FakeDatabase;
|
||||||
|
|
||||||
|
final class TestProfileController extends ProfileController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Request $request,
|
||||||
|
Config $config,
|
||||||
|
Database $database,
|
||||||
|
private readonly SessionManager $testSession,
|
||||||
|
private readonly FakeDatabase $fakeDb,
|
||||||
|
) {
|
||||||
|
parent::__construct($request, $config, $database);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sessionManager(): SessionManager
|
||||||
|
{
|
||||||
|
return $this->testSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sessionGuard(): SessionGuard
|
||||||
|
{
|
||||||
|
return new SessionGuard($this->testSession, $this->fakeDb, $this->config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizer(): Authorizer
|
||||||
|
{
|
||||||
|
return new Authorizer($this->fakeDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function userDirectory(): UserDirectory
|
||||||
|
{
|
||||||
|
return new UserDirectory($this->fakeDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function userRepository(): UserRepository
|
||||||
|
{
|
||||||
|
return new UserRepository($this->fakeDb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ProfileControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $touchedKeys = [];
|
||||||
|
|
||||||
|
private SessionManager $session;
|
||||||
|
private string $csrf = '';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->setEnv('SESSION_LIFETIME_IDLE', '14400');
|
||||||
|
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000');
|
||||||
|
$this->setEnv('STAFF_PIN_MIN_LENGTH', '4');
|
||||||
|
$this->setEnv('STAFF_PIN_MAX_LENGTH', '12');
|
||||||
|
$this->setEnv('ARGON2_MEMORY_COST', '1024');
|
||||||
|
$this->setEnv('ARGON2_TIME_COST', '1');
|
||||||
|
$this->setEnv('ARGON2_THREADS', '1');
|
||||||
|
|
||||||
|
$this->session = new SessionManager(new Config(), true);
|
||||||
|
$now = time();
|
||||||
|
$this->session->set('user_id', 1);
|
||||||
|
$this->session->set('role_id', 1);
|
||||||
|
$this->session->set('logged_in_at', $now - 100);
|
||||||
|
$this->session->set('last_activity', $now - 50);
|
||||||
|
$this->csrf = Csrf::token($this->session);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 permittedDb(): FakeDatabase
|
||||||
|
{
|
||||||
|
$db = new FakeDatabase();
|
||||||
|
$db->guardUserRow = ['is_active' => 1];
|
||||||
|
$db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur'];
|
||||||
|
$db->canResult = true;
|
||||||
|
$db->permissionCodes = ['category.manage'];
|
||||||
|
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $form
|
||||||
|
*/
|
||||||
|
private function post(array $form): Request
|
||||||
|
{
|
||||||
|
return new Request(
|
||||||
|
'POST',
|
||||||
|
'/admin/profile/pin',
|
||||||
|
[],
|
||||||
|
['content-type' => 'application/x-www-form-urlencoded'],
|
||||||
|
http_build_query($form),
|
||||||
|
'203.0.113.5',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controller(Request $request, FakeDatabase $db): TestProfileController
|
||||||
|
{
|
||||||
|
return new TestProfileController($request, new Config(), new Database(new Config()), $this->session, $db);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedirectsToLoginWithoutSession(): void
|
||||||
|
{
|
||||||
|
$request = new Request('GET', '/admin/profile/pin', [], [], '', '203.0.113.5');
|
||||||
|
$response = $this->controller($request, new FakeDatabase())->showPin();
|
||||||
|
|
||||||
|
self::assertSame(302, $response->status());
|
||||||
|
self::assertSame('/login', $response->header('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShowPinReflectsStatus(): void
|
||||||
|
{
|
||||||
|
$request = new Request('GET', '/admin/profile/pin', [], [], '', '203.0.113.5');
|
||||||
|
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$db->userPinSet = false;
|
||||||
|
$response = $this->controller($request, $db)->showPin();
|
||||||
|
self::assertSame(200, $response->status());
|
||||||
|
self::assertStringContainsString('name="pin"', $response->body());
|
||||||
|
self::assertStringContainsString('aucun PIN defini', $response->body());
|
||||||
|
|
||||||
|
$db2 = $this->permittedDb();
|
||||||
|
$db2->userPinSet = true;
|
||||||
|
self::assertStringContainsString('un PIN est defini', $this->controller($request, $db2)->showPin()->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdatePinValidStoresHashAndRedirects(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin();
|
||||||
|
|
||||||
|
self::assertSame(302, $response->status());
|
||||||
|
self::assertSame('/admin/profile/pin', $response->header('Location'));
|
||||||
|
self::assertSame('PIN enregistre.', $this->session->get('_flash'));
|
||||||
|
|
||||||
|
// Invariant central : la cible est l'utilisateur de la SESSION (1, pose en
|
||||||
|
// setUp), jamais un champ de formulaire ; et c'est un hash, pas le PIN clair.
|
||||||
|
$write = null;
|
||||||
|
foreach ($db->writes as $w) {
|
||||||
|
if (str_contains($w['sql'], 'UPDATE user SET pin_hash')) {
|
||||||
|
$write = $w;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertNotNull($write);
|
||||||
|
self::assertSame(1, $write['params']['id'] ?? null);
|
||||||
|
self::assertNotSame('4729', $write['params']['hash'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdatePinFailsWhenNoRowAffected(): void
|
||||||
|
{
|
||||||
|
// Cible inexistante (0 ligne affectee) : pas de faux succes, pas de flash.
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$db->executeRowCount = 0;
|
||||||
|
|
||||||
|
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin();
|
||||||
|
|
||||||
|
self::assertSame(500, $response->status());
|
||||||
|
self::assertNull($this->session->get('_flash'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdatePinMismatchRerenders422(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '0000']), $db)->updatePin();
|
||||||
|
|
||||||
|
self::assertSame(422, $response->status());
|
||||||
|
self::assertStringContainsString('ne correspondent pas', $response->body());
|
||||||
|
self::assertFalse($db->wrote('UPDATE user SET pin_hash'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdatePinTooShortRerenders422(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '12', 'pin_confirm' => '12']), $db)->updatePin();
|
||||||
|
|
||||||
|
self::assertSame(422, $response->status());
|
||||||
|
self::assertFalse($db->wrote('UPDATE user SET pin_hash'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdatePinRejectsInvalidCsrf(): void
|
||||||
|
{
|
||||||
|
$db = $this->permittedDb();
|
||||||
|
$response = $this->controller($this->post(['_csrf' => 'wrong', 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin();
|
||||||
|
|
||||||
|
self::assertSame(403, $response->status());
|
||||||
|
self::assertFalse($db->wrote('UPDATE user SET pin_hash'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue