feat(admin): RBAC - matrice roles/permissions + roles custom (PIN+audit diff) (P3) #39

Merged
Corentin merged 1 commit from feat/p3-roles into dev 2026-06-17 14:25:44 +02:00
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>
<?php endif; ?>
<?php if ($can('user.read')): ?>
<?php if ($can('user.read') || $can('role.manage')): ?>
<div class="sidebar-section">
<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>
<?php endif; ?>
<?php /*
Items de nav volontairement absents tant que leur page n'existe pas
(un lien vers une route non enregistree renvoie un 404). A reactiver
avec leur route respective : Commandes (order.read), Roles (role.manage)
-- lot RBAC / P4.
avec leur route respective : Commandes (order.read) -- domaine P4.
*/ ?>
</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\ProfileController;
use App\Controllers\StatsController;
use App\Controllers\RoleController;
use App\Controllers\UserController;
use App\Core\Autoloader;
use App\Core\Config;
@ -90,6 +91,14 @@ try {
$router->add('GET', '/admin/users/{id}/erase', [UserController::class, 'confirmErase']);
$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.
$router->add('GET', '/admin/categories', [CategoryController::class, 'index']);
$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 = [];
/**
* 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,
* 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;
}
// 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')) {
return ['id' => $this->lastInsertId];
}
@ -472,6 +519,26 @@ final class FakeDatabase implements DatabaseInterface
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 (!$this->roleActive) {
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'));
}
}