<?php
/**
 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 * @link          http://cakephp.org CakePHP(tm) Project
 * @since         3.5.0
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
 */
namespace Cake\Test\TestCase\Http\Middleware;

use Cake\Http\Middleware\CsrfProtectionMiddleware;
use Cake\Http\Response;
use Cake\Http\ServerRequest;
use Cake\I18n\Time;
use Cake\TestSuite\TestCase;
use Laminas\Diactoros\Response as DiactorosResponse;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Test for CsrfProtection
 */
class CsrfProtectionMiddlewareTest extends TestCase
{
    /**
     * Data provider for HTTP method tests.
     *
     * HEAD and GET do not populate $_POST or request->data.
     *
     * @return array
     */
    public static function safeHttpMethodProvider()
    {
        return [
            ['GET'],
            ['HEAD'],
        ];
    }

    /**
     * Data provider for HTTP methods that can contain request bodies.
     *
     * @return array
     */
    public static function httpMethodProvider()
    {
        return [
            ['OPTIONS'], ['PATCH'], ['PUT'], ['POST'], ['DELETE'], ['PURGE'], ['INVALIDMETHOD'],
        ];
    }

    /**
     * Provides the callback for the next middleware
     *
     * @return callable
     */
    protected function _getNextClosure()
    {
        return function ($request, $response) {
            return $response;
        };
    }

    /**
     * Test setting the cookie value
     *
     * @return void
     */
    public function testSettingCookie()
    {
        $request = new ServerRequest([
            'environment' => ['REQUEST_METHOD' => 'GET'],
            'webroot' => '/dir/',
        ]);
        $response = new Response();

        $closure = function ($request, $response) {
            $cookie = $response->getCookie('csrfToken');
            $this->assertNotEmpty($cookie, 'Should set a token.');
            $this->assertRegExp('/^[a-f0-9]+$/', $cookie['value'], 'Should look like a hash.');
            $this->assertEquals(0, $cookie['expire'], 'session duration.');
            $this->assertEquals('/dir/', $cookie['path'], 'session path.');
            $this->assertEquals($cookie['value'], $request->getParam('_csrfToken'));
        };

        $middleware = new CsrfProtectionMiddleware();
        $middleware($request, $response, $closure);
    }

    /**
     * Test that the CSRF tokens are not required for idempotent operations
     *
     * @dataProvider safeHttpMethodProvider
     * @return void
     */
    public function testSafeMethodNoCsrfRequired($method)
    {
        $request = new ServerRequest([
            'environment' => [
                'REQUEST_METHOD' => $method,
                'HTTP_X_CSRF_TOKEN' => 'nope',
            ],
            'cookies' => ['csrfToken' => 'testing123'],
        ]);
        $response = new Response();

        // No exception means the test is valid
        $middleware = new CsrfProtectionMiddleware();
        $response = $middleware($request, $response, $this->_getNextClosure());
        $this->assertInstanceOf(Response::class, $response);
    }

    /**
     * Test that the X-CSRF-Token works with the various http methods.
     *
     * @dataProvider httpMethodProvider
     * @return void
     */
    public function testValidTokenInHeader($method)
    {
        $request = new ServerRequest([
            'environment' => [
                'REQUEST_METHOD' => $method,
                'HTTP_X_CSRF_TOKEN' => 'testing123',
            ],
            'post' => ['a' => 'b'],
            'cookies' => ['csrfToken' => 'testing123'],
        ]);
        $response = new Response();

        // No exception means the test is valid
        $middleware = new CsrfProtectionMiddleware();
        $response = $middleware($request, $response, $this->_getNextClosure());
        $this->assertInstanceOf(Response::class, $response);
    }

    /**
     * Test that the X-CSRF-Token works with the various http methods.
     *
     * @dataProvider httpMethodProvider
     * @return void
     */
    public function testInvalidTokenInHeader($method)
    {
        $this->expectException(\Cake\Http\Exception\InvalidCsrfTokenException::class);
        $request = new ServerRequest([
            'environment' => [
                'REQUEST_METHOD' => $method,
                'HTTP_X_CSRF_TOKEN' => 'nope',
            ],
            'post' => ['a' => 'b'],
            'cookies' => ['csrfToken' => 'testing123'],
        ]);
        $response = new Response();

        $middleware = new CsrfProtectionMiddleware();
        $middleware($request, $response, $this->_getNextClosure());
    }

    /**
     * Test that request data works with the various http methods.
     *
     * @dataProvider httpMethodProvider
     * @return void
     */
    public function testValidTokenRequestData($method)
    {
        $request = new ServerRequest([
            'environment' => [
                'REQUEST_METHOD' => $method,
            ],
            'post' => ['_csrfToken' => 'testing123'],
            'cookies' => ['csrfToken' => 'testing123'],
        ]);
        $response = new Response();

        $closure = function ($request, $response) {
            $this->assertNull($request->getData('_csrfToken'));
        };

        // No exception means everything is OK
        $middleware = new CsrfProtectionMiddleware();
        $middleware($request, $response, $closure);
    }

    /**
     * Test that request data works with the various http methods.
     *
     * @dataProvider httpMethodProvider
     * @return void
     */
    public function testInvalidTokenRequestData($method)
    {
        $this->expectException(\Cake\Http\Exception\InvalidCsrfTokenException::class);
        $request = new ServerRequest([
            'environment' => [
                'REQUEST_METHOD' => $method,
            ],
            'post' => ['_csrfToken' => 'nope'],
            'cookies' => ['csrfToken' => 'testing123'],
        ]);
        $response = new Response();

        $middleware = new CsrfProtectionMiddleware();
        $middleware($request, $response, $this->_getNextClosure());
    }

