Complete TASK_CHECKUP: security, UX, tests, coverage, accessibility, config externalization

Billetterie:
- Partial refund support (STATUS_PARTIALLY_REFUNDED, refundedAmount field, migration)
- Race condition fix: PESSIMISTIC_WRITE lock on stock decrement in transaction
- Idempotency key on PaymentIntent::create, reuse existing PI if stripeSessionId set
- Disable checkout when event ended (server 400 + template hide)
- Webhook deduplication via cache (24h TTL on stripe event.id)
- Email validation (filter_var) in OrderController guest flow
- JSON cart validation (structure check before processing)
- Invitation expiration after 7 days (isExpired method + landing page message)
- Stripe Checkout fallback when JS fails to load (noscript + redirect)

Config externalization:
- Move Stripe fees (STRIPE_FEE_RATE, STRIPE_FEE_FIXED) and admin email (ADMIN_EMAIL) to .env/services.yaml
- Replace all hardcoded contact@e-cosplay.fr across 13 files
- MailerService: getAdminEmail()/getAdminFrom(), default $from=null resolves to admin

UX & Accessibility:
- ARIA tabs: role=tablist/tab/tabpanel, aria-selected, keyboard nav (arrows, Home, End)
- aria-label on cart +/- buttons and editor toolbar buttons
- tabindex=0 on editor toolbar buttons for keyboard access
- data-confirm handler in app.js (was only in admin.js)
- Cart error feedback on checkout failure
- Billet designer save feedback (loading/success/error states)
- Stock polling every 30s with rupture/low stock badges
- Back to event link on payment page

Security:
- HTML sanitizer: BLOCKED_TAGS list (script, style, iframe, svg, etc.) - content fully removed
- Stripe polling timeout (15s max) with fallback redirect
- Rate limiting on public order access (20/5min)
- .catch() on all fetch() calls (sortable, billet-designer)

Tests (92% PHP, 100% JS lines):
- PCOV added to dev Dockerfile
- Test DB setup: .env.test with DATABASE_URL, Redis auth, Meilisearch key
- Rate limiter disabled in test env
- Makefile: test_db_setup, test_db_reset, run_test_php, run_test_coverage_php/js
- New tests: InvitationFlowTest (21), AuditServiceTest (4), ExportServiceTest (9), InvoiceServiceTest (4)
- New tests: SuspendedUserSubscriberTest, RateLimiterSubscriberTest, MeilisearchServiceTest
- New tests: Stripe webhook payment_failed (6) + charge.refunded (6)
- New tests: BilletBuyer refund, User suspended, OrganizerInvitation expiration
- JS tests: stock polling (6), data-confirm (2), copy-url restore (1), editor ARIA (2), XSS (9), tabs keyboard (9)
- ESLint + PHP CS Fixer: 0 errors
- SonarQube exclusions aligned with vitest coverage config

Infra:
- Meilisearch consistency command (app:meilisearch:check-consistency --fix) + cron daily 3am
- MeilisearchService: getAllDocumentIds(), listIndexes()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 11:14:06 +01:00
parent 61200adc74
commit 04927ec988
68 changed files with 3317 additions and 141 deletions

View File

