<?php

declare(strict_types=1);

namespace Drupal\Tests\Core\Access;

use Drupal\Core\Access\AccessCheckInterface;
use Drupal\Core\Access\AccessException;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\CheckProvider;
use Drupal\Core\Cache\Context\CacheContextsManager;
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Access\AccessManager;
use Drupal\Core\Access\DefaultAccessCheck;
use Drupal\Tests\UnitTestCase;
use Drupal\router_test\Access\DefinedTestAccessCheck;
use Drupal\Core\Routing\RouteObjectInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

/**
 * @coversDefaultClass \Drupal\Core\Access\AccessManager
 * @group Access
 */
class AccessManagerTest extends UnitTestCase {

  /**
   * The dependency injection container.
   *
   * @var \Symfony\Component\DependencyInjection\ContainerBuilder
   */
  protected $container;

  /**
   * The collection of routes, which are tested.
   *
   * @var \Symfony\Component\Routing\RouteCollection
   */
  protected $routeCollection;

  /**
   * The access manager to test.
   *
   * @var \Drupal\Core\Access\AccessManager
   */
  protected $accessManager;

  /**
   * The route provider.
   *
   * @var \PHPUnit\Framework\MockObject\MockObject
   */
  protected $routeProvider;

  /**
   * The parameter converter.
   *
   * @var \Drupal\Core\ParamConverter\ParamConverterManagerInterface|\PHPUnit\Framework\MockObject\MockObject
   */
  protected $paramConverter;

  /**
   * The mocked account.
   *
   * @var \Drupal\Core\Session\AccountInterface|\PHPUnit\Framework\MockObject\MockObject
   */
  protected $account;

  /**
   * The access arguments resolver.
   *
   * @var \Drupal\Core\Access\AccessArgumentsResolverFactoryInterface|\PHPUnit\Framework\MockObject\MockObject
   */
  protected $argumentsResolverFactory;

  /**
   * @var \Drupal\Core\Session\AccountInterface|\PHPUnit\Framework\MockObject\MockObject
   */
  protected $currentUser;

  /**
   * @var \Drupal\Core\Access\CheckProvider
   */
  protected $checkProvider;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $this->container = new ContainerBuilder();
    $cache_contexts_manager = $this->prophesize(CacheContextsManager::class)->reveal();
    $this->container->set('cache_contexts_manager', $cache_contexts_manager);
    $this->container->setParameter('dynamic_access_check_services', []);
    \Drupal::setContainer($this->container);

    $this->routeCollection = new RouteCollection();
    $this->routeCollection->add('test_route_1', new Route('/test-route-1'));
    $this->routeCollection->add('test_route_2', new Route('/test-route-2', [], ['_access' => 'TRUE']));
    $this->routeCollection->add('test_route_3', new Route('/test-route-3', [], ['_access' => 'FALSE']));
    $this->routeCollection->add('test_route_4', new Route('/test-route-4/{value}', [], ['_access' => 'TRUE']));

    $this->routeProvider = $this->createMock('Drupal\Core\Routing\RouteProviderInterface');
    $map = [];
    foreach ($this->routeCollection->all() as $name => $route) {
      $map[] = [$name, $route];
    }
    $map[] = ['test_route_4', $this->routeCollection->get('test_route_4')];
    $this->routeProvider->expects($this->any())
      ->method('getRouteByName')
      ->willReturnMap($map);

    $map = [];
    $map[] = ['test_route_1', [], '/test-route-1'];
    $map[] = ['test_route_2', [], '/test-route-2'];
    $map[] = ['test_route_3', [], '/test-route-3'];
    $map[] = ['test_route_4', ['value' => 'example'], '/test-route-4/example'];

    $this->paramConverter = $this->createMock('Drupal\Core\ParamConverter\ParamConverterManagerInterface');

    $this->account = $this->createMock('Drupal\Core\Session\AccountInterface');
    $this->currentUser = $this->createMock('Drupal\Core\Session\AccountInterface');
    $this->argumentsResolverFactory = $this->createMock('Drupal\Core\Access\AccessArgumentsResolverFactoryInterface');
    $this->checkProvider = new CheckProvider([], $this->container);

