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:
@@ -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'])]
|
||||
|
||||
Reference in New Issue
Block a user