Merge remote-tracking branch 'forgejo/dev' into feat/r2-menu-maxi-variant
All checks were successful
CI / static-tests (pull_request) Successful in 59s
CI / js-tests (pull_request) Successful in 30s
CI / php-lint (push) Successful in 25s
CI / js-tests (push) Successful in 30s
CI / secret-scan (pull_request) Successful in 10s
CI / php-lint (pull_request) Successful in 30s
CI / secret-scan (push) Successful in 13s
CI / static-tests (push) Successful in 55s

This commit is contained in:
Imugiii 2026-06-22 09:48:47 +00:00
commit 1c134f5b86
9 changed files with 669 additions and 8 deletions

View file

@ -5,8 +5,13 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Auth\Csrf;
use App\Auth\GuardResult;
use App\Auth\PasswordHasher;
use App\Auth\PinThrottle;
use App\Auth\PinVerifier;
use App\Catalogue\MenuRepository;
use App\Catalogue\ProductRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
use App\Order\OrderQueryRepository;
use App\Order\OrderRepository;
@ -15,11 +20,12 @@ use App\Order\OrderValidationException;
/**
* Domaine commande back-office (P4 + P3 operationnel). GET /admin/orders : liste
* recente (order.read). POST /admin/orders/{number}/deliver : transition paid ->
* delivered (DELIVER_ORDER, geste unique, order.deliver), NON PIN-gated. L'annulation
* (CANCEL_ORDER, PIN + restock) et la file cuisine (KitchenController) sont traitees
* ailleurs.
* delivered (DELIVER_ORDER, geste unique, order.deliver), NON PIN-gated.
* GET/POST /admin/orders/{number}/cancel : annulation (CANCEL_ORDER, mlt 7.1,
* order.cancel) avec PIN equipier + audit + restock conditionnel (RG-T13/T14). La
* file cuisine (KitchenController) est traitee ailleurs.
*
* Non `final` : les tests sous-classent (seam db()/orderQuery()/orders()).
* Non `final` : les tests sous-classent (seam db()/orderQuery()/orders()/pin*()).
*/
class OrderAdminController extends AdminController
{
@ -37,6 +43,9 @@ class OrderAdminController extends AdminController
'title' => 'Commandes - Wakdo Admin',
'activeNav' => 'orders',
'orders' => $this->orderQuery()->recent(50),
// RG-T03 : adapte l'affichage (bouton Annuler) sans remplacer la garde
// par-action de cancel(). manager n'a PAS order.cancel (decision D5).
'canCancel' => $this->may($guard, 'order.cancel'),
], $guard);
}
@ -72,6 +81,98 @@ class OrderAdminController extends AdminController
return $this->redirect('/admin/orders');
}
/**
* Page de confirmation d'annulation (CANCEL_ORDER, mlt 7.1). Garde order.cancel.
* Affiche numero/statut/total + le formulaire PIN equipier (modele RG-T13). La
* commande est chargee en lecture seule (OrderRepository::findByNumber) ; statut
* terminal (delivered/cancelled) -> message bloquant, pas de formulaire.
*
* @param array<string, string> $params
*/
public function confirmCancel(array $params): Response
{
$guard = $this->guard('order.cancel');
if ($guard instanceof Response) {
return $guard;
}
$order = $this->orders()->findByNumber((string) ($params['number'] ?? ''));
if ($order === null) {
return $this->notFound($guard);
}
return $this->renderCancel($guard, $order, null);
}
/**
* Annulation effective (CANCEL_ORDER, mlt 7.1). POST + CSRF + garde order.cancel,
* puis flux PIN equipier IDENTIQUE a IngredientController::inventory (RG-T13/T22) :
* verrou throttle par utilisateur AGISSANT evalue AVANT la verification (leurre de
* timing, message generique) ; sur echec PIN -> pin.failed + increment throttle dans
* UNE transaction. Sur PIN OK -> OrderRepository::cancel (transition + restock
* conditionnel + audit dans sa propre transaction), reset du throttle, flash.
*
* @param array<string, string> $params
*/
public function cancel(array $params): Response
{
$guard = $this->guard('order.cancel');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$number = (string) ($params['number'] ?? '');
$order = $this->orders()->findByNumber($number);
if ($order === null) {
return $this->notFound($guard);
}
// RG-T22 : verrou du throttle par utilisateur AGISSANT (session), evalue AVANT
// la verification ; sous verrou, leurre de timing + message generique, pas de
// nouvelle ligne pin.failed (les echecs ayant arme le verrou sont deja audites).
$actorId = $guard->userId ?? 0;
if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) {
$this->pinVerifier()->payTimingDecoy($form['pin'] ?? '');
return $this->renderCancel($guard, $order, 'Email ou PIN invalide (requis pour annuler).', 422);
}
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) {
// RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans UNE
// transaction. pin.failed est un evenement securite (pas l'effet metier).
$email = trim($form['pin_email'] ?? '');
$entityId = (int) ($order['id'] ?? 0);
$this->db()->transaction(function (DatabaseInterface $db) use ($email, $entityId, $actorId): void {
$this->logFailedPin($db, $email, $entityId);
$this->pinThrottle()->recordFailureWithin($db, $actorId);
});
return $this->renderCancel($guard, $order, 'Email ou PIN invalide (requis pour annuler).', 422);
}
try {
$this->orders()->cancel($number, (int) $actor['id'], (int) $actor['role_id']);
// PIN valide : reinitialise le compteur de l'acteur de SESSION (RG-T22, cle
// = $actorId), surtout pas $actor['id'] (l'equipier resolu par le PIN).
$this->pinThrottle()->reset($actorId);
$this->setFlash('Commande annulee.');
} catch (OrderValidationException $exception) {
$this->setFlash(match ($exception->getMessage()) {
'ORDER_NOT_FOUND' => 'Commande introuvable.',
'CANNOT_CANCEL_IN_STATE' => 'Annulation impossible : la commande est livree ou deja annulee.',
default => 'Transition invalide : la commande a change d\'etat.',
});
}
return $this->redirect('/admin/orders');
}
protected function orderQuery(): OrderQueryRepository
{
return new OrderQueryRepository($this->db());
@ -84,6 +185,68 @@ class OrderAdminController extends AdminController
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
}
protected function pinVerifier(): PinVerifier
{
return new PinVerifier($this->db(), $this->config, $this->passwordHasher());
}
protected function pinThrottle(): PinThrottle
{
return new PinThrottle($this->db(), $this->config);
}
protected function passwordHasher(): PasswordHasher
{
return new PasswordHasher($this->config);
}
/**
* RG-T03 : la permission est-elle detenue par le role de la session courante ?
* Sert a adapter l'affichage (bouton Annuler) sans remplacer la garde par-action.
*/
private function may(GuardResult $guard, string $permission): bool
{
return $guard->roleId !== null && $this->authorizer()->can($guard->roleId, $permission);
}
/**
* Trace une tentative de PIN echouee sur l'annulation (RG-T14) : rend le
* brute-force d'attribution detectable. Acteur inconnu (PIN non resolu).
*/
private function logFailedPin(DatabaseInterface $db, string $email, int $orderId): void
{
$db->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
[
'uid' => null,
'rid' => null,
'code' => 'pin.failed',
'etype' => 'customer_order',
'eid' => $orderId,
'summary' => 'Echec PIN annulation (email tente: ' . $email . ')',
],
);
}
/**
* @param array{id:int, order_number:string, total_ttc_cents:int, status:string} $order
*/
private function renderCancel(GuardResult $guard, array $order, ?string $error, int $status = 200): Response
{
return $this->adminView('admin/orders/cancel', [
'title' => 'Annuler une commande - Wakdo Admin',
'activeNav' => 'orders',
'order' => $order,
'error' => $error,
], $guard, $status);
}
private function notFound(GuardResult $guard): Response
{
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'orders'], $guard, 404);
}
private function redirect(string $location): Response
{
return Response::make('', 302, ['Location' => $location]);

View file

@ -341,6 +341,123 @@ class OrderRepository
return $result;
}
/**
* Annulation d'une commande (CANCEL_ORDER, mlt 7.1). Transition gardee
* pending_payment|paid -> cancelled, re-credit de stock CONDITIONNEL et ecriture
* audit_log dans UNE transaction (RG-T07/T08/T11/T14).
*
* Le re-credit n'a lieu que si la commande etait `paid` AVANT l'annulation : une
* commande `pending_payment` n'avait jamais decremente le stock (le decrement est
* pose a la transition `paid`, cf. pay()), il n'y a donc rien a re-crediter. Le
* re-credit reutilise consumption() (memes unites que le decrement de pay()),
* inversees (delta positif) ; un ingredient entierement retire (modifieur remove)
* n'a pas ete decremente -> consumption() ne le retourne pas -> pas de re-credit.
*
* Concurrence (RG-T07/RG-T20) : le statut est relu A L'INTERIEUR de la transaction
* via l'UPDATE garde par `status IN ('pending_payment','paid')` ; 0 ligne affectee
* = course perdue (un autre appel a deja transite) -> INVALID_TRANSITION. Le
* re-credit se base sur le pre-status lu en entree (coherent : seul l'appel qui a
* remporte la garde poursuit, et il n'y a pas de SELECT FOR UPDATE RG-T20).
*
* @param int|null $actingUserId equipier resolu par PIN (audit_log.actor_user_id +
* stock_movement.user_id) ; le controleur le fournit.
* @param int|null $actingRoleId role de l'equipier resolu par PIN (audit_log.actor_role_id).
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
* @throws OrderValidationException
*/
public function cancel(string $orderNumber, ?int $actingUserId, ?int $actingRoleId): array
{
$order = $this->db->fetch(
'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n',
['n' => $orderNumber],
);
if ($order === null) {
throw new OrderValidationException('ORDER_NOT_FOUND');
}
$preStatus = (string) $order['status'];
if (!in_array($preStatus, ['pending_payment', 'paid'], true)) {
throw new OrderValidationException('CANNOT_CANCEL_IN_STATE'); // delivered / cancelled (statut terminal).
}
$result = [
'id' => (int) $order['id'],
'order_number' => (string) $order['order_number'],
'total_ttc_cents' => (int) $order['total_ttc_cents'],
'status' => 'cancelled',
];
$orderId = (int) $order['id'];
$totalTtc = (int) $order['total_ttc_cents'];
$this->db->transaction(function (DatabaseInterface $db) use ($orderId, $preStatus, $totalTtc, $actingUserId, $actingRoleId): void {
$affected = $db->execute(
'UPDATE customer_order SET status = \'cancelled\', cancelled_at = NOW(), updated_at = NOW() '
. 'WHERE id = :id AND status IN (\'pending_payment\', \'paid\')',
['id' => $orderId],
);
if ($affected === 0) {
// Course perdue : la garde RG-T07 n'a affecte aucune ligne (un autre
// appel a deja transite vers un statut terminal). Pas d'issue idempotente
// pour l'annulation (a la difference de pay/deliver) : on signale la
// transition invalide et la transaction est annulee (aucun re-credit).
throw new OrderValidationException('INVALID_TRANSITION');
}
// RG-3 : re-credit CONDITIONNEL. On le decide sur l'EXISTENCE de mouvements
// 'sale' pour cette commande (poses au decrement de pay()), PAS sur le
// pre-status lu hors transaction : insensible a la course
// pending_payment -> paid -> cancel (sinon un pay() concurrent gagnant
// laisserait le stock decremente sans re-credit, derive silencieuse). De
// fait idempotent : sans mouvement 'sale', rien a re-crediter. Memes unites
// que pay() (consumption), inversees (delta positif).
$restocked = $this->hasSaleMovements($db, $orderId);
if ($restocked) {
foreach ($this->consumption($db, $orderId) as $ingredientId => $units) {
$db->execute(
'UPDATE ingredient SET stock_quantity = stock_quantity + :u WHERE id = :id',
['u' => $units, 'id' => $ingredientId],
);
$db->execute(
'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) '
. 'VALUES (:ing, \'cancellation\', :delta, :oid, :uid, NULL)',
['ing' => $ingredientId, 'delta' => $units, 'oid' => $orderId, 'uid' => $actingUserId],
);
}
}
// RG-6/RG-T14 : trace d'audit immuable dans la meme transaction que l'effet.
$recredit = $restocked ? $totalTtc : 0;
$summary = 'Annulation depuis ' . $preStatus . ', re-credit ' . $recredit . 'c';
$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' => $actingUserId,
'rid' => $actingRoleId,
'code' => 'order.cancel',
'etype' => 'customer_order',
'eid' => $orderId,
'summary' => $summary,
],
);
});
return $result;
}
/**
* Vrai si la commande porte au moins un mouvement de stock `sale` (donc elle a
* deja ete encaissee/decrementee par pay()). Sert a decider le re-credit a
* l'annulation independamment du statut observe hors transaction (anti-course).
*/
private function hasSaleMovements(DatabaseInterface $db, int $orderId): bool
{
return $db->fetch(
'SELECT 1 AS x FROM stock_movement WHERE order_id = :oid AND movement_type = \'sale\' LIMIT 1',
['oid' => $orderId],
) !== null;
}
/**
* Unites de stock a decrementer, AGREGEES par ingredient_id sur toute la
* commande (lecture des lignes persistees + recettes des produits supports).

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* Confirmation d'annulation d'une commande (CANCEL_ORDER 7.1), injectee dans
* admin/layout.php. Action sensible de manipulation d'argent : exige le PIN
* equipier (email + PIN, RG-T13). L'annulation est tracee (audit_log) et re-credite
* le stock si la commande etait payee. CSRF cache. Tout texte echappe (RG-T15).
*
* @var array<string, mixed> $order
* @var string|null $error
* @var string $csrfToken
*/
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
/** @var array<string, mixed> $o */
$o = isset($order) && is_array($order) ? $order : [];
$errorMessage = isset($error) && is_string($error) ? $error : null;
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
$number = (string) ($o['order_number'] ?? '');
$status = (string) ($o['status'] ?? '');
$statusLabel = static fn (string $s): string => match ($s) {
'pending_payment' => 'En attente',
'paid' => 'Payee',
'delivered' => 'Livree',
'cancelled' => 'Annulee',
default => $s,
};
// PRE-3 (7.1) : seuls pending_payment / paid peuvent transiter vers cancelled.
$cancellable = in_array($status, ['pending_payment', 'paid'], true);
?>
<div class="page-header">
<div>
<h1 class="page-title">Annuler une commande</h1>
<p class="page-subtitle">Commande <?= $esc($number) ?> - <?= $esc($statusLabel($status)) ?> - <?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></p>
</div>
</div>
<section>
<?php if ($errorMessage !== null): ?>
<p class="form-error" role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<?php if (!$cancellable): ?>
<p role="alert">Cette commande est livree ou deja annulee : elle ne peut plus etre annulee.</p>
<div class="form-actions">
<a class="btn btn-secondary" href="/admin/orders">Retour</a>
</div>
<?php else: ?>
<form method="post" action="/admin/orders/<?= rawurlencode($number) ?>/cancel" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<p><small>L'annulation est tracee (audit) et re-credite le stock si la commande etait payee. Renseignez votre email et votre PIN.</small></p>
<fieldset class="form-group">
<legend>Confirmation par PIN equipier</legend>
<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>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Annuler la commande</button>
<a class="btn btn-secondary" href="/admin/orders">Retour</a>
</div>
</form>
<?php endif; ?>
</section>