    $this->accessManager = new AccessManager($this->routeProvider, $this->paramConverter, $this->argumentsResolverFactory, $this->currentUser, $this->checkProvider);
  }

  /**
   * Tests \Drupal\Core\Access\AccessManager::setChecks().
   */
  public function testSetChecks(): void {
    // Check setChecks without any access checker defined yet.
    $this->checkProvider->setChecks($this->routeCollection);

    foreach ($this->routeCollection->all() as $route) {
      $this->assertNull($route->getOption('_access_checks'));
    }

    $this->setupAccessChecker();

    $this->checkProvider->setChecks($this->routeCollection);

    $this->assertNull($this->routeCollection->get('test_route_1')->getOption('_access_checks'));
    $this->assertEquals(['test_access_default'], $this->routeCollection->get('test_route_2')->getOption('_access_checks'));
    $this->assertEquals(['test_access_default'], $this->routeCollection->get('test_route_3')->getOption('_access_checks'));
  }

  /**
   * Tests setChecks with a dynamic access checker.
   */
  public function testSetChecksWithDynamicAccessChecker(): void {
    // Setup the dynamic access checker.
    $access_check = $this->createMock('Drupal\Tests\Core\Access\TestAccessCheckInterface');
    $this->container->set('test_access', $access_check);
    $this->checkProvider = new CheckProvider(['test_access'], $this->container);
    $this->checkProvider->addCheckService('test_access', 'access');

    // Setup the access manager.
    $this->accessManager = new AccessManager($this->routeProvider, $this->paramConverter, $this->argumentsResolverFactory, $this->currentUser, $this->checkProvider);

    $route = new Route('/test-path', [], ['_foo' => '1', '_bar' => '1']);
    $route2 = new Route('/test-path', [], ['_foo' => '1', '_bar' => '2']);
    $collection = new RouteCollection();
    $collection->add('test_route', $route);
    $collection->add('test_route2', $route2);

    $access_check->expects($this->exactly(2))
      ->method('applies')
      ->with($this->isInstanceOf('Symfony\Component\Routing\Route'))
      ->willReturnCallback(function (Route $route) {
        return $route->getRequirement('_bar') == 2;
      });

    $this->checkProvider->setChecks($collection);
    $this->assertEmpty($route->getOption('_access_checks'));
    $this->assertEquals(['test_access'], $route2->getOption('_access_checks'));
  }

  /**
   * Tests \Drupal\Core\Access\AccessManager::check().
   */
  public function testCheck(): void {
    $route_matches = [];

    // Construct route match objects.
    foreach ($this->routeCollection->all() as $route_name => $route) {
      $route_matches[$route_name] = new RouteMatch($route_name, $route, [], []);
    }

    // Check route access without any access checker defined yet.
    foreach ($route_matches as $route_match) {
      $this->assertEquals(FALSE, $this->accessManager->check($route_match, $this->account));
      $this->assertEquals(AccessResult::neutral(), $this->accessManager->check($route_match, $this->account, NULL, TRUE));
    }

    $this->setupAccessChecker();

    // An access checker got setup, but the routes haven't been setup using
    // setChecks.
    foreach ($route_matches as $route_match) {
      $this->assertEquals(FALSE, $this->accessManager->check($route_match, $this->account));
      $this->assertEquals(AccessResult::neutral(), $this->accessManager->check($route_match, $this->account, NULL, TRUE));
    }

    // Now applicable access checks have been saved on each route object.
    $this->checkProvider->setChecks($this->routeCollection);
    $this->setupAccessArgumentsResolverFactory();

    $this->assertEquals(FALSE, $this->accessManager->check($route_matches['test_route_1'], $this->account));
    $this->assertEquals(TRUE, $this->accessManager->check($route_matches['test_route_2'], $this->account));
    $this->assertEquals(FALSE, $this->accessManager->check($route_matches['test_route_3'], $this->account));
    $this->assertEquals(TRUE, $this->accessManager->check($route_matches['test_route_4'], $this->account));
    $this->assertEquals(AccessResult::neutral(), $this->accessManager->check($route_matches['test_route_1'], $this->account, NULL, TRUE));
    $this->assertEquals(AccessResult::allowed(), $this->accessManager->check($route_matches['test_route_2'], $this->account, NULL, TRUE));
    $this->assertEquals(AccessResult::forbidden(), $this->accessManager->check($route_matches['test_route_3'], $this->account, NULL, TRUE));
    $this->assertEquals(AccessResult::allowed(), $this->accessManager->check($route_matches['test_route_4'], $this->account, NULL, TRUE));
  }

  /**
   * Tests \Drupal\Core\Access\AccessManager::check() with no account specified.
   *
   * @covers ::check
   */
  public function testCheckWithNullAccount(): void {
    $this->setupAccessChecker();
    $this->checkProvider->setChecks($this->routeCollection);

    $route = $this->routeCollection->get('test_route_2');
    $route_match = new RouteMatch('test_route_2', $route, [], []);

    // Asserts that the current user is passed to the access arguments resolver
    // factory.
    $this->setupAccessArgumentsResolverFactory()
      ->with($route_match, $this->currentUser, NULL);

    $this->assertTrue($this->accessManager->check($route_match));
  }

  /**
   * Provides data for the conjunction test.
   *
   * @return array
   *   An array of data for check conjunctions.
   *
   * @see \Drupal\Tests\Core\Access\AccessManagerTest::testCheckConjunctions()
   */
  public static function providerTestCheckConjunctions() {
    $access_allow = AccessResult::allowed();
    $access_deny = AccessResult::neutral();
    $access_kill = AccessResult::forbidden();

    $access_configurations = [];
    $access_configurations[] = [
      'name' => 'test_route_4',
      'condition_one' => 'TRUE',
      'condition_two' => 'FALSE',
      'expected_access' => $access_kill,
    ];
    $access_configurations[] = [
      'name' => 'test_route_5',
      'condition_one' => 'TRUE',
      'condition_two' => 'NULL',
      'expected_access' => $access_deny,
    ];
    $access_configurations[] = [
      'name' => 'test_route_6',
      'condition_one' => 'FALSE',
      'condition_two' => 'NULL',
      'expected_access' => $access_kill,
    ];
    $access_configurations[] = [
      'name' => 'test_route_7',
      'condition_one' => 'TRUE',
      'condition_two' => 'TRUE',
      'expected_access' => $access_allow,
    ];
    $access_configurations[] = [
      'name' => 'test_route_8',
      'condition_one' => 'FALSE',
      'condition_two' => 'FALSE',
      'expected_access' => $access_kill,
    ];
    $access_configurations[] = [
      'name' => 'test_route_9',
      'condition_one' => 'NULL',
      'condition_two' => 'NULL',
      'expected_access' => $access_deny,
    ];

    return $access_configurations;
  }

  /**
   * Tests \Drupal\Core\Access\AccessManager::check() with conjunctions.
   *
   * @dataProvider providerTestCheckConjunctions
   */
  public function testCheckConjunctions($name, $condition_one, $condition_two, $expected_access): void {
    $this->setupAccessChecker();
    $this->container->register('test_access_defined', DefinedTestAccessCheck::class);
    $this->checkProvider->addCheckService('test_access_defined', 'access', ['_test_access']);

    $route_collection = new RouteCollection();
    // Setup a test route for each access configuration.
    $requirements = [
      '_access' => $condition_one,
      '_test_access' => $condition_two,
    ];
    $route = new Route($name, [], $requirements);
    $route_collection->add($name, $route);

    $this->checkProvider->setChecks($route_collection);
    $this->setupAccessArgumentsResolverFactory();

    $route_match = new RouteMatch($name, $route, [], []);
    $this->assertEquals($expected_access->isAllowed(), $this->accessManager->check($route_match, $this->account));
    $this->assertEquals($expected_access, $this->accessManager->check($route_match, $this->account, NULL, TRUE));
  }

  /**
   * Tests the checkNamedRoute method.
   *
   * @see \Drupal\Core\Access\AccessManager::checkNamedRoute()
   */
  public function testCheckNamedRoute(): void {
    $this->setupAccessChecker();
    $this->checkProvider->setChecks($this->routeCollection);
    $this->setupAccessArgumentsResolverFactory();

    $this->paramConverter->expects($this->exactly(4))
      ->method('convert')
      ->willReturnMap([
        [[RouteObjectInterface::ROUTE_NAME => 'test_route_2', RouteObjectInterface::ROUTE_OBJECT => $this->routeCollection->get('test_route_2')], []],
        [['value' => 'example', RouteObjectInterface::ROUTE_NAME => 'test_route_4', RouteObjectInterface::ROUTE_OBJECT => $this->routeCollection->get('test_route_4')], ['value' => 'example']],
      ]);

    // Tests the access with routes with parameters without given request.
    $this->assertEquals(TRUE, $this->accessManager->checkNamedRoute('test_route_2', [], $this->account));
    $this->assertEquals(AccessResult::allowed(), $this->accessManager->checkNamedRoute('test_route_2', [], $this->account, TRUE));
    $this->assertEquals(TRUE, $this->accessManager->checkNamedRoute('test_route_4', ['value' => 'example'], $this->account));
    $this->assertEquals(AccessResult::allowed(), $this->accessManager->checkNamedRoute('test_route_4', ['value' => 'example'], $this->account, TRUE));
  }

  /**
   * Tests the checkNamedRoute with upcasted values.
   *
   * @see \Drupal\Core\Access\AccessManager::checkNamedRoute()
   */
  public function testCheckNamedRouteWithUpcastedValues(): void {
    $this->routeCollection = new RouteCollection();
    $route = new Route('/test-route-1/{value}', [], ['_test_access' => 'TRUE']);
    $this->routeCollection->add('test_route_1', $route);

    $this->routeProvider = $this->createMock('Drupal\Core\Routing\RouteProviderInterface');
    $this->routeProvider->expects($this->any())
      ->method('getRouteByName')
      ->with('test_route_1')
      ->willReturn($route);

    $map[] = ['test_route_1', ['value' => 'example'], '/test-route-1/example'];

    $this->paramConverter = $this->createMock('Drupal\Core\ParamConverter\ParamConverterManagerInterface');
    $this->paramConverter->expects($this->atLeastOnce())
      ->method('convert')
      ->with(['value' => 'example', RouteObjectInterface::ROUTE_NAME => 'test_route_1', RouteObjectInterface::ROUTE_OBJECT => $route])
      ->willReturn(['value' => 'upcasted_value']);

    $this->setupAccessArgumentsResolverFactory($this->exactly(2))
      ->with($this->callback(function ($route_match) {
        return $route_match->getParameters()->get('value') == 'upcasted_value';
      }));

    $access_check = $this->createMock('Drupal\Tests\Core\Access\TestAccessCheckInterface');
    $access_check->expects($this->atLeastOnce())
      ->method('applies')
      ->willReturn(TRUE);
    $access_check->expects($this->atLeastOnce())
      ->method('access')
      ->willReturn(AccessResult::forbidden());

    $this->container->set('test_access', $access_check);
    $this->checkProvider = new CheckProvider(['test_access'], $this->container);
    $this->checkProvider->addCheckService('test_access', 'access');
    $this->checkProvider->setChecks($this->routeCollection);

    $this->accessManager = new AccessManager($this->routeProvider, $this->paramConverter, $this->argumentsResolverFactory, $this->currentUser, $this->checkProvider);

    $this->assertEquals(FALSE, $this->accessManager->checkNamedRoute('test_route_1', ['value' => 'example'], $this->account));
    $this->assertEquals(AccessResult::forbidden(), $this->accessManager->checkNamedRoute('test_route_1', ['value' => 'example'], $this->account, TRUE));
  }

  /**
   * Tests the checkNamedRoute with default values.
   *
   * @covers ::checkNamedRoute
   */
  public function testCheckNamedRouteWithDefaultValue(): void {
    $this->routeCollection = new RouteCollection();
    $route = new Route('/test-route-1/{value}', ['value' => 'example'], ['_test_access' => 'TRUE']);
    $this->routeCollection->add('test_route_1', $route);

    $this->routeProvider = $this->createMock('Drupal\Core\Routing\RouteProviderInterface');
    $this->routeProvider->expects($this->any())
      ->method('getRouteByName')
      ->with('test_route_1')
      ->willReturn($route);

    $map[] = ['test_route_1', ['value' => 'example'], '/test-route-1/example'];

    $this->paramConverter = $this->createMock('Drupal\Core\ParamConverter\ParamConverterManagerInterface');
    $this->paramConverter->expects($this->atLeastOnce())
      ->method('convert')
      ->with(['value' => 'example', RouteObjectInterface::ROUTE_NAME => 'test_route_1', RouteObjectInterface::ROUTE_OBJECT => $route])
      ->willReturn(['value' => 'upcasted_value']);

    $this->setupAccessArgumentsResolverFactory($this->exactly(2))
      ->with($this->callback(function ($route_match) {
        return $route_match->getParameters()->get('value') == 'upcasted_value';
      }));

    $access_check = $this->createMock('Drupal\Tests\Core\Access\TestAccessCheckInterface');
    $access_check->expects($this->atLeastOnce())
      ->method('applies')
      ->willReturn(TRUE);
    $access_check->expects($this->atLeastOnce())
      ->method('access')
      ->willReturn(AccessResult::forbidden());

    $this->container->set('test_access', $access_check);
    $this->checkProvider = new CheckProvider(['test_access'], $this->container);
    $this->checkProvider->addCheckService('test_access', 'access');
    $this->checkProvider->setChecks($this->routeCollection);

    $this->accessManager = new AccessManager($this->routeProvider, $this->paramConverter, $this->argumentsResolverFactory, $this->currentUser, $this->checkProvider);

    $this->assertEquals(FALSE, $this->accessManager->checkNamedRoute('test_route_1', [], $this->account));
    $this->assertEquals(AccessResult::forbidden(), $this->accessManager->checkNamedRoute('test_route_1', [], $this->account, TRUE));
  }

  /**
   * Tests checkNamedRoute given an invalid/non existing route name.
   */
  public function testCheckNamedRouteWithNonExistingRoute(): void {
    $this->routeProvider->expects($this->any())
      ->method('getRouteByName')
      ->will($this->throwException(new RouteNotFoundException()));

    $this->setupAccessChecker();

    $this->assertEquals(FALSE, $this->accessManager->checkNamedRoute('test_route_1', [], $this->account), 'A non existing route lead to access.');
    $this->assertEquals(AccessResult::forbidden()->addCacheTags(['config:core.extension']), $this->accessManager->checkNamedRoute('test_route_1', [], $this->account, TRUE), 'A non existing route lead to access.');
  }

  /**
   * Tests that an access checker throws an exception for not allowed values.
   *
   * @dataProvider providerCheckException
   */
  public function testCheckException($return_value): void {
    $route_provider = $this->createMock('Drupal\Core\Routing\RouteProviderInterface');

    // Setup a test route for each access configuration.
    $requirements = [
      '_test_incorrect_value' => 'TRUE',
    ];
    $options = [
      '_access_checks' => [
        'test_incorrect_value',
      ],
    ];
    $route = new Route('', [], $requirements, $options);

    $route_provider->expects($this->any())
      ->method('getRouteByName')
      ->willReturn($route);

    $this->paramConverter = $this->createMock('Drupal\Core\ParamConverter\ParamConverterManagerInterface');
    $this->paramConverter->expects($this->any())
      ->method('convert')
      ->willReturn([]);

    $this->setupAccessArgumentsResolverFactory();

    $container = new ContainerBuilder();

    // Register a service that will return an incorrect value.
    $access_check = $this->createMock('Drupal\Tests\Core\Access\TestAccessCheckInterface');
    $access_check->expects($this->any())
      ->method('access')
      ->willReturn($return_value);
    $container->set('test_incorrect_value', $access_check);

    $this->checkProvider = new CheckProvider([], $container);
    $this->checkProvider->addCheckService('test_incorrect_value', 'access');
    $access_manager = new AccessManager($route_provider, $this->paramConverter, $this->argumentsResolverFactory, $this->currentUser, $this->checkProvider);

    $this->expectException(AccessException::class);
    $access_manager->checkNamedRoute('test_incorrect_value', [], $this->account);
  }

  /**
   * Data provider for testCheckException.
   *
   * @return array
   */
  public static function providerCheckException() {
    return [
      [[1]],
      ['string'],
      [0],
      [1],
    ];
  }

  /**
   * Adds a default access check service to the container and the access manager.
   */
  protected function setupAccessChecker() {
    $this->container->register('test_access_default', DefaultAccessCheck::class);
    $this->checkProvider->addCheckService('test_access_default', 'access', ['_access']);
  }

  /**
   * Add default expectations to the access arguments resolver factory.
   */
  protected function setupAccessArgumentsResolverFactory($constraint = NULL) {
    if (!isset($constraint)) {
      $constraint = $this->any();
    }
    return $this->argumentsResolverFactory->expects($constraint)
      ->method('getArgumentsResolver')
      ->willReturnCallback(function ($route_match, $account) {
        $resolver = $this->createMock('Drupal\Component\Utility\ArgumentsResolverInterface');
        $resolver->expects($this->any())
          ->method('getArguments')
          ->willReturnCallback(function ($callable) use ($route_match) {
            return [$route_match->getRouteObject()];
          });

        return $resolver;
      });
  }

}

/**
 * Defines an interface with a defined access() method for mocking.
 */
interface TestAccessCheckInterface extends AccessCheckInterface {

  public function access();

}
