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

@@ -23,4 +23,36 @@ describe('app.js', () => {
expect(initMobileMenu).toHaveBeenCalled()
expect(initTabs).toHaveBeenCalled()
})
it('data-confirm prevents submit when cancelled', async () => {
document.body.innerHTML = '<form data-confirm="Sure?"><button type="submit">Go</button></form>'
await import('../../assets/app.js')
document.dispatchEvent(new Event('DOMContentLoaded'))
globalThis.confirm = vi.fn().mockReturnValue(false)
const form = document.querySelector('form')
const event = new Event('submit', { cancelable: true })
form.dispatchEvent(event)
expect(globalThis.confirm).toHaveBeenCalledWith('Sure?')
expect(event.defaultPrevented).toBe(true)
})
it('data-confirm allows submit when confirmed', async () => {
document.body.innerHTML = '<form data-confirm="Sure?"><button type="submit">Go</button></form>'
await import('../../assets/app.js')
document.dispatchEvent(new Event('DOMContentLoaded'))
globalThis.confirm = vi.fn().mockReturnValue(true)
const form = document.querySelector('form')
const event = new Event('submit', { cancelable: true })
form.dispatchEvent(event)
expect(globalThis.confirm).toHaveBeenCalledWith('Sure?')
expect(event.defaultPrevented).toBe(false)
})
})

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { initCart } from '../../assets/modules/cart.js'
function createBilletterie(billets) {
let html = '<div id="billetterie">'
function createBilletterie(billets, stockUrl = '') {
let html = `<div id="billetterie"${stockUrl ? ` data-stock-url="${stockUrl}"` : ''}>`
for (const b of billets) {
html += `
@@ -11,10 +11,12 @@ function createBilletterie(billets) {
<input data-cart-qty type="number" min="0" max="${b.max || 99}" value="0" readonly>
<button data-cart-plus></button>
<span data-cart-line-total></span>
<p data-stock-label></p>
</div>
`
}
html += '<div id="cart-error" class="hidden"><p id="cart-error-text"></p></div>'
html += '<span id="cart-total"></span><span id="cart-count"></span><button id="cart-checkout" disabled data-order-url="/order"></button></div>'
document.body.innerHTML = html
}
@@ -131,6 +133,7 @@ describe('initCart', () => {
it('posts cart data on checkout click', () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ redirect: '/commande/1/informations' }),
})
globalThis.fetch = fetchMock
@@ -160,6 +163,7 @@ describe('initCart', () => {
it('redirects after successful checkout', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ redirect: '/commande/1/paiement' }),
})
globalThis.fetch = fetchMock
@@ -178,6 +182,7 @@ describe('initCart', () => {
it('does not redirect when response has no redirect', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
})
globalThis.fetch = fetchMock
@@ -265,6 +270,23 @@ describe('initCart', () => {
expect(fetchMock).not.toHaveBeenCalled()
})
it('shows error message on HTTP error', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 400 })
globalThis.fetch = fetchMock
createBilletterie([{ id: 1, price: '10.00', max: 5 }])
initCart()
document.querySelector('[data-cart-plus]').click()
document.getElementById('cart-checkout').click()
await new Promise(r => setTimeout(r, 10))
const errorEl = document.getElementById('cart-error')
expect(errorEl.classList.contains('hidden')).toBe(false)
expect(document.getElementById('cart-error-text').textContent).toContain('erreur')
})
it('does not post without order url', () => {
const fetchMock = vi.fn()
globalThis.fetch = fetchMock
@@ -289,3 +311,100 @@ describe('initCart', () => {
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('stock polling', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
function mockStock(stock) {
return vi.fn().mockResolvedValue({
json: () => Promise.resolve(stock),
})
}
it('polls stock URL and updates labels for out of stock', async () => {
const fetchMock = mockStock({ 1: 0 })
globalThis.fetch = fetchMock
globalThis.setInterval = (fn) => { fn(); return 1 }
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
initCart()
document.querySelector('[data-cart-plus]').click()
document.querySelector('[data-cart-plus]').click()
await new Promise(r => setTimeout(r, 20))
expect(fetchMock).toHaveBeenCalledWith('/stock')
expect(document.querySelector('[data-stock-label]').innerHTML).toContain('Rupture')
expect(document.querySelector('[data-cart-qty]').value).toBe('0')
})
it('polls stock URL and shows low stock warning', async () => {
globalThis.fetch = mockStock({ 1: 5 })
globalThis.setInterval = (fn) => { fn(); return 1 }
createBilletterie([{ id: 1, price: '10.00', max: 20 }], '/stock')
initCart()
await new Promise(r => setTimeout(r, 20))
expect(document.querySelector('[data-stock-label]').innerHTML).toContain('Plus que')
})
it('polls stock URL and shows normal stock', async () => {
globalThis.fetch = mockStock({ 1: 50 })
globalThis.setInterval = (fn) => { fn(); return 1 }
createBilletterie([{ id: 1, price: '10.00', max: 100 }], '/stock')
initCart()
await new Promise(r => setTimeout(r, 20))
expect(document.querySelector('[data-stock-label]').innerHTML).toContain('disponible')
})
it('clamps qty when stock decreases below current selection', async () => {
globalThis.fetch = mockStock({ 1: 2 })
globalThis.setInterval = (fn) => { fn(); return 1 }
createBilletterie([{ id: 1, price: '10.00', max: 10 }], '/stock')
initCart()
for (let i = 0; i < 5; i++) {
document.querySelector('[data-cart-plus]').click()
}
await new Promise(r => setTimeout(r, 20))
expect(document.querySelector('[data-cart-qty]').value).toBe('2')
})
it('does not poll without stock URL', () => {
const fetchMock = vi.fn()
globalThis.fetch = fetchMock
const origSetInterval = globalThis.setInterval
const intervalSpy = vi.fn()
globalThis.setInterval = intervalSpy
createBilletterie([{ id: 1, price: '10.00', max: 5 }])
initCart()
expect(intervalSpy).not.toHaveBeenCalled()
globalThis.setInterval = origSetInterval
})
it('handles stock poll fetch error gracefully', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network'))
globalThis.setInterval = (fn) => { fn(); return 1 }
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
initCart()
await new Promise(r => setTimeout(r, 20))
// No crash, label unchanged
expect(document.querySelector('[data-stock-label]').innerHTML).toBe('')
})
})

View File

@@ -29,5 +29,28 @@ describe('initCopyUrl', () => {
await new Promise(r => { setTimeout(r, 10) })
expect(writeText).toHaveBeenCalledWith('https://example.com/event/1-test')
expect(document.getElementById('copy-url-btn').textContent).toBe('Copie !')
})
it('restores button text after 2 seconds', async () => {
vi.useFakeTimers()
document.body.innerHTML = `
<p id="event-url">https://example.com</p>
<button id="copy-url-btn">Copier le lien</button>
`
globalThis.navigator = { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }
initCopyUrl()
document.getElementById('copy-url-btn').click()
await vi.advanceTimersByTimeAsync(100)
expect(document.getElementById('copy-url-btn').textContent).toBe('Copie !')
vi.advanceTimersByTime(2000)
expect(document.getElementById('copy-url-btn').textContent).toBe('Copier le lien')
vi.useRealTimers()
})
})

