feat(admin): CRUD produits avec PIN conditionnel et audit (#17)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 22s
CI / static-tests (push) Successful in 36s
CI / auto-merge (push) Has been skipped

This commit is contained in:
Corentin JOGUET 2026-06-15 22:35:50 +02:00
parent f63ac9873c
commit 2756fb4080
13 changed files with 1358 additions and 3 deletions

View file

@ -63,6 +63,41 @@ final class PinVerifier
return $this->hasher->verify($pin, $hash);
}
/**
* Modele "identifiant equipier + PIN" (RG-T13) : sur un poste a session
* partagee, l'individu qui realise l'action sensible se -authentifie par
* email + PIN. Resout l'utilisateur ACTIF par email, verifie le PIN contre son
* pin_hash, et renvoie son identite {id, role_id} (l'acteur ecrit dans
* audit_log) ou null. Email/PIN absent ou inconnu : verify leurre (timing).
*
* @return array{id: int, role_id: int}|null
*/
public function resolveActingUser(string $email, string $pin): ?array
{
if ($pin === '' || $email === '') {
$this->hasher->verifyDecoy($pin);
return null;
}
$row = $this->db->fetch(
'SELECT id, role_id, pin_hash FROM user WHERE email = :email AND is_active = 1 LIMIT 1',
['email' => $email],
);
$hash = is_string($row['pin_hash'] ?? null) ? (string) $row['pin_hash'] : '';
if ($hash === '' || !$this->hasher->verify($pin, $hash)) {
if ($hash === '') {
$this->hasher->verifyDecoy($pin);
}
return null;
}
return ['id' => (int) ($row['id'] ?? 0), 'role_id' => (int) ($row['role_id'] ?? 0)];
}
/**
* Politique de PIN a verifier cote serveur avant de hacher un nouveau PIN
* (P3, definition du PIN) : chiffres ASCII uniquement, bornes min ET max

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Catalogue;
use App\Core\DatabaseInterface;
/**
* Acces aux donnees de la table product (sous-domaine Catalogue). Suit le pattern
* etabli par CategoryRepository. La suppression dure peut etre bloquee par des FK
* RESTRICT (order_item, menu.burger_product_id, menu_slot_option,
* order_item_selection) : le controleur attrape la violation (SQLSTATE 23000) ->
* 422, plutot que de pre-tester chaque reference.
*/
final class ProductRepository
{
public function __construct(private readonly DatabaseInterface $db)
{
}
/**
* Liste pour le back-office, avec le libelle de categorie.
*
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
return $this->db->fetchAll(
'SELECT p.id, p.category_id, p.name, p.price_cents, p.vat_rate, p.is_available, '
. 'p.display_order, c.name AS category_name '
. 'FROM product p JOIN category c ON c.id = p.category_id '
. 'ORDER BY p.display_order, p.name',
);
}
/**
* @return array<string, mixed>|null
*/
public function find(int $id): ?array
{
return $this->db->fetch(
'SELECT id, category_id, name, description, price_cents, vat_rate, image_path, '
. 'is_available, display_order FROM product WHERE id = :id',
['id' => $id],
);
}
public function categoryExists(int $categoryId): bool
{
return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $categoryId]) !== null;
}
/**
* @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
*/
public function create(array $data): void
{
$this->db->execute(
'INSERT INTO product (category_id, name, description, price_cents, vat_rate, image_path, is_available, display_order) '
. 'VALUES (:category, :name, :description, :price, :vat, :image, :available, :ord)',
$this->bind($data),
);
}
/**
* @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
*/
public function update(int $id, array $data): void
{
$this->db->execute(
'UPDATE product SET category_id = :category, name = :name, description = :description, '
. 'price_cents = :price, vat_rate = :vat, image_path = :image, is_available = :available, '
. 'display_order = :ord WHERE id = :id',
$this->bind($data) + ['id' => $id],
);
}
public function delete(int $id): int
{
return $this->db->execute('DELETE FROM product WHERE id = :id', ['id' => $id]);
}
/**
* Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees.
*
* @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
* @return array<string, mixed>
*/
private function bind(array $data): array
{
return [
'category' => $data['category_id'],
'name' => $data['name'],
'description' => $data['description'],
'price' => $data['price_cents'],
'vat' => $data['vat_rate'],
'image' => $data['image_path'],
'available' => $data['is_available'],
'ord' => $data['display_order'],
];
}
}

View file

