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.
+
+
+
+
+ - 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)
})
})