<?php

declare(strict_types=1);

namespace Drupal\Tests\Core\Template;

use Drupal\Core\Template\Attribute;
use Drupal\Core\Template\Attribute\TwigAllowed;
use Drupal\Core\Template\Loader\StringLoader;
use Drupal\Core\Template\TwigSandboxPolicy;
use Drupal\Tests\Core\Entity\ContentEntityBaseMockableClass;
use Drupal\Tests\UnitTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
use Twig\Sandbox\SecurityError;

/**
 * Tests the twig sandbox policy.
 */
#[CoversClass(TwigSandboxPolicy::class)]
#[Group('Template')]
class TwigSandboxTest extends UnitTestCase {

  /**
   * The Twig environment loaded with the sandbox extension.
   *
   * @var \Twig\Environment
   */
  protected $twig;

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

    $loader = new StringLoader();
    $this->twig = new Environment($loader);
    $policy = new TwigSandboxPolicy();
    $sandbox = new SandboxExtension($policy, TRUE);
    $this->twig->addExtension($sandbox);
  }

  /**
   * Tests that dangerous methods cannot be called in entity objects.
   */
  #[DataProvider('getTwigEntityDangerousMethods')]
  public function testEntityDangerousMethods($template): void {
    $entity = $this->createMock('Drupal\Core\Entity\EntityInterface');
    $this->expectException(SecurityError::class);
    $this->twig->render($template, ['entity' => $entity]);
  }

  /**
   * Data provider for ::testEntityDangerousMethods.
   *
   * @return array
   *   An array of dangerous methods.
   */
  public static function getTwigEntityDangerousMethods(): array {
    return [
      ['{{ entity.delete }}'],
      ['{{ entity.save }}'],
      ['{{ entity.create }}'],
    ];
  }

  /**
   * Tests that white listed classes can be extended.
   */
  public function testExtendedClass(): void {
    $this->assertEquals(' class=&quot;kitten&quot;', $this->twig->render('{{ attribute.addClass("kitten") }}', ['attribute' => new TestAttribute()]));
  }

  /**
   * Tests that prefixed methods can be called from within Twig templates.
   *
   * Currently "get", "has", and "is" are the only allowed prefixes.
   */
  public function testEntitySafePrefixes(): void {
    $entity = $this->createMock('Drupal\Core\Entity\EntityInterface');
    $entity->expects($this->atLeastOnce())
      ->method('hasLinkTemplate')
      ->with('test')
      ->willReturn(TRUE);
    $result = $this->twig->render('{{ entity.hasLinkTemplate("test") }}', ['entity' => $entity]);
    $this->assertTrue((bool) $result, 'Sandbox policy allows has* functions to be called.');

    $entity = $this->createMock('Drupal\Core\Entity\EntityInterface');
    $entity->expects($this->atLeastOnce())
      ->method('isNew')
      ->willReturn(TRUE);
    $result = $this->twig->render('{{ entity.isNew }}', ['entity' => $entity]);
    $this->assertTrue((bool) $result, 'Sandbox policy allows is* functions to be called.');

    $entity = $this->createMock('Drupal\Core\Entity\EntityInterface');
    $entity->expects($this->atLeastOnce())
      ->method('getEntityType')
      ->willReturn('test');
    $result = $this->twig->render('{{ entity.getEntityType }}', ['entity' => $entity]);
    $this->assertEquals('test', $result, 'Sandbox policy allows get* functions to be called.');
  }

  /**
   * Tests that valid methods can be called from within Twig templates.
   *
   * Currently the following methods are in the allowed list: id, label, bundle,
   * and get.
   */
  public function testEntitySafeMethods(): void {
    $entity = $this->getMockBuilder(ContentEntityBaseMockableClass::class)
      ->disableOriginalConstructor()
      ->getMock();
    $entity->expects($this->atLeastOnce())
      ->method('get')
      ->with('title')
      ->willReturn('test');
    $result = $this->twig->render('{{ entity.get("title") }}', ['entity' => $entity]);
    $this->assertEquals('test', $result, 'Sandbox policy allows get() to be called.');

    $entity = $this->createMock('Drupal\Core\Entity\EntityInterface');
    $entity->expects($this->atLeastOnce())
      ->method('id')
      ->willReturn('1234');
    $result = $this->twig->render('{{ entity.id }}', ['entity' => $entity]);
    $this->assertEquals('1234', $result, 'Sandbox policy allows get() to be called.');

    $entity = $this->createMock('Drupal\Core\Entity\EntityInterface');
    $entity->expects($this->atLeastOnce())
      ->method('label')
      ->willReturn('testing');
    $result = $this->twig->render('{{ entity.label }}', ['entity' => $entity]);
    $this->assertEquals('testing', $result, 'Sandbox policy allows get() to be called.');

    $entity = $this->createMock('Drupal\Core\Entity\EntityInterface');
    $entity->expects($this->atLeastOnce())
      ->method('bundle')
      ->willReturn('testing');
    $result = $this->twig->render('{{ entity.bundle }}', ['entity' => $entity]);
    $this->assertEquals('testing', $result, 'Sandbox policy allows get() to be called.');
  }

  /**
   * Tests that safe methods inside Url objects can be called.
   */
  public function testUrlSafeMethods(): void {
    $url = $this->getMockBuilder('Drupal\Core\Url')
      ->disableOriginalConstructor()
      ->getMock();
    $url->expects($this->once())
      ->method('toString')
      ->willReturn('http://kittens.cat/are/cute');
    $result = $this->twig->render('{{ url.toString }}', ['url' => $url]);
    $this->assertEquals('http://kittens.cat/are/cute', $result, 'Sandbox policy allows toString() to be called.');
  }

  /**
   * Tests that method with TwigAllowed attribute is allowed.
   */
  public function testTwigMethodAttributeAllowed(): void {
    $object = new AttributeAllowTestClass();
    $result = $this->twig->render('{{ object.allowed() }}', ['object' => $object]);
    $this->assertTrue((bool) $result, 'TwigAllowed attribute allows method to be called.');
  }

  /**
   * Tests that method without TwigAllowed attribute is not allowed.
   */
  public function testTwigMethodAttributeNotAllowed(): void {
    $object = new AttributeAllowTestClass();
    $this->expectException(SecurityError::class);
    $this->twig->render('{{ object.notAllowed() }}', ['object' => $object]);
  }

}

/**
 * Test class for HTML attributes collector, sanitizer, and renderer.
 */
class TestAttribute extends Attribute {}

/**
 * Test class for TwigAllowed attribute.
 */
class AttributeAllowTestClass {

  #[TwigAllowed]
  public function allowed(): string {
    return __METHOD__;
  }

  public function notAllowed(): string {
    return __METHOD__;
  }

}