View file

@ -4,12 +4,17 @@ declare(strict_types=1);
/**
* Liste des commandes (order.read), injectee dans admin/layout.php. Lecture seule :
* numero, mode, chevalet, statut, total ttc, date. Tri du plus recent au plus ancien
* (cf. OrderQueryRepository::recent). Toute valeur est echappee (RG-T15).
* numero, mode, chevalet, statut, total ttc, date. Une colonne d'actions affiche le
* lien Annuler (CANCEL_ORDER 7.1) pour les commandes pending_payment/paid quand le
* role detient order.cancel (manager ne l'a PAS, D5). Tri du plus recent au plus
* ancien (cf. OrderQueryRepository::recent). Toute valeur est echappee (RG-T15).
*
* @var list<array<string, mixed>> $orders
* @var bool $canCancel
*/
$canCancelOrder = isset($canCancel) && $canCancel === true;
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
@ -54,17 +59,31 @@ $rows = isset($orders) && is_array($orders) ? $orders : [];
<th>Statut</th>
<th>Total</th>
<th>Date</th>
<?php if ($canCancelOrder): ?><th></th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $o): ?>
<?php
$number = (string) ($o['order_number'] ?? '');
$status = (string) ($o['status'] ?? '');
// PRE-3 (7.1) : seules les commandes pending_payment/paid sont annulables.
$rowCancellable = in_array($status, ['pending_payment', 'paid'], true);
?>
<tr>
<td><strong><?= $esc($o['order_number'] ?? '') ?></strong></td>
<td><strong><?= $esc($number) ?></strong></td>
<td><?= $esc($modeLabel((string) ($o['service_mode'] ?? ''))) ?></td>
<td><?= ($o['service_tag'] ?? '') !== '' ? $esc($o['service_tag']) : '—' ?></td>
<td><span class="pill <?= $esc($statusPill((string) ($o['status'] ?? ''))) ?>"><?= $esc($statusLabel((string) ($o['status'] ?? ''))) ?></span></td>
<td><span class="pill <?= $esc($statusPill($status)) ?>"><?= $esc($statusLabel($status)) ?></span></td>
<td><?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></td>
<td><?= $esc($o['created_at'] ?? '') ?></td>
<?php if ($canCancelOrder): ?>
<td>
<?php if ($rowCancellable): ?>
<a class="btn btn-secondary" href="/admin/orders/<?= rawurlencode($number) ?>/cancel">Annuler</a>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>

