feat(admin): gestion des comptes back-office (CRUD users + RGPD, PIN+audit) (P3)
All checks were successful
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 24s
CI / static-tests (pull_request) Successful in 47s
CI / js-tests (pull_request) Successful in 20s
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 52s
CI / js-tests (push) Successful in 18s
CI / auto-merge (pull_request) Successful in 5s
CI / auto-merge (push) Has been skipped

Lot U du cycle P3 (Users/RBAC/Stats). Gestion complete des comptes back-office
(mlt domaine 10) : toutes les mutations sont des actions sensibles (RG-T13) avec
re-autorisation par PIN equipier + ligne audit_log dans la meme transaction
(RG-T14), throttle PIN par acteur agissant (RG-T22).

- UserRepository : all (JOIN role) / find / emailExists / activeRoleExists /
  create / update (allowlist RG-T16) / setPasswordHash / clearPin / deactivate /
  anonymise (RGPD mlt 10.5, tombstone idempotent) / activeAdminCount / isAdmin.
- UserController (user.read/create/update/deactivate) : index ; create/store ;
  edit/update ; deactivate ; reset-pin ; erase-PII. Helper resolvePin mutualise
  le flux throttle+verif+pin.failed. details JSON d'audit = noms de champs/role
  (pas de PII). Conflit d'unicite email -> 409 (convention PR-0).
- Garde-fous d'integrite : pas d'auto-desactivation (mlt 10.3 PRE-2 -> 403) ; on
  ne peut ni desactiver, ni retrograder, ni anonymiser le DERNIER admin actif
  (anti-lockout) ; erase deja anonymise -> 409.
- Vues admin/users/{index,form,confirm} (PIN inline), 11 routes, nav Administration.

Tests : unit 251, integration 285 / 867 assertions (WAKDO_DB_TESTS=1, dont
UserControllerTest 18 + UserRepositoryDbTest 5), PHPStan L6 propre.
This commit is contained in:
Imugiii 2026-06-17 11:47:28 +00:00
parent 9c2844c116
commit d3dcc36bc4
11 changed files with 1718 additions and 8 deletions

View file