@ -75,7 +75,7 @@ abstract class AdminController extends AuthenticatedController
protected function userDirectory(): UserDirectory
{
return new UserDirectory($this->database);
return new UserDirectory($this->db());
}
/**

View file

@ -8,6 +8,7 @@ use App\Auth\Authorizer;
use App\Auth\SessionGuard;
use App\Auth\SessionManager;
use App\Core\Controller;
use App\Core\DatabaseInterface;
/**
* Base des controleurs proteges : fournit la session, la garde de session
@ -24,13 +25,23 @@ abstract class AuthenticatedController extends Controller
return new SessionManager($this->config);
}
/**
* Acces aux donnees via l'interface. Centralise le seam pour que toutes les
* dependances DB (garde, autorisation, repositories, transactions, audit)
* passent par un point unique surchargeable en test.
*/
protected function db(): DatabaseInterface
{
return $this->database;
}
protected function sessionGuard(): SessionGuard
{
return new SessionGuard($this->sessionManager(), $this->database, $this->config);
return new SessionGuard($this->sessionManager(), $this->db(), $this->config);
}
protected function authorizer(): Authorizer
{
return new Authorizer($this->database);
return new Authorizer($this->db());
}
}

View file

@ -0,0 +1,417 @@
<?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\PinVerifier;
use App\Catalogue\CategoryRepository;
use App\Catalogue\ProductRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
/**
* CRUD des produits (P3). Cas riche du catalogue + premier usage reel des actions
* sensibles (RG-T13/RG-T14) :
* - create (product.create) : pas de PIN (mlt 8.1) ;
* - update (product.update) : PIN equipier + audit UNIQUEMENT si prix ou TVA
* change (mlt 8.2 RG-4) ; sinon mise a jour simple ;
* - delete (product.delete) : PIN equipier + audit, suppression dure seulement si
* le produit n'est reference nulle part (FK RESTRICT -> 422 sinon).
* Le PIN suit le modele "identifiant equipier + PIN" : email + PIN resolus en un
* acting_user_id ecrit dans audit_log, dans la meme transaction que l'effet (RG-T08).
*
* Non `final` : les tests sous-classent pour injecter des doubles.
*/
class ProductController extends AdminController
{
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard('product.read');
if ($guard instanceof Response) {
return $guard;
}
return $this->adminView('admin/products/index', [
'title' => 'Produits - Wakdo Admin',
'activeNav' => 'products',
'products' => $this->productRepository()->all(),
], $guard);
}
/**
* @param array<string, string> $params
*/
public function create(array $params = []): Response
{
$guard = $this->guard('product.create');
if ($guard instanceof Response) {
return $guard;
}
return $this->renderForm($guard, 0, [], []);
}
/**
* @param array<string, string> $params
*/
public function store(array $params = []): Response
{
$guard = $this->guard('product.create');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
[$data, $errors] = $this->validate($form);
if ($errors !== []) {
return $this->renderForm($guard, 0, $form, $errors, 422);
}
$this->productRepository()->create($data);
$this->setFlash('Produit cree.');
return $this->redirect('/admin/products');
}
/**
* @param array<string, string> $params
*/
public function edit(array $params): Response
{
$guard = $this->guard('product.update');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$product = $this->productRepository()->find($id);
if ($product === null) {
return $this->notFound($guard);
}
return $this->renderForm($guard, $id, $product, []);
}
/**
* @param array<string, string> $params
*/
public function update(array $params): Response
{
$guard = $this->guard('product.update');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$current = $this->productRepository()->find($id);
if ($current === null) {
return $this->notFound($guard);
}
[$data, $errors] = $this->validate($form);
if ($errors !== []) {
return $this->renderForm($guard, $id, $form, $errors, 422);
}
// RG-T13/8.2 : seul un changement de prix ou de TVA est une action sensible.
$priceChanged = $data['price_cents'] !== (int) ($current['price_cents'] ?? 0);
$vatChanged = $data['vat_rate'] !== (int) ($current['vat_rate'] ?? 0);
if (!$priceChanged && !$vatChanged) {
$this->productRepository()->update($id, $data);
$this->setFlash('Produit mis a jour.');
return $this->redirect('/admin/products');
}
// Changement sensible : exige email + PIN (modele equipier + PIN, RG-T13).
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) {
$this->logFailedPin(trim($form['pin_email'] ?? ''), $id);
return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422);
}
$summary = $this->changeSummary($current, $data, $priceChanged, $vatChanged);
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $data, $actor, $summary): void {
(new ProductRepository($db))->update($id, $data);
$this->writeAudit($db, 'product.update', $actor['id'], $actor['role_id'], $id, $summary);
});
$this->setFlash('Produit mis a jour (changement de prix/TVA trace).');
return $this->redirect('/admin/products');
}
/**
* @param array<string, string> $params
*/
public function confirmDelete(array $params): Response
{
$guard = $this->guard('product.delete');
if ($guard instanceof Response) {
return $guard;
}
$id = (int) ($params['id'] ?? 0);
$product = $this->productRepository()->find($id);
if ($product === null) {
return $this->notFound($guard);
}
return $this->renderDelete($guard, $id, $product, null);
}
/**
* @param array<string, string> $params
*/
public function destroy(array $params): Response
{
$guard = $this->guard('product.delete');
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);
$product = $this->productRepository()->find($id);
if ($product === null) {
return $this->notFound($guard);
}
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) {
$this->logFailedPin(trim($form['pin_email'] ?? ''), $id);
return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).');
}
$name = (string) ($product['name'] ?? '');
try {
$this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void {
$deleted = (new ProductRepository($db))->delete($id);
if ($deleted === 1) {
$this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, 'Suppression produit: ' . $name);
}
});
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return $this->renderDelete($guard, $id, $product, 'Produit reference par des commandes ou menus : suppression impossible. Masquez-le plutot.');
}
throw $exception;
}
$this->setFlash('Produit supprime.');
return $this->redirect('/admin/products');
}
protected function productRepository(): ProductRepository
{
return new ProductRepository($this->db());
}
protected function categoryRepository(): CategoryRepository
{
return new CategoryRepository($this->db());
}
protected function pinVerifier(): PinVerifier
{
return new PinVerifier($this->db(), $this->config, $this->passwordHasher());
}
protected function passwordHasher(): PasswordHasher
{
return new PasswordHasher($this->config);
}
/**
* Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees, erreurs].
*
* @param array<string, string> $form
* @return array{0: array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}, 1: array<string, string>}
*/
private function validate(array $form): array
{
$errors = [];
$categoryRaw = trim($form['category_id'] ?? '');
$categoryId = ctype_digit($categoryRaw) ? (int) $categoryRaw : 0;
if ($categoryId === 0 || !$this->productRepository()->categoryExists($categoryId)) {
$errors['category_id'] = 'Categorie requise et valide.';
}
$name = trim($form['name'] ?? '');
if ($name === '' || mb_strlen($name) > 120) {
$errors['name'] = 'Le nom est requis (120 caracteres max).';
}
$priceRaw = trim($form['price_cents'] ?? '');
$priceValid = ctype_digit($priceRaw) && (int) $priceRaw > 0 && (int) $priceRaw <= 4294967295;
if (!$priceValid) {
$errors['price_cents'] = 'Le prix (en centimes) doit etre un entier strictement positif.';
}
$vat = ctype_digit(trim($form['vat_rate'] ?? '')) ? (int) trim($form['vat_rate'] ?? '') : 0;
if ($vat !== 55 && $vat !== 100) {
$errors['vat_rate'] = 'La TVA doit valoir 55 (5,5%) ou 100 (10%).';
}
$image = trim($form['image_path'] ?? '');
if ($image !== '' && mb_strlen($image) > 255) {
$errors['image_path'] = 'Chemin image trop long (255 max).';
}
$orderRaw = trim($form['display_order'] ?? '0');
if (!ctype_digit($orderRaw) || (int) $orderRaw > 65535) {
$errors['display_order'] = 'L ordre d affichage doit etre un entier entre 0 et 65535.';
}
$description = trim($form['description'] ?? '');
$data = [
'category_id' => $categoryId,
'name' => $name,
'description' => $description !== '' ? $description : null,
'price_cents' => $priceValid ? (int) $priceRaw : 0,
'vat_rate' => ($vat === 55 || $vat === 100) ? $vat : 100,
'image_path' => $image !== '' ? $image : null,
'is_available' => ($form['is_available'] ?? '') !== '' ? 1 : 0,
'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0,
];
return [$data, $errors];
}
/**
* @param array<string, mixed> $current
* @param array{price_cents: int, vat_rate: int} $data
*/
private function changeSummary(array $current, array $data, bool $priceChanged, bool $vatChanged): string
{
$parts = [];
if ($priceChanged) {
$parts[] = sprintf('price_cents %d -> %d', (int) ($current['price_cents'] ?? 0), $data['price_cents']);
}
if ($vatChanged) {
$parts[] = sprintf('vat_rate %d -> %d', (int) ($current['vat_rate'] ?? 0), $data['vat_rate']);
}
return implode(', ', $parts);
}
/**
* Trace une tentative de PIN echouee sur une action sensible (RG-T14) : rend
* le brute-force d'attribution detectable/alertable (un pic de pin.failed pour
* un email cible est visible en revue). Acteur inconnu (PIN non resolu).
*
* NB : ce n'est PAS un verrou. Un throttling degressif du PIN (par compte/IP)
* reste a ajouter en hardening dedie (decision de schema, cf. SESSION_RESUME).
*/
private function logFailedPin(string $email, int $productId): void
{
$this->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' => 'product',
'eid' => $productId,
'summary' => 'Echec PIN action sensible (email tente: ' . $email . ')',
],
);
}
private function writeAudit(DatabaseInterface $db, string $action, int $userId, int $roleId, int $entityId, string $summary): 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' => $userId, 'rid' => $roleId, 'code' => $action, 'etype' => 'product', 'eid' => $entityId, 'summary' => $summary],
);
}
/**
* @param array<string, mixed> $values
* @param array<string, string> $errors
*/
private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response
{
return $this->adminView('admin/products/form', [
'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' produit - Wakdo Admin',
'activeNav' => 'products',
'productId' => $id,
'categories' => $this->categoryRepository()->all(),
'values' => [
'category_id' => (string) ($values['category_id'] ?? ''),
'name' => (string) ($values['name'] ?? ''),
'description' => (string) ($values['description'] ?? ''),
'price_cents' => (string) ($values['price_cents'] ?? ''),
'vat_rate' => (string) ($values['vat_rate'] ?? '100'),
'image_path' => (string) ($values['image_path'] ?? ''),
// Defaut coche a la creation (errors vide + values vide) ; sur un
// re-rendu POST (erreurs), refleter la presence reelle du champ
// (case decochee = absente = non cochee), pas le defaut a 1.
'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values),
'display_order' => (string) ($values['display_order'] ?? '0'),
],
'errors' => $errors,
], $guard, $status);
}
/**
* @param array<string, mixed> $product
*/
private function renderDelete(GuardResult $guard, int $id, array $product, ?string $error): Response
{
return $this->adminView('admin/products/delete', [
'title' => 'Supprimer un produit - Wakdo Admin',
'activeNav' => 'products',
'productId' => $id,
'name' => (string) ($product['name'] ?? ''),
'error' => $error,
], $guard, $error !== null ? 422 : 200);
}
private function notFound(GuardResult $guard): Response
{
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'products'], $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

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* Confirmation de suppression d'un produit (action sensible RG-T13) : exige
* l'email + le PIN de l'equipier. Injecte dans admin/layout.php.
*
* @var int $productId
* @var string $name
* @var string|null $error
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$id = (int) ($productId ?? 0);
$productName = htmlspecialchars((string) ($name ?? ''), ENT_QUOTES, 'UTF-8');
$errorMessage = isset($error) && is_string($error) ? $error : null;
?>
<div class="page-header">
<div>
<h1 class="page-title">Supprimer un produit</h1>
<p class="page-subtitle">Confirmez la suppression de "<?= $productName ?>".</p>
</div>
</div>
<section>
<?php if ($errorMessage !== null): ?>
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" action="/admin/products/<?= $id ?>/delete" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<p><small>La suppression est tracee (audit). Renseignez votre email et votre PIN.</small></p>
<div class="form-group">
<label class="form-label" for="pin_email">Votre email</label>
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off" required>
</div>
<div class="form-group">
<label class="form-label" for="pin">Votre PIN</label>
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Supprimer definitivement</button>
<a class="btn btn-secondary" href="/admin/products">Annuler</a>
</div>
</form>
</section>

