<?php

namespace Tests\Feature;

use App\Support\PublicHttp;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Tests\TestCase;

/**
 * The address checked is the address connected to.
 *
 * The redirect guard closed half of this: every hop was checked. The other half
 * stayed open and was written into the docblock as a known limit - the host was
 * resolved to decide and resolved again to connect, so a name with a one-second
 * TTL could answer a public address for the check and 127.0.0.1 for the fetch.
 * No amount of checking closes that, because there is no check that holds across
 * two lookups.
 *
 * So the resolution is done once and curl is handed the address, with its own
 * redirect following turned off and each hop verified and pinned in turn.
 *
 * What these pin is the mechanism rather than a live race, because reproducing
 * the race needs an attacker-controlled zone: that curl is given an address, that
 * it is one we checked, that following happens under our control, and that the
 * chain cannot be walked into the network or round in a circle.
 */
class TheNameIsResolvedOnceNotTwiceTest extends TestCase
{
    private function request()
    {
        return Http::accept('text/html');
    }

    /**
     * The one lookup, and the pin built from it.
     *
     * Both are private, because a second public way in is a second way for the
     * check and the pin to come from different answers - which is the defect. Read
     * through reflection rather than reopened.
     *
     * @return array{resolved: array<string, mixed>, pin: array<string, mixed>}
     */
    private function resolveAndPin(string $url): array
    {
        $resolve = new \ReflectionMethod(PublicHttp::class, 'resolveOnce');
        $pin = new \ReflectionMethod(PublicHttp::class, 'pinFor');

        $resolved = $resolve->invoke(null, $url);

        return ['resolved' => $resolved, 'pin' => $pin->invoke(null, $resolved)];
    }

    /**
     * The pinning tests need a name that really resolves.
     *
     * Said out loud rather than allowed to fail as if the pin were missing: with
     * no DNS there is nothing to pin, correctly, and a red test there would point
     * at the wrong thing entirely.
     */
    private function requireDns(): void
    {
        if (@dns_get_record('example.com', DNS_A) === false || @dns_get_record('example.com', DNS_A) === []) {
            $this->markTestSkipped('No DNS here, so there is no resolved address to pin.');
        }
    }

    // --------------------------------------------------------------- the pinning

    /** curl is told the address, so it has no reason to ask DNS again. */
    public function test_curl_is_given_a_resolved_address(): void
    {
        $this->requireDns();

        $options = $this->resolveAndPin('https://example.com/landing')['pin'];

        $this->assertArrayHasKey('curl', $options, 'nothing was pinned, so the name is resolved twice');

        $pins = $options['curl'][CURLOPT_RESOLVE];

        $this->assertCount(1, $pins);
        $this->assertMatchesRegularExpression(
            '/^example\.com:443:(\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])$/i',
            $pins[0],
            'the pin is not host:port:address',
        );
    }

    /** The port comes from the URL when it names one, not from the scheme. */
    public function test_the_pin_uses_the_url_port(): void
    {
        $this->requireDns();

        $pins = $this->resolveAndPin('https://example.com:8443/x')['pin']['curl'][CURLOPT_RESOLVE];

        $this->assertStringStartsWith('example.com:8443:', $pins[0]);
    }

    /** Plain http defaults to 80, and https to 443. */
    public function test_the_default_port_follows_the_scheme(): void
    {
        $this->requireDns();

        $this->assertStringStartsWith(
            'example.com:80:',
            $this->resolveAndPin('http://example.com/x')['pin']['curl'][CURLOPT_RESOLVE][0],
        );
    }

    /**
     * A URL that already names an address is not pinned.
     *
     * There was never a lookup to race, and an entry mapping an address to
     * itself is noise in every request log.
     */
    public function test_an_address_literal_is_not_pinned(): void
    {
        $this->assertSame([], $this->resolveAndPin('https://93.184.216.34/x')['pin']);
        $this->assertSame([], $this->resolveAndPin('https://[2606:2800:220:1:248:1893:25c8:1946]/x')['pin']);
    }

    /** A name that resolves nowhere is not pinned, and is not refused either. */
    public function test_an_unresolvable_name_is_not_pinned(): void
    {
        $this->assertSame([], $this->resolveAndPin('https://nothing.invalid/x')['pin']);
    }

    /**
     * The address pinned is one of the addresses that were checked.
     *
     * The property that matters, and the one the two-lookup version could not
     * promise: isPublicUrl() vetted the answers from its own lookup and
     * pinnedOptions() pinned an address from a later one, so a name that moved in
     * between produced a pin nothing had vetted. One lookup now feeds both.
     */
    public function test_the_pinned_address_is_one_that_was_checked(): void
    {
        $this->requireDns();

        ['resolved' => $resolved, 'pin' => $pin] = $this->resolveAndPin('https://example.com/landing');

        $this->assertTrue($resolved['ok']);
        $this->assertNotEmpty($resolved['addresses']);

        $pinned = explode(':', $pin['curl'][CURLOPT_RESOLVE][0]);
        $address = trim(implode(':', array_slice($pinned, 2)), '[]');

        $this->assertContains($address, $resolved['addresses'], 'the pin is not one of the checked addresses');
    }

