diff --git a/.env b/.env index 758e9d8..0d6dd93 100644 --- a/.env +++ b/.env @@ -47,6 +47,12 @@ STRIPE_WEBHOOK_SECRET= STRIPE_MODE=test SMIME_PASSPHRASE='KLreLnyR07x5h#3$AC' +###> SonarQube ### +SONARQUBE_URL=https://sn.esy-web.dev +SONARQUBE_BADGE_TOKEN=changeme +SONARQUBE_PROJECT_KEY=e-ticket +###< SonarQube ### + ###> SSO E-Cosplay (Keycloak OIDC) ### OAUTH_KEYCLOAK_CLIENT_ID=e-ticket OAUTH_KEYCLOAK_CLIENT_SECRET=changeme diff --git a/.env.test b/.env.test index 978776d..43429df 100644 --- a/.env.test +++ b/.env.test @@ -3,3 +3,6 @@ KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' MEILISEARCH_URL=http://meilisearch:7700 MEILISEARCH_API_KEY=test +SONARQUBE_URL=https://sn.esy-web.dev +SONARQUBE_BADGE_TOKEN=test +SONARQUBE_PROJECT_KEY=e-ticket diff --git a/ansible/env.local.j2 b/ansible/env.local.j2 index 15d1f54..741ad47 100644 --- a/ansible/env.local.j2 +++ b/ansible/env.local.j2 @@ -19,6 +19,9 @@ STRIPE_MODE=live SMIME_PASSPHRASE='{{ smime_passphrase }}' MEILISEARCH_URL=http://meilisearch:7700 MEILISEARCH_API_KEY={{ meilisearch_api_key }} +SONARQUBE_URL=https://sn.esy-web.dev +SONARQUBE_BADGE_TOKEN={{ sonarqube_badge_token }} +SONARQUBE_PROJECT_KEY=e-ticket OAUTH_KEYCLOAK_CLIENT_ID=e-ticket OAUTH_KEYCLOAK_CLIENT_SECRET=1oLwbhJDNVmGH8CES1OdQtzR7dECOlII OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev diff --git a/ansible/vault.yml b/ansible/vault.yml index 34eccfa..15be9ba 100644 --- a/ansible/vault.yml +++ b/ansible/vault.yml @@ -9,3 +9,4 @@ stripe_sk: sk_live_51SUA1rP4ub49xK2TR9CKVBChBDLMFWRI9AAxdLLKi0zL5RTSho7t8WniREqE stripe_webhook_secret: CHANGE_ME smime_passphrase: 'KLreLnyR07x5h#3$AC' meilisearch_api_key: e-ticket +sonarqube_badge_token: sqb_630fba527176cec0567f773ca0cf87a3195a0f8f diff --git a/assets/admin.js b/assets/admin.js index 5fa5a5c..2aa103d 100644 --- a/assets/admin.js +++ b/assets/admin.js @@ -3,7 +3,7 @@ import "./admin.scss" document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('[data-confirm]').forEach(form => { form.addEventListener('submit', (e) => { - if (!window.confirm(form.dataset.confirm)) { + if (!globalThis.confirm(form.dataset.confirm)) { e.preventDefault() } }) diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 757fd75..fc056f5 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -242,6 +242,7 @@ class AdminController extends AbstractController try { $meilisearch->deleteDocument('buyers', $userId); } catch (\Throwable) { + // Meilisearch failure should not block user deletion } $this->addFlash('success', sprintf('Compte de %s supprime.', $name)); diff --git a/src/Controller/LegalController.php b/src/Controller/LegalController.php index 55adb19..d1b56af 100644 --- a/src/Controller/LegalController.php +++ b/src/Controller/LegalController.php @@ -43,4 +43,10 @@ class LegalController extends AbstractController { return $this->render('legal/rgpd.html.twig'); } + + #[Route('/conformite', name: 'app_conformite')] + public function conformite(): Response + { + return $this->render('legal/conformite.html.twig'); + } } diff --git a/src/Controller/SonarBadgeController.php b/src/Controller/SonarBadgeController.php new file mode 100644 index 0000000..12db240 --- /dev/null +++ b/src/Controller/SonarBadgeController.php @@ -0,0 +1,52 @@ + '[a-z_]+'])] + public function badge( + string $metric, + HttpClientInterface $httpClient, + #[Autowire(env: 'SONARQUBE_URL')] string $sonarUrl, + #[Autowire(env: 'SONARQUBE_BADGE_TOKEN')] string $sonarToken, + #[Autowire(env: 'SONARQUBE_PROJECT_KEY')] string $projectKey, + ): Response { + if (!\in_array($metric, self::ALLOWED_METRICS, true)) { + return new Response('', 404); + } + + $url = sprintf( + '%s/api/project_badges/measure?project=%s&metric=%s&token=%s', + $sonarUrl, + $projectKey, + $metric, + $sonarToken, + ); + + $response = $httpClient->request('GET', $url); + $svg = $response->getContent(false); + + return new Response($svg, 200, [ + 'Content-Type' => 'image/svg+xml', + 'Cache-Control' => 'public, max-age=300', + ]); + } +} diff --git a/templates/admin/buyers.html.twig b/templates/admin/buyers.html.twig index 4272867..ac0671d 100644 --- a/templates/admin/buyers.html.twig +++ b/templates/admin/buyers.html.twig @@ -28,16 +28,16 @@