View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/**
* Formulaire produit (creation/edition), injecte dans admin/layout.php. Reaffiche
* valeurs + erreurs (RG-T18). La section email + PIN n'est requise que pour un
* changement de prix/TVA en edition (RG-T13, modele equipier + PIN). CSRF cache.
*
* @var int $productId
* @var array<int, array<string, mixed>> $categories
* @var array<string, mixed> $values
* @var array<string, string> $errors
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$id = (int) ($productId ?? 0);
$action = $id !== 0 ? '/admin/products/' . $id : '/admin/products';
/** @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>> $cats */
$cats = isset($categories) && is_array($categories) ? $categories : [];
$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]) ? $errs[$k] : '';
$selectedCat = (string) ($vals['category_id'] ?? '');
$selectedVat = (string) ($vals['vat_rate'] ?? '100');
$available = (bool) ($vals['is_available'] ?? true);
?>
<div class="page-header">
<div>
<h1 class="page-title"><?= $id !== 0 ? 'Modifier le produit' : 'Nouveau produit' ?></h1>
</div>
</div>
<form method="post" action="<?= htmlspecialchars($action, ENT_QUOTES, 'UTF-8') ?>" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="category_id">Categorie</label>
<select class="form-input" id="category_id" name="category_id" required>
<option value="">-- choisir --</option>
<?php foreach ($cats as $cat): ?>
<?php $cid = (string) ($cat['id'] ?? ''); ?>
<option value="<?= htmlspecialchars($cid, ENT_QUOTES, 'UTF-8') ?>"<?= $cid === $selectedCat ? ' selected' : '' ?>>
<?= htmlspecialchars((string) ($cat['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($err('category_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('category_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="name">Nom</label>
<input class="form-input" type="text" id="name" name="name" maxlength="120" value="<?= $val('name') ?>" required>
<?php if ($err('name') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('name'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="description">Description</label>
<textarea class="form-input" id="description" name="description"><?= $val('description') ?></textarea>
</div>
<div class="form-group">
<label class="form-label" for="price_cents">Prix (en centimes)</label>
<input class="form-input" type="number" id="price_cents" name="price_cents" min="1" value="<?= $val('price_cents') ?>" required>
<?php if ($err('price_cents') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('price_cents'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="vat_rate">TVA</label>
<select class="form-input" id="vat_rate" name="vat_rate">
<option value="100"<?= $selectedVat === '100' ? ' selected' : '' ?>>10% (sur place / general)</option>
<option value="55"<?= $selectedVat === '55' ? ' selected' : '' ?>>5,5% (a emporter)</option>
</select>
<?php if ($err('vat_rate') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('vat_rate'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="image_path">Chemin de l'image (optionnel)</label>
<input class="form-input" type="text" id="image_path" name="image_path" maxlength="255" value="<?= $val('image_path') ?>">
<?php if ($err('image_path') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('image_path'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="display_order">Ordre d'affichage</label>
<input class="form-input" type="number" id="display_order" name="display_order" min="0" value="<?= $val('display_order') ?>">
<?php if ($err('display_order') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('display_order'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label"><input type="checkbox" name="is_available" value="1"<?= $available ? ' checked' : '' ?>> Disponible</label>
</div>
<?php if ($id !== 0): ?>
<fieldset class="form-group">
<legend>Changement de prix ou de TVA : confirmation par PIN</legend>
<p><small>Renseignez votre email et votre PIN uniquement si vous modifiez le prix ou la TVA (action tracee).</small></p>
<div class="form-group">
<label class="form-label" for="pin_email">Votre email</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">Votre 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"><?= htmlspecialchars($err('pin'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</fieldset>
<?php endif; ?>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Enregistrer</button>
<a class="btn btn-secondary" href="/admin/products">Annuler</a>
</div>
</form>

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Liste des produits (CRUD admin), injectee dans admin/layout.php. Texte echappe.
*
* @var array<int, array<string, mixed>> $products
*/
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($products) && is_array($products) ? $products : [];
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR';
?>
<div class="page-header">
<div>
<h1 class="page-title">Produits</h1>
<p class="page-subtitle">Gestion des produits du catalogue</p>
</div>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/products/new">Nouveau produit</a>
</div>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Nom</th>
<th>Categorie</th>
<th>Prix</th>
<th>TVA</th>
<th>Statut</th>
<th style="width:160px;"></th>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="6" class="muted">Aucun produit.</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$available = (int) ($row['is_available'] ?? 0) === 1;
$vat = (int) ($row['vat_rate'] ?? 100);
?>
<tr>
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
<td class="muted"><?= $esc($row['category_name'] ?? '') ?></td>
<td><?= $esc($euros((int) ($row['price_cents'] ?? 0))) ?></td>
<td class="muted"><?= $vat === 55 ? '5,5%' : '10%' ?></td>
<td>
<?php if ($available): ?>
<span class="pill pill-success">Disponible</span>
<?php else: ?>
<span class="pill pill-neutral">Indisponible</span>
<?php endif; ?>
</td>
<td>
<a class="btn btn-secondary" href="/admin/products/<?= $id ?>/edit">Modifier</a>
<a class="btn btn-secondary" href="/admin/products/<?= $id ?>/delete">Supprimer</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View file

@ -18,6 +18,7 @@ use App\Controllers\HealthController;
use App\Controllers\HomeController;
use App\Controllers\MeController;
use App\Controllers\PasswordResetController;
use App\Controllers\ProductController;
use App\Controllers\ProfileController;
use App\Core\Autoloader;
use App\Core\Config;
@ -79,6 +80,16 @@ try {
$router->add('GET', '/admin/profile/pin', [ProfileController::class, 'showPin']);
$router->add('POST', '/admin/profile/pin', [ProfileController::class, 'updatePin']);
// CRUD Produits (product.read/create/update/delete). PIN equipier + audit sur
// changement prix/TVA (update) et suppression (delete).
$router->add('GET', '/admin/products', [ProductController::class, 'index']);
$router->add('GET', '/admin/products/new', [ProductController::class, 'create']);
$router->add('POST', '/admin/products', [ProductController::class, 'store']);
$router->add('GET', '/admin/products/{id}/edit', [ProductController::class, 'edit']);
$router->add('POST', '/admin/products/{id}', [ProductController::class, 'update']);
$router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']);
$router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']);
$response = $router->dispatch(Request::fromGlobals());
$response->send();
} catch (Throwable $exception) {

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Catalogue\ProductRepository;
use App\Core\Config;
use App\Core\Database;
/**
* CRUD reel de ProductRepository contre une vraie MariaDB (schema migre + seede).
* Auto-skip si WAKDO_DB_TESTS != 1. Produit jetable (nom it-prod-*) dans une
* categorie seedee ; nettoyage en tearDown.
*/
final class ProductRepositoryDbTest extends TestCase
{
private Database $db;
private string $name = '';
private int $categoryId = 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->categoryId = (int) ($this->db->fetch('SELECT id FROM category ORDER BY id LIMIT 1')['id'] ?? 0);
$this->name = 'it-prod-' . bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
if ($this->name !== '') {
$this->db->execute('DELETE FROM product WHERE name = :name', ['name' => $this->name]);
}
}
public function testCreateFindUpdateDelete(): void
{
$repo = new ProductRepository($this->db);
self::assertGreaterThan(0, $this->categoryId);
self::assertTrue($repo->categoryExists($this->categoryId));
self::assertFalse($repo->categoryExists(0));
$repo->create([
'category_id' => $this->categoryId,
'name' => $this->name,
'description' => null,
'price_cents' => 999,
'vat_rate' => 100,
'image_path' => null,
'is_available' => 1,
'display_order' => 99,
]);
$id = (int) ($this->db->fetch('SELECT id FROM product WHERE name = :name', ['name' => $this->name])['id'] ?? 0);
self::assertGreaterThan(0, $id);
$found = $repo->find($id);
self::assertNotNull($found);
self::assertSame(999, (int) ($found['price_cents'] ?? 0));
$repo->update($id, [
'category_id' => $this->categoryId,
'name' => $this->name,
'description' => 'maj',
'price_cents' => 1099,
'vat_rate' => 55,
'image_path' => null,
'is_available' => 0,
'display_order' => 100,
]);
$updated = $repo->find($id);
self::assertNotNull($updated);
self::assertSame(1099, (int) ($updated['price_cents'] ?? 0));
self::assertSame(55, (int) ($updated['vat_rate'] ?? 0));
// all() porte le libelle de categorie joint.
$names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $repo->all());
self::assertContains($this->name, $names);
// Produit non reference : suppression dure OK.
self::assertSame(1, $repo->delete($id));
self::assertNull($repo->find($id));
}
}

View file

@ -120,6 +120,28 @@ final class FakeDatabase implements DatabaseInterface
/** Resultat de UserRepository::pinIsSet() (true = un PIN est defini). */
public bool $userPinSet = false;
/**
* Lignes renvoyees par ProductRepository::all().
*
* @var list<array<string, mixed>>
*/
public array $productsRows = [];
/**
* Ligne renvoyee par ProductRepository::find() ; null = introuvable.
*
* @var array<string, mixed>|null
*/
public ?array $productRow = null;
/**
* Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ;
* null = email inconnu/inactif.
*
* @var array<string, mixed>|null
*/
public ?array $actingUserRow = null;
/** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */
public ?Throwable $failOnExecute = null;
@ -132,6 +154,15 @@ final class FakeDatabase implements DatabaseInterface
/** @var list<string> */
public array $transactionEvents = [];
/**
* Journal ordonne entrelacant ecritures et bornes de transaction, pour
* verifier qu'une ecriture (ex. audit_log) tombe bien ENTRE begin et commit
* (atomicite RG-T08), ce que deux listes disjointes ne prouvent pas.
*
* @var list<string>
*/
public array $eventLog = [];
public function fetch(string $sql, array $params = []): ?array
{
$this->reads[] = ['sql' => $sql, 'params' => $params];
@ -176,6 +207,16 @@ final class FakeDatabase implements DatabaseInterface
return $this->userPinSet ? ['id' => 1] : null;
}
// Exige is_active = 1 (garde RG-T13) : retirer le predicat en production
// ferait virer au rouge les tests de resolveActingUser.
if (str_contains($sql, 'pin_hash FROM user WHERE email') && str_contains($sql, 'is_active = 1')) {
return $this->actingUserRow;
}
if (str_contains($sql, 'FROM product WHERE id = :id')) {
return $this->productRow;
}
if (str_contains($sql, 'FROM category WHERE id = :id')) {
return $this->categoryRow;
}
@ -207,6 +248,10 @@ final class FakeDatabase implements DatabaseInterface
return $this->categoriesRows;
}
if (str_contains($sql, 'FROM product p JOIN category')) {
return $this->productsRows;
}
if (str_contains($sql, 'SELECT p.code FROM role_permission')) {
if (!$this->roleActive) {
return [];
@ -225,6 +270,7 @@ final class FakeDatabase implements DatabaseInterface
}
$this->writes[] = ['sql' => $sql, 'params' => $params];
$this->eventLog[] = 'write:' . substr($sql, 0, 24);
return $this->executeRowCount;
}
@ -232,12 +278,15 @@ final class FakeDatabase implements DatabaseInterface
public function transaction(callable $fn): void
{
$this->transactionEvents[] = 'begin';
$this->eventLog[] = 'begin';
try {
$fn($this);
$this->transactionEvents[] = 'commit';
$this->eventLog[] = 'commit';
} catch (\Throwable $exception) {
$this->transactionEvents[] = 'rollback';
$this->eventLog[] = 'rollback';
throw $exception;
}

View file

@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use App\Auth\Csrf;
use App\Auth\SessionManager;
use App\Controllers\ProductController;
use App\Core\Config;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Request;
use App\Tests\Support\FakeDatabase;
/**
* Sous-classe de test : grace au seam db(), une seule surcharge DB suffit ;
* sessionManager() injecte la session test.
*/
final class TestProductController extends ProductController
{
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 ProductControllerTest 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' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur'];
$db->canResult = true;
$db->permissionCodes = ['product.read', 'product.create', 'product.update', 'product.delete'];
$db->categoryRow = ['id' => 3, 'name' => 'Burgers']; // categoryExists -> true
return $db;
}
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): TestProductController
{
return new TestProductController($request, new Config(), new Database(new Config()), $this->session, $db);
}
/**
* @param array<string, string> $overrides
* @return array<string, string>
*/
private function validForm(array $overrides = []): array
{
return array_merge([
'_csrf' => $this->csrf,
'category_id' => '3',
'name' => 'Big Mac',
'price_cents' => '590',
'vat_rate' => '100',
'display_order' => '1',
'is_available' => '1',
], $overrides);
}
private function actingPin(FakeDatabase $db): void
{
// Equipier dont le PIN '4729' est valide (modele identifiant + PIN).
$db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new \App\Auth\PasswordHasher(new Config()))->hash('4729')];
}
public function testIndexRequiresProductRead(): void
{
$db = $this->permittedDb();
$db->canResult = false;
self::assertSame(403, $this->controller($this->get('/admin/products'), $db)->index()->status());
}
public function testIndexListsProducts(): void
{
$db = $this->permittedDb();
$db->productsRows = [
['id' => 1, 'category_id' => 3, 'name' => 'Big Mac', 'price_cents' => 590, 'vat_rate' => 100, 'is_available' => 1, 'category_name' => 'Burgers'],
];
$response = $this->controller($this->get('/admin/products'), $db)->index();
self::assertSame(200, $response->status());
self::assertStringContainsString('Big Mac', $response->body());
self::assertStringContainsString('Nouveau produit', $response->body());
}
public function testStoreCreatesWithoutPin(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post($this->validForm(), '/admin/products'), $db)->store();
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('INSERT INTO product'));
self::assertFalse($db->wrote('INSERT INTO audit_log')); // create = pas d'action sensible
self::assertSame('Produit cree.', $this->session->get('_flash'));
}
public function testStoreValidationErrorNoWrite(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post($this->validForm(['name' => '', 'price_cents' => '0']), '/admin/products'), $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO product'));
}
public function testUpdateWithoutPriceChangeNeedsNoPin(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Old', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1];
// Nom change, prix/TVA inchanges -> pas de PIN, pas d'audit.
$response = $this->controller($this->post($this->validForm(['name' => 'Renamed']), '/admin/products/5'), $db)->update(['id' => '5']);
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('UPDATE product SET'));
self::assertFalse($db->wrote('INSERT INTO audit_log'));
self::assertSame([], $db->transactionEvents);
}
public function testUpdatePriceChangeRequiresPin(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1];
// Prix change sans email/PIN -> 422, pas de mise a jour.
$response = $this->controller($this->post($this->validForm(['price_cents' => '620']), '/admin/products/5'), $db)->update(['id' => '5']);
self::assertSame(422, $response->status());
self::assertStringContainsString('PIN', $response->body());
self::assertFalse($db->wrote('UPDATE product SET'));
// PIN echoue trace (detectabilite du brute-force, RG-T14).
self::assertSame(['pin.failed'], $db->auditActions());
}
public function testUpdateVatChangeRequiresPin(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1];
// Prix inchange (590), TVA 100 -> 55 : sensible -> PIN requis.
$response = $this->controller($this->post($this->validForm(['vat_rate' => '55']), '/admin/products/5'), $db)->update(['id' => '5']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('UPDATE product SET'));
}
public function testUpdateVatChangeWithValidPinAudits(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1];
$this->actingPin($db);
$form = $this->validForm(['vat_rate' => '55', 'pin_email' => 'staff@wakdo.local', 'pin' => '4729']);
$response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']);
self::assertSame(302, $response->status());
self::assertSame(['begin', 'commit'], $db->transactionEvents);
$audit = $this->firstAudit($db);
self::assertNotNull($audit);
self::assertSame('product.update', $audit['params']['code'] ?? null);
self::assertStringContainsString('vat_rate 100 -> 55', (string) ($audit['params']['summary'] ?? ''));
}
public function testUpdatePriceChangeWithValidPinAuditsInTransaction(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1];
$this->actingPin($db);
$form = $this->validForm(['price_cents' => '620', 'pin_email' => 'staff@wakdo.local', 'pin' => '4729']);
$response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']);
self::assertSame(302, $response->status());
self::assertSame(['begin', 'commit'], $db->transactionEvents);
self::assertTrue($db->wrote('UPDATE product SET'));
// Acteur = utilisateur RESOLU PAR PIN (id 9, role 4), pas la session (id 1).
$audit = $this->firstAudit($db);
self::assertNotNull($audit);
self::assertSame('product.update', $audit['params']['code'] ?? null);
self::assertSame(9, $audit['params']['uid'] ?? null);
self::assertSame(4, $audit['params']['rid'] ?? null);
// Audit ecrit DANS la transaction (RG-T08), entre begin et commit.
$this->assertAuditWithinTransaction($db);
}
public function testEditNotFoundReturns404(): void
{
$db = $this->permittedDb();
$db->productRow = null;
self::assertSame(404, $this->controller($this->get('/admin/products/999/edit'), $db)->edit(['id' => '999'])->status());
}
public function testConfirmDeleteShowsPinForm(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
$response = $this->controller($this->get('/admin/products/5/delete'), $db)->confirmDelete(['id' => '5']);
self::assertSame(200, $response->status());
self::assertStringContainsString('name="pin"', $response->body());
}
public function testDestroyRequiresValidPin(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
$db->actingUserRow = null; // email/PIN invalide
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'x@y.z', 'pin' => '0000'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']);
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('DELETE FROM product'));
self::assertSame(['pin.failed'], $db->auditActions());
}
public function testDestroyWithValidPinDeletesAndAudits(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
$this->actingPin($db);
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']);
self::assertSame(302, $response->status());
self::assertTrue($db->wrote('DELETE FROM product'));
$audit = $this->firstAudit($db);
self::assertNotNull($audit);
self::assertSame('product.delete', $audit['params']['code'] ?? null);
self::assertSame(9, $audit['params']['uid'] ?? null); // acteur = PIN, pas la session (1)
self::assertSame(4, $audit['params']['rid'] ?? null);
$this->assertAuditWithinTransaction($db);
}
public function testDestroyReferencedReturns422(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 5, 'name' => 'Big Mac'];
$this->actingPin($db);
$db->failOnExecute = new \PDOException('fk', 23000); // FK RESTRICT a la suppression
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']);
self::assertSame(422, $response->status());
self::assertStringContainsString('reference', $response->body());
}
public function testStoreRejectsInvalidCsrf(): void
{
$db = $this->permittedDb();
$response = $this->controller($this->post($this->validForm(['_csrf' => 'wrong']), '/admin/products'), $db)->store();
self::assertSame(403, $response->status());
self::assertFalse($db->wrote('INSERT INTO product'));
}
/**
* @return array{sql: string, params: array<string|int, mixed>}|null
*/
private function firstAudit(FakeDatabase $db): ?array
{
foreach ($db->writes as $write) {
if (str_contains($write['sql'], 'INSERT INTO audit_log')) {
return $write;
}
}
return null;
}
private function assertAuditWithinTransaction(FakeDatabase $db): void
{
$log = $db->eventLog;
$begin = array_search('begin', $log, true);
$commit = array_search('commit', $log, true);
$auditAt = null;
foreach ($log as $i => $event) {
if (str_contains($event, 'INSERT INTO audit_log')) {
$auditAt = $i;
}
}
self::assertIsInt($begin);
self::assertIsInt($commit);
self::assertNotNull($auditAt);
self::assertTrue($begin < $auditAt && $auditAt < $commit, 'audit_log doit etre ecrit entre begin et commit');
}
}

View file

@ -93,6 +93,36 @@ final class PinVerifierTest extends TestCase
self::assertFalse($this->verifier()->verify(7, ''));
}
public function testResolveActingUserReturnsIdentityWhenPinMatches(): void
{
$this->db->actingUserRow = ['id' => 7, 'role_id' => 4, 'pin_hash' => $this->hasher->hash('4729')];
self::assertSame(['id' => 7, 'role_id' => 4], $this->verifier()->resolveActingUser('staff@wakdo.local', '4729'));
// Garde RG-T13 : la resolution filtre is_active = 1 (retirer le predicat
// ferait echouer ce cas, comme pour verify()).
self::assertStringContainsString('is_active = 1', $this->db->reads[0]['sql']);
}
public function testResolveActingUserNullWhenPinWrong(): void
{
$this->db->actingUserRow = ['id' => 7, 'role_id' => 4, 'pin_hash' => $this->hasher->hash('4729')];
self::assertNull($this->verifier()->resolveActingUser('staff@wakdo.local', '0000'));
}
public function testResolveActingUserNullWhenEmailUnknown(): void
{
$this->db->actingUserRow = null;
self::assertNull($this->verifier()->resolveActingUser('ghost@wakdo.local', '4729'));
}
public function testResolveActingUserNullWhenInputEmpty(): void
{
self::assertNull($this->verifier()->resolveActingUser('', '4729'));
self::assertNull($this->verifier()->resolveActingUser('staff@wakdo.local', ''));
}
public function testMeetsLengthPolicy(): void
{
$verifier = $this->verifier();