    /** A private address is refused by the same single lookup. */
    public function test_a_private_address_is_refused_by_the_resolution_itself(): void
    {
        foreach ([
            'http://127.0.0.1:6379/',
            'http://169.254.169.254/latest/meta-data/',
            'http://10.0.0.5:8500/',
            'http://[::1]/',
            'http://localhost/admin',
            'file:///etc/passwd',
        ] as $url) {
            $this->assertFalse(
                $this->resolveAndPin($url)['resolved']['ok'],
                "{$url} was accepted",
            );
        }
    }

    // ------------------------------------------------------ following, by hand

    /** The straightforward case: one request, no redirect, the body comes back. */
    public function test_a_page_with_no_redirect_is_returned(): void
    {
        Http::fake(['*' => Http::response('<h1>Hello</h1>')]);

        $response = PublicHttp::send($this->request(), 'https://example.com/landing');

        $this->assertSame('<h1>Hello</h1>', $response->body());
    }

    /** A public redirect is followed, and the final body is the one returned. */
    public function test_a_public_redirect_is_followed(): void
    {
        Http::fake([
            'example.com/start' => Http::response('', 302, ['Location' => 'https://example.com/end']),
            'example.com/end' => Http::response('<h1>Arrived</h1>'),
        ]);

        $this->assertSame(
            '<h1>Arrived</h1>',
            PublicHttp::send($this->request(), 'https://example.com/start')->body(),
        );
    }

    /** A relative Location is resolved against the URL it came from. */
    public function test_a_relative_redirect_is_resolved(): void
    {
        Http::fake([
            'example.com/a/start' => Http::response('', 301, ['Location' => '../end']),
            'example.com/end' => Http::response('<h1>Arrived</h1>'),
        ]);

        $this->assertSame(
            '<h1>Arrived</h1>',
            PublicHttp::send($this->request(), 'https://example.com/a/start')->body(),
        );
    }

    /**
     * The defect this all exists for: a public page handing the fetch inward.
     *
     * The user never types a private address, so a check on what they typed never
     * fires on anything that matters.
     */
    public function test_a_redirect_into_the_network_is_refused(): void
    {
        Http::fake([
            'example.com/start' => Http::response('', 302, ['Location' => 'http://169.254.169.254/latest/meta-data/']),
            '*' => Http::response('SECRET-INSTANCE-CREDENTIALS'),
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('not publicly reachable');

        PublicHttp::send($this->request(), 'https://example.com/start');
    }

    /** And it is refused before the request is made, not after reading it. */
    public function test_the_private_address_is_never_requested(): void
    {
        Http::fake([
            'example.com/start' => Http::response('', 302, ['Location' => 'http://127.0.0.1:6379/']),
            '*' => Http::response('SECRET'),
        ]);

        try {
            PublicHttp::send($this->request(), 'https://example.com/start');
        } catch (RuntimeException) {
            // expected
        }

        Http::assertNotSent(fn ($request): bool => str_contains($request->url(), '127.0.0.1'));
    }

    /** A scheme change is refused too: the chain stays in http and https. */
    public function test_a_redirect_out_of_http_is_refused(): void
    {
        Http::fake([
            'example.com/start' => Http::response('', 302, ['Location' => 'file:///etc/passwd']),
        ]);

        $this->expectException(RuntimeException::class);

        PublicHttp::send($this->request(), 'https://example.com/start');
    }

    /** A chain that comes back to a URL it already used is a loop. */
    public function test_a_redirect_loop_is_refused(): void
    {
        Http::fake([
            'example.com/a' => Http::response('', 302, ['Location' => 'https://example.com/b']),
            'example.com/b' => Http::response('', 302, ['Location' => 'https://example.com/a']),
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('returned to');

        PublicHttp::send($this->request(), 'https://example.com/a');
    }

    /** And a chain that never arrives is bounded rather than endless. */
    public function test_the_chain_is_bounded(): void
    {
        $hop = 0;

        Http::fake(function () use (&$hop) {
            $hop++;

            return Http::response('', 302, ['Location' => 'https://example.com/hop-'.$hop]);
        });

        try {
            PublicHttp::send($this->request(), 'https://example.com/start', max: 3);
            $this->fail('an endless chain was not abandoned');
        } catch (RuntimeException $e) {
            $this->assertStringContainsString('followed 3 redirects', $e->getMessage());
        }

        // Four requests: the first, then three hops.
        $this->assertSame(4, $hop);
    }

    /** The caller's own headers survive being wrapped. */
    public function test_the_callers_headers_are_still_sent(): void
    {
        Http::fake(['*' => Http::response('ok')]);

        PublicHttp::send(
            Http::withHeaders(['User-Agent' => 'ArbBot/1.0', 'Accept-Language' => 'en-US']),
            'https://example.com/landing',
        );

        Http::assertSent(fn ($request): bool => $request->header('User-Agent')[0] === 'ArbBot/1.0'
            && $request->header('Accept-Language')[0] === 'en-US');
    }
}