View file

@ -117,6 +117,11 @@ try {
$router->add('GET', '/admin/orders', [OrderAdminController::class, 'index']);
// Remise au client : paid -> delivered (order.deliver, geste unique, POST + CSRF).
$router->add('POST', '/admin/orders/{number}/deliver', [OrderAdminController::class, 'deliver']);
// Annulation : pending_payment|paid -> cancelled (CANCEL_ORDER mlt 7.1, order.cancel).
// PIN equipier + audit + restock conditionnel (RG-T13/T14). {number} = un seul
// segment (numero K/C/D + id) ; /cancel ne chevauche ni /deliver ni la liste.
$router->add('GET', '/admin/orders/{number}/cancel', [OrderAdminController::class, 'confirmCancel']);
$router->add('POST', '/admin/orders/{number}/cancel', [OrderAdminController::class, 'cancel']);
// Affichage cuisine (KDS) : file des commandes payees (order.read). Landing du role
// kitchen (seed role.default_route = /kitchen/display) ; corrige le 404 d'apres-login.
$router->add('GET', '/kitchen/display', [KitchenController::class, 'display']);

View file

@ -141,6 +141,14 @@ final class FakeDatabase implements DatabaseInterface
*/
public ?array $menuRow = null;
/**
* Ligne renvoyee par OrderRepository::findByNumber() / cancel() (lecture par
* order_number) ; null = numero inconnu.
*
* @var array<string, mixed>|null
*/
public ?array $orderByNumberRow = null;
/**
* Lignes renvoyees par MenuRepository::all().
*
@ -428,6 +436,10 @@ final class FakeDatabase implements DatabaseInterface
return $this->menuRow;
}
if (str_contains($sql, 'FROM customer_order WHERE order_number')) {
return $this->orderByNumberRow;
}
if (str_contains($sql, 'FROM order_item WHERE menu_id')) {
return $this->menuReferenced ? ['menu_id' => 1] : null;
}

View file

@ -40,6 +40,10 @@ final class FakeOrderDatabase implements DatabaseInterface
/** Statut relu apres une transition gardee a 0 ligne (course concurrente). */
public string $recheckStatus = 'paid';
/** La commande porte-t-elle des mouvements 'sale' (= deja encaissee/decrementee) ?
* Pilote le re-credit a l'annulation (OrderRepository::hasSaleMovements). */
public bool $saleMovementsExist = false;
/** Lignes order_item renvoyees pour la commande encaissee. */
/** @var list<array<string,mixed>> */
public array $orderItems = [];
@ -77,6 +81,9 @@ final class FakeOrderDatabase implements DatabaseInterface
if (str_contains($sql, 'FROM menu WHERE id = :id')) {
return $this->menus[(int) $params['id']] ?? null;
}
if (str_contains($sql, 'FROM stock_movement WHERE order_id')) {
return $this->saleMovementsExist ? ['x' => 1] : null;
}
return null;
}
@ -133,6 +140,18 @@ final class FakeOrderDatabase implements DatabaseInterface
return [];
}
/** SQL de la premiere ecriture dont le texte contient $needle (chaine vide sinon). */
public function firstWriteSql(string $needle): string
{
foreach ($this->writes as $write) {
if (str_contains($write['sql'], $needle)) {
return $write['sql'];
}
}
return '';
}
public function countWrites(string $needle): int
{
return count(array_filter($this->writes, static fn (array $w): bool => str_contains($w['sql'], $needle)));

View file

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use App\Auth\Csrf;
use App\Auth\PasswordHasher;
use App\Auth\SessionManager;
use App\Controllers\OrderAdminController;
use App\Core\Config;
@ -62,17 +64,24 @@ final class OrderAdminControllerTest 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', 2);
$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
@ -107,6 +116,33 @@ final class OrderAdminControllerTest extends TestCase
return new TestOrderAdminController($request, new Config(), new Database(new Config()), $this->session, $db);
}
private function controllerWith(Request $request, FakeDatabase $db): TestOrderAdminController
{
return new TestOrderAdminController($request, new Config(), new Database(new Config()), $this->session, $db);
}
/**
* @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 cancelDb(): FakeDatabase
{
$db = $this->permittedDb();
$db->permissionCodes = ['order.read', 'order.cancel'];
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'K42', 'total_ttc_cents' => 1990, 'status' => 'paid'];
return $db;
}
private function actingPin(FakeDatabase $db): void
{
$db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')];
}
public function testRequiresOrderRead(): void
{
$db = $this->permittedDb();
@ -146,4 +182,86 @@ final class OrderAdminControllerTest extends TestCase
self::assertSame(403, $response->status());
}
// --- CANCEL_ORDER (7.1, order.cancel + PIN equipier RG-T13) ---
public function testCancelRequiresOrderCancelPermission(): void
{
$db = $this->cancelDb();
$db->canResult = false; // pas de order.cancel -> 403 avant toute action
$request = $this->post([
'_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729',
], '/admin/orders/K42/cancel');
self::assertSame(403, $this->controllerWith($request, $db)->cancel(['number' => 'K42'])->status());
}
public function testCancelRejectsInvalidCsrf(): void
{
// order.cancel accorde mais jeton CSRF absent -> 403 avant toute transition.
$db = $this->cancelDb();
$request = $this->post(['pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/orders/K42/cancel');
self::assertSame(403, $this->controllerWith($request, $db)->cancel(['number' => 'K42'])->status());
}
public function testCancelWithBadPinLogsFailedAndDoesNotCancel(): void
{
$db = $this->cancelDb();
$db->actingUserRow = null; // email/PIN non resolu
$request = $this->post([
'_csrf' => $this->csrf, 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000',
], '/admin/orders/K42/cancel');
$response = $this->controllerWith($request, $db)->cancel(['number' => 'K42']);
self::assertSame(422, $response->status());
self::assertSame(['pin.failed'], $db->auditActions()); // trace detective (RG-T22)
self::assertFalse($db->wrote('UPDATE customer_order SET status')); // aucune transition
}
public function testCancelWithValidPinTransitionsToCancelled(): void
{
$db = $this->cancelDb();
$this->actingPin($db); // equipier id 9, PIN 4729
$request = $this->post([
'_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729',
], '/admin/orders/K42/cancel');
$response = $this->controllerWith($request, $db)->cancel(['number' => 'K42']);
self::assertSame(302, $response->status());
self::assertSame('/admin/orders', $response->header('Location'));
self::assertTrue($db->wrote('UPDATE customer_order SET status'));
// L'annulation est tracee avec l'acteur resolu par PIN (RG-T14).
self::assertSame(['order.cancel'], $db->auditActions());
}
public function testCancelUnknownOrderReturns404(): void
{
$db = $this->cancelDb();
$db->orderByNumberRow = null; // numero inconnu
$request = $this->post([
'_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729',
], '/admin/orders/K99/cancel');
self::assertSame(404, $this->controllerWith($request, $db)->cancel(['number' => 'K99'])->status());
}
public function testConfirmCancelRendersPinForm(): void
{
$db = $this->cancelDb();
$request = new Request('GET', '/admin/orders/K42/cancel', [], [], '', '203.0.113.5');
$response = $this->controllerWith($request, $db)->confirmCancel(['number' => 'K42']);
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('K42', $body);
self::assertStringContainsString('PIN', $body);
}
}

View file

@ -403,4 +403,133 @@ final class OrderRepositoryTest extends TestCase
$this->expectExceptionMessage('INVALID_TRANSITION');
$this->repo($db)->deliver('K100');
}
// --- cancel() : transition gardee + re-credit conditionnel + audit (RG-T07/T11/T14) ---
public function testCancelPendingTransitionsWithoutRecredit(): void
{
// pending_payment n'avait jamais decremente le stock (le decrement est pose a
// `paid`) : annulation = transition + audit, AUCUN re-credit (RG-3).
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
$res = $this->repo($db)->cancel('K100', 9, 4);
self::assertSame('cancelled', $res['status']);
self::assertSame('K100', $res['order_number']);
self::assertSame(1, $db->countWrites('UPDATE customer_order SET status'));
// Aucun mouvement de stock (re-credit) : la commande n'etait pas payee.
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
// Trace d'audit ecrite avec l'acteur resolu par PIN.
self::assertSame(1, $db->countWrites('INSERT INTO audit_log'));
$audit = $db->firstWrite('INSERT INTO audit_log');
self::assertSame('order.cancel', $audit['code']);
self::assertSame('customer_order', $audit['etype']);
self::assertSame(100, $audit['eid']);
self::assertSame(9, $audit['uid']);
self::assertSame(4, $audit['rid']);
}
public function testCancelPaidRecreditsStockAndWritesMovementAndAudit(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid'];
$db->saleMovementsExist = true; // payee -> mouvements 'sale' poses -> re-credit attendu
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
$res = $this->repo($db)->cancel('K100', 9, 4);
self::assertSame('cancelled', $res['status']);
self::assertSame(1, $db->countWrites('UPDATE customer_order SET status'));
// 2 unites consommees (qn 1 * quantite 2) -> re-credit +2 sur l'ingredient 5.
$inc = $db->firstWrite('UPDATE ingredient SET stock_quantity');
self::assertSame(2, $inc['u']);
self::assertSame(5, $inc['id']);
// Type 'cancellation' code en dur dans le SQL (cf. pay() qui code 'sale').
self::assertStringContainsString("'cancellation'", $db->firstWriteSql('INSERT INTO stock_movement'));
$move = $db->firstWrite('INSERT INTO stock_movement');
self::assertSame(2, $move['delta']); // delta POSITIF (re-credit)
self::assertSame(100, $move['oid']);
self::assertSame(9, $move['uid']); // acteur resolu par PIN
// Audit ecrit avec le montant re-credite (pre-status paid).
self::assertSame(1, $db->countWrites('INSERT INTO audit_log'));
$audit = $db->firstWrite('INSERT INTO audit_log');
self::assertSame('order.cancel', $audit['code']);
self::assertStringContainsString('paid', (string) $audit['summary']);
self::assertStringContainsString('890c', (string) $audit['summary']);
}
public function testCancelRecreditsWhenSaleMovementsExistEvenIfPreStatusPending(): void
{
// Anti-course pending_payment -> paid -> cancel : la commande lue en pending a
// en realite ete payee (mouvements 'sale' poses par un pay() concurrent). Le
// re-credit se decide sur l'existence des mouvements 'sale', pas sur le
// pre-status -> il a bien lieu (pas de derive de stock silencieuse).
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$db->saleMovementsExist = true;
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
$this->repo($db)->cancel('K100', 9, 4);
self::assertSame(2, $db->firstWrite('UPDATE ingredient SET stock_quantity')['u']);
self::assertStringContainsString("'cancellation'", $db->firstWriteSql('INSERT INTO stock_movement'));
}
public function testCancelRejectsUnknownOrder(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = null;
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('ORDER_NOT_FOUND');
$this->repo($db)->cancel('K404', 9, 4);
}
public function testCancelRejectsTerminalStatus(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'delivered'];
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('CANNOT_CANCEL_IN_STATE');
$this->repo($db)->cancel('K100', 9, 4);
}
public function testCancelAlreadyCancelledRejected(): void
{
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'cancelled'];
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('CANNOT_CANCEL_IN_STATE');
$this->repo($db)->cancel('K100', 9, 4);
}
public function testCancelConcurrentRaceThrowsInvalidTransition(): void
{
// La garde RG-T07 (status IN (...)) n'affecte 0 ligne : un autre appel a deja
// transite vers un statut terminal -> INVALID_TRANSITION, aucun re-credit.
$db = new FakeOrderDatabase();
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid'];
$db->payUpdateAffected = 0;
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
try {
$this->repo($db)->cancel('K100', 9, 4);
self::fail('expected OrderValidationException');
} catch (OrderValidationException $exception) {
self::assertSame('INVALID_TRANSITION', $exception->getMessage());
}
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
self::assertSame(0, $db->countWrites('INSERT INTO audit_log'));
}
}