<?php

/**
 * Unit tests for generic Request_Client class
 *
 * @group kohana
 * @group kohana.core
 * @group kohana.core.request
 *
 * @package    Kohana
 * @category   Tests
 * @author     Kohana Team
 * @author	   Andrew Coulton
 * @copyright  (c) Kohana Team
 * @license    https://koseven.ga/LICENSE.md
 */
class Kohana_Request_ClientTest extends Unittest_TestCase
{
	protected $_inital_request;
	protected static $_original_routes;

	// @codingStandardsIgnoreStart - PHPUnit does not follow standards
	/**
	 * Sets up a new route to ensure that we have a matching route for our
	 * Controller_RequestClientDummy class.
	 */
	public static function setUpBeforeClass(): void
	{
		// @codingStandardsIgnoreEnd
		parent::setUpBeforeClass();

		// Set a new Route to the ClientTest controller as the first route
		// This requires reflection as the API for editing defined routes is limited
		$route_class = new ReflectionClass('Route');
		$routes_prop = $route_class->getProperty('_routes');
		$routes_prop->setAccessible(TRUE);

		self::$_original_routes = $routes_prop->getValue('Route');

		$routes = [
			'ko_request_clienttest' => new Route('<controller>/<action>/<data>',['data'=>'.+'])
		] + self::$_original_routes;

		$routes_prop->setValue('Route',$routes);

	}

	// @codingStandardsIgnoreStart - PHPUnit does not follow standards
	/**
	 * Resets the application's routes to their state prior to this test case
	 */
	public static function tearDownAfterClass(): void
	{
		// @codingStandardsIgnoreEnd
		// Reset routes
		$route_class = new ReflectionClass('Route');
		$routes_prop = $route_class->getProperty('_routes');
		$routes_prop->setAccessible(TRUE);
		$routes_prop->setValue('Route',self::$_original_routes);

		parent::tearDownAfterClass();
	}

	// @codingStandardsIgnoreStart - PHPUnit does not follow standards
	public function setUp(): void
	{
		// @codingStandardsIgnoreEnd
		parent::setUp();
		$this->_initial_request = Request::$initial;
		Request::$initial = new Request('/');
	}

	// @codingStandardsIgnoreStart - PHPUnit does not follow standards
	public function tearDown(): void
	{
		// @codingStandardsIgnoreEnd
		Request::$initial = $this->_initial_request;
		parent::tearDown();
	}

	/**
	 * Generates an internal URI to the [Controller_RequestClientDummy] shunt
	 * controller - the URI contains an encoded form of the required server
	 * response.
	 *
	 * @param string $status  HTTP response code to issue
	 * @param array $headers  HTTP headers to send with the response
	 * @param string $body    A string to send back as response body (included in the JSON response)
	 * @return string
	 */
	protected function _dummy_uri($status, $headers, $body)
	{
		$data = [
			'status' => $status,
			'header' => $headers,
			'body'   => $body
		];
		return "/requestclientdummy/fake".'/'.urlencode(http_build_query($data));
	}

	/**
	 * Shortcut method to generate a simple redirect URI - the first request will
	 * receive a redirect with the given HTTP status code and the second will
	 * receive a 200 response. The 'body' data value in the first response will
	 * be 'not-followed' and in the second response it will be 'followed'. This
	 * allows easy assertion that a redirect has taken place.
	 *
	 * @param string $status  HTTP response code to issue
	 * @return string
	 */
	protected function _dummy_redirect_uri($status)
	{
		return $this->_dummy_uri($status,
			['Location' => $this->_dummy_uri(200, NULL, 'followed')],
			'not-followed');
	}

	/**
	 * Provider for test_follows_redirects
	 * @return array
	 */
	public function provider_follows_redirects()
	{
		return [
			[TRUE, $this->_dummy_uri(200, NULL, 'not-followed'), 'not-followed'],
			[TRUE, $this->_dummy_redirect_uri(200), 'not-followed'],
			[TRUE, $this->_dummy_redirect_uri(302), 'followed'],
			[FALSE, $this->_dummy_redirect_uri(302), 'not-followed'],
		];
	}

	/**
	 * Tests that the client optionally follows properly formed redirects
	 *
	 * @dataProvider provider_follows_redirects
	 *
	 * @param  bool   $follow           Option value to set
	 * @param  string $request_url      URL to request initially (contains data to set up redirect etc)
	 * @param  string $expect_body      Body text expected in the eventual result
	 */
	public function test_follows_redirects($follow, $request_url, $expect_body)
	{
		$response = Request::factory($request_url,
			['follow' => $follow])
			->execute();

		$data = json_decode($response->body(), TRUE);
		$this->assertEquals($expect_body, $data['body']);
	}

