<?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\TrustedProxies;
use App\Tests\TestCase;

/**
 * Pins the contract for R01-N05 + R01-N07: the proxy-aware client-IP and
 * HTTPS detector. The class is pure (no DB/session) so we exercise it with
 * plain `$_SERVER`-shaped arrays.
 */
final class TrustedProxiesTest extends TestCase
{
    public function testEmptyConfigReturnsRemoteAddrVerbatim(): void
    {
        $tp = new TrustedProxies([]);
        self::assertSame('203.0.113.5', $tp->clientIp([
            'REMOTE_ADDR'           => '203.0.113.5',
            'HTTP_X_FORWARDED_FOR'  => '198.51.100.7, 192.0.2.1',
        ]));
    }

    public function testTrustsXffWhenRemoteIsTrusted(): void
    {
        $tp = new TrustedProxies(['10.0.0.0/8']);
        self::assertSame('203.0.113.5', $tp->clientIp([
            'REMOTE_ADDR'           => '10.5.5.5',
            'HTTP_X_FORWARDED_FOR'  => '203.0.113.5',
        ]));
    }

    public function testWalksRightmostUntrustedHop(): void
    {
        // Two trusted internal hops in front of the real client.
        $tp = new TrustedProxies(['10.0.0.0/8', '192.168.0.0/16']);
        self::assertSame('203.0.113.5', $tp->clientIp([
            'REMOTE_ADDR'           => '10.0.0.1',
            'HTTP_X_FORWARDED_FOR'  => '203.0.113.5, 192.168.1.1, 10.0.0.2',
        ]));
    }

    public function testFallsBackToLeftmostWhenAllHopsTrusted(): void
    {
        $tp = new TrustedProxies(['10.0.0.0/8']);
        self::assertSame('10.0.0.7', $tp->clientIp([
            'REMOTE_ADDR'           => '10.0.0.1',
            'HTTP_X_FORWARDED_FOR'  => '10.0.0.7, 10.0.0.2',
        ]));
    }

    public function testIgnoresXffWhenRemoteUntrusted(): void
    {
        // Attacker hits the app directly and forges X-Forwarded-For. We
        // must NOT believe the header.
        $tp = new TrustedProxies(['10.0.0.0/8']);
        self::assertSame('203.0.113.42', $tp->clientIp([
            'REMOTE_ADDR'           => '203.0.113.42',
            'HTTP_X_FORWARDED_FOR'  => '127.0.0.1, 10.0.0.1',
        ]));
    }

    public function testStripsPortFromXffHops(): void
    {
        $tp = new TrustedProxies(['10.0.0.0/8']);
        self::assertSame('203.0.113.5', $tp->clientIp([
            'REMOTE_ADDR'           => '10.0.0.1',
            'HTTP_X_FORWARDED_FOR'  => '203.0.113.5:51234',
        ]));
    }

    public function testIpv6Cidr(): void
    {
        $tp = new TrustedProxies(['2001:db8::/32']);
        self::assertSame('2001:db8::1', $tp->clientIp([
            'REMOTE_ADDR'           => '2001:db8::a',
            'HTTP_X_FORWARDED_FOR'  => '2001:db8::1',
        ]));
    }

    public function testBareIpTreatedAsHostMask(): void
    {
        $tp = new TrustedProxies(['127.0.0.1']);
        self::assertTrue($tp->isTrusted('127.0.0.1'));
        self::assertFalse($tp->isTrusted('127.0.0.2'));
    }

    public function testInvalidEntriesAreSkippedNotTrusted(): void
    {
        // Typos must not silently widen trust.
        $tp = new TrustedProxies(['nonsense', '10.0.0.0/40', '10.0.0.0/8']);
        self::assertTrue($tp->isTrusted('10.1.2.3'));
        self::assertFalse($tp->isTrusted('203.0.113.5'));
    }

    public function testFromEnvParsesCommaList(): void
    {
        $prev = getenv('TRUSTED_PROXIES');
        try {
            putenv('TRUSTED_PROXIES=10.0.0.0/8 , 192.168.0.0/16');
            $tp = TrustedProxies::fromEnv();
            self::assertTrue($tp->isTrusted('10.5.5.5'));
            self::assertTrue($tp->isTrusted('192.168.0.1'));
            self::assertFalse($tp->isTrusted('172.16.0.1'));
        } finally {
            $prev === false ? putenv('TRUSTED_PROXIES') : putenv('TRUSTED_PROXIES=' . $prev);
        }
    }

    public function testFromEnvWithBlankReturnsNoTrust(): void
    {
        $prev = getenv('TRUSTED_PROXIES');
        try {
            putenv('TRUSTED_PROXIES=   ');
            $tp = TrustedProxies::fromEnv();
            self::assertFalse($tp->isTrusted('10.5.5.5'));
            self::assertFalse($tp->isTrusted('127.0.0.1'));
        } finally {
            $prev === false ? putenv('TRUSTED_PROXIES') : putenv('TRUSTED_PROXIES=' . $prev);
        }
    }

    public function testIsHttpsDirectTls(): void
    {
        $tp = new TrustedProxies([]);
        self::assertTrue($tp->isHttps(['HTTPS' => 'on']));
        self::assertTrue($tp->isHttps(['REQUEST_SCHEME' => 'https']));
        self::assertTrue($tp->isHttps(['SERVER_PORT' => '443']));
        self::assertFalse($tp->isHttps(['HTTPS' => 'off']));
        self::assertFalse($tp->isHttps([]));
    }

    public function testIsHttpsTrustsXfpFromTrustedProxyOnly(): void
    {
        $tp = new TrustedProxies(['10.0.0.0/8']);
        self::assertTrue($tp->isHttps([
            'REMOTE_ADDR'             => '10.0.0.1',
            'HTTP_X_FORWARDED_PROTO'  => 'https',
        ]));
        // Same header but from an untrusted peer — must NOT be honoured.
        self::assertFalse($tp->isHttps([
            'REMOTE_ADDR'             => '203.0.113.5',
            'HTTP_X_FORWARDED_PROTO'  => 'https',
        ]));
    }

    public function testIsHttpsHandlesMultiHopXfpList(): void
    {
        // RFC-style "https, http" → leftmost is what the user actually saw.
        $tp = new TrustedProxies(['10.0.0.0/8']);
        self::assertTrue($tp->isHttps([
            'REMOTE_ADDR'             => '10.0.0.1',
            'HTTP_X_FORWARDED_PROTO'  => 'https, http',
        ]));
        self::assertFalse($tp->isHttps([
            'REMOTE_ADDR'             => '10.0.0.1',
            'HTTP_X_FORWARDED_PROTO'  => 'http, https',
        ]));
    }
}
