feat(admin): gestion des comptes back-office (CRUD users + RGPD, PIN+audit) (P3) (#38)
All checks were successful
CI / js-tests (push) Successful in 19s
CI / secret-scan (push) Successful in 7s
CI / php-lint (push) Successful in 19s
CI / static-tests (push) Successful in 45s
CI / auto-merge (push) Has been skipped

This commit is contained in:
Corentin JOGUET 2026-06-17 13:49:02 +02:00
parent 9c2844c116
commit e430f54d85
11 changed files with 1718 additions and 8 deletions

View file

@ -7,9 +7,15 @@ 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.
* Acces aux donnees de l'entite user : definition du PIN self-service ET gestion
* complete des comptes back-office (mlt domaine 10 : create/update/deactivate +
* 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
{
@ -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
* 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>
<?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 /*
Items de nav volontairement absents tant que leur page n'existe pas
(un lien vers une route non enregistree renvoie un 404). A reactiver
avec leur route respective : Commandes (order.read), Utilisateurs
(user.read), Roles (role.manage) -- P3 suite / P4.
avec leur route respective : Commandes (order.read), Roles (role.manage)
-- lot RBAC / P4.
*/ ?>
</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\ProfileController;
use App\Controllers\StatsController;
use App\Controllers\UserController;
use App\Core\Autoloader;
use App\Core\Config;
use App\Core\Database;
@ -74,6 +75,21 @@ try {
// catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4).
$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.
$router->add('GET', '/admin/categories', [CategoryController::class, 'index']);
$router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']);

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Integration;
use PDOException;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Auth\PasswordHasher;
@ -20,6 +21,10 @@ final class UserRepositoryDbTest extends TestCase
private Database $db;
private Config $config;
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
{
@ -36,6 +41,8 @@ final class UserRepositoryDbTest extends TestCase
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);
$hasher = new PasswordHasher($this->config);
$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->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
@ -77,4 +102,89 @@ final class UserRepositoryDbTest extends TestCase
self::assertNotSame('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). */
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,
* 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;
}
// --- 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')) {
return $this->userRow;
}
@ -396,6 +460,14 @@ final class FakeDatabase implements DatabaseInterface
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')) {
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
// permission est presente ET la page existe.
self::assertStringContainsString('/admin/products', $body); // product.read present + page existante
// user.read est present, mais la page /admin/users n'existe pas encore :
// le lien est retire pour ne pas exposer un 404 (cf. layout.php).
self::assertStringNotContainsString('/admin/users', $body);
// user.read present + la page /admin/users existe desormais (lot Users) :
// le lien de nav Administration apparait.
self::assertStringContainsString('/admin/users', $body);
self::assertStringNotContainsString('/admin/roles', $body); // pas de page + role.manage absent
// Deconnexion = formulaire POST avec CSRF.
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));
}
}