	/**
	 * Tests that only specified headers are resent following a redirect
	 */
	public function test_follows_with_headers()
	{
		$response = Request::factory(
			$this->_dummy_redirect_uri(301),
			[
				'follow' => TRUE,
				'follow_headers' => ['Authorization', 'X-Follow-With-Value']
			])
			->headers([
				'Authorization' => 'follow',
				'X-Follow-With-Value' => 'follow',
				'X-Not-In-Follow' => 'no-follow'
			])
			->execute();

		$data = json_decode($response->body(),TRUE);
		$headers = $data['rq_headers'];

		$this->assertEquals('followed', $data['body']);
		$this->assertEquals('follow', $headers['authorization']);
		$this->assertEquals('follow', $headers['x-follow-with-value']);
		$this->assertFalse(isset($headers['x-not-in-follow']), 'X-Not-In-Follow should not be passed to next request');
	}

	/**
	 * Tests that the follow_headers are only added to a redirect request if they were present in the original
	 *
	 * @ticket 4790
	 */
	public function test_follow_does_not_add_extra_headers()
	{
		$response = Request::factory(
			            $this->_dummy_redirect_uri(301),
			            [
			                 'follow' => TRUE,
			                 'follow_headers' => ['Authorization']
			            ])
		            ->headers([])
		            ->execute();

		$data = json_decode($response->body(),TRUE);
		$headers = $data['rq_headers'];

		$this->assertArrayNotHasKey('authorization', $headers, 'Empty headers should not be added when following redirects');
	}


	/**
	 * Provider for test_follows_with_strict_method
	 *
	 * @return array
	 */
	public function provider_follows_with_strict_method()
	{
		return [
			[201, NULL, Request::POST, Request::GET],
			[301, NULL, Request::GET, Request::GET],
			[302, TRUE, Request::POST, Request::POST],
			[302, FALSE, Request::POST, Request::GET],
			[303, NULL, Request::POST, Request::GET],
			[307, NULL, Request::POST, Request::POST],
		];
	}

	/**
	 * Tests that the correct method is used (allowing for the strict_redirect setting)
	 * for follow requests.
	 *
	 * @dataProvider provider_follows_with_strict_method
	 *
	 * @param string $status_code   HTTP response code to fake
	 * @param bool   $strict_redirect Option value to set
	 * @param string $orig_method   Request method for the original request
	 * @param string $expect_method Request method expected for the follow request
	 */
	public function test_follows_with_strict_method($status_code, $strict_redirect, $orig_method, $expect_method)
	{
		$response = Request::factory($this->_dummy_redirect_uri($status_code),
			[
				'follow' => TRUE,
				'strict_redirect' => $strict_redirect
			])
			->method($orig_method)
			->execute();

		$data = json_decode($response->body(), TRUE);

		$this->assertEquals('followed', $data['body']);
		$this->assertEquals($expect_method, $data['rq_method']);
	}

	/**
	 * Provider for test_follows_with_body_if_not_get
	 *
	 * @return array
	 */
	public function provider_follows_with_body_if_not_get()
	{
		return [
			['GET','301',NULL],
			['POST','303',NULL],
			['POST','307','foo-bar']
		];
	}

	/**
	 * Tests that the original request body is sent when following a redirect
	 * (unless redirect method is GET)
	 *
	 * @dataProvider provider_follows_with_body_if_not_get
	 * @depends test_follows_with_strict_method
	 * @depends test_follows_redirects
	 *
	 * @param string $original_method  Request method to use for the original request
	 * @param string $status  Redirect status that will be issued
	 * @param string $expect_body      Expected value of body() in the second request
	 */
	public function test_follows_with_body_if_not_get($original_method, $status, $expect_body)
	{
		$response = Request::factory($this->_dummy_redirect_uri($status),
			['follow' => TRUE])
			->method($original_method)
			->body('foo-bar')
			->execute();

		$data = json_decode($response->body(), TRUE);

		$this->assertEquals('followed', $data['body']);
		$this->assertEquals($expect_body, $data['rq_body']);
	}

	/**
	 * Provider for test_triggers_header_callbacks
	 *
	 * @return array
	 */
	public function provider_triggers_header_callbacks()
	{
		return [
			// Straightforward response manipulation
			[
				['X-test-1' =>
					function($request, $response, $client)
					{
						$response->body(json_encode(['body'=>'test1-body-changed']));
						return $response;
				}],
				$this->_dummy_uri(200, ['X-test-1' => 'foo'], 'test1-body'),
				'test1-body-changed'
			],
			// Subsequent request execution
			[
				['X-test-2' =>
					function($request, $response, $client)
					{
						return Request::factory($response->headers('X-test-2'));
				}],
				$this->_dummy_uri(200,
					['X-test-2' => $this->_dummy_uri(200, NULL, 'test2-subsequent-body')],
					'test2-orig-body'),
				'test2-subsequent-body'
			],
			// No callbacks triggered
			[
				['X-test-3' =>
					function ($request, $response, $client)
					{
						throw new Exception("Unexpected execution of X-test-3 callback");
				}],
				$this->_dummy_uri(200, ['X-test-1' => 'foo'], 'test3-body'),
				'test3-body'
			],
			// Callbacks not triggered once a previous callback has created a new response
			[
				[
					'X-test-1' =>
						function($request, $response, $client)
						{
							return Request::factory($response->headers('X-test-1'));
						},
					'X-test-2' =>
						function($request, $response, $client)
						{
							return Request::factory($response->headers('X-test-2'));
						}
				],
				$this->_dummy_uri(200,
					[
						'X-test-1' => $this->_dummy_uri(200, NULL, 'test1-subsequent-body'),
						'X-test-2' => $this->_dummy_uri(200, NULL, 'test2-subsequent-body')
					],
					'test2-orig-body'),
				'test1-subsequent-body'
			],
			// Nested callbacks are supported if callback creates new request
			[
				[
					'X-test-1' =>
						function($request, $response, $client)
						{
							return Request::factory($response->headers('X-test-1'));
						},
					'X-test-2' =>
						function($request, $response, $client)
						{
							return Request::factory($response->headers('X-test-2'));
						}
				],
				$this->_dummy_uri(200,
					[
						'X-test-1' => $this->_dummy_uri(
							200,
							['X-test-2' => $this->_dummy_uri(200, NULL, 'test2-subsequent-body')],
							'test1-subsequent-body'),
					],
					'test-orig-body'),
				'test2-subsequent-body'
			],
		];
	}