Creer un acheteur

- - + +
- - + +
- - + +
diff --git a/templates/base.html.twig b/templates/base.html.twig index 5407363..af6c578 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -162,6 +162,7 @@ Politique RGPD Hebergement Tarifs + Conformite diff --git a/templates/legal/conformite.html.twig b/templates/legal/conformite.html.twig new file mode 100644 index 0000000..34aa81d --- /dev/null +++ b/templates/legal/conformite.html.twig @@ -0,0 +1,96 @@ +{% extends 'base.html.twig' %} + +{% block title %}Conformite technique - E-Ticket{% endblock %} +{% block description %}Conformite technique, securite des paiements et qualite du code de la plateforme E-Ticket{% endblock %} + +{% block body %} +
+

Conformite Technique

+

Transparence sur nos pratiques de securite, paiement et qualite logicielle.

+ +
+ +
+

1. Securite des paiements

+
+

E-Ticket utilise Stripe comme prestataire de paiement. Stripe est certifie PCI DSS niveau 1, le plus haut niveau de certification de l'industrie des paiements.

+
    +
  • PSD2 / SCA : Authentification forte du client (Strong Customer Authentication) via 3D Secure 2 pour toutes les transactions europeennes
  • +
  • 3D Secure : Verification supplementaire aupres de la banque emettrice pour reduire la fraude
  • +
  • Tokenisation : Aucune donnee de carte bancaire ne transite ou n'est stockee sur nos serveurs
  • +
  • HTTPS : Toutes les communications sont chiffrees via TLS 1.3
  • +
  • Cloudflare : Protection DDoS et WAF (Web Application Firewall)
  • +
+

E-Ticket ne collecte, ne stocke et ne traite aucune donnee de carte bancaire. Stripe gere l'integralite du flux de paiement.

+
+
+ +
+

2. Qualite du code source

+
+

Le code source est analyse en continu par SonarQube, un outil d'analyse statique qui detecte les bugs, vulnerabilites et mauvaises pratiques.

+ +
+ Quality Gate + Security Rating + Reliability Rating + Maintainability Rating + Coverage + Vulnerabilities + Bugs + Code Smells +
+ +
    +
  • PHPStan : Analyse statique PHP niveau 6
  • +
  • PHP CS Fixer : Respect des standards de codage
  • +
  • ESLint / Stylelint : Analyse du code JavaScript et SCSS
  • +
  • PHPUnit : Tests unitaires et fonctionnels PHP
  • +
  • Vitest : Tests unitaires JavaScript
  • +
+
+
+ +
+

3. Integration et deploiement continus

+
+

Chaque modification du code passe par un pipeline d'integration continue automatise avant d'etre deploye en production.

+
    +
  • Gitea : Plateforme Git auto-hebergee pour le controle de version
  • +
  • CI/CD : Tests automatiques, analyse de code et deploiement via Gitea Actions
  • +
  • SonarQube : Analyse de qualite auto-hebergee apres chaque push
  • +
  • Deploiement automatise : Deploiement en production uniquement si tous les controles passent
  • +