@ -7,9 +7,15 @@ namespace App\Auth;
use App\Core\DatabaseInterface; use App\Core\DatabaseInterface;
/** /**
* Ecritures sur l'entite user necessaires hors du flux d'authentification * Acces aux donnees de l'entite user : definition du PIN self-service ET gestion
* (definition du PIN en self-service ici ; la gestion complete des comptes * complete des comptes back-office (mlt domaine 10 : create/update/deactivate +
* arrive avec le CRUD Users). Lecture seule d'affichage = UserDirectory. * effacement RGPD). Lecture seule d'affichage topbar = UserDirectory.
*
* Allowlist d'ecriture (RG-T16) : aucune methode ne lie `pin_hash`, `is_active`
* (hors deactivate/anonymise dedies) ni les compteurs de throttle depuis une
* requete. Le hash de mot de passe est calcule par l'appelant (PasswordHasher),
* jamais le mot de passe en clair. L'anonymisation RGPD (mlt 10.5) preserve la
* ligne (FK entrantes stock_movement/customer_order/audit_log) en la vidant.
*/ */
final class UserRepository final class UserRepository
{ {
@ -17,6 +23,156 @@ final class UserRepository
{ {
} }
/**
* Liste pour le back-office, avec le libelle de role (JOIN). Pas de hash ni de
* secret expose. Inclut les comptes anonymises (tombstones) pour tracabilite.
*
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
return $this->db->fetchAll(
'SELECT u.id, u.email, u.first_name, u.last_name, u.role_id, u.is_active, '
. 'u.last_login_at, u.anonymized_at, r.label AS role_label, r.code AS role_code '
. 'FROM user u JOIN role r ON r.id = u.role_id '
. 'ORDER BY u.is_active DESC, u.last_name, u.first_name',
);
}
/**
* @return array<string, mixed>|null
*/
public function find(int $id): ?array
{
return $this->db->fetch(
'SELECT id, email, first_name, last_name, role_id, is_active, anonymized_at '
. 'FROM user WHERE id = :id',
['id' => $id],
);
}
public function emailExists(string $email, int $exceptId = 0): bool
{
return $this->db->fetch(
'SELECT id FROM user WHERE email = :email AND id <> :id',
['email' => $email, 'id' => $exceptId],
) !== null;
}
/** Le role existe ET est actif (PRE-3 de CREATE_USER, vecteur d'escalade). */
public function activeRoleExists(int $roleId): bool
{
return $this->db->fetch('SELECT id FROM role WHERE id = :id AND is_active = 1', ['id' => $roleId]) !== null;
}
/**
* Creation (mlt 10.1). `is_active` est pose cote serveur (=1), pas lie a la
* requete (RG-T16). Le hash est argon2id, calcule par l'appelant. Retourne l'id.
*
* @param array{email: string, password_hash: string, first_name: string, last_name: string, role_id: int} $data
*/
public function create(array $data): int
{
$this->db->execute(
'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) '
. 'VALUES (:email, :hash, :first, :last, :role, 1)',
[
'email' => $data['email'],
'hash' => $data['password_hash'],
'first' => $data['first_name'],
'last' => $data['last_name'],
'role' => $data['role_id'],
],
);
return (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
}
/**
* Mise a jour (mlt 10.2). Allowlist RG-T16 : email/prenom/nom/role_id/is_active.
* Le mot de passe (re-hachage optionnel) et le PIN passent par des methodes
* dediees, jamais lies ici.
*
* @param array{email: string, first_name: string, last_name: string, role_id: int, is_active: int} $data
*/
public function update(int $id, array $data): void
{
$this->db->execute(
'UPDATE user SET email = :email, first_name = :first, last_name = :last, '
. 'role_id = :role, is_active = :active WHERE id = :id',
[
'email' => $data['email'],
'first' => $data['first_name'],
'last' => $data['last_name'],
'role' => $data['role_id'],
'active' => $data['is_active'],
'id' => $id,
],
);
}
/** Re-hachage du mot de passe par un admin (mlt 10.2 RG-1, reset cote admin). */
public function setPasswordHash(int $id, string $hash): int
{
return $this->db->execute('UPDATE user SET password_hash = :hash WHERE id = :id', ['hash' => $hash, 'id' => $id]);
}
/**
* Reinitialise le PIN d'un equipier (admin) : on le met a NULL plutot que d'en
* poser un (l'admin n'a pas a connaitre le PIN d'autrui) ; l'equipier le
* redefinit ensuite en self-service (ProfileController).
*/
public function clearPin(int $id): int
{
return $this->db->execute('UPDATE user SET pin_hash = NULL WHERE id = :id', ['id' => $id]);
}
/** Desactivation (mlt 10.3) : soft, l'historique reste intact. */
public function deactivate(int $id): int
{
return $this->db->execute('UPDATE user SET is_active = 0 WHERE id = :id', ['id' => $id]);
}
/**
* Anonymisation RGPD (mlt 10.5 RG-1) : vide la PII en GARDANT la ligne (les FK
* entrantes stock_movement/customer_order/audit_log restent valides). Email ->
* placeholder unique en `.invalid` (RFC 2606), conserve l'unicite sans etre
* identifiant. Idempotence : ne reanonymise pas une ligne deja anonymisee
* (clause anonymized_at IS NULL) -> 0 ligne affectee si deja fait.
*/
public function anonymise(int $id): int
{
return $this->db->execute(
"UPDATE user SET email = CONCAT('anon-', id, '@wakdo.invalid'), first_name = '', "
. "last_name = '', password_hash = '', pin_hash = NULL, password_reset_token_hash = NULL, "
. 'is_active = 0, anonymized_at = NOW() WHERE id = :id AND anonymized_at IS NULL',
['id' => $id],
);
}
/**
* Nombre d'administrateurs ACTIFS (role code 'admin'). Garde-fou : empeche de
* desactiver/anonymiser/retrograder le dernier admin actif (verrouillage total
* du back-office). Ce garde-fou va au-dela du mlt (qui ne borne que
* l'auto-desactivation) mais previent un lock-out irrecuperable.
*/
public function activeAdminCount(): int
{
return (int) ($this->db->fetch(
"SELECT COUNT(*) AS n FROM user u JOIN role r ON r.id = u.role_id "
. "WHERE r.code = 'admin' AND u.is_active = 1",
)['n'] ?? 0);
}
/** L'utilisateur a-t-il le role admin (actif ou non) ? */
public function isAdmin(int $id): bool
{
return $this->db->fetch(
"SELECT u.id FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id AND r.code = 'admin'",
['id' => $id],
) !== null;
}
/** /**
* Retourne le nombre de lignes affectees (1 attendu). Le hash argon2id * Retourne le nombre de lignes affectees (1 attendu). Le hash argon2id
* change a chaque appel (sel aleatoire), donc une cible existante donne * change a chaque appel (sel aleatoire), donc une cible existante donne

View file

@ -0,0 +1,683 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use PDOException;
use App\Auth\Csrf;
use App\Auth\GuardResult;
use App\Auth\PasswordHasher;
use App\Auth\PinThrottle;
use App\Auth\PinVerifier;
use App\Auth\UserRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
/**
* Gestion des comptes back-office (mlt domaine 10). Toutes les MUTATIONS sont des
* actions sensibles (RG-T13) : re-autorisation par PIN equipier + ligne `audit_log`
* dans la meme transaction que l'effet (RG-T14). Le throttle du PIN (RG-T22) est
* evalue AVANT la verification, par utilisateur agissant meme pattern que
* ProductController.
*
* - index (user.read) : liste, lecture seule ;
* - create/store (user.create), edit/update (user.update), deactivate
* (user.deactivate), reset-pin + erase-PII (user.update) : PIN + audit.
*
* Garde-fous d'integrite : pas d'auto-desactivation (mlt 10.3 PRE-2, 403) ; on ne
* peut ni desactiver, ni retrograder, ni anonymiser le DERNIER admin actif
* (anti-lockout, au-dela du mlt). Conflit d'unicite email -> 409 (convention PR-0).
*
* Non `final` : les tests sous-classent (seam db()/sessionManager()).
*/
class UserController extends AdminController
{
private const ENTITY = 'user';
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard('user.read');
if ($guard instanceof Response) {
return $guard;
}
return $this->adminView('admin/users/index', [
'title' => 'Utilisateurs - Wakdo Admin',
'activeNav' => 'users',
'users' => $this->userRepository()->all(),
'currentId' => $guard->userId ?? 0,
'canCreate' => $this->may($guard, 'user.create'),
'canUpdate' => $this->may($guard, 'user.update'),
'canDeactiv' => $this->may($guard, 'user.deactivate'),
], $guard);
}
/**
* @param array<string, string> $params
*/
public function create(array $params = []): Response
{
$guard = $this->guard('user.create');
if ($guard instanceof Response) {
return $guard;
}
return $this->renderForm($guard, 0, [], []);
}
/**
* @param array<string, string> $params
*/
public function store(array $params = []): Response
{
$guard = $this->guard('user.create');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
[$data, $errors] = $this->validate($form, false);
if ($errors !== []) {
return $this->renderForm($guard, 0, $form, $errors, 422);
}
if ($this->userRepository()->emailExists($data['email'])) {
return $this->renderForm($guard, 0, $form, ['email' => 'Cet email est deja utilise.'], 409);
}
[$actor, $errorMsg] = $this->resolvePin($guard, $form, 0);
if ($actor === null) {
return $this->renderForm($guard, 0, $form, ['pin' => $errorMsg], 422);
}
$hash = $this->passwordHasher()->hash((string) $data['password']);
try {
$this->db()->transaction(function (DatabaseInterface $db) use ($data, $hash, $actor): void {
$newId = (new UserRepository($db))->create([
'email' => $data['email'],
'password_hash' => $hash,
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'role_id' => $data['role_id'],
]);
$this->writeAudit($db, 'user.create', $actor['id'], $actor['role_id'], $newId, 'Creation utilisateur', ['role_id' => $data['role_id']]);
});
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return $this->renderForm($guard, 0, $form, ['email' => 'Cet email est deja utilise.'], 409);
}
throw $exception;
}
$this->pinThrottle()->reset($guard->userId ?? 0);
$this->setFlash('Utilisateur cree.');
return $this->redirect('/admin/users');
}
/**
* @param array<string, string> $params
*/
public function edit(array $params): Response
{
$guard = $this->guard('user.update');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$user = $this->userRepository()->find($id);
if ($user === null) {
return $this->notFound($guard);
}
return $this->renderForm($guard, $id, $user, []);
}
/**
* @param array<string, string> $params
*/
public function update(array $params): Response
{
$guard = $this->guard('user.update');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$current = $this->userRepository()->find($id);
if ($current === null) {
return $this->notFound($guard);
}
[$data, $errors] = $this->validate($form, true);
if ($errors !== []) {
return $this->renderForm($guard, $id, $form, $errors, 422);
}
if ($this->userRepository()->emailExists($data['email'], $id)) {
return $this->renderForm($guard, $id, $form, ['email' => 'Cet email est deja utilise.'], 409);
}
$isActive = isset($form['is_active']) ? 1 : 0;
// Anti-lockout : on ne retire pas le statut d'admin actif au DERNIER admin
// actif (desactivation OU changement de role) -> sinon back-office inaccessible.
if ($this->isLastActiveAdmin($current) && ($isActive === 0 || $data['role_id'] !== (int) ($current['role_id'] ?? 0))) {
return $this->renderForm($guard, $id, $form, ['role_id' => 'Impossible de retirer le dernier administrateur actif.'], 422);
}
[$actor, $errorMsg] = $this->resolvePin($guard, $form, $id);
if ($actor === null) {
return $this->renderForm($guard, $id, $form, ['pin' => $errorMsg], 422);
}
$changed = $this->changedFields($current, $data, $isActive);
$newHash = $data['password'] !== null ? $this->passwordHasher()->hash((string) $data['password']) : null;
try {
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $data, $isActive, $newHash, $actor, $changed): void {
$repo = new UserRepository($db);
$repo->update($id, [
'email' => $data['email'],
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'role_id' => $data['role_id'],
'is_active' => $isActive,
]);
if ($newHash !== null) {
$repo->setPasswordHash($id, $newHash);
}
$this->writeAudit($db, 'user.update', $actor['id'], $actor['role_id'], $id, 'Mise a jour utilisateur', ['fields' => $changed]);
});
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return $this->renderForm($guard, $id, $form, ['email' => 'Cet email est deja utilise.'], 409);
}
throw $exception;
}
$this->pinThrottle()->reset($guard->userId ?? 0);
$this->setFlash('Utilisateur mis a jour.');
return $this->redirect('/admin/users');
}
/**
* @param array<string, string> $params
*/
public function confirmDeactivate(array $params): Response
{
$guard = $this->guard('user.deactivate');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$user = $this->userRepository()->find($id);
if ($user === null) {
return $this->notFound($guard);
}
return $this->renderConfirm($guard, 'deactivate', $id, $user, null);
}
/**
* @param array<string, string> $params
*/
public function deactivate(array $params): Response
{
$guard = $this->guard('user.deactivate');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$user = $this->userRepository()->find($id);
if ($user === null) {
return $this->notFound($guard);
}
// mlt 10.3 PRE-2 : pas d'auto-desactivation (on ne se coupe pas l'acces).
if ($id === ($guard->userId ?? 0)) {
return $this->renderConfirm($guard, 'deactivate', $id, $user, 'Vous ne pouvez pas desactiver votre propre compte.', 403);
}
if ($this->isLastActiveAdmin($user)) {
return $this->renderConfirm($guard, 'deactivate', $id, $user, 'Impossible de desactiver le dernier administrateur actif.', 422);
}
[$actor, $errorMsg] = $this->resolvePin($guard, $form, $id);
if ($actor === null) {
return $this->renderConfirm($guard, 'deactivate', $id, $user, $errorMsg, 422);
}
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor): void {
(new UserRepository($db))->deactivate($id);
$this->writeAudit($db, 'user.deactivate', $actor['id'], $actor['role_id'], $id, 'Desactivation utilisateur', null);
});
$this->pinThrottle()->reset($guard->userId ?? 0);
$this->setFlash('Utilisateur desactive.');
return $this->redirect('/admin/users');
}
/**
* @param array<string, string> $params
*/
public function confirmResetPin(array $params): Response
{
$guard = $this->guard('user.update');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$user = $this->userRepository()->find($id);
if ($user === null) {
return $this->notFound($guard);
}
return $this->renderConfirm($guard, 'reset-pin', $id, $user, null);
}
/**
* @param array<string, string> $params
*/
public function resetPin(array $params): Response
{
$guard = $this->guard('user.update');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$user = $this->userRepository()->find($id);
if ($user === null) {
return $this->notFound($guard);
}
[$actor, $errorMsg] = $this->resolvePin($guard, $form, $id);
if ($actor === null) {
return $this->renderConfirm($guard, 'reset-pin', $id, $user, $errorMsg, 422);
}
// Met le PIN a NULL : l'equipier le redefinit en self-service. L'admin
// n'a jamais connaissance du PIN d'autrui.
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor): void {
(new UserRepository($db))->clearPin($id);
$this->writeAudit($db, 'user.update', $actor['id'], $actor['role_id'], $id, 'Reinitialisation du PIN', ['fields' => ['pin_hash']]);
});
$this->pinThrottle()->reset($guard->userId ?? 0);
$this->setFlash('PIN reinitialise : l\'equipier doit le redefinir.');
return $this->redirect('/admin/users');
}
/**
* @param array<string, string> $params
*/
public function confirmErase(array $params): Response
{
$guard = $this->guard('user.update');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$user = $this->userRepository()->find($id);
if ($user === null) {
return $this->notFound($guard);
}
return $this->renderConfirm($guard, 'erase', $id, $user, null);
}
/**
* Effacement RGPD (mlt 10.5) : anonymise la ligne (tombstone), preserve les FK.
*
* @param array<string, string> $params
*/
public function erase(array $params): Response
{
$guard = $this->guard('user.update');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$user = $this->userRepository()->find($id);
if ($user === null) {
return $this->notFound($guard);
}
// PRE-3 : deja anonymise -> 409.
if (($user['anonymized_at'] ?? null) !== null) {
return $this->renderConfirm($guard, 'erase', $id, $user, 'Ce compte est deja anonymise.', 409);
}
if ($id === ($guard->userId ?? 0)) {
return $this->renderConfirm($guard, 'erase', $id, $user, 'Vous ne pouvez pas anonymiser votre propre compte.', 403);
}
if ($this->isLastActiveAdmin($user)) {
return $this->renderConfirm($guard, 'erase', $id, $user, 'Impossible d\'anonymiser le dernier administrateur actif.', 422);
}
[$actor, $errorMsg] = $this->resolvePin($guard, $form, $id);
if ($actor === null) {
return $this->renderConfirm($guard, 'erase', $id, $user, $errorMsg, 422);
}
$erased = 0;
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, &$erased): void {
$erased = (new UserRepository($db))->anonymise($id);
if ($erased === 1) {
$this->writeAudit($db, 'user.erase_pii', $actor['id'], $actor['role_id'], $id, 'Anonymisation RGPD (droit a l effacement)', null);
}
});
// Course : anonymise entre la lecture et l'effacement -> 0 ligne (409).
if ($erased !== 1) {
return $this->renderConfirm($guard, 'erase', $id, $user, 'Ce compte est deja anonymise.', 409);
}
$this->pinThrottle()->reset($guard->userId ?? 0);
$this->setFlash('Compte anonymise (RGPD).');
return $this->redirect('/admin/users');
}
// --- Helpers ---
protected function userRepository(): UserRepository
{
return new UserRepository($this->db());
}
protected function pinVerifier(): PinVerifier
{
return new PinVerifier($this->db(), $this->config, $this->passwordHasher());
}
protected function pinThrottle(): PinThrottle
{
return new PinThrottle($this->db(), $this->config);
}
protected function passwordHasher(): PasswordHasher
{
return new PasswordHasher($this->config);
}
private function may(GuardResult $guard, string $permission): bool
{
return $guard->roleId !== null && $this->authorizer()->can($guard->roleId, $permission);
}
/**
* Le compte cible est-il le dernier administrateur ACTIF ? (actif + role admin
* + un seul admin actif au total). Garde anti-lockout du back-office.
*
* @param array<string, mixed> $user
*/
private function isLastActiveAdmin(array $user): bool
{
return (int) ($user['is_active'] ?? 0) === 1
&& $this->userRepository()->isAdmin((int) ($user['id'] ?? 0))
&& $this->userRepository()->activeAdminCount() === 1;
}
/**
* Porte du PIN d'action sensible (RG-T13 + throttle RG-T22), mutualisee par
* toutes les mutations. Verrou evalue AVANT la verification (leurre de timing) ;
* sur echec hors verrou, ecrit pin.failed + increment du throttle dans UNE
* transaction (RG-T08/RG-T14). Retourne [acteur resolu, null] au succes, sinon
* [null, message generique]. La reinitialisation du compteur (succes) est
* laissee a l'appelant, apres l'effet.
*
* @param array<string, string> $form
* @return array{0: array<string, mixed>|null, 1: string}
*/
private function resolvePin(GuardResult $guard, array $form, int $entityId): array
{
$generic = 'Email ou PIN invalide (requis pour cette action).';
$actorId = $guard->userId ?? 0;
if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) {
$this->pinVerifier()->payTimingDecoy($form['pin'] ?? '');
return [null, $generic];
}
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) {
$email = trim($form['pin_email'] ?? '');
$this->db()->transaction(function (DatabaseInterface $db) use ($email, $entityId, $actorId): void {
$this->logFailedPin($db, $email, $entityId);
$this->pinThrottle()->recordFailureWithin($db, $actorId);
});
return [null, $generic];
}
return [$actor, ''];
}
/**
* Validation serveur (RG-T18) + normalisation. Mot de passe requis a la creation
* (>= 8), optionnel a l'edition (re-hache seulement si fourni, mlt 10.2 RG-1/2).
*
* @param array<string, string> $form
* @return array{0: array{email: string, first_name: string, last_name: string, role_id: int, password: ?string}, 1: array<string, string>}
*/
private function validate(array $form, bool $isUpdate): array
{
$errors = [];
$email = trim($form['email'] ?? '');
if ($email === '' || mb_strlen($email) > 254 || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Email valide requis (254 caracteres max).';
}
$first = trim($form['first_name'] ?? '');
if ($first === '' || mb_strlen($first) > 60) {
$errors['first_name'] = 'Le prenom est requis (60 caracteres max).';
}
$last = trim($form['last_name'] ?? '');
if ($last === '' || mb_strlen($last) > 60) {
$errors['last_name'] = 'Le nom est requis (60 caracteres max).';
}
$roleRaw = trim($form['role_id'] ?? '');
$roleId = ctype_digit($roleRaw) ? (int) $roleRaw : 0;
if ($roleId === 0 || !$this->userRepository()->activeRoleExists($roleId)) {
$errors['role_id'] = 'Role requis et actif.';
}
$password = (string) ($form['password'] ?? '');
if (!$isUpdate && mb_strlen($password) < 8) {
$errors['password'] = 'Mot de passe requis (8 caracteres min).';
} elseif ($isUpdate && $password !== '' && mb_strlen($password) < 8) {
$errors['password'] = 'Le nouveau mot de passe doit faire 8 caracteres min.';
}
$data = [
'email' => $email,
'first_name' => $first,
'last_name' => $last,
'role_id' => $roleId,
'password' => $password !== '' ? $password : null,
];
return [$data, $errors];
}
/**
* Noms des champs modifies (pas les valeurs, pas de PII) pour le `details`
* d'audit (RG-T14).
*
* @param array<string, mixed> $current
* @param array{email: string, first_name: string, last_name: string, role_id: int, password: ?string} $data
* @return list<string>
*/
private function changedFields(array $current, array $data, int $isActive): array
{
$changed = [];
if ($data['email'] !== (string) ($current['email'] ?? '')) {
$changed[] = 'email';
}
if ($data['first_name'] !== (string) ($current['first_name'] ?? '')) {
$changed[] = 'first_name';
}
if ($data['last_name'] !== (string) ($current['last_name'] ?? '')) {
$changed[] = 'last_name';
}
if ($data['role_id'] !== (int) ($current['role_id'] ?? 0)) {
$changed[] = 'role_id';
}
if ($isActive !== (int) ($current['is_active'] ?? 0)) {
$changed[] = 'is_active';
}
if ($data['password'] !== null) {
$changed[] = 'password_hash';
}
return $changed;
}
private function logFailedPin(DatabaseInterface $db, string $email, int $entityId): void
{
$db->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
[
'uid' => null,
'rid' => null,
'code' => 'pin.failed',
'etype' => self::ENTITY,
'eid' => $entityId > 0 ? $entityId : null,
'summary' => 'Echec PIN gestion utilisateur (email tente: ' . $email . ')',
],
);
}
/**
* @param array<string, mixed>|null $details
*/
private function writeAudit(DatabaseInterface $db, string $action, int $userId, int $roleId, int $entityId, string $summary, ?array $details): void
{
$db->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary, details) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary, :details)',
[
'uid' => $userId,
'rid' => $roleId,
'code' => $action,
'etype' => self::ENTITY,
'eid' => $entityId,
'summary' => $summary,
'details' => $details !== null ? (string) json_encode($details) : null,
],
);
}
/**
* @param array<string, mixed> $values
* @param array<string, string> $errors
*/
private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response
{
return $this->adminView('admin/users/form', [
'title' => ($id !== 0 ? 'Modifier' : 'Nouvel') . ' utilisateur - Wakdo Admin',
'activeNav' => 'users',
'userId' => $id,
'roles' => $this->rolesForSelect(),
'values' => [
'email' => (string) ($values['email'] ?? ''),
'first_name' => (string) ($values['first_name'] ?? ''),
'last_name' => (string) ($values['last_name'] ?? ''),
'role_id' => (string) ($values['role_id'] ?? ''),
// Defaut actif a la creation ; sur re-rendu refleter la presence du champ.
'is_active' => $id === 0 ? true : ((int) ($values['is_active'] ?? 1) === 1),
],
'errors' => $errors,
'csrfToken' => Csrf::token($this->sessionManager()),
], $guard, $status);
}
/**
* @param array<string, mixed> $user
*/
private function renderConfirm(GuardResult $guard, string $kind, int $id, array $user, ?string $error, ?int $status = null): Response
{
return $this->adminView('admin/users/confirm', [
'title' => 'Confirmation - Wakdo Admin',
'activeNav' => 'users',
'kind' => $kind,
'userId' => $id,
'userLabel' => trim(((string) ($user['first_name'] ?? '')) . ' ' . ((string) ($user['last_name'] ?? ''))) ?: (string) ($user['email'] ?? ''),
'error' => $error,
'csrfToken' => Csrf::token($this->sessionManager()),
], $guard, $status ?? ($error !== null ? 422 : 200));
}
/**
* Roles actifs pour le select (id + label), via une lecture directe (pas de
* repo dedie avant le lot RBAC).
*
* @return list<array{id:int, label:string}>
*/
private function rolesForSelect(): array
{
$rows = $this->db()->fetchAll('SELECT id, label FROM role WHERE is_active = 1 ORDER BY label');
return array_map(static fn (array $r): array => [
'id' => (int) ($r['id'] ?? 0),
'label' => (string) ($r['label'] ?? ''),
], $rows);
}
private function notFound(GuardResult $guard): Response
{
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'users'], $guard, 404);
}
private function redirect(string $location): Response
{
return Response::make('', 302, ['Location' => $location]);
}
private function invalidCsrf(): Response
{
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
}
}