	/**
	 * Tests that header callbacks are triggered in sequence when specific headers
	 * are present in the response
	 *
	 * @dataProvider provider_triggers_header_callbacks
	 *
	 * @param array $callbacks     Array of header callbacks
	 * @param array  $headers      Headers that will be received in the response
	 * @param string $expect_body  Response body content to expect
	 */
	public function test_triggers_header_callbacks($callbacks, $uri, $expect_body)
	{
		$response = Request::factory($uri,
			['header_callbacks' => $callbacks])
			->execute();

		$data = json_decode($response->body(), TRUE);

		$this->assertEquals($expect_body, $data['body']);
	}
	
	/**
	 * Tests that the Request_Client is protected from too many recursions of
	 * requests triggered by header callbacks.
	 *
	 */
	public function test_deep_recursive_callbacks_are_aborted()
	{
		$uri = $this->_dummy_uri('200', ['x-cb' => '1'], 'body');

		// Temporary property to track requests
		$this->requests_executed = 0;

		try
		{
			$response = Request::factory(
					$uri,
					[
						'header_callbacks' => [
							'x-cb' => 
								function ($request, $response, $client)
								{
									$client->callback_params('testcase')->requests_executed++;
									// Recurse into a new request
									return Request::factory($request->uri());
								}],
						'max_callback_depth' => 2,
						'callback_params' => [
							'testcase' => $this,
						]
					])
					->execute();
		}
		catch (Request_Client_Recursion_Exception $e)
		{
			// Verify that two requests were executed
			$this->assertEquals(2, $this->requests_executed);
			return;
		}

		$this->fail('Expected Request_Client_Recursion_Exception was not thrown');
	}

	/**
	 * Header callback for testing that arbitrary callback_params are available
	 * to the callback.
	 *
	 * @param Request $request
	 * @param Response $response
	 * @param Request_Client $client
	 */
	public function callback_assert_params($request, $response, $client)
	{
		$this->assertEquals('foo', $client->callback_params('constructor_param'));
		$this->assertEquals('bar', $client->callback_params('setter_param'));
		$response->body('assertions_ran');
	}

	/**
	 * Test that arbitrary callback_params can be passed to the callback through
	 * the Request_Client and are assigned to subsequent requests
	 */
	public function test_client_can_hold_params_for_callbacks()
	{
		// Test with param in constructor
		$request = Request::factory(
				$this->_dummy_uri(
						302,
						['Location' => $this->_dummy_uri('200',['X-cb'=>'1'], 'followed')],
						'not-followed'),
				[
					'follow' => TRUE,
					'header_callbacks' => [
						'x-cb' => [$this, 'callback_assert_params'],
						'location' => 'Request_Client::on_header_location',
					],
					'callback_params' => [
						'constructor_param' => 'foo'
					]
				]);

		// Test passing param to setter
		$request->client()->callback_params('setter_param', 'bar');

		// Callback will throw assertion exceptions when executed
		$response = $request->execute();
		$this->assertEquals('assertions_ran', $response->body());
	}

} // End Kohana_Request_ClientTest


/**
 * Dummy controller class that acts as a shunt - passing back request information
 * in the response to allow inspection.
 */
class Controller_RequestClientDummy extends Controller {

	/**
	 * Takes a urlencoded 'data' parameter from the route and uses it to craft a
	 * response. Redirect chains can be tested by passing another encoded uri
	 * as a location header with an appropriate status code.
	 */
	public function action_fake()
	{
		parse_str(urldecode($this->request->param('data')), $data);
		$this->response->status(Arr::get($data, 'status', 200));
		$this->response->headers(Arr::get($data, 'header', []));
		$this->response->body(json_encode([
			'body'=> Arr::get($data,'body','ok'),
			'rq_headers' => $this->request->headers(),
			'rq_body' => $this->request->body(),
			'rq_method' => $this->request->method(),
		]));
	}

} // End Controller_RequestClientDummy