@@ -90,7 +90,9 @@ class AccountController extends AbstractController
if (BilletBuyer::STATUS_PAID === $o->getStatus()) {
$financeStats['paid'] += $ht;
$financeStats['commissionEticket'] += $ht * ($rate / 100);
$financeStats['commissionStripe'] += $ht * 0.015 + 0.25;
$stripeFeeRate = (float) $this->getParameter('stripe_fee_rate');
$stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed');
$financeStats['commissionStripe'] += $ht * $stripeFeeRate + $stripeFeeFixed / 100;
} elseif (BilletBuyer::STATUS_PENDING === $o->getStatus()) {
$financeStats['pending'] += $ht;
} elseif (BilletBuyer::STATUS_REFUNDED === $o->getStatus()) {
@@ -226,6 +228,7 @@ class AccountController extends AbstractController
return $this->redirectToRoute('app_account');
}
/** @codeCoverageIgnore Stripe redirect callback */
#[Route('/stripe/connect/return', name: 'app_stripe_connect_return')]
public function stripeConnectReturn(): Response
{
@@ -234,6 +237,7 @@ class AccountController extends AbstractController
return $this->redirectToRoute('app_account');
}
/** @codeCoverageIgnore Stripe redirect callback */
#[Route('/stripe/connect/refresh', name: 'app_stripe_connect_refresh')]
public function stripeConnectRefresh(): Response
{
@@ -347,7 +351,7 @@ class AccountController extends AbstractController
}
#[Route('/mon-compte/evenement/creer', name: 'app_account_create_event', methods: ['GET', 'POST'])]
public function createEvent(Request $request, EntityManagerInterface $em, EventIndexService $eventIndex): Response
public function createEvent(Request $request, EntityManagerInterface $em, EventIndexService $eventIndex, AuditService $audit): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -364,6 +368,8 @@ class AccountController extends AbstractController
$eventIndex->indexEvent($event);
$audit->log('event_created', 'Event', $event->getId(), ['title' => $event->getTitle()]);
$this->addFlash('success', 'Evenement cree avec succes.');
return $this->redirectToRoute('app_account', ['tab' => 'events']);
@@ -379,7 +385,7 @@ class AccountController extends AbstractController
}
#[Route('/mon-compte/evenement/{id}/modifier', name: 'app_account_edit_event', methods: ['GET', 'POST'])]
public function editEvent(Event $event, Request $request, EntityManagerInterface $em, EventIndexService $eventIndex, PaginatorInterface $paginator, OrderIndexService $orderIndex): Response
public function editEvent(Event $event, Request $request, EntityManagerInterface $em, EventIndexService $eventIndex, PaginatorInterface $paginator, OrderIndexService $orderIndex, AuditService $audit): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -396,6 +402,8 @@ class AccountController extends AbstractController
$eventIndex->indexEvent($event);
$audit->log('event_updated', 'Event', $event->getId(), ['title' => $event->getTitle()]);
$this->addFlash('success', 'Evenement modifie avec succes.');
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId()]);
@@ -473,7 +481,7 @@ class AccountController extends AbstractController
}
#[Route('/mon-compte/evenement/{id}/categorie/ajouter', name: 'app_account_event_add_category', methods: ['POST'])]
public function addCategory(Event $event, Request $request, EntityManagerInterface $em): Response
public function addCategory(Event $event, Request $request, EntityManagerInterface $em, AuditService $audit): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -514,13 +522,15 @@ class AccountController extends AbstractController
$em->persist($category);
$em->flush();
$audit->log('category_created', 'Category', $category->getId(), ['name' => $name, 'event' => $event->getTitle()]);
$this->addFlash('success', sprintf('Categorie "%s" ajoutee.', $name));
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
}
#[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/modifier', name: 'app_account_event_edit_category', methods: ['GET', 'POST'])]
public function editCategory(Event $event, int $categoryId, Request $request, EntityManagerInterface $em): Response
public function editCategory(Event $event, int $categoryId, Request $request, EntityManagerInterface $em, AuditService $audit): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -556,6 +566,8 @@ class AccountController extends AbstractController
$em->flush();
$audit->log('category_updated', 'Category', $category->getId(), ['name' => $category->getName(), 'event' => $event->getTitle()]);
$this->addFlash('success', 'Categorie modifiee.');
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
@@ -574,7 +586,7 @@ class AccountController extends AbstractController
}
#[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/supprimer', name: 'app_account_event_delete_category', methods: ['POST'])]
public function deleteCategory(Event $event, int $categoryId, EntityManagerInterface $em): Response
public function deleteCategory(Event $event, int $categoryId, EntityManagerInterface $em, AuditService $audit): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -586,8 +598,11 @@ class AccountController extends AbstractController
$category = $em->getRepository(Category::class)->find($categoryId);
if ($category && $category->getEvent()->getId() === $event->getId()) {
$catName = $category->getName();
$catId = $category->getId();
$em->remove($category);
$em->flush();
$audit->log('category_deleted', 'Category', $catId, ['name' => $catName, 'event' => $event->getTitle()]);
$this->addFlash('success', 'Categorie supprimee.');
}
@@ -620,7 +635,7 @@ class AccountController extends AbstractController
}
#[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/billet/ajouter', name: 'app_account_event_add_billet', methods: ['GET', 'POST'])]
public function addBillet(Event $event, int $categoryId, Request $request, EntityManagerInterface $em, StripeService $stripeService): Response
public function addBillet(Event $event, int $categoryId, Request $request, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -645,6 +660,8 @@ class AccountController extends AbstractController
$this->syncBilletToStripe($billet, $user, $stripeService);
$em->flush();
$audit->log('billet_created', 'Billet', $billet->getId(), ['name' => $billet->getName(), 'event' => $event->getTitle()]);
$this->addFlash('success', 'Billet ajoute avec succes.');
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
@@ -664,7 +681,7 @@ class AccountController extends AbstractController
}
#[Route('/mon-compte/evenement/{id}/billet/{billetId}/modifier', name: 'app_account_event_edit_billet', methods: ['GET', 'POST'])]
public function editBillet(Event $event, int $billetId, Request $request, EntityManagerInterface $em, StripeService $stripeService): Response
public function editBillet(Event $event, int $billetId, Request $request, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -684,6 +701,8 @@ class AccountController extends AbstractController
$this->syncBilletToStripe($billet, $user, $stripeService);
$em->flush();
$audit->log('billet_updated', 'Billet', $billet->getId(), ['name' => $billet->getName(), 'event' => $event->getTitle()]);
$this->addFlash('success', 'Billet modifie avec succes.');
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
@@ -703,7 +722,7 @@ class AccountController extends AbstractController
}
#[Route('/mon-compte/evenement/{id}/billet/{billetId}/supprimer', name: 'app_account_event_delete_billet', methods: ['POST'])]
public function deleteBillet(Event $event, int $billetId, EntityManagerInterface $em, StripeService $stripeService): Response
public function deleteBillet(Event $event, int $billetId, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -718,11 +737,15 @@ class AccountController extends AbstractController
throw $this->createNotFoundException();
}
$billetName = $billet->getName();
$billetDbId = $billet->getId();
$this->deleteBilletFromStripe($billet, $user, $stripeService);
$em->remove($billet);
$em->flush();
$audit->log('billet_deleted', 'Billet', $billetDbId, ['name' => $billetName, 'event' => $event->getTitle()]);
$this->addFlash('success', 'Billet supprime avec succes.');
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
@@ -1039,7 +1062,7 @@ class AccountController extends AbstractController
}
#[Route('/mon-compte/evenement/{id}/supprimer', name: 'app_account_delete_event', methods: ['POST'])]
public function deleteEvent(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex): Response
public function deleteEvent(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex, AuditService $audit): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -1049,11 +1072,15 @@ class AccountController extends AbstractController
throw $this->createAccessDeniedException();
}
$eventTitle = $event->getTitle();
$eventDbId = $event->getId();
$eventIndex->removeEvent($event);
$em->remove($event);
$em->flush();
$audit->log('event_deleted', 'Event', $eventDbId, ['title' => $eventTitle]);
$this->addFlash('success', 'Evenement supprime.');
return $this->redirectToRoute('app_account', ['tab' => 'events']);
@@ -1163,8 +1190,6 @@ class AccountController extends AbstractController
}
/**
* @param list<BilletBuyer> $paidOrders
*
* @return array{totalHT: int, totalSold: int, billetStats: array<int, array{name: string, sold: int, revenue: int}>}
*/
#[Route('/mon-compte/export/{year}/{month}', name: 'app_account_export', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]