    /**
     * Test that missing post field fails
     *
     * @return void
     */
    public function testInvalidTokenRequestDataMissing()
    {
        $this->expectException(\Cake\Http\Exception\InvalidCsrfTokenException::class);
        $request = new ServerRequest([
            'environment' => [
                'REQUEST_METHOD' => 'POST',
            ],
            'post' => [],
            'cookies' => ['csrfToken' => 'testing123'],
        ]);
        $response = new Response();

        $middleware = new CsrfProtectionMiddleware();
        $middleware($request, $response, $this->_getNextClosure());
    }

    /**
     * Test that request data works with the various http methods.
     *
     * @return void
     */
    public function testInvalidTokenNonStringData()
    {
        $this->expectException(\Cake\Http\Exception\InvalidCsrfTokenException::class);
        $request = new ServerRequest([
            'environment' => [
                'REQUEST_METHOD' => 'POST',
            ],
            'post' => ['_csrfToken' => ['nope']],
            'cookies' => ['csrfToken' => ['nope']],
        ]);
        $response = new Response();

        $middleware = new CsrfProtectionMiddleware();
        $middleware($request, $response, $this->_getNextClosure());
    }

    /**
     * Test that missing header and cookie fails
     *
     * @dataProvider httpMethodProvider
     * @return void
     */
    public function testInvalidTokenMissingCookie($method)
    {
        $this->expectException(\Cake\Http\Exception\InvalidCsrfTokenException::class);
        $request = new ServerRequest([
            'environment' => [
                'REQUEST_METHOD' => $method,
            ],
            'post' => ['_csrfToken' => 'could-be-valid'],
            'cookies' => [],
        ]);
        $response = new Response();

        $middleware = new CsrfProtectionMiddleware();
        $middleware($request, $response, $this->_getNextClosure());
    }

    /**
     * Test that the configuration options work.
     *
     * @return void
     */
    public function testConfigurationCookieCreate()
    {
        $request = new ServerRequest([
            'environment' => ['REQUEST_METHOD' => 'GET'],
            'webroot' => '/dir/',
        ]);
        $response = new Response();

        $closure = function ($request, $response) {
            $this->assertEmpty($response->getCookie('csrfToken'));
            $cookie = $response->getCookie('token');
            $this->assertNotEmpty($cookie, 'Should set a token.');
            $this->assertRegExp('/^[a-f0-9]+$/', $cookie['value'], 'Should look like a hash.');
            $this->assertWithinRange((new Time('+1 hour'))->format('U'), $cookie['expire'], 1, 'session duration.');
            $this->assertEquals('/dir/', $cookie['path'], 'session path.');
            $this->assertTrue($cookie['secure'], 'cookie security flag missing');
            $this->assertTrue($cookie['httpOnly'], 'cookie httpOnly flag missing');
        };

        $middleware = new CsrfProtectionMiddleware([
            'cookieName' => 'token',
            'expiry' => '+1 hour',
            'secure' => true,
            'httpOnly' => true,
        ]);
        $middleware($request, $response, $closure);
    }

    /**
     * Test that the configuration options work.
     *
     * There should be no exception thrown.
     *
     * @return void
     */
    public function testConfigurationValidate()
    {
        $request = new ServerRequest([
            'environment' => ['REQUEST_METHOD' => 'POST'],
            'cookies' => ['csrfToken' => 'nope', 'token' => 'yes'],
            'post' => ['_csrfToken' => 'no match', 'token' => 'yes'],
        ]);
        $response = new Response();

        $middleware = new CsrfProtectionMiddleware([
            'cookieName' => 'token',
            'field' => 'token',
            'expiry' => 90,
        ]);
        $response = $middleware($request, $response, $this->_getNextClosure());
        $this->assertInstanceOf(Response::class, $response);
    }

    /**
     * @return void
     */
    public function testSkippingTokenCheckUsingWhitelistCallback()
    {
        $request = new ServerRequest([
            'environment' => [
                'REQUEST_METHOD' => 'POST',
            ],
        ]);
        $response = new Response();

        $middleware = new CsrfProtectionMiddleware();
        $middleware->whitelistCallback(function (ServerRequestInterface $request) {
            $this->assertSame('POST', $request->getServerParams()['REQUEST_METHOD']);

            return true;
        });
        $response = $middleware($request, $response, $this->_getNextClosure());
        $this->assertInstanceOf(Response::class, $response);
    }

    /**
     * Test the situation where the app is upgraded from 3.9.2 or earlier to 3.9.3 or later,
     * without deleting route cache.
     *
     * @return void
     */
    public function testMissingSamesite()
    {
        $request = new ServerRequest([
            'environment' => ['REQUEST_METHOD' => 'GET'],
            'webroot' => '/dir/',
        ]);
        $response = new Response();

        $closure = function ($request, $response) {
        };

        $middleware = new CsrfProtectionMiddleware();

        // simulate 3.9.2 or earlier by deleting "samesite" config
        $reflection = new \ReflectionClass($middleware);
        $property = $reflection->getProperty('_config');
        $property->setAccessible(true);
        $defaultConfig = $property->getValue($middleware);
        unset($defaultConfig['samesite']);
        $property->setValue($middleware, $defaultConfig);

        $middleware($request, $response, $closure);
        $this->assertTrue(true);
    }
}
