diff --git a/src/app/Auth/UserRepository.php b/src/app/Auth/UserRepository.php index 4648235..096f210 100644 --- a/src/app/Auth/UserRepository.php +++ b/src/app/Auth/UserRepository.php @@ -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> + */ + 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|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 diff --git a/src/app/Controllers/UserController.php b/src/app/Controllers/UserController.php new file mode 100644 index 0000000..258ee2a --- /dev/null +++ b/src/app/Controllers/UserController.php @@ -0,0 +1,683 @@ + 409 (convention PR-0). + * + * Non `final` : les tests sous-classent (seam db()/sessionManager()). + */ +class UserController extends AdminController +{ + private const ENTITY = 'user'; + + /** + * @param array $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 $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 $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 $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 $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 $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 $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 $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 $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 $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 $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 $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 $form + * @return array{0: array|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 $form + * @return array{0: array{email: string, first_name: string, last_name: string, role_id: int, password: ?string}, 1: array} + */ + 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 $current + * @param array{email: string, first_name: string, last_name: string, role_id: int, password: ?string} $data + * @return list + */ + 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|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 $values + * @param array $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 $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 + */ + 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']); + } +} diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index 99a4aa8..4fb894b 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -125,11 +125,18 @@ $navClass = static function (string $code, string $current): string { + + + + diff --git a/src/app/Views/admin/users/confirm.php b/src/app/Views/admin/users/confirm.php new file mode 100644 index 0000000..2cb812e --- /dev/null +++ b/src/app/Views/admin/users/confirm.php @@ -0,0 +1,76 @@ + $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']; +?> + + +
+ + +

Compte cible :

+

+ +

+ +
+ Re-autorisation (PIN equipier) +
+ + +
+
+ + +
+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/users/form.php b/src/app/Views/admin/users/form.php new file mode 100644 index 0000000..128fe83 --- /dev/null +++ b/src/app/Views/admin/users/form.php @@ -0,0 +1,104 @@ + $roles + * @var array $values + * @var array $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 $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var list $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); +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + > +

+
+ + +
+ +
+ + +
+ Re-autorisation (PIN equipier) +

La gestion des comptes est une action sensible : confirmez avec votre email et votre PIN.

+
+ + +
+
+ + +
+

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/users/index.php b/src/app/Views/admin/users/index.php new file mode 100644 index 0000000..daa246e --- /dev/null +++ b/src/app/Views/admin/users/index.php @@ -0,0 +1,93 @@ +> $users + * @var int $currentId id de l'acteur (pas d'auto-desactivation) + * @var bool $canCreate + * @var bool $canUpdate + * @var bool $canDeactiv + */ + +/** @var array> $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'); +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NomEmailRoleStatut
Aucun utilisateur.
(vous)' : '' ?> + + Anonymise + + Actif + + Inactif + + + + + Modifier + Reset PIN + + + Desactiver + + + Anonymiser + + +
+
+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index be27c16..c01cec8 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -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']); diff --git a/tests/Integration/UserRepositoryDbTest.php b/tests/Integration/UserRepositoryDbTest.php index 2b9757a..229394b 100644 --- a/tests/Integration/UserRepositoryDbTest.php +++ b/tests/Integration/UserRepositoryDbTest.php @@ -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 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 + } } diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 222bb2b..b81d40a 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -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> + */ + public array $usersRows = []; + + /** + * Ligne renvoyee par UserRepository::find() (gestion des comptes) ; null = absent. + * + * @var array|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> + */ + 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; } diff --git a/tests/Unit/Admin/DashboardControllerTest.php b/tests/Unit/Admin/DashboardControllerTest.php index 9199fbe..7e7ca0d 100644 --- a/tests/Unit/Admin/DashboardControllerTest.php +++ b/tests/Unit/Admin/DashboardControllerTest.php @@ -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); diff --git a/tests/Unit/Admin/UserControllerTest.php b/tests/Unit/Admin/UserControllerTest.php new file mode 100644 index 0000000..c2cf11f --- /dev/null +++ b/tests/Unit/Admin/UserControllerTest.php @@ -0,0 +1,393 @@ +testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } +} + +final class UserControllerTest extends TestCase +{ + /** @var list */ + 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 $overrides + * @return array + */ + 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 $overrides + * @return array + */ + 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 $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}|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)); + } +}