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:
@@ -43,9 +43,25 @@ export function initBilletDesigner() {
|
||||
}
|
||||
}
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true
|
||||
saveBtn.textContent = 'Enregistrement...'
|
||||
}
|
||||
|
||||
globalThis.fetch(saveUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}).then(r => {
|
||||
if (!r.ok) throw new Error(r.status)
|
||||
if (saveBtn) {
|
||||
saveBtn.textContent = 'Enregistre !'
|
||||
setTimeout(() => { saveBtn.textContent = 'Enregistrer'; saveBtn.disabled = false }, 1500)
|
||||
}
|
||||
}).catch(() => {
|
||||
if (saveBtn) {
|
||||
saveBtn.textContent = 'Erreur — Reessayer'
|
||||
saveBtn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export function initCart() {
|
||||
const totalEl = document.getElementById('cart-total')
|
||||
const countEl = document.getElementById('cart-count')
|
||||
const checkoutBtn = document.getElementById('cart-checkout')
|
||||
const errorEl = document.getElementById('cart-error')
|
||||
const errorText = document.getElementById('cart-error-text')
|
||||
if (!totalEl || !countEl) return
|
||||
|
||||
function updateTotals() {
|
||||
@@ -77,13 +79,17 @@ export function initCart() {
|
||||
|
||||
checkoutBtn.disabled = true
|
||||
checkoutBtn.textContent = 'Chargement...'
|
||||
if (errorEl) errorEl.classList.add('hidden')
|
||||
|
||||
globalThis.fetch(orderUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cart),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(r.status)
|
||||
return r.json()
|
||||
})
|
||||
.then(data => {
|
||||
if (data.redirect) {
|
||||
globalThis.location.href = data.redirect
|
||||
@@ -92,9 +98,55 @@ export function initCart() {
|
||||
.catch(() => {
|
||||
checkoutBtn.disabled = false
|
||||
checkoutBtn.textContent = 'Commander'
|
||||
if (errorEl && errorText) {
|
||||
errorText.textContent = 'Une erreur est survenue. Veuillez reessayer.'
|
||||
errorEl.classList.remove('hidden')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
updateTotals()
|
||||
|
||||
const stockUrl = billetterie.dataset.stockUrl
|
||||
if (stockUrl) {
|
||||
setInterval(() => {
|
||||
globalThis.fetch(stockUrl)
|
||||
.then(r => r.json())
|
||||
.then(stock => {
|
||||
for (const item of items) {
|
||||
const billetId = item.dataset.billetId
|
||||
const qty = stock[billetId]
|
||||
if (qty === undefined || qty === null) continue
|
||||
|
||||
const max = qty
|
||||
item.dataset.max = String(max)
|
||||
|
||||
const qtyInput = item.querySelector('[data-cart-qty]')
|
||||
qtyInput.max = max
|
||||
|
||||
const current = Number.parseInt(qtyInput.value, 10) || 0
|
||||
if (max > 0 && current > max) {
|
||||
qtyInput.value = max
|
||||
}
|
||||
|
||||
const label = item.querySelector('[data-stock-label]')
|
||||
if (label) {
|
||||
if (max === 0) {
|
||||
label.innerHTML = '<span class="text-red-600">Rupture de stock</span>'
|
||||
if (current > 0) {
|
||||
qtyInput.value = 0
|
||||
}
|
||||
} else if (max <= 10) {
|
||||
label.innerHTML = '<span class="text-orange-500">Plus que ' + max + ' place' + (max > 1 ? 's' : '') + ' !</span>'
|
||||
} else {
|
||||
label.innerHTML = '<span class="text-gray-400">' + max + ' place' + (max > 1 ? 's' : '') + ' disponible' + (max > 1 ? 's' : '') + '</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
updateTotals()
|
||||
})
|
||||
.catch(() => {})
|
||||
}, 30000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ const ALLOWED_TAGS = new Set([
|
||||
'ul', 'li',
|
||||
])
|
||||
|
||||
const BLOCKED_TAGS = new Set([
|
||||
'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'select', 'textarea',
|
||||
'link', 'meta', 'noscript', 'template', 'svg', 'math',
|
||||
])
|
||||
|
||||
export function sanitizeHtml(html) {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = html
|
||||
@@ -35,7 +40,12 @@ function sanitizeNode(node) {
|
||||
fragment.appendChild(document.createTextNode(child.textContent))
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const tagName = child.tagName.toLowerCase()
|
||||
if (BLOCKED_TAGS.has(tagName)) {
|
||||
continue
|
||||
}
|
||||
if (ALLOWED_TAGS.has(tagName)) {
|
||||
// createElement produces a bare element — no attributes from source are copied,
|
||||
// which strips onclick, style, class, id, onerror, etc. by design.
|
||||
const el = document.createElement(tagName)
|
||||
el.appendChild(sanitizeNode(child))
|
||||
fragment.appendChild(el)
|
||||
@@ -86,6 +96,8 @@ export class ETicketEditor extends HTMLElement {
|
||||
btn.classList.add('ete-btn')
|
||||
btn.innerHTML = action.icon
|
||||
btn.title = action.title
|
||||
btn.setAttribute('aria-label', action.title)
|
||||
btn.tabIndex = 0
|
||||
btn.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault()
|
||||
this._exec(action)
|
||||
|
||||
@@ -42,6 +42,9 @@ function makeSortable(list, itemSelector, idAttr) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(order),
|
||||
}).catch(() => {
|
||||
/* reload to restore server order on failure */
|
||||
globalThis.location.reload()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
const STRIPE_POLL_INTERVAL = 100
|
||||
const STRIPE_POLL_TIMEOUT = 15000
|
||||
|
||||
function waitForStripe() {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof globalThis.Stripe !== 'undefined') {
|
||||
resolve()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let elapsed = 0
|
||||
const interval = setInterval(() => {
|
||||
if (typeof globalThis.Stripe !== 'undefined') {
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
} else {
|
||||
elapsed += STRIPE_POLL_INTERVAL
|
||||
if (elapsed >= STRIPE_POLL_TIMEOUT) {
|
||||
clearInterval(interval)
|
||||
reject(new Error('Stripe failed to load after ' + STRIPE_POLL_TIMEOUT + 'ms'))
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}, STRIPE_POLL_INTERVAL)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +33,7 @@ export function initStripePayment() {
|
||||
const stripeAccount = container.dataset.stripeAccount
|
||||
const intentUrl = container.dataset.intentUrl
|
||||
const returnUrl = container.dataset.returnUrl
|
||||
const fallbackUrl = container.dataset.fallbackUrl
|
||||
const amount = container.dataset.amount
|
||||
|
||||
if (!publicKey || !intentUrl) return
|
||||
@@ -73,6 +85,15 @@ export function initStripePayment() {
|
||||
const paymentElement = elements.create('payment', { layout: 'tabs' })
|
||||
paymentElement.mount('#payment-element')
|
||||
})
|
||||
.catch(() => {
|
||||
if (fallbackUrl) {
|
||||
globalThis.location.href = fallbackUrl
|
||||
} else {
|
||||
messageText.textContent = 'Impossible de charger le module de paiement. Veuillez rafraichir la page.'
|
||||
messageEl.classList.remove('hidden')
|
||||
submitBtn.disabled = true
|
||||
}
|
||||
})
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
if (!stripe || !elements) return
|
||||
|
||||
@@ -1,13 +1,71 @@
|
||||
export function initTabs() {
|
||||
document.querySelectorAll('[data-tab]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const targetId = button.dataset.tab
|
||||
document.querySelectorAll('[data-tab]').forEach(b => {
|
||||
const isActive = b.dataset.tab === targetId
|
||||
b.style.backgroundColor = isActive ? '#111827' : 'white'
|
||||
b.style.color = isActive ? 'white' : '#111827'
|
||||
document.getElementById(b.dataset.tab).style.display = isActive ? 'block' : 'none'
|
||||
})
|
||||
const buttons = document.querySelectorAll('[data-tab]')
|
||||
if (buttons.length === 0) return
|
||||
|
||||
const tablist = buttons[0].parentElement
|
||||
if (tablist) {
|
||||
tablist.setAttribute('role', 'tablist')
|
||||
}
|
||||
|
||||
buttons.forEach(button => {
|
||||
const targetId = button.dataset.tab
|
||||
const panel = document.getElementById(targetId)
|
||||
|
||||
button.setAttribute('role', 'tab')
|
||||
button.setAttribute('aria-controls', targetId)
|
||||
if (!button.id) {
|
||||
button.id = 'tab-btn-' + targetId
|
||||
}
|
||||
|
||||
if (panel) {
|
||||
panel.setAttribute('role', 'tabpanel')
|
||||
panel.setAttribute('aria-labelledby', button.id)
|
||||
}
|
||||
|
||||
const isActive = panel && panel.style.display !== 'none'
|
||||
button.setAttribute('aria-selected', isActive ? 'true' : 'false')
|
||||
button.setAttribute('tabindex', isActive ? '0' : '-1')
|
||||
|
||||
button.addEventListener('click', () => activateTab(buttons, button))
|
||||
|
||||
button.addEventListener('keydown', (e) => {
|
||||
const tabs = Array.from(buttons)
|
||||
const index = tabs.indexOf(button)
|
||||
|
||||
let target = null
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
target = tabs[(index + 1) % tabs.length]
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
target = tabs[(index - 1 + tabs.length) % tabs.length]
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
target = tabs[0]
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
target = tabs[tabs.length - 1]
|
||||
}
|
||||
|
||||
if (target) {
|
||||
activateTab(buttons, target)
|
||||
target.focus()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function activateTab(buttons, activeButton) {
|
||||
buttons.forEach(b => {
|
||||
const isActive = b === activeButton
|
||||
b.style.backgroundColor = isActive ? '#111827' : 'white'
|
||||
b.style.color = isActive ? 'white' : '#111827'
|
||||
b.setAttribute('aria-selected', isActive ? 'true' : 'false')
|
||||
b.setAttribute('tabindex', isActive ? '0' : '-1')
|
||||
|
||||
const panel = document.getElementById(b.dataset.tab)
|
||||
if (panel) {
|
||||
panel.style.display = isActive ? 'block' : 'none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user