<?php

namespace Tests\Unit\Services\PromptLandingPageEditor;

use App\Services\PromptLandingPageEditor\SanitizeDocument;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;

/**
 * The editor's sanitiser, against the spellings a browser accepts.
 *
 * It removed a script only when the document contained a literal `</script>`.
 * A parser is not that strict: it ends a script at `</script `, `</script/`, a
 * newline before the `>`, or any `</script` followed by junk, and if the tag is
 * never closed it treats the rest of the file as script. Each of those saved a
 * working script into a published landing page.
 *
 * Inline event handlers were not touched at all, which is the wider hole:
 * `<img src=x onerror=alert(1)>` needs no script tag and passed every rule.
 * So did an unquoted `href=javascript:alert(1)`, because the URL check required
 * the value to be quoted.
 *
 * These pages are published to the public web under our own domains, so what
 * gets through here is stored XSS on a page we serve.
 */
class EverySpellingOfAScriptTagTest extends TestCase
{
    private function html(string $html): string
    {
        return app(SanitizeDocument::class)->sanitize($html, '', '')['html'];
    }

    /** @return list<array{0: string, 1: string}> */
    public static function scriptSpellings(): array
    {
        return [
            'the one that already worked' => ['<p>a</p><script>alert(1)</script><p>b</p>', 'alert(1)'],
            'space before the close' => ['<p>a</p><script>alert(2)</script ><p>b</p>', 'alert(2)'],
            'slash before the close' => ['<p>a</p><script>alert(3)</script/><p>b</p>', 'alert(3)'],
            'newline before the close' => ["<p>a</p><script>alert(4)</script\n><p>b</p>", 'alert(4)'],
            'junk before the close' => ['<p>a</p><script>alert(5)</script foo=bar><p>b</p>', 'alert(5)'],
            'uppercase' => ['<p>a</p><SCRIPT>alert(6)</SCRIPT><p>b</p>', 'alert(6)'],
            'attributes on the open' => ['<script type="text/javascript">alert(7)</script>', 'alert(7)'],
            'never closed at all' => ['<p>a</p><script>alert(8)', 'alert(8)'],
            'open tag truncated' => ['<p>a</p><script src="//evil.test/x.js"', 'evil.test'],
            'src with no body' => ['<script src="//evil.test/y.js"></script>', 'evil.test'],
        ];
    }

    #[DataProvider('scriptSpellings')]
    public function test_a_script_does_not_survive_any_spelling(string $input, string $payload): void
    {
        $this->assertStringNotContainsString(
            $payload,
            $this->html($input),
            'this spelling of a script tag reached the published page',
        );
    }

    /** And the content before an unterminated script is kept, not thrown away. */
    public function test_the_page_before_an_unterminated_script_survives(): void
    {
        $result = $this->html('<h1>Our offer</h1><p>Real copy.</p><script>alert(1)');

        $this->assertStringContainsString('Our offer', $result);
        $this->assertStringContainsString('Real copy.', $result);
        $this->assertStringNotContainsString('alert(1)', $result);
    }

    // ------------------------------------------------- handlers, the wider hole

    /** @return list<array{0: string}> */
    public static function handlers(): array
    {
        return [
            'unquoted' => ['<img src="x.png" onerror=alert(1)>'],
            'double quoted' => ['<img src="x.png" onerror="alert(1)">'],
            'single quoted' => ["<img src='x.png' onerror='alert(1)'>"],
            'uppercase' => ['<img src="x.png" ONERROR="alert(1)">'],
            'spaces round the equals' => ['<img src="x.png" onerror = "alert(1)">'],
            'on a body tag' => ['<body onload="alert(1)"><p>a</p></body>'],
            'on a link' => ['<a href="/x" onclick="alert(1)">Go</a>'],
            'focus handler' => ['<input name="email" onfocus="alert(1)" autofocus>'],
            'animation handler' => ['<div onanimationstart="alert(1)">a</div>'],
        ];
    }

    #[DataProvider('handlers')]
    public function test_an_inline_handler_is_removed(string $input): void
    {
        $result = $this->html($input);

        $this->assertStringNotContainsString('alert(1)', $result, 'the handler body survived');
        $this->assertDoesNotMatchRegularExpression('/\son[a-z]+\s*=/i', $result, 'a handler attribute survived');
    }

    /**
     * A data attribute that merely starts with "on" is left alone.
     *
     * Over-stripping here would silently break real pages, and the whole design
     * of this sanitiser is that markup and styling survive it intact.
     */
    public function test_a_data_attribute_is_not_mistaken_for_a_handler(): void
    {
        $result = $this->html('<div data-onclick="track" data-only="1" class="hero">a</div>');

        $this->assertStringContainsString('data-onclick="track"', $result);
        $this->assertStringContainsString('data-only="1"', $result);
        $this->assertStringContainsString('class="hero"', $result);
    }

    // --------------------------------------------------------- script-bearing URLs

    /** @return list<array{0: string}> */
    public static function scriptUrls(): array
    {
        return [
            'unquoted href' => ['<a href=javascript:alert(1)>Go</a>'],
            'quoted href' => ['<a href="javascript:alert(1)">Go</a>'],
            'entity encoded' => ['<a href="java&#115;cript:alert(1)">Go</a>'],
            'tab inside the scheme' => ["<a href=\"java\tscript:alert(1)\">Go</a>"],
            'vbscript' => ['<a href="vbscript:alert(1)">Go</a>'],
            'form action' => ['<form action=javascript:alert(1)><input name="e"></form>'],
            'formaction on a button' => ['<button formaction="javascript:alert(1)">Send</button>'],
            'object data' => ['<object data="javascript:alert(1)"></object>'],
            'poster' => ['<video poster=javascript:alert(1)></video>'],
        ];
    }

    #[DataProvider('scriptUrls')]
    public function test_a_script_url_is_pointed_at_nothing(string $input): void
    {
        $result = $this->html($input);

        $this->assertStringNotContainsString('alert(1)', $result, 'the script URL survived');
    }

    /** Ordinary links, including tracking parameters, are untouched. */
    public function test_a_real_url_is_left_exactly_as_written(): void
    {
        $input = '<a href="/signup?utm_source=hero&amp;utm_medium=cpc">Sign up</a>'
            .'<img src="https://cdn.example.test/hero.png" alt="Hero">';

        $result = $this->html($input);

        $this->assertStringContainsString('/signup?utm_source=hero&amp;utm_medium=cpc', $result);
        $this->assertStringContainsString('https://cdn.example.test/hero.png', $result);
    }

    // ------------------------------------------------------------------- srcdoc

    /**
     * srcdoc carries a whole document, and nothing else here looks inside it.
     *
     * Scripts, handlers and javascript: URLs all survive inside an attribute
     * value, so the attribute goes rather than being filtered.
     */
    public function test_an_iframe_srcdoc_document_is_removed(): void
    {
        $result = $this->html('<iframe srcdoc="<script>alert(1)</script>" title="x"></iframe>');

        $this->assertStringNotContainsString('alert(1)', $result);
        $this->assertStringNotContainsString('srcdoc', $result);
        $this->assertStringContainsString('title="x"', $result, 'the rest of the iframe was destroyed');
    }

    /** An embedded video is still allowed, because landing pages use them. */
    public function test_an_ordinary_iframe_embed_survives(): void
    {
        $result = $this->html('<iframe src="https://www.youtube.com/embed/abc" title="Demo"></iframe>');

        $this->assertStringContainsString('https://www.youtube.com/embed/abc', $result);
    }
}