View file

@ -125,11 +125,18 @@ $navClass = static function (string $code, string $current): string {
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($can('user.read')): ?>
<div class="sidebar-section">
<div class="sidebar-section-label">Administration</div>
<a href="/admin/users" class="<?= $navClass('users', $active) ?>">Utilisateurs</a>
</div>
<?php endif; ?>
<?php /* <?php /*
Items de nav volontairement absents tant que leur page n'existe pas Items de nav volontairement absents tant que leur page n'existe pas
(un lien vers une route non enregistree renvoie un 404). A reactiver (un lien vers une route non enregistree renvoie un 404). A reactiver
avec leur route respective : Commandes (order.read), Utilisateurs avec leur route respective : Commandes (order.read), Roles (role.manage)
(user.read), Roles (role.manage) -- P3 suite / P4. -- lot RBAC / P4.
*/ ?> */ ?>
</nav> </nav>

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* Page de confirmation des actions sensibles sur un compte (desactivation,
* reinitialisation de PIN, anonymisation RGPD), injectee dans admin/layout.php.
* Chaque action exige une re-autorisation par PIN equipier (RG-T13). Texte echappe.
*
* @var string $kind deactivate | reset-pin | erase
* @var int $userId
* @var string $userLabel
* @var string|null $error
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$id = (int) ($userId ?? 0);
$kind = (string) ($kind ?? '');
$label = htmlspecialchars((string) ($userLabel ?? ''), ENT_QUOTES, 'UTF-8');
$err = isset($error) && is_string($error) ? htmlspecialchars($error, ENT_QUOTES, 'UTF-8') : '';
/** @var array<string, array{path:string, title:string, message:string, button:string}> $kinds */
$kinds = [
'deactivate' => [
'path' => '/admin/users/' . $id . '/deactivate',
'title' => 'Desactiver le compte',
'message' => 'L\'utilisateur ne pourra plus se connecter. L\'historique reste intact. Reversible (reactivation via Modifier).',
'button' => 'Desactiver',
],
'reset-pin' => [
'path' => '/admin/users/' . $id . '/reset-pin',
'title' => 'Reinitialiser le PIN',
'message' => 'Le PIN d\'action sensible de cet equipier sera efface. Il devra en redefinir un en self-service.',
'button' => 'Reinitialiser le PIN',
],
'erase' => [
'path' => '/admin/users/' . $id . '/erase',
'title' => 'Anonymiser le compte (RGPD)',
'message' => 'Les donnees personnelles seront effacees definitivement (droit a l\'effacement). La ligne est conservee anonymisee pour preserver l\'historique. Action IRREVERSIBLE.',
'button' => 'Anonymiser definitivement',
],
];
$c = $kinds[$kind] ?? $kinds['deactivate'];
?>
<div class="page-header">
<div>
<h1 class="page-title"><?= htmlspecialchars($c['title'], ENT_QUOTES, 'UTF-8') ?></h1>
</div>
</div>
<form method="post" action="<?= htmlspecialchars($c['path'], ENT_QUOTES, 'UTF-8') ?>" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<p>Compte cible : <strong><?= $label ?></strong></p>
<p class="muted"><?= htmlspecialchars($c['message'], ENT_QUOTES, 'UTF-8') ?></p>
<?php if ($err !== ''): ?><p class="form-error"><?= $err ?></p><?php endif; ?>
<fieldset class="form-group">
<legend>Re-autorisation (PIN equipier)</legend>
<div class="form-group">
<label class="form-label" for="pin_email">Email equipier</label>
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label" for="pin">PIN</label>
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off">
</div>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit"><?= htmlspecialchars($c['button'], ENT_QUOTES, 'UTF-8') ?></button>
<a class="btn btn-secondary" href="/admin/users">Annuler</a>
</div>
</form>

View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/**
* Formulaire utilisateur (creation/edition), injecte dans admin/layout.php.
* Reaffiche valeurs + erreurs (RG-T18). Toute soumission est une action sensible :
* le bloc PIN equipier (email + PIN) est requis (RG-T13). Le mot de passe est requis
* a la creation, optionnel a l'edition (laisser vide = inchange, mlt 10.2 RG-2).
*
* @var int $userId
* @var list<array{id:int, label:string}> $roles
* @var array<string, mixed> $values
* @var array<string, string> $errors
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$id = (int) ($userId ?? 0);
$action = $id !== 0 ? '/admin/users/' . $id : '/admin/users';
/** @var array<string, mixed> $vals */
$vals = isset($values) && is_array($values) ? $values : [];
/** @var array<string, string> $errs */
$errs = isset($errors) && is_array($errors) ? $errors : [];
/** @var list<array{id:int, label:string}> $roleList */
$roleList = isset($roles) && is_array($roles) ? $roles : [];
$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8');
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? htmlspecialchars($errs[$k], ENT_QUOTES, 'UTF-8') : '';
$selectedRole = (string) ($vals['role_id'] ?? '');
$active = (bool) ($vals['is_active'] ?? true);
?>
<div class="page-header">
<div>
<h1 class="page-title"><?= $id !== 0 ? 'Modifier l\'utilisateur' : 'Nouvel utilisateur' ?></h1>
</div>
</div>
<form method="post" action="<?= htmlspecialchars($action, ENT_QUOTES, 'UTF-8') ?>" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="email">Email</label>
<input class="form-input" type="email" id="email" name="email" maxlength="254" value="<?= $val('email') ?>" required>
<?php if ($err('email') !== ''): ?><p class="form-error"><?= $err('email') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="first_name">Prenom</label>
<input class="form-input" type="text" id="first_name" name="first_name" maxlength="60" value="<?= $val('first_name') ?>" required>
<?php if ($err('first_name') !== ''): ?><p class="form-error"><?= $err('first_name') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="last_name">Nom</label>
<input class="form-input" type="text" id="last_name" name="last_name" maxlength="60" value="<?= $val('last_name') ?>" required>
<?php if ($err('last_name') !== ''): ?><p class="form-error"><?= $err('last_name') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="role_id">Role</label>
<select class="form-input" id="role_id" name="role_id" required>
<option value="">-- choisir --</option>
<?php foreach ($roleList as $role): ?>
<?php $rid = (string) $role['id']; ?>
<option value="<?= htmlspecialchars($rid, ENT_QUOTES, 'UTF-8') ?>"<?= $rid === $selectedRole ? ' selected' : '' ?>>
<?= htmlspecialchars($role['label'], ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($err('role_id') !== ''): ?><p class="form-error"><?= $err('role_id') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="password"><?= $id !== 0 ? 'Nouveau mot de passe (laisser vide = inchange)' : 'Mot de passe' ?></label>
<input class="form-input" type="password" id="password" name="password" autocomplete="new-password"<?= $id === 0 ? ' required' : '' ?>>
<?php if ($err('password') !== ''): ?><p class="form-error"><?= $err('password') ?></p><?php endif; ?>
</div>
<?php if ($id !== 0): ?>
<div class="form-group">
<label class="form-label"><input type="checkbox" name="is_active" value="1"<?= $active ? ' checked' : '' ?>> Compte actif</label>
</div>
<?php endif; ?>
<fieldset class="form-group">
<legend>Re-autorisation (PIN equipier)</legend>
<p><small>La gestion des comptes est une action sensible : confirmez avec votre email et votre PIN.</small></p>
<div class="form-group">
<label class="form-label" for="pin_email">Email equipier</label>
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label" for="pin">PIN</label>
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off">
</div>
<?php if ($err('pin') !== ''): ?><p class="form-error"><?= $err('pin') ?></p><?php endif; ?>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Enregistrer</button>
<a class="btn btn-secondary" href="/admin/users">Annuler</a>
</div>
</form>

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/**
* Liste des utilisateurs back-office (CRUD admin), injectee dans admin/layout.php.
* Texte echappe (RG-T15). Les mutations sont gardees par permission cote serveur ;
* les boutons ne s'affichent que selon les capacites de l'acteur.
*
* @var array<int, array<string, mixed>> $users
* @var int $currentId id de l'acteur (pas d'auto-desactivation)
* @var bool $canCreate
* @var bool $canUpdate
* @var bool $canDeactiv
*/
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($users) && is_array($users) ? $users : [];
$me = (int) ($currentId ?? 0);
$canCreate = (bool) ($canCreate ?? false);
$canUpdate = (bool) ($canUpdate ?? false);
$canDeactiv = (bool) ($canDeactiv ?? false);
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
?>
<div class="page-header">
<div>
<h1 class="page-title">Utilisateurs</h1>
<p class="page-subtitle">Comptes du back-office. Les operations sensibles exigent un PIN.</p>
</div>
<?php if ($canCreate): ?>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/users/new">Nouvel utilisateur</a>
</div>
<?php endif; ?>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Nom</th>
<th>Email</th>
<th>Role</th>
<th>Statut</th>
<th style="width:280px;"></th>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="5" class="muted">Aucun utilisateur.</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
$anon = ($row['anonymized_at'] ?? null) !== null;
$isSelf = $id === $me;
$name = trim(((string) ($row['first_name'] ?? '')) . ' ' . ((string) ($row['last_name'] ?? '')));
?>
<tr>
<td class="fw-600"><?= $esc($name !== '' ? $name : '(anonymise)') ?><?= $isSelf ? ' <span class="muted">(vous)</span>' : '' ?></td>
<td class="muted"><?= $esc($row['email'] ?? '') ?></td>
<td class="muted"><?= $esc($row['role_label'] ?? '') ?></td>
<td>
<?php if ($anon): ?>
<span class="pill pill-neutral">Anonymise</span>
<?php elseif ($active): ?>
<span class="pill pill-success">Actif</span>
<?php else: ?>
<span class="pill pill-neutral">Inactif</span>
<?php endif; ?>
</td>
<td>
<?php if (!$anon): ?>
<?php if ($canUpdate): ?>
<a class="btn btn-secondary" href="/admin/users/<?= $id ?>/edit">Modifier</a>
<a class="btn btn-secondary" href="/admin/users/<?= $id ?>/reset-pin">Reset PIN</a>
<?php endif; ?>
<?php if ($canDeactiv && $active && !$isSelf): ?>
<a class="btn btn-secondary" href="/admin/users/<?= $id ?>/deactivate">Desactiver</a>
<?php endif; ?>
<?php if ($canUpdate && !$isSelf): ?>
<a class="btn btn-secondary" href="/admin/users/<?= $id ?>/erase">Anonymiser</a>
<?php endif; ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View file

@ -23,6 +23,7 @@ use App\Controllers\PasswordResetController;
use App\Controllers\ProductController; use App\Controllers\ProductController;
use App\Controllers\ProfileController; use App\Controllers\ProfileController;
use App\Controllers\StatsController; use App\Controllers\StatsController;
use App\Controllers\UserController;
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,21 @@ try {
// catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4). // catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4).
$router->add('GET', '/admin/stats', [StatsController::class, 'index']); $router->add('GET', '/admin/stats', [StatsController::class, 'index']);
// Gestion des comptes (mlt domaine 10). user.read (liste) ; user.create/update/
// deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un
// seul segment (pas de collision avec /edit, /deactivate, /reset-pin, /erase).
$router->add('GET', '/admin/users', [UserController::class, 'index']);
$router->add('GET', '/admin/users/new', [UserController::class, 'create']);
$router->add('POST', '/admin/users', [UserController::class, 'store']);
$router->add('GET', '/admin/users/{id}/edit', [UserController::class, 'edit']);
$router->add('POST', '/admin/users/{id}', [UserController::class, 'update']);
$router->add('GET', '/admin/users/{id}/deactivate', [UserController::class, 'confirmDeactivate']);
$router->add('POST', '/admin/users/{id}/deactivate', [UserController::class, 'deactivate']);
$router->add('GET', '/admin/users/{id}/reset-pin', [UserController::class, 'confirmResetPin']);
$router->add('POST', '/admin/users/{id}/reset-pin', [UserController::class, 'resetPin']);
$router->add('GET', '/admin/users/{id}/erase', [UserController::class, 'confirmErase']);
$router->add('POST', '/admin/users/{id}/erase', [UserController::class, 'erase']);
// CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active. // CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active.
$router->add('GET', '/admin/categories', [CategoryController::class, 'index']); $router->add('GET', '/admin/categories', [CategoryController::class, 'index']);
$router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']); $router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']);

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Integration; namespace App\Tests\Integration;
use PDOException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Throwable; use Throwable;
use App\Auth\PasswordHasher; use App\Auth\PasswordHasher;
@ -20,6 +21,10 @@ final class UserRepositoryDbTest extends TestCase
private Database $db; private Database $db;
private Config $config; private Config $config;
private int $userId = 0; private int $userId = 0;
private int $counterRoleId = 0;
private int $adminRoleId = 0;
/** @var list<int> ids des comptes crees par les tests CRUD (nettoyes par id). */
private array $createdIds = [];
protected function setUp(): void protected function setUp(): void
{ {
@ -36,6 +41,8 @@ final class UserRepositoryDbTest extends TestCase
self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); self::markTestSkipped('Base injoignable: ' . $exception->getMessage());
} }
$this->counterRoleId = (int) ($this->db->fetch("SELECT id FROM role WHERE code = 'counter'")['id'] ?? 0);
$this->adminRoleId = (int) ($this->db->fetch("SELECT id FROM role WHERE code = 'admin'")['id'] ?? 0);
$roleId = (int) ($this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1')['id'] ?? 0); $roleId = (int) ($this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1')['id'] ?? 0);
$hasher = new PasswordHasher($this->config); $hasher = new PasswordHasher($this->config);
$this->db->execute( $this->db->execute(
@ -58,6 +65,24 @@ final class UserRepositoryDbTest extends TestCase
$this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]); $this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]);
$this->userId = 0; $this->userId = 0;
} }
foreach ($this->createdIds as $id) {
$this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $id]);
}
$this->createdIds = [];
}
private function makeUser(UserRepository $repo, string $tag, int $roleId): int
{
$id = $repo->create([
'email' => 'it-user-' . $tag . '-' . bin2hex(random_bytes(3)) . '@wakdo.test',
'password_hash' => '$argon2id$placeholder',
'first_name' => 'Test',
'last_name' => 'User' . $tag,
'role_id' => $roleId,
]);
$this->createdIds[] = $id;
return $id;
} }
public function testSetPinHashAndPinIsSet(): void public function testSetPinHashAndPinIsSet(): void
@ -77,4 +102,89 @@ final class UserRepositoryDbTest extends TestCase
self::assertNotSame('4729', $stored); self::assertNotSame('4729', $stored);
self::assertTrue($hasher->verify('4729', $stored)); self::assertTrue($hasher->verify('4729', $stored));
} }
public function testCreateFindUpdate(): void
{
$repo = new UserRepository($this->db);
self::assertTrue($repo->activeRoleExists($this->counterRoleId));
self::assertFalse($repo->activeRoleExists(0));
$id = $this->makeUser($repo, 'a', $this->counterRoleId);
self::assertGreaterThan(0, $id);
$found = $repo->find($id);
self::assertNotNull($found);
self::assertSame($this->counterRoleId, (int) $found['role_id']);
self::assertSame(1, (int) $found['is_active']);
self::assertTrue($repo->emailExists((string) $found['email']));
self::assertFalse($repo->emailExists((string) $found['email'], $id)); // s'exclut lui-meme
$repo->update($id, [
'email' => (string) $found['email'],
'first_name' => 'Renamed',
'last_name' => 'Person',
'role_id' => $this->adminRoleId,
'is_active' => 0,
]);
$updated = $repo->find($id);
self::assertNotNull($updated);
self::assertSame('Renamed', (string) $updated['first_name']);
self::assertSame($this->adminRoleId, (int) $updated['role_id']);
self::assertSame(0, (int) $updated['is_active']);
$emails = array_map(static fn (array $r): string => (string) ($r['email'] ?? ''), $repo->all());
self::assertContains((string) $found['email'], $emails); // all() joint le libelle de role
}
public function testDuplicateEmailViolatesUnique(): void
{
$repo = new UserRepository($this->db);
$id = $this->makeUser($repo, 'dup', $this->counterRoleId);
$email = (string) ($repo->find($id)['email'] ?? '');
$violated = false;
try {
$newId = $repo->create(['email' => $email, 'password_hash' => 'x', 'first_name' => 'D', 'last_name' => 'U', 'role_id' => $this->counterRoleId]);
$this->createdIds[] = $newId;
} catch (PDOException $exception) {
$violated = (string) $exception->getCode() === '23000';
}
self::assertTrue($violated, 'uk_user_email doit rejeter un doublon (SQLSTATE 23000).');
}
public function testDeactivateThenAnonymiseIsIdempotent(): void
{
$repo = new UserRepository($this->db);
$id = $this->makeUser($repo, 'rgpd', $this->counterRoleId);
self::assertSame(1, $repo->deactivate($id));
self::assertSame(0, (int) ($repo->find($id)['is_active'] ?? -1));
self::assertSame(1, $repo->anonymise($id)); // vide la PII, garde la ligne (tombstone)
$anon = $repo->find($id);
self::assertNotNull($anon);
self::assertSame('', (string) $anon['first_name']);
self::assertSame('', (string) $anon['last_name']);
self::assertSame('anon-' . $id . '@wakdo.invalid', (string) $anon['email']);
self::assertNotNull($anon['anonymized_at']);
self::assertSame(0, $repo->anonymise($id)); // idempotent : deja anonymise
}
public function testActiveAdminCountAndIsAdmin(): void
{
$repo = new UserRepository($this->db);
$before = $repo->activeAdminCount();
self::assertGreaterThanOrEqual(1, $before); // le seed pose un admin actif
$adminId = $this->makeUser($repo, 'adm', $this->adminRoleId);
self::assertSame($before + 1, $repo->activeAdminCount());
self::assertTrue($repo->isAdmin($adminId));
$counterId = $this->makeUser($repo, 'cnt', $this->counterRoleId);
self::assertFalse($repo->isAdmin($counterId));
$repo->deactivate($adminId);
self::assertSame($before, $repo->activeAdminCount()); // redescend
}
} }