View File

@@ -40,6 +40,46 @@ describe('sanitizeHtml', () => {
const html = '<p style="color:red">Text</p>'
expect(sanitizeHtml(html)).toBe('<p>Text</p>')
})
it('strips onclick from allowed tags', () => {
const html = '<b onclick="alert(1)">Bold</b>'
expect(sanitizeHtml(html)).toBe('<b>Bold</b>')
})
it('strips onerror from allowed tags', () => {
const html = '<p onerror="alert(1)">Text</p>'
expect(sanitizeHtml(html)).toBe('<p>Text</p>')
})
it('strips class and id from allowed tags', () => {
const html = '<p class="evil" id="inject">Text</p>'
expect(sanitizeHtml(html)).toBe('<p>Text</p>')
})
it('strips data attributes from allowed tags', () => {
const html = '<ul data-x="1"><li data-y="2">Item</li></ul>'
expect(sanitizeHtml(html)).toBe('<ul><li>Item</li></ul>')
})
it('strips script tags entirely', () => {
const html = '<p>Safe</p><script>alert(1)</script>'
expect(sanitizeHtml(html)).toBe('<p>Safe</p>')
})
it('strips img onerror XSS', () => {
const html = '<img src=x onerror="alert(1)">'
expect(sanitizeHtml(html)).toBe('')
})
it('strips nested disallowed tags with attributes', () => {
const html = '<div style="background:url(evil)"><p onclick="steal()">OK</p></div>'
expect(sanitizeHtml(html)).toBe('<p>OK</p>')
})
it('strips href from anchor but keeps text', () => {
const html = '<a href="javascript:alert(1)">Click</a>'
expect(sanitizeHtml(html)).toBe('Click')
})
})
function createEditor(innerHtml = '<textarea></textarea>') {
@@ -91,6 +131,22 @@ describe('ETicketEditor', () => {
expect(buttons.length).toBeGreaterThan(0)
})
it('toolbar buttons have aria-label', () => {
const editor = createEditor()
const buttons = editor.querySelectorAll('.ete-btn')
buttons.forEach(btn => {
expect(btn.getAttribute('aria-label')).toBeTruthy()
})
})
it('toolbar buttons have tabindex=0', () => {
const editor = createEditor()
const buttons = editor.querySelectorAll('.ete-btn')
buttons.forEach(btn => {
expect(btn.tabIndex).toBe(0)
})
})
it('toolbar has separators', () => {
const editor = createEditor()
const separators = editor.querySelectorAll('.ete-separator')

View File

@@ -218,6 +218,25 @@ describe('initSortable', () => {
})
})
it('reloads page on fetch error during drop', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('Network'))
globalThis.fetch = fetchMock
const reloadMock = vi.fn()
globalThis.location = { reload: reloadMock }
const list = createList('/api/reorder')
addItem(list, 1)
initSortable()
const dropEvent = createDragEvent('drop')
list.dispatchEvent(dropEvent)
await new Promise(r => setTimeout(r, 10))
expect(reloadMock).toHaveBeenCalled()
})
it('initializes billets-list sortable', () => {
const list = document.createElement('div')
list.classList.add('billets-list')

View File

@@ -1,29 +1,31 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { initTabs } from '../../assets/modules/tabs.js'
describe('initTabs', () => {
beforeEach(() => {
document.body.innerHTML = `
function setup() {
document.body.innerHTML = `
<div id="tablist">
<button data-tab="tab-a" style="background-color:#111827;color:white;">Tab A</button>
<button data-tab="tab-b" style="background-color:white;color:#111827;">Tab B</button>
<div id="tab-a" style="display:block;">Content A</div>
<div id="tab-b" style="display:none;">Content B</div>
`
})
<button data-tab="tab-c" style="background-color:white;color:#111827;">Tab C</button>
</div>
<div id="tab-a" style="display:block;">Content A</div>
<div id="tab-b" style="display:none;">Content B</div>
<div id="tab-c" style="display:none;">Content C</div>
`
initTabs()
}
describe('initTabs', () => {
beforeEach(() => setup())
it('switches active tab on click', () => {
initTabs()
const btnB = document.querySelector('[data-tab="tab-b"]')
btnB.click()
document.querySelector('[data-tab="tab-b"]').click()
expect(document.getElementById('tab-a').style.display).toBe('none')
expect(document.getElementById('tab-b').style.display).toBe('block')
})
it('updates button styles on click', () => {
initTabs()
const btnA = document.querySelector('[data-tab="tab-a"]')
const btnB = document.querySelector('[data-tab="tab-b"]')
btnB.click()
@@ -35,8 +37,6 @@ describe('initTabs', () => {
})
it('switches back to first tab', () => {
initTabs()
const btnA = document.querySelector('[data-tab="tab-a"]')
const btnB = document.querySelector('[data-tab="tab-b"]')
@@ -52,3 +52,136 @@ describe('initTabs', () => {
expect(() => initTabs()).not.toThrow()
})
})
describe('ARIA attributes', () => {
beforeEach(() => setup())
it('sets role=tablist on parent', () => {
expect(document.getElementById('tablist').getAttribute('role')).toBe('tablist')
})
it('sets role=tab on each button', () => {
document.querySelectorAll('[data-tab]').forEach(btn => {
expect(btn.getAttribute('role')).toBe('tab')
})
})
it('sets aria-controls matching panel id', () => {
const btn = document.querySelector('[data-tab="tab-b"]')
expect(btn.getAttribute('aria-controls')).toBe('tab-b')
})
it('sets role=tabpanel on panels', () => {
expect(document.getElementById('tab-a').getAttribute('role')).toBe('tabpanel')
expect(document.getElementById('tab-b').getAttribute('role')).toBe('tabpanel')
})
it('sets aria-labelledby on panels', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
expect(document.getElementById('tab-a').getAttribute('aria-labelledby')).toBe(btnA.id)
})
it('sets aria-selected=true on active tab', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
const btnB = document.querySelector('[data-tab="tab-b"]')
expect(btnA.getAttribute('aria-selected')).toBe('true')
expect(btnB.getAttribute('aria-selected')).toBe('false')
})
it('updates aria-selected on click', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
const btnB = document.querySelector('[data-tab="tab-b"]')
btnB.click()
expect(btnA.getAttribute('aria-selected')).toBe('false')
expect(btnB.getAttribute('aria-selected')).toBe('true')
})
it('sets tabindex=0 on active, -1 on inactive', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
const btnB = document.querySelector('[data-tab="tab-b"]')
expect(btnA.getAttribute('tabindex')).toBe('0')
expect(btnB.getAttribute('tabindex')).toBe('-1')
})
it('updates tabindex on click', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
const btnB = document.querySelector('[data-tab="tab-b"]')
btnB.click()
expect(btnA.getAttribute('tabindex')).toBe('-1')
expect(btnB.getAttribute('tabindex')).toBe('0')
})
})
describe('keyboard navigation', () => {
beforeEach(() => setup())
it('ArrowRight moves to next tab', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
expect(document.querySelector('[data-tab="tab-b"]').getAttribute('aria-selected')).toBe('true')
expect(document.getElementById('tab-b').style.display).toBe('block')
})
it('ArrowLeft moves to previous tab', () => {
const btnB = document.querySelector('[data-tab="tab-b"]')
btnB.click()
btnB.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
expect(document.querySelector('[data-tab="tab-a"]').getAttribute('aria-selected')).toBe('true')
})
it('ArrowRight wraps from last to first', () => {
const btnC = document.querySelector('[data-tab="tab-c"]')
btnC.click()
btnC.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
expect(document.querySelector('[data-tab="tab-a"]').getAttribute('aria-selected')).toBe('true')
})
it('ArrowLeft wraps from first to last', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
expect(document.querySelector('[data-tab="tab-c"]').getAttribute('aria-selected')).toBe('true')
})
it('Home moves to first tab', () => {
const btnC = document.querySelector('[data-tab="tab-c"]')
btnC.click()
btnC.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }))
expect(document.querySelector('[data-tab="tab-a"]').getAttribute('aria-selected')).toBe('true')
})
it('End moves to last tab', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }))
expect(document.querySelector('[data-tab="tab-c"]').getAttribute('aria-selected')).toBe('true')
})
it('ArrowDown moves to next tab', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
expect(document.querySelector('[data-tab="tab-b"]').getAttribute('aria-selected')).toBe('true')
})
it('ArrowUp moves to previous tab', () => {
const btnB = document.querySelector('[data-tab="tab-b"]')
btnB.click()
btnB.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
expect(document.querySelector('[data-tab="tab-a"]').getAttribute('aria-selected')).toBe('true')
})
it('other keys do nothing', () => {
const btnA = document.querySelector('[data-tab="tab-a"]')
btnA.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
expect(btnA.getAttribute('aria-selected')).toBe('true')
})
})