release: dev -> main v0.2.0 #93
13 changed files with 1358 additions and 3 deletions
|
|
@ -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 ré-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
|
||||
|
|
|
|||
103
src/app/Catalogue/ProductRepository.php
Normal file
103
src/app/Catalogue/ProductRepository.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ abstract class AdminController extends AuthenticatedController
|
|||
|
||||
protected function userDirectory(): UserDirectory
|
||||
{
|
||||
return new UserDirectory($this->database);
|
||||
return new UserDirectory($this->db());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
417
src/app/Controllers/ProductController.php
Normal file
417
src/app/Controllers/ProductController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
52
src/app/Views/admin/products/delete.php
Normal file
52
src/app/Views/admin/products/delete.php
Normal 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>
|
||||
119
src/app/Views/admin/products/form.php
Normal file
119
src/app/Views/admin/products/form.php
Normal 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>
|
||||
70
src/app/Views/admin/products/index.php
Normal file
70
src/app/Views/admin/products/index.php
Normal 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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
98
tests/Integration/ProductRepositoryDbTest.php
Normal file
98
tests/Integration/ProductRepositoryDbTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
360
tests/Unit/Admin/ProductControllerTest.php
Normal file
360
tests/Unit/Admin/ProductControllerTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue