diff --git a/src/app/Auth/PinVerifier.php b/src/app/Auth/PinVerifier.php index 85c2b9a..99e1d7c 100644 --- a/src/app/Auth/PinVerifier.php +++ b/src/app/Auth/PinVerifier.php @@ -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 diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php new file mode 100644 index 0000000..790bf2c --- /dev/null +++ b/src/app/Catalogue/ProductRepository.php @@ -0,0 +1,103 @@ + + * 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> + */ + 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|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 + */ + 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'], + ]; + } +} diff --git a/src/app/Controllers/AdminController.php b/src/app/Controllers/AdminController.php index 54ca114..3ebf4bc 100644 --- a/src/app/Controllers/AdminController.php +++ b/src/app/Controllers/AdminController.php @@ -75,7 +75,7 @@ abstract class AdminController extends AuthenticatedController protected function userDirectory(): UserDirectory { - return new UserDirectory($this->database); + return new UserDirectory($this->db()); } /** diff --git a/src/app/Controllers/AuthenticatedController.php b/src/app/Controllers/AuthenticatedController.php index 413cff0..0121f00 100644 --- a/src/app/Controllers/AuthenticatedController.php +++ b/src/app/Controllers/AuthenticatedController.php @@ -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()); } } diff --git a/src/app/Controllers/ProductController.php b/src/app/Controllers/ProductController.php new file mode 100644 index 0000000..72a49a0 --- /dev/null +++ b/src/app/Controllers/ProductController.php @@ -0,0 +1,417 @@ + 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 $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 $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 $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 $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 $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 $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 $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 $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} + */ + 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 $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 $values + * @param array $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 $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']); + } +} diff --git a/src/app/Views/admin/products/delete.php b/src/app/Views/admin/products/delete.php new file mode 100644 index 0000000..d844179 --- /dev/null +++ b/src/app/Views/admin/products/delete.php @@ -0,0 +1,52 @@ + + + +
+ +

+ + +
+ + +

La suppression est tracee (audit). Renseignez votre email et votre PIN.

+ +
+ + +
+ +
+ + +
+ +
+ + Annuler +
+
+
diff --git a/src/app/Views/admin/products/form.php b/src/app/Views/admin/products/form.php new file mode 100644 index 0000000..2df115c --- /dev/null +++ b/src/app/Views/admin/products/form.php @@ -0,0 +1,119 @@ +> $categories + * @var array $values + * @var array $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 $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var array> $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); +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ +
+ + +
+ Changement de prix ou de TVA : confirmation par PIN +

Renseignez votre email et votre PIN uniquement si vous modifiez le prix ou la TVA (action tracee).

+
+ + +
+
+ + +
+

+
+ + +
+ + Annuler +
+
diff --git a/src/app/Views/admin/products/index.php b/src/app/Views/admin/products/index.php new file mode 100644 index 0000000..cb57b85 --- /dev/null +++ b/src/app/Views/admin/products/index.php @@ -0,0 +1,70 @@ +> $products + */ + +/** @var array> $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'; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NomCategoriePrixTVAStatut
Aucun produit.
+ + Disponible + + Indisponible + + + Modifier + Supprimer +
+
+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 7352aad..7259dac 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -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) { diff --git a/tests/Integration/ProductRepositoryDbTest.php b/tests/Integration/ProductRepositoryDbTest.php new file mode 100644 index 0000000..18474e0 --- /dev/null +++ b/tests/Integration/ProductRepositoryDbTest.php @@ -0,0 +1,98 @@ +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)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index e5bb09a..4db6b5e 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -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> + */ + public array $productsRows = []; + + /** + * Ligne renvoyee par ProductRepository::find() ; null = introuvable. + * + * @var array|null + */ + public ?array $productRow = null; + + /** + * Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ; + * null = email inconnu/inactif. + * + * @var array|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 */ 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 + */ + 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; } diff --git a/tests/Unit/Admin/ProductControllerTest.php b/tests/Unit/Admin/ProductControllerTest.php new file mode 100644 index 0000000..9d98fc6 --- /dev/null +++ b/tests/Unit/Admin/ProductControllerTest.php @@ -0,0 +1,360 @@ +testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } +} + +final class ProductControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $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 $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 $overrides + * @return array + */ + 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}|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'); + } +} diff --git a/tests/Unit/Auth/PinVerifierTest.php b/tests/Unit/Auth/PinVerifierTest.php index 969fd76..0080db5 100644 --- a/tests/Unit/Auth/PinVerifierTest.php +++ b/tests/Unit/Auth/PinVerifierTest.php @@ -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();