<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Tests\Http;

use App\Http\View;
use App\Tests\TestCase;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

/**
 * R01-N21: Twig auto-escape is the only barrier between user-supplied
 * strings (sprint name, task title, audit JSON, …) and stored XSS in
 * the views that render them. One careless `|raw` or `{% autoescape
 * false %}` opens the door.
 *
 * Two complementary checks:
 *
 *   - **Behaviour pin**: render a known XSS payload through a synthetic
 *     Twig template using the same `View` env the controllers use, and
 *     assert the output is HTML-escaped. This catches a future change
 *     to `View`'s `autoescape` config (e.g. someone flips it to `'name'`
 *     so a template-name-derived strategy kicks in).
 *
 *   - **Static guard**: scan every `views` `.twig` template for the
 *     escape-bypass forms — `|raw`, `|safe`, `{% autoescape false %}`,
 *     and any `{% autoescape ... %}` override — and fail if any are
 *     present. Tests like this beat code review when the codebase
 *     grows.
 */
final class TwigAutoescapeTest extends TestCase
{
    public function testHtmlAutoescapeEscapesXssPayloadEndToEnd(): void
    {
        $view = new View(__DIR__ . '/../../views');
        $twig = $view->twig();

        $payload = '<script>alert(1)</script>';
        $tpl     = $twig->createTemplate('{{ x }}');
        $out     = $tpl->render(['x' => $payload]);

        // The literal `<script>` opener must NOT survive — a real
        // autoescape=html env will produce `&lt;script&gt;…`. We
        // assert both the negative (no raw tag) and the positive
        // (the entity-encoded form is present) so a flip to
        // `autoescape: false` would fail loudly.
        self::assertStringNotContainsString(
            '<script>',
            $out,
            'autoescape=html must HTML-escape user values rendered with {{ }}',
        );
        self::assertStringContainsString('&lt;script&gt;', $out);
        self::assertStringContainsString('&lt;/script&gt;', $out);
    }

    public function testAttributeContextEscapesQuoteCharacters(): void
    {
        // The `e('html_attr')` filter is the documented attribute-context
        // recipe in this codebase. Auto-escape inside `"…"` attribute
        // values defaults to HTML escape, which is enough for the
        // double-quote → `&quot;` replacement that closes the attribute
        // injection vector.
        $view = new View(__DIR__ . '/../../views');
        $twig = $view->twig();

        $tpl = $twig->createTemplate('<div title="{{ x }}"></div>');
        $out = $tpl->render(['x' => '" onmouseover="alert(1)']);

        self::assertStringNotContainsString('onmouseover="alert(1)', $out);
        self::assertStringContainsString('&quot;', $out);
    }

    /**
     * Scan every `.twig` file under `views` for escape-bypass forms.
     * A future template with `|raw` (or `{% autoescape false %}`)
     * would make stored XSS one careless commit away — this test
     * fails fast with the offending lines so the reviewer sees
     * them in CI.
     */
    public function testNoRawSafeOrAutoescapeOverrideInViews(): void
    {
        $viewsDir = realpath(__DIR__ . '/../../views');
        self::assertNotFalse($viewsDir, 'views/ must exist');

        $iter = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($viewsDir, FilesystemIterator::SKIP_DOTS)
        );

        // Two independent regexes — running both on the same line keeps
        // the failure message precise about which guard tripped.
        $rawFilter   = '/\|\s*(raw|safe)\b/';
        $autoescape  = '/\{\%\s*autoescape\b/';

        $offences = [];
        $scanned  = 0;
        /** @var \SplFileInfo $file */
        foreach ($iter as $file) {
            if (!$file->isFile() || $file->getExtension() !== 'twig') {
                continue;
            }
            $scanned++;
            $contents = file_get_contents($file->getPathname());
            self::assertNotFalse($contents, "could not read {$file->getPathname()}");

            $lines = preg_split('/\R/', $contents) ?: [];
            foreach ($lines as $i => $line) {
                if (preg_match($rawFilter, $line, $m)) {
                    $offences[] = sprintf(
                        '%s:%d  |%s filter — bypasses HTML autoescape (R01-N21)',
                        self::relativePath($file->getPathname(), $viewsDir),
                        $i + 1,
                        $m[1],
                    );
                }
                if (preg_match($autoescape, $line)) {
                    $offences[] = sprintf(
                        '%s:%d  {%% autoescape … %%} override — bypasses HTML autoescape (R01-N21)',
                        self::relativePath($file->getPathname(), $viewsDir),
                        $i + 1,
                    );
                }
            }
        }

        self::assertGreaterThan(0, $scanned, 'no .twig files were scanned — wrong views directory?');
        self::assertSame(
            [],
            $offences,
            "Forbidden escape-bypass usage in views:\n  - " . implode("\n  - ", $offences)
                . "\n\nIf one of these is genuinely safe (e.g., an HTML constant the operator controls),"
                . " add an allow-list comment alongside this test and document why the input is trusted.",
        );
    }

    private static function relativePath(string $abs, string $base): string
    {
        if (str_starts_with($abs, $base . '/')) {
            return substr($abs, strlen($base) + 1);
        }
        return $abs;
    }
}
