feat(admin): gestion des comptes back-office (CRUD users + RGPD, PIN+audit) (P3) (#38)
This commit is contained in:
parent
9c2844c116
commit
e430f54d85
11 changed files with 1718 additions and 8 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
683
src/app/Controllers/UserController.php
Normal file
683
src/app/Controllers/UserController.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
76
src/app/Views/admin/users/confirm.php
Normal file
76
src/app/Views/admin/users/confirm.php
Normal 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>
|
||||||
104
src/app/Views/admin/users/form.php
Normal file
104
src/app/Views/admin/users/form.php
Normal 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>
|
||||||
93
src/app/Views/admin/users/index.php
Normal file
93
src/app/Views/admin/users/index.php
Normal 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>
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
393
tests/Unit/Admin/UserControllerTest.php
Normal file
393
tests/Unit/Admin/UserControllerTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue