<?php

/**
 * 文件功能：安全加固回归测试
 *
 * 覆盖可信代理 IP 解析、Banner 广播净化，以及 Horizon 访问门禁，
 * 确保本轮安全修复不会在后续迭代中退化。
 *
 * @author   ChatRoom Laravel
 *
 * @version  1.0.0
 */

namespace Tests\Feature;

use App\Events\BannerNotification;
use App\Http\Controllers\Admin\BannerBroadcastController;
use App\Http\Middleware\CloudflareProxies;
use App\Models\User;
use App\Providers\AppServiceProvider;
use App\Providers\HorizonServiceProvider;
use Illuminate\Broadcasting\PendingBroadcast;
use Illuminate\Contracts\Broadcasting\Factory as BroadcastFactory;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;

/**
 * 类功能：验证本轮安全修复的核心保护行为。
 */
class SecurityHardeningTest extends TestCase
{
    /**
     * 每个测试后重置 URL 生成器的强制协议，避免污染后续用例。
     */
    protected function tearDown(): void
    {
        URL::forceScheme(null);

        parent::tearDown();
    }

    /**
     * 验证只有来自可信代理的请求才允许采用透传客户端 IP。
     */
    public function test_cloudflare_proxies_only_accepts_forwarded_ip_from_trusted_proxy(): void
    {
        config()->set('app.trusted_proxies', ['127.0.0.1', '::1']);
        $middleware = new CloudflareProxies;

        $trustedRequest = Request::create('/rooms', 'GET', server: [
            'REMOTE_ADDR' => '127.0.0.1',
            'HTTP_CF_CONNECTING_IP' => '203.0.113.10',
        ]);

        $middleware->handle($trustedRequest, fn () => response('ok'));
        $this->assertSame('203.0.113.10', $trustedRequest->server->get('REMOTE_ADDR'));

        $untrustedRequest = Request::create('/rooms', 'GET', server: [
            'REMOTE_ADDR' => '198.51.100.25',
            'HTTP_CF_CONNECTING_IP' => '203.0.113.11',
        ]);

        $middleware->handle($untrustedRequest, fn () => response('ok'));
        $this->assertSame('198.51.100.25', $untrustedRequest->server->get('REMOTE_ADDR'));
    }

    /**
     * 验证 Banner 广播会将危险 HTML 净化为纯文本后再下发。
     */
    public function test_banner_broadcast_sanitizes_user_controlled_html(): void
    {
        $fakeBroadcastFactory = new class
        {
            /**
             * 记录控制器最终广播出去的事件对象。
             */
            public ?BannerNotification $capturedEvent = null;

            /**
             * 截获 broadcast() helper 传入的事件实例。
             */
            public function event($event): PendingBroadcast
            {
                $this->capturedEvent = $event;

                return new class($event) extends PendingBroadcast
                {
                    /**
                     * 构造一个不会二次派发的占位 PendingBroadcast。
                     */
                    public function __construct(mixed $event)
                    {
                        parent::__construct(app('events'), $event);
                    }

                    /**
                     * 测试替身无需在析构时再次派发事件。
                     */
                    public function __destruct() {}
                };
            }

            /**
             * 满足广播工厂抽象依赖，当前测试不会走到该分支。
             */
            public function connection($name = null): never
            {
                throw new \RuntimeException('SecurityHardeningTest 不应调用广播连接。');
            }
        };
        $this->app->instance(BroadcastFactory::class, $fakeBroadcastFactory);

        $request = Request::create('/admin/banner/broadcast', 'POST', [
            'target' => 'room',
            'target_id' => 1,
            'options' => [
                'title' => '<span onclick="alert(1)">危险标题</span>',
                'name' => '<b>管理员</b>',
                'body' => '<span onclick="alert(1)">危险正文</span>',
                'sub' => '<img src=x onerror=alert(1)>副标题',
                'titleColor' => 'url(javascript:1)',
                'gradient' => ['#123456', 'rgba(1,2,3,0.5)', 'url(javascript:alert(1))'],
                'buttons' => [
                    [
                        'label' => '<b>立即处理</b>',
                        'color' => 'rgba(16,185,129,1)',
                        'action' => 'close',
                    ],
                ],
            ],
        ]);
        $request->headers->set('Accept', 'application/json');

        $response = app(BannerBroadcastController::class)->send($request);

        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('success', $response->getData(true)['status']);
        $this->assertInstanceOf(BannerNotification::class, $fakeBroadcastFactory->capturedEvent);
        $this->assertSame('危险标题', $fakeBroadcastFactory->capturedEvent->options['title']);
        $this->assertSame('管理员', $fakeBroadcastFactory->capturedEvent->options['name']);
        $this->assertSame('危险正文', $fakeBroadcastFactory->capturedEvent->options['body']);
        $this->assertSame('副标题', $fakeBroadcastFactory->capturedEvent->options['sub']);
        $this->assertStringNotContainsString('"', $fakeBroadcastFactory->capturedEvent->options['titleColor']);
        $this->assertStringNotContainsString('javascript', $fakeBroadcastFactory->capturedEvent->options['gradient'][2]);
        $this->assertSame('立即处理', $fakeBroadcastFactory->capturedEvent->options['buttons'][0]['label']);
    }

