Add conformite page, SonarQube badge proxy, coverage fixes, and code quality

- Add /conformite page: PSD2/3DS/Stripe, SonarQube badges, CI/CD, security
- Create SonarBadgeController proxy to serve SonarQube badges without exposing token
- Store SonarQube badge token in ansible/vault.yml instead of env files
- Add Meilisearch coverage tests: search with results, search error, sync, delete
- Fix MeilisearchService delete catch block with comment
- Fix ESLint: use globalThis.confirm instead of window.confirm
- Fix accessibility: add for/id attributes to buyer creation form labels
- Add conformite link to site footer
- Add SonarBadgeControllerTest and LegalControllerTest for /conformite

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-19 14:25:04 +01:00
parent a4884e8b20
commit 9bcb41306b
15 changed files with 299 additions and 14 deletions

6
.env
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()
}
})

View File

@@ -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));

View File

@@ -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');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class SonarBadgeController extends AbstractController
{
private const ALLOWED_METRICS = [
'alert_status',
'security_rating',
'reliability_rating',
'sqale_rating',
'coverage',
'vulnerabilities',
'bugs',
'code_smells',
];
#[Route('/badge/sonar/{metric}.svg', name: 'app_sonar_badge', requirements: ['metric' => '[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',
]);
}
}

View File

@@ -28,16 +28,16 @@
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:1rem;">Creer un acheteur</h2>
<form method="post" action="{{ path('app_admin_create_buyer') }}" style="display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-end;">
<div style="flex:1;min-width:140px;">
<label style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Nom</label>
<input type="text" name="last_name" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="Dupont">
<label for="create_last_name" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Nom</label>
<input type="text" id="create_last_name" name="last_name" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="Dupont">
</div>
<div style="flex:1;min-width:140px;">
<label style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Prenom</label>
<input type="text" name="first_name" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="Jean">
<label for="create_first_name" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Prenom</label>
<input type="text" id="create_first_name" name="first_name" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="Jean">
</div>
<div style="flex:2;min-width:200px;">
<label style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Email</label>
<input type="email" name="email" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="jean.dupont@exemple.fr">
<label for="create_email" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Email</label>
<input type="email" id="create_email" name="email" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="jean.dupont@exemple.fr">
</div>
<button type="submit" style="padding:0.5rem 1rem;border:2px solid #111827;background:#fabf04;cursor:pointer;white-space:nowrap;" class="font-black uppercase text-xs tracking-widest hover:bg-green-500 hover:text-black transition-all">Creer</button>
</form>

View File

@@ -162,6 +162,7 @@
<a href="{{ path('app_rgpd') }}" style="font-size:10px;padding:0.25rem 0.5rem;" class="font-black uppercase bg-gray-900 text-white hover:bg-indigo-600 transition-colors">Politique RGPD</a>
<a href="{{ path('app_hosting') }}" style="font-size:10px;padding:0.25rem 0.5rem;" class="font-black uppercase bg-gray-900 text-white hover:bg-indigo-600 transition-colors">Hebergement</a>
<a href="{{ path('app_tarifs') }}" style="font-size:10px;padding:0.25rem 0.5rem;" class="font-black uppercase bg-gray-900 text-white hover:bg-indigo-600 transition-colors">Tarifs</a>
<a href="{{ path('app_conformite') }}" style="font-size:10px;padding:0.25rem 0.5rem;" class="font-black uppercase bg-gray-900 text-white hover:bg-indigo-600 transition-colors">Conformite</a>
</div>
</div>
</div>

View File