+

L'infrastructure CI/CD est entierement auto-hebergee et administree par l'association E-Cosplay.

+
+
+ +
+

4. Securite des communications

+
+
    +
  • S/MIME : Tous les emails envoyes par la plateforme sont signes numeriquement
  • +
  • CSP : Politique de securite du contenu stricte pour prevenir les injections XSS
  • +
  • HSTS : Force les connexions HTTPS
  • +
  • SSO : Authentification unique via OpenID Connect
  • +
  • CSRF : Protection contre les attaques Cross-Site Request Forgery
  • +
+
+
+ +
+

5. Donnees et conformite

+
+
    +
  • RGPD : Conforme au reglement general sur la protection des donnees (voir notre politique RGPD)
  • +
  • Hebergement : Infrastructure hebergee en France (voir notre hebergeur)
  • +
  • Chiffrement : Donnees sensibles chiffrees au repos et en transit
  • +
+
+
+ +
+ +

Derniere mise a jour : {{ "now"|date("d/m/Y") }}. Contact : contact@e-cosplay.fr

+
+{% endblock %} diff --git a/tests/Controller/AdminControllerTest.php b/tests/Controller/AdminControllerTest.php index f7ee251..2711f40 100644 --- a/tests/Controller/AdminControllerTest.php +++ b/tests/Controller/AdminControllerTest.php @@ -91,13 +91,24 @@ class AdminControllerTest extends WebTestCase self::assertResponseIsSuccessful(); } - public function testSyncMeilisearch(): void + public function testSyncMeilisearchWithBuyers(): void { $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); $admin = $this->createUser(['ROLE_ROOT']); + $buyer = new User(); + $buyer->setEmail('test-sync-'.uniqid().'@example.com'); + $buyer->setFirstName('Sync'); + $buyer->setLastName('Test'); + $buyer->setPassword('$2y$13$hashed'); + $buyer->setIsVerified(true); + $em->persist($buyer); + $em->flush(); + $meilisearch = $this->createMock(MeilisearchService::class); $meilisearch->expects(self::once())->method('createIndexIfNotExists'); + $meilisearch->expects(self::once())->method('addDocuments'); static::getContainer()->set(MeilisearchService::class, $meilisearch); $client->loginUser($admin); @@ -106,6 +117,49 @@ class AdminControllerTest extends WebTestCase self::assertResponseRedirects('/admin'); } + public function testBuyersSearchWithMeilisearchError(): void + { + $client = static::createClient(); + $admin = $this->createUser(['ROLE_ROOT']); + + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->method('search')->willThrowException(new \RuntimeException('Meilisearch down')); + static::getContainer()->set(MeilisearchService::class, $meilisearch); + + $client->loginUser($admin); + $client->request('GET', '/admin/acheteurs?q=test'); + + self::assertResponseIsSuccessful(); + } + + public function testBuyersSearchWithResults(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $admin = $this->createUser(['ROLE_ROOT']); + + $buyer = new User(); + $buyer->setEmail('test-search-hit-'.uniqid().'@example.com'); + $buyer->setFirstName('Found'); + $buyer->setLastName('User'); + $buyer->setPassword('$2y$13$hashed'); + $buyer->setIsVerified(true); + $em->persist($buyer); + $em->flush(); + + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->method('search')->willReturn([ + 'hits' => [['id' => $buyer->getId()]], + 'estimatedTotalHits' => 1, + ]); + static::getContainer()->set(MeilisearchService::class, $meilisearch); + + $client->loginUser($admin); + $client->request('GET', '/admin/acheteurs?q=Found'); + + self::assertResponseIsSuccessful(); + } + public function testCreateBuyerWithValidData(): void { $client = static::createClient(); @@ -210,6 +264,10 @@ class AdminControllerTest extends WebTestCase $em->flush(); $buyerId = $buyer->getId(); + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->expects(self::once())->method('deleteDocument')->with('buyers', $buyerId); + static::getContainer()->set(MeilisearchService::class, $meilisearch); + $client->loginUser($admin); $client->request('POST', '/admin/acheteur/'.$buyerId.'/supprimer'); @@ -219,6 +277,32 @@ class AdminControllerTest extends WebTestCase self::assertNull($deleted); } + public function testDeleteBuyerMeilisearchFailureDoesNotBlock(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $admin = $this->createUser(['ROLE_ROOT']); + + $buyer = new User(); + $buyer->setEmail('test-delete-fail-'.uniqid().'@example.com'); + $buyer->setFirstName('DeleteFail'); + $buyer->setLastName('Test'); + $buyer->setPassword('$2y$13$hashed'); + $em->persist($buyer); + $em->flush(); + $buyerId = $buyer->getId(); + + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->method('deleteDocument')->willThrowException(new \RuntimeException('Meilisearch down')); + static::getContainer()->set(MeilisearchService::class, $meilisearch); + + $client->loginUser($admin); + $client->request('POST', '/admin/acheteur/'.$buyerId.'/supprimer'); + + self::assertResponseRedirects('/admin/acheteurs'); + } + public function testForceVerification(): void { $client = static::createClient(); diff --git a/tests/Controller/LegalControllerTest.php b/tests/Controller/LegalControllerTest.php index ba37f14..9fa9905 100644 --- a/tests/Controller/LegalControllerTest.php +++ b/tests/Controller/LegalControllerTest.php @@ -53,4 +53,12 @@ class LegalControllerTest extends WebTestCase self::assertResponseIsSuccessful(); } + + public function testConformite(): void + { + $client = static::createClient(); + $client->request('GET', '/conformite'); + + self::assertResponseIsSuccessful(); + } } diff --git a/tests/Controller/SonarBadgeControllerTest.php b/tests/Controller/SonarBadgeControllerTest.php new file mode 100644 index 0000000..14b0cb6 --- /dev/null +++ b/tests/Controller/SonarBadgeControllerTest.php @@ -0,0 +1,24 @@ +request('GET', '/badge/sonar/alert_status.svg'); + + self::assertResponseIsSuccessful(); + } + + public function testBadgeWithInvalidMetric(): void + { + $client = static::createClient(); + $client->request('GET', '/badge/sonar/invalid_metric.svg'); + + self::assertResponseStatusCodeSame(404); + } +} diff --git a/tests/js/admin.test.js b/tests/js/admin.test.js index 6b9e6e4..8e1b701 100644 --- a/tests/js/admin.test.js +++ b/tests/js/admin.test.js @@ -4,7 +4,7 @@ describe('admin.js', () => { beforeEach(() => { document.body.innerHTML = '' vi.restoreAllMocks() - window.confirm = vi.fn() + globalThis.confirm = vi.fn() }) it('prevents form submit when confirm is cancelled', async () => { @@ -14,7 +14,7 @@ describe('admin.js', () => { ` - window.confirm.mockReturnValue(false) + globalThis.confirm.mockReturnValue(false) await import('../../assets/admin.js') document.dispatchEvent(new Event('DOMContentLoaded')) @@ -23,7 +23,7 @@ describe('admin.js', () => { const event = new Event('submit', { cancelable: true }) form.dispatchEvent(event) - expect(window.confirm).toHaveBeenCalledWith('Are you sure?') + expect(globalThis.confirm).toHaveBeenCalledWith('Are you sure?') expect(event.defaultPrevented).toBe(true) }) @@ -34,7 +34,7 @@ describe('admin.js', () => { ` - window.confirm.mockReturnValue(true) + globalThis.confirm.mockReturnValue(true) await import('../../assets/admin.js') document.dispatchEvent(new Event('DOMContentLoaded')) @@ -43,7 +43,7 @@ describe('admin.js', () => { const event = new Event('submit', { cancelable: true }) form.dispatchEvent(event) - expect(window.confirm).toHaveBeenCalledWith('Are you sure?') + expect(globalThis.confirm).toHaveBeenCalledWith('Are you sure?') expect(event.defaultPrevented).toBe(false) }) @@ -61,7 +61,7 @@ describe('admin.js', () => { const event = new Event('submit', { cancelable: true }) form.dispatchEvent(event) - expect(window.confirm).not.toHaveBeenCalled() + expect(globalThis.confirm).not.toHaveBeenCalled() expect(event.defaultPrevented).toBe(false) }) })