    /**
     * 验证 Horizon 面板仅允许站长账号访问。
     */
    public function test_horizon_gate_only_allows_site_owner(): void
    {
        $provider = new class($this->app) extends HorizonServiceProvider
        {
            /**
             * 显式注册测试所需的 Horizon gate。
             */
            public function registerTestGate(): void
            {
                $this->gate();
            }
        };
        $provider->registerTestGate();

        $siteOwner = new User([
            'username' => 'site-owner',
            'user_level' => 1,
        ]);
        $siteOwner->id = 1;

        $revokedManager = new User([
            'username' => 'revoked-manager',
            'user_level' => 100,
        ]);

        $this->assertTrue(Gate::forUser($siteOwner)->allows('viewHorizon'));
        $this->assertFalse(Gate::forUser($revokedManager)->allows('viewHorizon'));
    }

    /**
     * 验证开启强制 HTTPS 配置后，应用生成的资源链接会统一使用 https。
     */
    public function test_app_force_https_makes_generated_asset_urls_use_https(): void
    {
        config()->set('app.force_https', true);

        $provider = new AppServiceProvider($this->app);
        $provider->boot();

        $this->assertStringStartsWith('https://', url('/build/assets/app.css'));
    }

    /**
     * 验证前台登录入口在命中限流后会直接返回 429。
     */
    public function test_chat_login_route_returns_429_when_rate_limited(): void
    {
        $rateLimitKey = md5('chat-login'.'chat-login|testuser|127.0.0.1');
        RateLimiter::clear($rateLimitKey);

        foreach (range(1, 5) as $attempt) {
            RateLimiter::hit($rateLimitKey, 60);
        }

        $response = $this->withoutMiddleware(ValidateCsrfToken::class)
            ->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
            ->postJson('/login', [
                'username' => 'TestUser',
                'password' => 'secret',
                'captcha' => 'invalid',
            ]);

        $response->assertStatus(429)
            ->assertJsonPath('status', 'error');

        RateLimiter::clear($rateLimitKey);
    }

    /**
     * 验证隐藏后台登录入口在命中限流后会直接拒绝请求。
     */
    public function test_hidden_admin_login_route_returns_429_when_rate_limited(): void
    {
        $rateLimitKey = md5('admin-hidden-login'.'admin-hidden-login|site-owner|127.0.0.1');
        RateLimiter::clear($rateLimitKey);

        foreach (range(1, 5) as $attempt) {
            RateLimiter::hit($rateLimitKey, 60);
        }

        $response = $this->withoutMiddleware(ValidateCsrfToken::class)
            ->from('/lkddi')
            ->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
            ->post('/lkddi', [
                'username' => 'site-owner',
                'password' => 'secret',
                'captcha' => 'invalid',
            ]);

        $response->assertStatus(429);
        $this->assertStringContainsString('/lkddi', $response->headers->get('Location', ''));

        RateLimiter::clear($rateLimitKey);
    }
}