View file

@ -201,6 +201,42 @@ final class FakeDatabase implements DatabaseInterface
/** Compteur renvoye par ProductRepository::compositionCount() (trace cascade #27). */ /** Compteur renvoye par ProductRepository::compositionCount() (trace cascade #27). */
public int $productCompositionCount = 0; public int $productCompositionCount = 0;
/**
* Lignes renvoyees par UserRepository::all() (JOIN role).
*
* @var list<array<string, mixed>>
*/
public array $usersRows = [];
/**
* Ligne renvoyee par UserRepository::find() (gestion des comptes) ; null = absent.
*
* @var array<string, mixed>|null
*/
public ?array $userManageRow = null;
/** Resultat de UserRepository::emailExists(). */
public bool $userEmailTaken = false;
/** Resultat de UserRepository::activeRoleExists() (role existe ET actif). */
public bool $roleActiveExists = true;
/** Id renvoye par SELECT LAST_INSERT_ID() (create user/menu). */
public int $lastInsertId = 0;
/** Compteur renvoye par UserRepository::activeAdminCount() (garde dernier admin). */
public int $activeAdminCount = 0;
/** Resultat de UserRepository::isAdmin(). */
public bool $userIsAdmin = false;
/**
* Lignes {id,label} renvoyees par le select de roles (UserController::rolesForSelect).
*
* @var list<array<string, mixed>>
*/
public array $rolesRows = [];
/** /**
* Allowlist optionnelle de codes de permission accordes (RG-T03). Si non nul, * Allowlist optionnelle de codes de permission accordes (RG-T03). Si non nul,
* can() repond par appartenance du :code lie a cette liste (permet de tester la * can() repond par appartenance du :code lie a cette liste (permet de tester la
@ -259,6 +295,34 @@ final class FakeDatabase implements DatabaseInterface
return $this->userDisplayRow; return $this->userDisplayRow;
} }
// --- Gestion des comptes (UserController/UserRepository) ---
// AVANT le lookup auth 'FROM user u JOIN role' : les agregats RBAC le
// contiennent aussi (COUNT admins, isAdmin), il faut les router en premier.
if (str_contains($sql, 'COUNT(*) AS n FROM user u JOIN role')) {
return ['n' => $this->activeAdminCount];
}
if (str_contains($sql, "WHERE u.id = :id AND r.code = 'admin'")) {
return $this->userIsAdmin ? ['id' => 1] : null;
}
// AVANT 'SELECT id FROM user WHERE email' (emailLookupRow) : unicite (exclut une id).
if (str_contains($sql, 'FROM user WHERE email = :email AND id <> :id')) {
return $this->userEmailTaken ? ['id' => 1] : null;
}
if (str_contains($sql, 'anonymized_at FROM user WHERE id')) {
return $this->userManageRow;
}
if (str_contains($sql, 'FROM role WHERE id = :id AND is_active = 1')) {
return $this->roleActiveExists ? ['id' => 1] : null;
}
if (str_contains($sql, 'LAST_INSERT_ID')) {
return ['id' => $this->lastInsertId];
}
if (str_contains($sql, 'FROM user u JOIN role')) { if (str_contains($sql, 'FROM user u JOIN role')) {
return $this->userRow; return $this->userRow;
} }
@ -396,6 +460,14 @@ final class FakeDatabase implements DatabaseInterface
return $this->compositionRows; return $this->compositionRows;
} }
if (str_contains($sql, 'FROM user u JOIN role r ON r.id = u.role_id')) {
return $this->usersRows;
}
if (str_contains($sql, 'FROM role WHERE is_active = 1 ORDER BY label')) {
return $this->rolesRows;
}
if (str_contains($sql, 'FROM stock_movement WHERE ingredient_id')) { if (str_contains($sql, 'FROM stock_movement WHERE ingredient_id')) {
return $this->movementsRows; return $this->movementsRows;
} }

View file

@ -153,9 +153,9 @@ final class DashboardControllerTest extends TestCase
// Navigation conditionnee aux permissions : un lien n'apparait que si la // Navigation conditionnee aux permissions : un lien n'apparait que si la
// permission est presente ET la page existe. // permission est presente ET la page existe.
self::assertStringContainsString('/admin/products', $body); // product.read present + page existante self::assertStringContainsString('/admin/products', $body); // product.read present + page existante
// user.read est present, mais la page /admin/users n'existe pas encore : // user.read present + la page /admin/users existe desormais (lot Users) :
// le lien est retire pour ne pas exposer un 404 (cf. layout.php). // le lien de nav Administration apparait.
self::assertStringNotContainsString('/admin/users', $body); self::assertStringContainsString('/admin/users', $body);
self::assertStringNotContainsString('/admin/roles', $body); // pas de page + role.manage absent self::assertStringNotContainsString('/admin/roles', $body); // pas de page + role.manage absent
// Deconnexion = formulaire POST avec CSRF. // Deconnexion = formulaire POST avec CSRF.
self::assertStringContainsString('action="/logout"', $body); self::assertStringContainsString('action="/logout"', $body);

View file

@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PDOException;
use PHPUnit\Framework\TestCase;
use App\Auth\Csrf;
use App\Auth\PasswordHasher;
use App\Auth\SessionManager;
use App\Controllers\UserController;
use App\Core\Config;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Request;
use App\Tests\Support\FakeDatabase;
final class TestUserController extends UserController
{
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 db(): DatabaseInterface
{
return $this->fakeDb;
}
}
final class UserControllerTest 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); // acteur de session = id 1 (admin)
$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' => 'Cor', 'last_name' => 'J', 'role_label' => 'Administrateur'];
$db->canResult = true;
$db->permissionCodes = ['user.read', 'user.create', 'user.update', 'user.deactivate'];
$db->roleActiveExists = true;
$db->rolesRows = [['id' => 4, 'label' => 'Counter Staff']];
return $db;
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function target(array $overrides = []): array
{
return array_merge([
'id' => 5, 'email' => 'staff@wakdo.local', 'first_name' => 'Sam', 'last_name' => 'Staff',
'role_id' => 4, 'is_active' => 1, 'anonymized_at' => null,
], $overrides);
}
private function actingPin(FakeDatabase $db): void
{
$db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')];
}
/**
* @param array<string, string> $overrides
* @return array<string, string>
*/
private function createForm(array $overrides = []): array
{
return array_merge([
'_csrf' => $this->csrf,
'email' => 'new@wakdo.local',
'first_name' => 'New',
'last_name' => 'Hire',
'role_id' => '4',
'password' => 'motdepasse8',
'pin_email' => 'sam@wakdo.local',
'pin' => '4729',
], $overrides);
}
private function get(string $path): Request
{
return new Request('GET', $path, [], [], '', '203.0.113.5');
}
/**
* @param array<string, string> $form
*/
private function post(array $form, string $path): Request
{
return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5');
}
private function controller(Request $request, FakeDatabase $db): TestUserController
{
return new TestUserController($request, new Config(), new Database(new Config()), $this->session, $db);
}
/**
* @return array{sql: string, params: array<string|int, mixed>}|null
*/
private function findWrite(FakeDatabase $db, string $needle): ?array
{
foreach ($db->writes as $write) {
if (str_contains($write['sql'], $needle)) {
return $write;
}
}
return null;
}
// --- Lecture (user.read) ---
public function testIndexRequiresUserRead(): void
{
$db = $this->permittedDb();
$db->canResult = false;
self::assertSame(403, $this->controller($this->get('/admin/users'), $db)->index()->status());
}
public function testIndexListsUsers(): void
{
$db = $this->permittedDb();
$db->usersRows = [$this->target(['email' => 'sam@wakdo.local']) + ['role_label' => 'Counter Staff']];
$response = $this->controller($this->get('/admin/users'), $db)->index();
self::assertSame(200, $response->status());
self::assertStringContainsString('sam@wakdo.local', $response->body());
}
// --- Creation (user.create) : PIN + audit ---
public function testStoreCreatesWithValidPinAndAudits(): void
{
$db = $this->permittedDb();
$this->actingPin($db);
$db->lastInsertId = 42;
$response = $this->controller($this->post($this->createForm(), '/admin/users'), $db)->store();
self::assertSame(302, $response->status());
self::assertSame(['begin', 'commit'], $db->transactionEvents);
self::assertTrue($db->wrote('INSERT INTO user'));
$audit = $this->findWrite($db, 'INSERT INTO audit_log');
self::assertNotNull($audit);
self::assertSame('user.create', $audit['params']['code'] ?? null);
self::assertSame(9, $audit['params']['uid'] ?? null); // acteur resolu par PIN, pas la session
}
public function testStoreWithoutValidPinLogsFailedAndDoesNotCreate(): void
{
$db = $this->permittedDb();
$db->actingUserRow = null; // PIN non resolu
$response = $this->controller($this->post($this->createForm(['pin' => '0000']), '/admin/users'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO user'));
self::assertSame(['pin.failed'], $db->auditActions());
}
public function testStoreRejectsDuplicateEmailWith409(): void
{
$db = $this->permittedDb();
$this->actingPin($db);
$db->userEmailTaken = true;
$response = $this->controller($this->post($this->createForm(), '/admin/users'), $db)->store();
self::assertSame(409, $response->status());
self::assertFalse($db->wrote('INSERT INTO user'));
}
public function testStoreValidationRejectsShortPasswordAndBadEmail(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post($this->createForm(['email' => 'nope', 'password' => 'short']), '/admin/users'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO user'));
}
public function testStoreRejectsInvalidCsrf(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post($this->createForm(['_csrf' => 'bad']), '/admin/users'), $db)->store();
self::assertSame(403, $response->status());
self::assertFalse($db->wrote('INSERT INTO user'));
}
public function testStoreTranslatesUniqueRaceTo409(): void
{
$db = $this->permittedDb();
$this->actingPin($db);
$db->failOnExecute = new PDOException('dup', 23000);
$response = $this->controller($this->post($this->createForm(), '/admin/users'), $db)->store();
self::assertSame(409, $response->status());
}
// --- Mise a jour (user.update) ---
public function testUpdateNotFound(): void
{
$db = $this->permittedDb();
$db->userManageRow = null;
self::assertSame(404, $this->controller($this->post($this->createForm(), '/admin/users/9'), $db)->update(['id' => '9'])->status());
}
public function testUpdateAppliesWithPinAndAudits(): void
{
$db = $this->permittedDb();
$db->userManageRow = $this->target();
$this->actingPin($db);
$form = $this->createForm(['email' => 'staff@wakdo.local', 'first_name' => 'Renamed', 'is_active' => '1']);
$response = $this->controller($this->post($form, '/admin/users/5'), $db)->update(['id' => '5']);
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('UPDATE user SET email'));
$audit = $this->findWrite($db, 'INSERT INTO audit_log');
self::assertNotNull($audit);
self::assertSame('user.update', $audit['params']['code'] ?? null);
}
public function testUpdateBlocksRemovingLastActiveAdmin(): void
{
$db = $this->permittedDb();
$db->userManageRow = $this->target(['is_active' => 1]); // cible admin actif
$db->userIsAdmin = true;
$db->activeAdminCount = 1; // dernier admin actif
// is_active absent du form -> desactivation tentee -> bloquee.
$form = $this->createForm(['email' => 'staff@wakdo.local']);
unset($form['pin_email'], $form['pin']);
$response = $this->controller($this->post($form, '/admin/users/5'), $db)->update(['id' => '5']);
self::assertSame(422, $response->status());
self::assertStringContainsString('dernier administrateur', $response->body());
self::assertFalse($db->wrote('UPDATE user SET email'));
}
// --- Desactivation (user.deactivate) ---
public function testDeactivateSelfForbidden(): void
{
$db = $this->permittedDb();
$db->userManageRow = $this->target(['id' => 1]); // cible = acteur de session
$this->actingPin($db);
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/users/1/deactivate'), $db)->deactivate(['id' => '1']);
self::assertSame(403, $response->status());
self::assertFalse($db->wrote('SET is_active = 0'));
}
public function testDeactivateBlocksLastActiveAdmin(): void
{
$db = $this->permittedDb();
$db->userManageRow = $this->target(['id' => 5]);
$db->userIsAdmin = true;
$db->activeAdminCount = 1;
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/users/5/deactivate'), $db)->deactivate(['id' => '5']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('SET is_active = 0'));
}
public function testDeactivateWithPinAndAudit(): void
{
$db = $this->permittedDb();
$db->userManageRow = $this->target(['id' => 5]);
$db->userIsAdmin = false; // pas admin -> garde dernier-admin non declenchee
$this->actingPin($db);
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/users/5/deactivate'), $db)->deactivate(['id' => '5']);
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('SET is_active = 0'));
self::assertSame('user.deactivate', ($this->findWrite($db, 'INSERT INTO audit_log')['params']['code'] ?? null));
}
public function testDeactivateLockedActorReturns422WithoutEffect(): void
{
$db = $this->permittedDb();
$db->userManageRow = $this->target(['id' => 5]);
$this->actingPin($db);
$db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', time() + 300);
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/users/5/deactivate'), $db)->deactivate(['id' => '5']);
self::assertSame(422, $response->status());
self::assertSame([], $db->auditActions()); // pas de pin.failed sous verrou (RG-T22)
self::assertFalse($db->wrote('SET is_active = 0'));
}
// --- Reset PIN (user.update) ---
public function testResetPinClearsPin(): void
{
$db = $this->permittedDb();
$db->userManageRow = $this->target(['id' => 5]);
$this->actingPin($db);
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/users/5/reset-pin'), $db)->resetPin(['id' => '5']);
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('UPDATE user SET pin_hash = NULL'));
}
// --- Anonymisation RGPD (user.update) ---
public function testEraseRejectsAlreadyAnonymisedWith409(): void
{
$db = $this->permittedDb();
$db->userManageRow = $this->target(['id' => 5, 'anonymized_at' => '2026-01-01 00:00:00']);
$this->actingPin($db);
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/users/5/erase'), $db)->erase(['id' => '5']);
self::assertSame(409, $response->status());
self::assertFalse($db->wrote('anonymized_at = NOW()'));
}
public function testEraseAnonymisesWithPinAndAudit(): void
{
$db = $this->permittedDb();
$db->userManageRow = $this->target(['id' => 5, 'anonymized_at' => null]);
$db->userIsAdmin = false;
$this->actingPin($db);
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/users/5/erase'), $db)->erase(['id' => '5']);
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('anonymized_at = NOW()'));
self::assertSame('user.erase_pii', ($this->findWrite($db, 'INSERT INTO audit_log')['params']['code'] ?? null));
}
}