@@ -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 %}
<div style="max-width:50rem;margin:0 auto;padding:3rem 1rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Conformite Technique</h1>
<p class="font-bold text-gray-600 italic" style="margin-bottom:2rem;">Transparence sur nos pratiques de securite, paiement et qualite logicielle.</p>
<div style="display:flex;flex-direction:column;gap:2rem;">
<section>
<h2 class="text-xl font-black uppercase" style="margin-bottom:0.5rem;">1. Securite des paiements</h2>
<div style="border:4px solid #111827;padding:1.5rem;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<p style="margin-bottom:1rem;">E-Ticket utilise <strong>Stripe</strong> comme prestataire de paiement. Stripe est certifie <strong>PCI DSS niveau 1</strong>, le plus haut niveau de certification de l'industrie des paiements.</p>
<ul style="list-style:disc;padding-left:1.5rem;" class="text-sm font-bold text-gray-700">
<li><strong>PSD2 / SCA</strong> : Authentification forte du client (Strong Customer Authentication) via 3D Secure 2 pour toutes les transactions europeennes</li>
<li><strong>3D Secure</strong> : Verification supplementaire aupres de la banque emettrice pour reduire la fraude</li>
<li><strong>Tokenisation</strong> : Aucune donnee de carte bancaire ne transite ou n'est stockee sur nos serveurs</li>
<li><strong>HTTPS</strong> : Toutes les communications sont chiffrees via TLS 1.3</li>
<li><strong>Cloudflare</strong> : Protection DDoS et WAF (Web Application Firewall)</li>
</ul>
<p class="text-xs text-gray-500 italic" style="margin-top:1rem;">E-Ticket ne collecte, ne stocke et ne traite aucune donnee de carte bancaire. Stripe gere l'integralite du flux de paiement.</p>
</div>
</section>
<section>
<h2 class="text-xl font-black uppercase" style="margin-bottom:0.5rem;">2. Qualite du code source</h2>
<div style="border:4px solid #111827;padding:1.5rem;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<p style="margin-bottom:1rem;">Le code source est analyse en continu par <strong>SonarQube</strong>, un outil d'analyse statique qui detecte les bugs, vulnerabilites et mauvaises pratiques.</p>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:1rem;">
<img src="{{ path('app_sonar_badge', {metric: 'alert_status'}) }}" alt="Quality Gate">
<img src="{{ path('app_sonar_badge', {metric: 'security_rating'}) }}" alt="Security Rating">
<img src="{{ path('app_sonar_badge', {metric: 'reliability_rating'}) }}" alt="Reliability Rating">
<img src="{{ path('app_sonar_badge', {metric: 'sqale_rating'}) }}" alt="Maintainability Rating">
<img src="{{ path('app_sonar_badge', {metric: 'coverage'}) }}" alt="Coverage">
<img src="{{ path('app_sonar_badge', {metric: 'vulnerabilities'}) }}" alt="Vulnerabilities">
<img src="{{ path('app_sonar_badge', {metric: 'bugs'}) }}" alt="Bugs">
<img src="{{ path('app_sonar_badge', {metric: 'code_smells'}) }}" alt="Code Smells">
</div>
<ul style="list-style:disc;padding-left:1.5rem;" class="text-sm font-bold text-gray-700">
<li><strong>PHPStan</strong> : Analyse statique PHP niveau 6</li>
<li><strong>PHP CS Fixer</strong> : Respect des standards de codage</li>
<li><strong>ESLint / Stylelint</strong> : Analyse du code JavaScript et SCSS</li>
<li><strong>PHPUnit</strong> : Tests unitaires et fonctionnels PHP</li>
<li><strong>Vitest</strong> : Tests unitaires JavaScript</li>
</ul>
</div>
</section>
<section>
<h2 class="text-xl font-black uppercase" style="margin-bottom:0.5rem;">3. Integration et deploiement continus</h2>
<div style="border:4px solid #111827;padding:1.5rem;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<p style="margin-bottom:1rem;">Chaque modification du code passe par un pipeline d'integration continue automatise avant d'etre deploye en production.</p>
<ul style="list-style:disc;padding-left:1.5rem;" class="text-sm font-bold text-gray-700">
<li><strong>Gitea</strong> : Plateforme Git auto-hebergee pour le controle de version</li>
<li><strong>CI/CD</strong> : Tests automatiques, analyse de code et deploiement via Gitea Actions</li>
<li><strong>SonarQube</strong> : Analyse de qualite auto-hebergee apres chaque push</li>
<li><strong>Deploiement automatise</strong> : Deploiement en production uniquement si tous les controles passent</li>
</ul>
<p class="text-xs text-gray-500 italic" style="margin-top:1rem;">L'infrastructure CI/CD est entierement auto-hebergee et administree par l'association E-Cosplay.</p>
</div>
</section>
<section>
<h2 class="text-xl font-black uppercase" style="margin-bottom:0.5rem;">4. Securite des communications</h2>
<div style="border:4px solid #111827;padding:1.5rem;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<ul style="list-style:disc;padding-left:1.5rem;" class="text-sm font-bold text-gray-700">
<li><strong>S/MIME</strong> : Tous les emails envoyes par la plateforme sont signes numeriquement</li>
<li><strong>CSP</strong> : Politique de securite du contenu stricte pour prevenir les injections XSS</li>
<li><strong>HSTS</strong> : Force les connexions HTTPS</li>
<li><strong>SSO</strong> : Authentification unique via OpenID Connect</li>
<li><strong>CSRF</strong> : Protection contre les attaques Cross-Site Request Forgery</li>
</ul>
</div>
</section>
<section>
<h2 class="text-xl font-black uppercase" style="margin-bottom:0.5rem;">5. Donnees et conformite</h2>
<div style="border:4px solid #111827;padding:1.5rem;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<ul style="list-style:disc;padding-left:1.5rem;" class="text-sm font-bold text-gray-700">
<li><strong>RGPD</strong> : Conforme au reglement general sur la protection des donnees (<a href="{{ path('app_rgpd') }}" class="text-indigo-600 hover:underline">voir notre politique RGPD</a>)</li>
<li><strong>Hebergement</strong> : Infrastructure hebergee en France (<a href="{{ path('app_hosting') }}" class="text-indigo-600 hover:underline">voir notre hebergeur</a>)</li>
<li><strong>Chiffrement</strong> : Donnees sensibles chiffrees au repos et en transit</li>
</ul>
</div>
</section>
</div>
<p class="text-sm opacity-70 italic" style="margin-top:2rem;">Derniere mise a jour : {{ "now"|date("d/m/Y") }}. Contact : <a href="mailto:contact@e-cosplay.fr" class="text-indigo-600 hover:underline">contact@e-cosplay.fr</a></p>
</div>
{% endblock %}

View File

@@ -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();

View File

@@ -53,4 +53,12 @@ class LegalControllerTest extends WebTestCase
self::assertResponseIsSuccessful();
}
public function testConformite(): void
{
$client = static::createClient();
$client->request('GET', '/conformite');
self::assertResponseIsSuccessful();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SonarBadgeControllerTest extends WebTestCase
{
public function testBadgeWithValidMetric(): void
{
$client = static::createClient();
$client->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);
}
}

View File

@@ -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', () => {
</form>
`
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', () => {
</form>
`
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)
})
})