feat(admin): RBAC - matrice roles/permissions + roles custom (PIN+audit diff) (P3)
All checks were successful
CI / secret-scan (pull_request) Successful in 14s
CI / js-tests (pull_request) Successful in 28s
CI / php-lint (pull_request) Successful in 26s
CI / static-tests (pull_request) Successful in 1m1s
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 50s
CI / js-tests (push) Successful in 22s
CI / auto-merge (pull_request) Successful in 4s
CI / auto-merge (push) Has been skipped

Lot R du cycle P3 (Users/RBAC/Stats), dernier lot. Gestion RBAC (mlt 10.4
MANAGE_RBAC, permission role.manage) : matrice roles x permissions + roles
personnalises (RG-4). Action a fort impact (escalade de privileges) -> PIN
equipier + audit_log dans la meme transaction (RG-T13/14), throttle PIN (RG-T22).

- RoleRepository (App\Auth) : roles (CRUD, code immuable), matrice (permissionIds/
  CodesFor, setPermissions tx + variante raw replacePermissions pour enrobage
  controleur), sources visibles (role_visible_source, tx + raw). Catalogue de
  permissions fige (lecture seule).
- RoleController (role.manage) : index ; create/store (role custom : code+label+
  default_route+order_source) ; edit/update (champs role + matrice + sources, en
  UNE transaction). audit role.manage avec details=DIFF des codes de permission
  (ajoutes/retires, RG-6), calcule avant la reecriture delete-and-reinsert.
- Matrice soumise en champs SCALAIRES (perm_<id>, source_<enum>) : Request::formBody
  ne garde que les scalaires, donc pas de name[] ni de JS.
- Garde-fous anti-lockout : le role admin conserve role.manage ET reste actif ;
  code immuable apres creation ; order_source borne a l'ENUM ; code dupli -> 409.
- Vues admin/roles/{index,form}, 5 routes, nav Roles (gated role.manage).

Tests : unit 263, integration 301 / 916 assertions (WAKDO_DB_TESTS=1, dont
RoleControllerTest 12 + RoleRepositoryDbTest 4), PHPStan L6 propre.
This commit is contained in:
Imugiii 2026-06-17 12:23:46 +00:00
parent e430f54d85
commit de48ddf7cd
9 changed files with 1405 additions and 4 deletions

View file

