import { afterEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/app.js'; import { CrowdPulsePanel } from '../src/components/CrowdPulsePanel.js'; import { NightOwlPanel } from '../src/components/NightOwlPanel.js'; import { PlayerCard } from '../src/components/PlayerCard.js'; import { VideoPlayer } from '../src/components/VideoPlayer.js'; function elementStub() { return { innerHTML: '', textContent: '', style: {}, }; } function stubElements(map) { vi.stubGlobal('document', { getElementById: vi.fn((id) => map[id] || null), }); } function htmlStub(children = {}) { return { innerHTML: '', textContent: '', style: {}, dataset: {}, hidden: false, tagName: 'DIV', replaceChildren() { this.innerHTML = ''; }, insertAdjacentHTML(_position, html) { this.innerHTML = String(html); }, querySelector(selector) { return children[selector] || null; }, querySelectorAll(selector) { return children[selector] || []; }, addEventListener: vi.fn(), removeEventListener: vi.fn(), }; } describe('App frontend security', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('escapes HTML before rendering live travel route data', () => { const status = elementStub(); const options = elementStub(); stubElements({ 'live-travel-status': status, 'live-travel-options': options, }); const app = new App(); app.venueMapService = {}; app.liveTravelPlan = { venue: { name: '' }, distanceKm: 1.2, routeDistanceKm: 1.6, hasUserLocation: true, bestGate: { name: '' }, recommended: { label: 'Transit', etaMinutes: 12, }, alternatives: [ { label: '', etaMinutes: 12, badge: '', reason: '', }, ], }; app._renderLiveTravelCard(); const rendered = `${status.innerHTML}${options.innerHTML}`; expect(rendered).not.toContain(' { const feed = { scrollHeight: 10, scrollTop: 0, appended: null, children: [], appendChild(node) { this.appended = node; this.children.push(node); }, removeChild(node) { const idx = this.children.indexOf(node); if (idx >= 0) this.children.splice(idx, 1); }, }; const createdNodes = []; stubElements({ 'commentary-feed': feed }); document.createElement = vi.fn((tag) => { const node = { tag, className: '', textContent: '', children: [], append(...children) { this.children.push(...children); }, }; createdNodes.push(node); return node; }); const app = new App(); app._addCommentary('', { type: 'goal', minute: '', options: ['', ''], }); panel.updateLeaderboard(); const rendered = `${quizArea.innerHTML}${leaderboardBody.innerHTML}`; expect(rendered).not.toContain('`/`` tag survives. Guard against a real attribute by // asserting the dangerous tokens only appear inside escaped entities. expect(rendered).not.toMatch(/<(?:img|svg)[\s>]/i); expect(rendered).toContain('<script>'); expect(rendered).toContain('<img'); }); it('escapes hostile Night Owl alert, break reason, and catch-up summary content', () => { const alertArea = htmlStub(); const summaryEl = htmlStub(); const panel = new NightOwlPanel(htmlStub({ '#night-owl-alert-area': alertArea, '#catchup-summary-content': summaryEl, }), { isBreakSafe: () => ({ safe: false, reason: '', }), }); panel.showBigMomentAlert({ type: 'goal', minute: '', details: '', }); panel.checkBreakWindow(); panel.showCatchUpSummary({ summary: '' }); const rendered = `${alertArea.innerHTML}${summaryEl.innerHTML}`; expect(rendered).not.toContain(' { const playerContent = htmlStub(); const playerCard = new PlayerCard(htmlStub({ '#player-card-content': playerContent })); playerCard.show({ homeTeam: '', awayTeam: '', score: '', minute: 'focus', phase: 'phase', funFact: '', player: '', confidence: 0.9, }); const overlay = htmlStub(); const videoPlayer = new VideoPlayer(htmlStub({ '#video-score-overlay': overlay })); videoPlayer.setOverlayScore('', '', ''); const rendered = `${playerContent.innerHTML}${overlay.innerHTML}`; expect(rendered).not.toContain(' { const videoPlayer = new VideoPlayer(htmlStub()); const safeSrc = videoPlayer._safeMediaSrc('https://evil.example/watch?next=youtube.com'); expect(safeSrc).not.toContain('evil.example'); expect(safeSrc).toContain('football-goal-1.mp4'); }); });