/**
 * Regression tests for the XSS hardening in pages/news.js (PR #3095).
 *
 * The render path runs every template literal through safeRenderHTML ->
 * window.DOMPurify.sanitize, so DOMPurify is the runtime XSS barrier.
 * The Number()/escapeHtml() wrappers added in PR #3095 are defense-in-depth:
 * they stop a payload from reaching DOMPurify in an executable shape —
 * notably inside `style="width: ..."` (where escapeHtml would not help) and
 * inside text nodes that DOMPurify would technically allow through if the
 * surrounding markup looked valid.
 *
 * To assert the *source-level* escaping rather than DOMPurify's output, we
 * spy on DOMPurify.sanitize and inspect the pre-sanitized htmlString that
 * the template produced. If the literal `<script>` substring shows up there,
 * a future edit has dropped one of the Number()/escapeHtml() wrappers.
 *
 * Scope: the fields touched by PR #3095 — impact_score (style attribute and
 * text node), upvotes/downvotes (text node), and time_ago (text node wrapped
 * by escapeHtml that previously only covered one branch of a || chain).
 *
 * The shared render scaffold lives in helpers/news-render-harness.js.
 */

import {
    installNewsRenderHarness,
    makeNewsItem as makeItem,
} from './helpers/news-render-harness.js';

const PAYLOAD = '<script>alert(1)</script>';

let harness;

beforeAll(async () => {
    harness = await installNewsRenderHarness();
});

async function renderWith(items) {
    return harness.renderWith(items);
}

function renderedHtmlForHeadline(headline) {
    return harness.capturedHtmlFor(headline) || '';
}

describe('pages/news.js — XSS regression for PR #3095', () => {
    it('does not leak raw <script> from impact_score into the style attribute', async () => {
        const html = await renderWith([makeItem({
            id: 'impact-payload',
            headline: 'IMPACT_CANARY',
            impact_score: PAYLOAD,
        })]).then(() => renderedHtmlForHeadline('IMPACT_CANARY'));

        expect(html).not.toBe('');
        // Style-attribute injection: a payload inside style="width:..."
        // would let an attacker inject e.g. `1;background:url(javascript:...)`.
        // Number() coerces the payload to NaN, so the literal <script>
        // substring must never appear in the produced HTML.
        expect(html).not.toContain(PAYLOAD);
        // The "/10" text node is interpolated from the same field and must
        // also be free of the payload.
        expect(html).not.toMatch(new RegExp(PAYLOAD.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*/10'));
    });

    it('does not leak raw <script> from upvotes/downvotes into vote buttons', async () => {
        const html = await renderWith([makeItem({
            id: 'vote-payload',
            headline: 'VOTE_CANARY',
            upvotes: PAYLOAD,
            downvotes: PAYLOAD,
        })]).then(() => renderedHtmlForHeadline('VOTE_CANARY'));

        expect(html).not.toBe('');
        expect(html).not.toContain(PAYLOAD);
    });

    it('does not leak raw <script> from time_ago into the date span', async () => {
        // time_ago only interpolates when formatNewsDate(item.created_at) is
        // falsy. Send no created_at so the right side of the || chain fires
        // — that's the branch PR #3095 wrapped in escapeHtml().
        const html = await renderWith([makeItem({
            id: 'time-payload',
            headline: 'TIME_CANARY',
            created_at: null,
            time_ago: PAYLOAD,
        })]).then(() => renderedHtmlForHeadline('TIME_CANARY'));

        expect(html).not.toBe('');
        // escapeHtml turns <script> into &lt;script&gt;. The literal payload
        // substring must not survive.
        expect(html).not.toContain(PAYLOAD);
        expect(html).toContain('&lt;script&gt;');
    });
});