@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Core\DatabaseInterface;
/**
* Acces aux donnees RBAC en ECRITURE (mlt 10.4 MANAGE_RBAC) : roles, matrice
* role_permission, sources visibles role_visible_source. La lecture des
* permissions pour l'autorisation reste dans Authorizer (rechargee a chaque
* verification). Le catalogue `permission` est fige au seed (lecture seule ici).
*
* Invariants : le `code` d'un role est UNIQUE et IMMUABLE apres creation (cle
* referencee par la logique applicative). La matrice et les sources sont reposees
* en delete-and-reinsert dans UNE transaction (RG-T08 / mlt 10.4 RG-1).
*/
final class RoleRepository
{
public function __construct(private readonly DatabaseInterface $db)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function allRoles(): array
{
return $this->db->fetchAll(
'SELECT id, code, label, description, default_route, order_source, is_active '
. 'FROM role ORDER BY id',
);
}
/**
* @return array<string, mixed>|null
*/
public function findRole(int $id): ?array
{
return $this->db->fetch(
'SELECT id, code, label, description, default_route, order_source, is_active '
. 'FROM role WHERE id = :id',
['id' => $id],
);
}
public function codeExists(string $code, int $exceptId = 0): bool
{
return $this->db->fetch(
'SELECT id FROM role WHERE code = :code AND id <> :id',
['code' => $code, 'id' => $exceptId],
) !== null;
}
/**
* Catalogue complet des permissions (fige au seed), pour peupler la matrice.
*
* @return array<int, array<string, mixed>>
*/
public function allPermissions(): array
{
return $this->db->fetchAll('SELECT id, code, label FROM permission ORDER BY id');
}
/**
* @return list<int>
*/
public function permissionIdsFor(int $roleId): array
{
$rows = $this->db->fetchAll(
'SELECT permission_id FROM role_permission WHERE role_id = :id',
['id' => $roleId],
);
return array_map(static fn (array $r): int => (int) ($r['permission_id'] ?? 0), $rows);
}
/**
* Codes de permission d'un role (pour le diff d'audit RG-6 : add/remove).
*
* @return list<string>
*/
public function permissionCodesFor(int $roleId): array
{
$rows = $this->db->fetchAll(
'SELECT p.code FROM role_permission rp JOIN permission p ON p.id = rp.permission_id '
. 'WHERE rp.role_id = :id ORDER BY p.code',
['id' => $roleId],
);
return array_map(static fn (array $r): string => (string) ($r['code'] ?? ''), $rows);
}
/**
* Reecrit la matrice d'un role (mlt 10.4 RG-1) : DELETE puis INSERT des paires
* selectionnees, dans UNE transaction. L'appelant a deja filtre les
* permission_id au catalogue existant (PRE-3).
*
* @param list<int> $permissionIds
*/
public function setPermissions(int $roleId, array $permissionIds): void
{
$this->db->transaction(function (DatabaseInterface $db) use ($roleId, $permissionIds): void {
$this->replacePermissions($db, $roleId, $permissionIds);
});
}
/**
* Variante SANS transaction propre : reecrit la matrice sur le $db fourni, pour
* que le controleur l'enrobe dans UNE transaction avec l'ecriture d'audit (RG-6,
* audit du diff dans la meme transaction que l'effet). Ne pas appeler hors d'une
* transaction de l'appelant.
*
* @param list<int> $permissionIds
*/
public function replacePermissions(DatabaseInterface $db, int $roleId, array $permissionIds): void
{
$db->execute('DELETE FROM role_permission WHERE role_id = :id', ['id' => $roleId]);
foreach (array_values(array_unique($permissionIds)) as $permissionId) {
$db->execute(
'INSERT INTO role_permission (role_id, permission_id) VALUES (:role, :perm)',
['role' => $roleId, 'perm' => $permissionId],
);
}
}
/**
* Creation d'un role personnalise (mlt 10.4 RG-4). `is_active` pose cote serveur.
* Retourne l'id. Allowlist RG-T16.
*
* @param array{code: string, label: string, description: ?string, default_route: ?string, order_source: ?string} $data
*/
public function createRole(array $data): int
{
$this->db->execute(
'INSERT INTO role (code, label, description, default_route, order_source, is_active) '
. 'VALUES (:code, :label, :description, :route, :source, 1)',
[
'code' => $data['code'],
'label' => $data['label'],
'description' => $data['description'],
'route' => $data['default_route'],
'source' => $data['order_source'],
],
);
return (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
}
/**
* Mise a jour d'un role. Le `code` n'est PAS lie (immuable apres creation).
*
* @param array{label: string, description: ?string, default_route: ?string, order_source: ?string, is_active: int} $data
*/
public function updateRole(int $id, array $data): void
{
$this->db->execute(
'UPDATE role SET label = :label, description = :description, default_route = :route, '
. 'order_source = :source, is_active = :active WHERE id = :id',
[
'label' => $data['label'],
'description' => $data['description'],
'route' => $data['default_route'],
'source' => $data['order_source'],
'active' => $data['is_active'],
'id' => $id,
],
);
}
/**
* @return list<string>
*/
public function visibleSources(int $roleId): array
{
$rows = $this->db->fetchAll(
'SELECT source FROM role_visible_source WHERE role_id = :id',
['id' => $roleId],
);
return array_map(static fn (array $r): string => (string) ($r['source'] ?? ''), $rows);
}
/**
* Reecrit les sources visibles d'un role (delete-and-reinsert, tx). L'appelant
* filtre $sources a l'ENUM valide ('kiosk','counter','drive').
*
* @param list<string> $sources
*/
public function setVisibleSources(int $roleId, array $sources): void
{
$this->db->transaction(function (DatabaseInterface $db) use ($roleId, $sources): void {
$this->replaceVisibleSources($db, $roleId, $sources);
});
}
/**
* Variante SANS transaction propre (cf. replacePermissions), pour enrobage par
* le controleur dans une transaction unique.
*
* @param list<string> $sources
*/
public function replaceVisibleSources(DatabaseInterface $db, int $roleId, array $sources): void
{
$db->execute('DELETE FROM role_visible_source WHERE role_id = :id', ['id' => $roleId]);
foreach (array_values(array_unique($sources)) as $source) {
$db->execute(
'INSERT INTO role_visible_source (role_id, source) VALUES (:role, :source)',
['role' => $roleId, 'source' => $source],
);
}
}
}

View file

@ -0,0 +1,459 @@
<?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\RoleRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
/**
* Gestion RBAC (mlt 10.4 MANAGE_RBAC), permission `role.manage`. Operations a fort
* impact (escalade de privileges) : PIN equipier + ligne `audit_log` dans la meme
* transaction que l'effet (RG-T13/RG-T14), throttle PIN par acteur (RG-T22). Le
* `details` d'audit enregistre le DIFF de permissions (codes ajoutes/retires, RG-6),
* calcule AVANT la reecriture delete-and-reinsert.
*
* Le catalogue de permissions est fige au seed (lecture seule). Le `code` d'un role
* est immuable apres creation. Garde-fou anti-lockout : le role `admin` conserve
* toujours `role.manage` et reste actif.
*
* Les cases de la matrice sont soumises en champs SCALAIRES (`perm_<id>`,
* `source_<enum>`) et non en tableaux `name[]` : Request::formBody ne conserve que
* les scalaires (pas de JS requis, pas de champ JSON cache).
*
* Non `final` : les tests sous-classent (seam db()/sessionManager()).
*/
class RoleController extends AdminController
{
private const ENTITY = 'role';
private const ADMIN_CODE = 'admin';
/** @var list<string> ENUM role_visible_source.source / customer_order.source */
private const SOURCES = ['kiosk', 'counter', 'drive'];
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard('role.manage');
if ($guard instanceof Response) {
return $guard;
}
return $this->adminView('admin/roles/index', [
'title' => 'Roles et permissions - Wakdo Admin',
'activeNav' => 'roles',
'roles' => $this->roleRepository()->allRoles(),
], $guard);
}
/**
* @param array<string, string> $params
*/
public function create(array $params = []): Response
{
$guard = $this->guard('role.manage');
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('role.manage');
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, true);
$permIds = $this->selectedPermissionIds($form);
$sources = $this->selectedSources($form);
if ($errors !== []) {
return $this->renderForm($guard, 0, $form, $errors, $permIds, $sources, 422);
}
if ($this->roleRepository()->codeExists((string) $data['code'])) {
return $this->renderForm($guard, 0, $form, ['code' => 'Ce code de role existe deja.'], $permIds, $sources, 409);
}
[$actor, $errorMsg] = $this->resolvePin($guard, $form, 0);
if ($actor === null) {
return $this->renderForm($guard, 0, $form, ['pin' => $errorMsg], $permIds, $sources, 422);
}
$addedCodes = $this->codesForIds($permIds);
try {
$this->db()->transaction(function (DatabaseInterface $db) use ($data, $permIds, $sources, $actor, $addedCodes): void {
$repo = new RoleRepository($db);
$newId = $repo->createRole([
'code' => (string) $data['code'],
'label' => (string) $data['label'],
'description' => $data['description'],
'default_route' => $data['default_route'],
'order_source' => $data['order_source'],
]);
$repo->replacePermissions($db, $newId, $permIds);
$repo->replaceVisibleSources($db, $newId, $sources);
$this->writeAudit($db, $actor['id'], $actor['role_id'], $newId, 'Creation role ' . (string) $data['code'], ['added' => $addedCodes, 'removed' => []]);
});
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return $this->renderForm($guard, 0, $form, ['code' => 'Ce code de role existe deja.'], $permIds, $sources, 409);
}
throw $exception;
}
$this->pinThrottle()->reset($guard->userId ?? 0);
$this->setFlash('Role cree.');
return $this->redirect('/admin/roles');
}
/**
* @param array<string, string> $params
*/
public function edit(array $params): Response
{
$guard = $this->guard('role.manage');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$role = $this->roleRepository()->findRole($id);
if ($role === null) {
return $this->notFound($guard);
}
return $this->renderForm(
$guard,
$id,
$role,
[],
$this->roleRepository()->permissionIdsFor($id),
$this->roleRepository()->visibleSources($id),
);
}
/**
* @param array<string, string> $params
*/
public function update(array $params): Response
{
$guard = $this->guard('role.manage');
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->roleRepository()->findRole($id);
if ($current === null) {
return $this->notFound($guard);
}
[$data, $errors] = $this->validate($form, false);
$permIds = $this->selectedPermissionIds($form);
$sources = $this->selectedSources($form);
if ($errors !== []) {
return $this->renderForm($guard, $id, $form + ['code' => $current['code']], $errors, $permIds, $sources, 422);
}
$isActive = isset($form['is_active']) ? 1 : 0;
$newCodes = $this->codesForIds($permIds);
// Garde-fou anti-lockout : le role admin garde role.manage ET reste actif.
if ((string) ($current['code'] ?? '') === self::ADMIN_CODE) {
if (!in_array('role.manage', $newCodes, true) || $isActive === 0) {
return $this->renderForm($guard, $id, $form + ['code' => $current['code']], ['permissions' => 'Le role administrateur doit conserver role.manage et rester actif.'], $permIds, $sources, 422);
}
}
[$actor, $errorMsg] = $this->resolvePin($guard, $form, $id);
if ($actor === null) {
return $this->renderForm($guard, $id, $form + ['code' => $current['code']], ['pin' => $errorMsg], $permIds, $sources, 422);
}
// Diff de permissions (RG-6), calcule AVANT la reecriture.
$currentCodes = $this->roleRepository()->permissionCodesFor($id);
$added = array_values(array_diff($newCodes, $currentCodes));
$removed = array_values(array_diff($currentCodes, $newCodes));
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $data, $isActive, $permIds, $sources, $actor, $added, $removed): void {
$repo = new RoleRepository($db);
$repo->updateRole($id, [
'label' => (string) $data['label'],
'description' => $data['description'],
'default_route' => $data['default_route'],
'order_source' => $data['order_source'],
'is_active' => $isActive,
]);
$repo->replacePermissions($db, $id, $permIds);
$repo->replaceVisibleSources($db, $id, $sources);
$this->writeAudit($db, $actor['id'], $actor['role_id'], $id, 'Mise a jour RBAC role ' . (string) ($data['code'] ?? ''), ['added' => $added, 'removed' => $removed]);
});
$this->pinThrottle()->reset($guard->userId ?? 0);
$this->setFlash('Role mis a jour.');
return $this->redirect('/admin/roles');
}
// --- Helpers ---
protected function roleRepository(): RoleRepository
{
return new RoleRepository($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);
}
/**
* @param array<string, string> $form
* @return list<int>
*/
private function selectedPermissionIds(array $form): array
{
$ids = [];
foreach ($this->roleRepository()->allPermissions() as $p) {
$pid = (int) ($p['id'] ?? 0);
if ($pid > 0 && ($form['perm_' . $pid] ?? '') !== '') {
$ids[] = $pid;
}
}
return $ids;
}
/**
* @param array<string, string> $form
* @return list<string>
*/
private function selectedSources(array $form): array
{
$out = [];
foreach (self::SOURCES as $source) {
if (($form['source_' . $source] ?? '') !== '') {
$out[] = $source;
}
}
return $out;
}
/**
* Codes de permission correspondant a une liste d'ids (via le catalogue).
*
* @param list<int> $ids
* @return list<string>
*/
private function codesForIds(array $ids): array
{
$map = [];
foreach ($this->roleRepository()->allPermissions() as $p) {
$map[(int) ($p['id'] ?? 0)] = (string) ($p['code'] ?? '');
}
$codes = [];
foreach ($ids as $id) {
if (isset($map[$id]) && $map[$id] !== '') {
$codes[] = $map[$id];
}
}
return $codes;
}
/**
* Porte du PIN sensible (RG-T13 + throttle RG-T22), identique a UserController.
*
* @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). `code` requis + immuable a la creation seulement.
*
* @param array<string, string> $form
* @return array{0: array{code: ?string, label: string, description: ?string, default_route: ?string, order_source: ?string}, 1: array<string, string>}
*/
private function validate(array $form, bool $isCreate): array
{
$errors = [];
$code = null;
if ($isCreate) {
$code = trim($form['code'] ?? '');
if ($code === '' || mb_strlen($code) > 40 || preg_match('/^[a-z][a-z0-9_]{1,39}$/', $code) !== 1) {
$errors['code'] = 'Code requis : minuscules/chiffres/_ , commence par une lettre (40 max).';
}
}
$label = trim($form['label'] ?? '');
if ($label === '' || mb_strlen($label) > 80) {
$errors['label'] = 'Le libelle est requis (80 caracteres max).';
}
$route = trim($form['default_route'] ?? '');
if (mb_strlen($route) > 120) {
$errors['default_route'] = 'Route par defaut trop longue (120 max).';
}
$source = trim($form['order_source'] ?? '');
if ($source !== '' && !in_array($source, self::SOURCES, true)) {
$errors['order_source'] = 'Source de commande invalide.';
}
$description = trim($form['description'] ?? '');
$data = [
'code' => $code,
'label' => $label,
'description' => $description !== '' ? $description : null,
'default_route' => $route !== '' ? $route : null,
'order_source' => $source !== '' ? $source : null,
];
return [$data, $errors];
}
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 RBAC (email tente: ' . $email . ')',
],
);
}
/**
* @param array<string, mixed> $details
*/
private function writeAudit(DatabaseInterface $db, 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' => 'role.manage',
'etype' => self::ENTITY,
'eid' => $entityId,
'summary' => $summary,
'details' => (string) json_encode($details),
],
);
}
/**
* @param array<string, mixed> $values
* @param array<string, string> $errors
* @param list<int> $selectedPermIds
* @param list<string> $selectedSources
*/
private function renderForm(GuardResult $guard, int $id, array $values, array $errors, array $selectedPermIds, array $selectedSources, int $status = 200): Response
{
return $this->adminView('admin/roles/form', [
'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' role - Wakdo Admin',
'activeNav' => 'roles',
'roleId' => $id,
'isAdminRole' => (string) ($values['code'] ?? '') === self::ADMIN_CODE,
'permissions' => $this->roleRepository()->allPermissions(),
'sources' => self::SOURCES,
'selectedPerms' => $selectedPermIds,
'selectedSources' => $selectedSources,
'values' => [
'code' => (string) ($values['code'] ?? ''),
'label' => (string) ($values['label'] ?? ''),
'description' => (string) ($values['description'] ?? ''),
'default_route' => (string) ($values['default_route'] ?? ''),
'order_source' => (string) ($values['order_source'] ?? ''),
'is_active' => $id === 0 ? true : ((int) ($values['is_active'] ?? 1) === 1),
],
'errors' => $errors,
'csrfToken' => Csrf::token($this->sessionManager()),
], $guard, $status);
}
private function notFound(GuardResult $guard): Response
{
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'roles'], $guard, 404);
}
private function redirect(string $location): Response
{
return Response::make('', 302, ['Location' => $location]);
}
private function invalidCsrf(): Response
{
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
}
}

View file

@ -125,18 +125,22 @@ $navClass = static function (string $code, string $current): string {
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($can('user.read')): ?> <?php if ($can('user.read') || $can('role.manage')): ?>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-label">Administration</div> <div class="sidebar-section-label">Administration</div>
<a href="/admin/users" class="<?= $navClass('users', $active) ?>">Utilisateurs</a> <?php if ($can('user.read')): ?>
<a href="/admin/users" class="<?= $navClass('users', $active) ?>">Utilisateurs</a>
<?php endif; ?>
<?php if ($can('role.manage')): ?>
<a href="/admin/roles" class="<?= $navClass('roles', $active) ?>">Roles</a>
<?php endif; ?>
</div> </div>
<?php endif; ?> <?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), Roles (role.manage) avec leur route respective : Commandes (order.read) -- domaine P4.
-- lot RBAC / P4.
*/ ?> */ ?>
</nav> </nav>

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/**
* Formulaire role (creation/edition RBAC), injecte dans admin/layout.php. La
* matrice de permissions et les sources visibles sont des cases SCALAIRES
* (`perm_<id>`, `source_<enum>`) : Request::formBody ne garde que les scalaires,
* donc pas de `name[]` ni de JS. Toute soumission exige le PIN equipier (RG-T13).
* Le `code` est editable a la creation, fige a l'edition (immuable).
*
* @var int $roleId
* @var bool $isAdminRole
* @var array<int, array<string, mixed>> $permissions catalogue {id, code, label}
* @var list<string> $sources enum visibles
* @var list<int> $selectedPerms
* @var list<string> $selectedSources
* @var array<string, mixed> $values
* @var array<string, string> $errors
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$id = (int) ($roleId ?? 0);
$action = $id !== 0 ? '/admin/roles/' . $id : '/admin/roles';
$isAdmin = (bool) ($isAdminRole ?? false);
/** @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 array<int, array<string, mixed>> $perms */
$perms = isset($permissions) && is_array($permissions) ? $permissions : [];
/** @var list<int> $selPerms */
$selPerms = isset($selectedPerms) && is_array($selectedPerms) ? array_map('intval', $selectedPerms) : [];
/** @var list<string> $selSources */
$selSources = isset($selectedSources) && is_array($selectedSources) ? $selectedSources : [];
/** @var list<string> $srcList */
$srcList = isset($sources) && is_array($sources) ? $sources : [];
$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') : '';
$selectedSource = (string) ($vals['order_source'] ?? '');
$active = (bool) ($vals['is_active'] ?? true);
?>
<div class="page-header">
<div>
<h1 class="page-title"><?= $id !== 0 ? 'Modifier le role' : 'Nouveau role' ?></h1>
<?php if ($isAdmin): ?><p class="page-subtitle">Role administrateur : il doit conserver <code>role.manage</code> et rester actif.</p><?php endif; ?>
</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="code">Code</label>
<?php if ($id === 0): ?>
<input class="form-input" type="text" id="code" name="code" maxlength="40" value="<?= $val('code') ?>" required>
<?php if ($err('code') !== ''): ?><p class="form-error"><?= $err('code') ?></p><?php endif; ?>
<?php else: ?>
<input class="form-input" type="text" id="code" value="<?= $val('code') ?>" disabled>
<p><small class="muted">Le code est immuable apres creation.</small></p>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="label">Libelle</label>
<input class="form-input" type="text" id="label" name="label" maxlength="80" value="<?= $val('label') ?>" required>
<?php if ($err('label') !== ''): ?><p class="form-error"><?= $err('label') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="description">Description</label>
<input class="form-input" type="text" id="description" name="description" value="<?= $val('description') ?>">
</div>
<div class="form-group">
<label class="form-label" for="default_route">Route par defaut (landing)</label>
<input class="form-input" type="text" id="default_route" name="default_route" maxlength="120" value="<?= $val('default_route') ?>">
<?php if ($err('default_route') !== ''): ?><p class="form-error"><?= $err('default_route') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="order_source">Source de commande auto-taggee</label>
<select class="form-input" id="order_source" name="order_source">
<option value="">-- aucune (admin/manager) --</option>
<?php foreach ($srcList as $src): ?>
<option value="<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>"<?= $src === $selectedSource ? ' selected' : '' ?>><?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
<?php if ($err('order_source') !== ''): ?><p class="form-error"><?= $err('order_source') ?></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' : '' ?>> Role actif</label>
</div>
<?php endif; ?>
<fieldset class="form-group">
<legend>Permissions</legend>
<?php if ($err('permissions') !== ''): ?><p class="form-error"><?= $err('permissions') ?></p><?php endif; ?>
<div style="max-height:320px; overflow-y:auto;">
<?php foreach ($perms as $p): ?>
<?php
$pid = (int) ($p['id'] ?? 0);
$checked = in_array($pid, $selPerms, true);
?>
<label style="display:block; padding:2px 0;">
<input type="checkbox" name="perm_<?= $pid ?>" value="1"<?= $checked ? ' checked' : '' ?>>
<code><?= htmlspecialchars((string) ($p['code'] ?? ''), ENT_QUOTES, 'UTF-8') ?></code>
<span class="muted">- <?= htmlspecialchars((string) ($p['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
</label>
<?php endforeach; ?>
</div>
</fieldset>
<fieldset class="form-group">
<legend>Sources de tableau de bord visibles</legend>
<?php foreach ($srcList as $src): ?>
<label style="display:inline-block; margin-right:1rem;">
<input type="checkbox" name="source_<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>" value="1"<?= in_array($src, $selSources, true) ? ' checked' : '' ?>>
<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>
</label>
<?php endforeach; ?>
</fieldset>
<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>
<?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/roles">Annuler</a>
</div>
</form>

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* Liste des roles (RBAC, role.manage), injectee dans admin/layout.php. Texte echappe.
*
* @var array<int, array<string, mixed>> $roles
*/
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($roles) && is_array($roles) ? $roles : [];
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
?>
<div class="page-header">
<div>
<h1 class="page-title">Roles et permissions</h1>
<p class="page-subtitle">Matrice RBAC. Modifier un role est une action sensible (PIN + audit).</p>
</div>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/roles/new">Nouveau role</a>
</div>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Code</th>
<th>Libelle</th>
<th>Route par defaut</th>
<th>Source</th>
<th>Statut</th>
<th style="width:120px;"></th>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="6" class="muted">Aucun role.</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
?>
<tr>
<td class="fw-600"><?= $esc($row['code'] ?? '') ?></td>
<td><?= $esc($row['label'] ?? '') ?></td>
<td class="muted"><?= $esc($row['default_route'] ?? '') ?></td>
<td class="muted"><?= $esc($row['order_source'] ?? '-') ?></td>
<td>
<?php if ($active): ?>
<span class="pill pill-success">Actif</span>
<?php else: ?>
<span class="pill pill-neutral">Inactif</span>
<?php endif; ?>
</td>
<td>
<a class="btn btn-secondary" href="/admin/roles/<?= $id ?>/edit">Modifier</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View file

@ -23,6 +23,7 @@ use App\Controllers\PasswordResetController;
use App\Controllers\ProductController; use App\Controllers\ProductController;
use App\Controllers\ProfileController; use App\Controllers\ProfileController;
use App\Controllers\StatsController; use App\Controllers\StatsController;
use App\Controllers\RoleController;
use App\Controllers\UserController; use App\Controllers\UserController;
use App\Core\Autoloader; use App\Core\Autoloader;
use App\Core\Config; use App\Core\Config;
@ -90,6 +91,14 @@ try {
$router->add('GET', '/admin/users/{id}/erase', [UserController::class, 'confirmErase']); $router->add('GET', '/admin/users/{id}/erase', [UserController::class, 'confirmErase']);
$router->add('POST', '/admin/users/{id}/erase', [UserController::class, 'erase']); $router->add('POST', '/admin/users/{id}/erase', [UserController::class, 'erase']);
// RBAC (mlt 10.4, role.manage) : matrice roles x permissions + roles custom.
// Toute mutation = PIN equipier + audit (details = diff de permissions, RG-6).
$router->add('GET', '/admin/roles', [RoleController::class, 'index']);
$router->add('GET', '/admin/roles/new', [RoleController::class, 'create']);
$router->add('POST', '/admin/roles', [RoleController::class, 'store']);
$router->add('GET', '/admin/roles/{id}/edit', [RoleController::class, 'edit']);
$router->add('POST', '/admin/roles/{id}', [RoleController::class, 'update']);
// CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active. // CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active.
$router->add('GET', '/admin/categories', [CategoryController::class, 'index']); $router->add('GET', '/admin/categories', [CategoryController::class, 'index']);
$router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']); $router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']);

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PDOException;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Auth\RoleRepository;
use App\Core\Config;
use App\Core\Database;
/**
* RoleRepository (RBAC, mlt 10.4) contre une vraie MariaDB (schema migre + seede).
* Auto-skip si WAKDO_DB_TESTS != 1. Role jetable (code it-role-*) ; CASCADE retire
* role_permission + role_visible_source a la suppression du role (teardown simple).
*/
final class RoleRepositoryDbTest extends TestCase
{
private Database $db;
private string $code = '';
private int $permA = 0;
private int $permB = 0;
protected function setUp(): void
{
if (getenv('WAKDO_DB_TESTS') !== '1') {
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).');
}
$this->db = new Database(new Config());
try {
$this->db->fetch('SELECT 1');
} catch (Throwable $exception) {
self::markTestSkipped('Base injoignable: ' . $exception->getMessage());
}
$this->code = 'it-role-' . bin2hex(random_bytes(4));
$this->permA = (int) ($this->db->fetch("SELECT id FROM permission WHERE code = 'stats.read'")['id'] ?? 0);
$this->permB = (int) ($this->db->fetch("SELECT id FROM permission WHERE code = 'user.read'")['id'] ?? 0);
}
protected function tearDown(): void
{
if ($this->code !== '') {
$this->db->execute('DELETE FROM role WHERE code = :c', ['c' => $this->code]); // CASCADE perms + sources
}
}
private function makeRole(RoleRepository $repo): int
{
return $repo->createRole([
'code' => $this->code,
'label' => 'IT Role',
'description' => 'jetable',
'default_route' => '/admin/dashboard',
'order_source' => null,
]);
}
public function testCreateRoleAndCodeUnique(): void
{
$repo = new RoleRepository($this->db);
$id = $this->makeRole($repo);
self::assertGreaterThan(0, $id);
$found = $repo->findRole($id);
self::assertNotNull($found);
self::assertSame($this->code, (string) $found['code']);
self::assertTrue($repo->codeExists($this->code));
self::assertFalse($repo->codeExists($this->code, $id)); // s'exclut lui-meme
$violated = false;
try {
$repo->createRole(['code' => $this->code, 'label' => 'Dup', 'description' => null, 'default_route' => null, 'order_source' => null]);
} catch (PDOException $exception) {
$violated = (string) $exception->getCode() === '23000';
}
self::assertTrue($violated, 'uk_role_code doit rejeter un doublon.');
}
public function testSetPermissionsReplacesAndExposesCodes(): void
{
$repo = new RoleRepository($this->db);
$id = $this->makeRole($repo);
$repo->setPermissions($id, [$this->permA, $this->permB]);
$ids = $repo->permissionIdsFor($id);
sort($ids);
$expected = [$this->permA, $this->permB];
sort($expected);
self::assertSame($expected, $ids);
$codes = $repo->permissionCodesFor($id);
self::assertContains('stats.read', $codes);
self::assertContains('user.read', $codes);
// Delete-and-reinsert : la nouvelle selection REMPLACE l'ancienne.
$repo->setPermissions($id, [$this->permA]);
self::assertSame([$this->permA], $repo->permissionIdsFor($id));
// 23 permissions au catalogue (fige au seed).
self::assertCount(23, $repo->allPermissions());
}
public function testSetVisibleSourcesReplaces(): void
{
$repo = new RoleRepository($this->db);
$id = $this->makeRole($repo);
$repo->setVisibleSources($id, ['counter', 'drive']);
$sources = $repo->visibleSources($id);
sort($sources);
self::assertSame(['counter', 'drive'], $sources);
$repo->setVisibleSources($id, ['kiosk']);
self::assertSame(['kiosk'], $repo->visibleSources($id));
}
public function testUpdateRoleKeepsCodeImmutable(): void
{
$repo = new RoleRepository($this->db);
$id = $this->makeRole($repo);
$repo->updateRole($id, [
'label' => 'Relabelled',
'description' => 'maj',
'default_route' => '/admin/stats',
'order_source' => 'counter',
'is_active' => 1,
]);
$updated = $repo->findRole($id);
self::assertNotNull($updated);
self::assertSame('Relabelled', (string) $updated['label']);
self::assertSame('/admin/stats', (string) $updated['default_route']);
self::assertSame('counter', (string) $updated['order_source']);
self::assertSame($this->code, (string) $updated['code']); // code inchange (immuable)
}
}

View file

@ -237,6 +237,44 @@ final class FakeDatabase implements DatabaseInterface
*/ */
public array $rolesRows = []; public array $rolesRows = [];
/**
* Lignes renvoyees par RoleRepository::allRoles().
*
* @var list<array<string, mixed>>
*/
public array $rolesAllRows = [];
/**
* Ligne renvoyee par RoleRepository::findRole() ; null = absent.
*
* @var array<string, mixed>|null
*/
public ?array $roleManageRow = null;
/** Resultat de RoleRepository::codeExists(). */
public bool $roleCodeTaken = false;
/**
* Catalogue renvoye par RoleRepository::allPermissions().
*
* @var list<array<string, mixed>>
*/
public array $permissionsRows = [];
/**
* Lignes {permission_id} renvoyees par RoleRepository::permissionIdsFor().
*
* @var list<array<string, mixed>>
*/
public array $rolePermIds = [];
/**
* Lignes {source} renvoyees par RoleRepository::visibleSources().
*
* @var list<array<string, mixed>>
*/
public array $roleSources = [];
/** /**
* 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
@ -319,6 +357,15 @@ final class FakeDatabase implements DatabaseInterface
return $this->roleActiveExists ? ['id' => 1] : null; return $this->roleActiveExists ? ['id' => 1] : null;
} }
// RBAC (RoleRepository) : findRole (7 colonnes) + codeExists (unicite).
if (str_contains($sql, 'order_source, is_active FROM role WHERE id = :id')) {
return $this->roleManageRow;
}
if (str_contains($sql, 'FROM role WHERE code = :code AND id <> :id')) {
return $this->roleCodeTaken ? ['id' => 1] : null;
}
if (str_contains($sql, 'LAST_INSERT_ID')) { if (str_contains($sql, 'LAST_INSERT_ID')) {
return ['id' => $this->lastInsertId]; return ['id' => $this->lastInsertId];
} }
@ -472,6 +519,26 @@ final class FakeDatabase implements DatabaseInterface
return $this->movementsRows; return $this->movementsRows;
} }
// --- RBAC (RoleRepository) ---
if (str_contains($sql, 'FROM role ORDER BY id')) {
return $this->rolesAllRows;
}
if (str_contains($sql, 'FROM permission ORDER BY id')) {
return $this->permissionsRows;
}
if (str_contains($sql, 'permission_id FROM role_permission WHERE role_id')) {
return $this->rolePermIds;
}
if (str_contains($sql, 'FROM role_visible_source WHERE role_id')) {
return $this->roleSources;
}
// Sert Authorizer::permissionsFor ET RoleRepository::permissionCodesFor
// (meme requete 'SELECT p.code FROM role_permission rp JOIN permission p') :
// les deux renvoient $permissionCodes (le diff RBAC reutilise ce bouton).
if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (str_contains($sql, 'SELECT p.code FROM role_permission')) {
if (!$this->roleActive) { if (!$this->roleActive) {
return []; return [];

View file

@ -0,0 +1,298 @@
<?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\RoleController;
use App\Core\Config;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Request;
use App\Tests\Support\FakeDatabase;
final class TestRoleController extends RoleController
{
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 RoleControllerTest extends TestCase
{
/** @var list<string> */
private array $touchedKeys = [];
private SessionManager $session;
private string $csrf = '';
protected function setUp(): void
{
$this->setEnv('SESSION_LIFETIME_IDLE', '14400');
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000');
$this->setEnv('STAFF_PIN_MIN_LENGTH', '4');
$this->setEnv('STAFF_PIN_MAX_LENGTH', '12');
$this->setEnv('ARGON2_MEMORY_COST', '1024');
$this->setEnv('ARGON2_TIME_COST', '1');
$this->setEnv('ARGON2_THREADS', '1');
$this->session = new SessionManager(new Config(), true);
$now = time();
$this->session->set('user_id', 1);
$this->session->set('role_id', 1);
$this->session->set('logged_in_at', $now - 100);
$this->session->set('last_activity', $now - 50);
$this->csrf = Csrf::token($this->session);
}
protected function tearDown(): void
{
foreach ($this->touchedKeys as $key) {
putenv($key);
}
$this->touchedKeys = [];
}
private function setEnv(string $key, string $value): void
{
$this->touchedKeys[] = $key;
putenv($key . '=' . $value);
}
private function permittedDb(): FakeDatabase
{
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 1];
$db->userDisplayRow = ['first_name' => 'Cor', 'last_name' => 'J', 'role_label' => 'Administrateur'];
$db->canResult = true;
$db->permissionCodes = ['role.manage'];
// Catalogue minimal : id 1 = role.manage (le vecteur de lockout).
$db->permissionsRows = [
['id' => 1, 'code' => 'role.manage', 'label' => 'Manage RBAC'],
['id' => 2, 'code' => 'stats.read', 'label' => 'Stats'],
['id' => 3, 'code' => 'user.read', 'label' => 'Users'],
];
return $db;
}
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,
'code' => 'kitchen_kds',
'label' => 'Kitchen KDS',
'default_route' => '/kitchen/display',
'order_source' => '',
'perm_1' => '1', // role.manage coche
'source_counter' => '1',
'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): TestRoleController
{
return new TestRoleController($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;
}
public function testIndexRequiresRoleManage(): void
{
$db = $this->permittedDb();
$db->canResult = false;
self::assertSame(403, $this->controller($this->get('/admin/roles'), $db)->index()->status());
}
public function testIndexListsRoles(): void
{
$db = $this->permittedDb();
$db->rolesAllRows = [['id' => 2, 'code' => 'manager', 'label' => 'Manager', 'default_route' => '/admin/stats', 'order_source' => null, 'is_active' => 1]];
$response = $this->controller($this->get('/admin/roles'), $db)->index();
self::assertSame(200, $response->status());
self::assertStringContainsString('manager', $response->body());
}
public function testStoreCreatesCustomRoleWithPinAndAudit(): void
{
$db = $this->permittedDb();
$this->actingPin($db);
$db->lastInsertId = 10;
$response = $this->controller($this->post($this->createForm(), '/admin/roles'), $db)->store();
self::assertSame(302, $response->status());
self::assertSame(['begin', 'commit'], $db->transactionEvents);
self::assertTrue($db->wrote('INSERT INTO role '));
self::assertTrue($db->wrote('INSERT INTO role_permission'));
$audit = $this->findWrite($db, 'INSERT INTO audit_log');
self::assertNotNull($audit);
self::assertSame('role.manage', $audit['params']['code'] ?? null);
self::assertSame(9, $audit['params']['uid'] ?? null); // acteur = PIN
}
public function testStoreRejectsDuplicateCodeWith409(): void
{
$db = $this->permittedDb();
$this->actingPin($db);
$db->roleCodeTaken = true;
$response = $this->controller($this->post($this->createForm(), '/admin/roles'), $db)->store();
self::assertSame(409, $response->status());
self::assertFalse($db->wrote('INSERT INTO role '));
}
public function testStoreRejectsInvalidCode(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post($this->createForm(['code' => 'Bad Code!']), '/admin/roles'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO role '));
}
public function testStoreRejectsInvalidCsrf(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post($this->createForm(['_csrf' => 'bad']), '/admin/roles'), $db)->store();
self::assertSame(403, $response->status());
}
public function testStoreWithoutValidPinLogsFailed(): void
{
$db = $this->permittedDb();
$db->actingUserRow = null;
$response = $this->controller($this->post($this->createForm(['pin' => '0000']), '/admin/roles'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO role '));
self::assertSame(['pin.failed'], $db->auditActions());
}
public function testUpdateNotFound(): void
{
$db = $this->permittedDb();
$db->roleManageRow = null;
self::assertSame(404, $this->controller($this->post($this->createForm(), '/admin/roles/9'), $db)->update(['id' => '9'])->status());
}
public function testUpdateAppliesWithPinAndAuditDiff(): void
{
$db = $this->permittedDb();
$db->roleManageRow = ['id' => 5, 'code' => 'counter', 'label' => 'Counter', 'description' => null, 'default_route' => '/counter/orders', 'order_source' => 'counter', 'is_active' => 1];
$db->permissionCodes = ['stats.read']; // permissions actuelles (diff RG-6 reutilise ce bouton)
$this->actingPin($db);
// perm_1 (role.manage) coche, is_active coche.
$form = ['_csrf' => $this->csrf, 'label' => 'Counter', 'default_route' => '/counter/orders', 'order_source' => 'counter', 'perm_1' => '1', 'is_active' => '1', 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'];
$response = $this->controller($this->post($form, '/admin/roles/5'), $db)->update(['id' => '5']);
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('UPDATE role SET'));
self::assertTrue($db->wrote('INSERT INTO role_permission'));
self::assertSame('role.manage', ($this->findWrite($db, 'INSERT INTO audit_log')['params']['code'] ?? null));
}
public function testUpdateBlocksRemovingRoleManageFromAdmin(): void
{
$db = $this->permittedDb();
$db->roleManageRow = ['id' => 1, 'code' => 'admin', 'label' => 'Administrator', 'description' => null, 'default_route' => '/admin/dashboard', 'order_source' => null, 'is_active' => 1];
// role.manage (perm_1) NON coche -> retirerait role.manage a l'admin.
$form = ['_csrf' => $this->csrf, 'label' => 'Administrator', 'perm_2' => '1', 'is_active' => '1', 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'];
$response = $this->controller($this->post($form, '/admin/roles/1'), $db)->update(['id' => '1']);
self::assertSame(422, $response->status());
self::assertStringContainsString('administrateur', $response->body());
self::assertFalse($db->wrote('UPDATE role SET'));
}
public function testUpdateBlocksDeactivatingAdminRole(): void
{
$db = $this->permittedDb();
$db->roleManageRow = ['id' => 1, 'code' => 'admin', 'label' => 'Administrator', 'description' => null, 'default_route' => '/admin/dashboard', 'order_source' => null, 'is_active' => 1];
// role.manage conserve mais is_active absent -> desactivation de l'admin -> bloque.
$form = ['_csrf' => $this->csrf, 'label' => 'Administrator', 'perm_1' => '1', 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'];
$response = $this->controller($this->post($form, '/admin/roles/1'), $db)->update(['id' => '1']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('UPDATE role SET'));
}
public function testUpdateLockedActorReturns422WithoutEffect(): void
{
$db = $this->permittedDb();
$db->roleManageRow = ['id' => 5, 'code' => 'counter', 'label' => 'Counter', 'description' => null, 'default_route' => null, 'order_source' => 'counter', 'is_active' => 1];
$this->actingPin($db);
$db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', time() + 300);
$form = ['_csrf' => $this->csrf, 'label' => 'Counter', 'perm_1' => '1', 'is_active' => '1', 'pin_email' => 'sam@wakdo.local', 'pin' => '4729'];
$response = $this->controller($this->post($form, '/admin/roles/5'), $db)->update(['id' => '5']);
self::assertSame(422, $response->status());
self::assertSame([], $db->auditActions()); // pas de pin.failed sous verrou
self::assertFalse($db->wrote('UPDATE role SET'));
}
}