<?php

declare(strict_types=1);

namespace Drupal\Tests\basic_auth\Functional;

use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\basic_auth\Traits\BasicAuthTestTrait;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests for BasicAuth authentication provider.
 */
#[Group('basic_auth')]
#[RunTestsInSeparateProcesses]
class BasicAuthTest extends BrowserTestBase {

  use BasicAuthTestTrait;

  /**
   * Modules installed for all tests.
   *
   * @var array
   */
  protected static $modules = [
    'basic_auth',
    'router_test',
    'locale',
    'basic_auth_test',
  ];

  /**
   * {@inheritdoc}
   */
  protected $defaultTheme = 'stark';

  /**
   * Tests http basic authentication.
   */
  public function testBasicAuth(): void {
    // Enable page caching.
    $config = $this->config('system.performance');
    $config->set('cache.page.max_age', 300);
    $config->save();

    $account = $this->drupalCreateUser();
    $url = Url::fromRoute('router_test.11');

    // Ensure we can log in with valid authentication details.
    $this->basicAuthGet($url, $account->getAccountName(), $account->pass_raw);
    $this->assertSession()->pageTextContains($account->getAccountName());
    $this->assertSession()->statusCodeEquals(200);
    $this->mink->resetSessions();
    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
    // Check that Cache-Control is not set to public.
    $this->assertSession()->responseHeaderNotContains('Cache-Control', 'public');

    // Ensure that invalid authentication details give access denied.
    $this->basicAuthGet($url, $account->getAccountName(), $this->randomMachineName());
    $this->assertSession()->pageTextNotContains($account->getAccountName());
    $this->assertSession()->statusCodeEquals(403);
    $this->mink->resetSessions();

    // Ensure that the user is prompted to authenticate if they are not yet
    // authenticated and the route only allows basic auth.
    $this->drupalGet($url);
    $this->assertSession()->responseHeaderEquals('WWW-Authenticate', 'Basic realm="' . $this->config('system.site')->get('name') . '"');
    $this->assertSession()->statusCodeEquals(401);

    // Ensure that a route without basic auth defined doesn't prompt for auth.
    $this->drupalGet('admin');
    $this->assertSession()->statusCodeEquals(403);

    $account = $this->drupalCreateUser(['access administration pages']);

    // Ensure that a route without basic auth defined doesn't allow login.
    $this->basicAuthGet(Url::fromRoute('system.admin'), $account->getAccountName(), $account->pass_raw);
    $this->assertSession()->linkNotExists('Log out', 'User is not logged in');
    $this->assertSession()->statusCodeEquals(403);
    $this->mink->resetSessions();

    // Ensure that pages already in the page cache aren't returned from page
    // cache if basic auth credentials are provided.
    $url = Url::fromRoute('router_test.10');
    $this->drupalGet($url);
    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS');
    $this->basicAuthGet($url, $account->getAccountName(), $account->pass_raw);
    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
    // Check that Cache-Control is not set to public.
    $this->assertSession()->responseHeaderNotContains('Cache-Control', 'public');
  }

  /**
   * Tests the global login flood control.
   */
  public function testGlobalLoginFloodControl(): void {
    $this->config('user.flood')
      ->set('ip_limit', 2)
      // Set a high per-user limit out so that it is not relevant in the test.
      ->set('user_limit', 4000)
      ->save();

    $user = $this->drupalCreateUser([]);
    $incorrect_user = clone $user;
    $incorrect_user->pass_raw .= 'incorrect';
    $url = Url::fromRoute('router_test.11');

    // Try 2 failed logins.
    for ($i = 0; $i < 2; $i++) {
      $this->basicAuthGet($url, $incorrect_user->getAccountName(), $incorrect_user->pass_raw);
    }

    // IP limit has reached to its limit. Even valid user credentials will fail.
    $this->basicAuthGet($url, $user->getAccountName(), $user->pass_raw);
    $this->assertSession()->statusCodeEquals(403);
  }

  /**
   * Tests the per-user login flood control.
   */
  public function testPerUserLoginFloodControl(): void {
    $this->config('user.flood')
      // Set a high global limit out so that it is not relevant in the test.
      ->set('ip_limit', 4000)
      ->set('user_limit', 2)
      ->save();

    $user = $this->drupalCreateUser([]);
    $incorrect_user = clone $user;
    $incorrect_user->pass_raw .= 'incorrect';
    $user2 = $this->drupalCreateUser([]);
    $url = Url::fromRoute('router_test.11');

    // Try a failed login.
    $this->basicAuthGet($url, $incorrect_user->getAccountName(), $incorrect_user->pass_raw);

    // A successful login will reset the per-user flood control count.
    $this->basicAuthGet($url, $user->getAccountName(), $user->pass_raw);
    $this->assertSession()->statusCodeEquals(200);

    // Try 2 failed logins for a user. They will trigger flood control.
    for ($i = 0; $i < 2; $i++) {
      $this->basicAuthGet($url, $incorrect_user->getAccountName(), $incorrect_user->pass_raw);
    }

    // Now the user account is blocked.
    $this->basicAuthGet($url, $user->getAccountName(), $user->pass_raw);
    $this->assertSession()->statusCodeEquals(403);

    // Try one successful attempt for a different user, it should not trigger
    // any flood control.
    $this->basicAuthGet($url, $user2->getAccountName(), $user2->pass_raw);
    $this->assertSession()->statusCodeEquals(200);
  }

  /**
   * Tests compatibility with locale/UI translation.
   */
  public function testLocale(): void {
    ConfigurableLanguage::createFromLangcode('de')->save();
    $this->config('system.site')->set('default_langcode', 'de')->save();

    $account = $this->drupalCreateUser();
    $url = Url::fromRoute('router_test.11');

    $this->basicAuthGet($url, $account->getAccountName(), $account->pass_raw);
    $this->assertSession()->pageTextContains($account->getAccountName());
    $this->assertSession()->statusCodeEquals(200);
  }

  /**
   * Tests if a comprehensive message is displayed when the route is denied.
   */
  public function testUnauthorizedErrorMessage(): void {
    $account = $this->drupalCreateUser();
    $url = Url::fromRoute('router_test.11');

    // Case when no credentials are passed, a user friendly access
    // unauthorized message is displayed.
    $this->drupalGet($url);
    $this->assertSession()->statusCodeEquals(401);
    $this->assertSession()->pageTextNotContains('Exception');
    $this->assertSession()->pageTextContains('Log in to access this page.');

    // Case when empty credentials are passed, a user friendly access denied
    // message is displayed.
    $this->basicAuthGet($url, NULL, NULL);
    $this->assertSession()->statusCodeEquals(403);
    $this->assertSession()->pageTextContains('Access denied');

    // Case when wrong credentials are passed, a user friendly access denied
    // message is displayed.
    $this->basicAuthGet($url, $account->getAccountName(), $this->randomMachineName());
    $this->assertSession()->statusCodeEquals(403);
    $this->assertSession()->pageTextContains('Access denied');

    // Case when correct credentials but hasn't access to the route, an user
    // friendly access denied message is displayed.
    $url = Url::fromRoute('router_test.15');
    $this->basicAuthGet($url, $account->getAccountName(), $account->pass_raw);
    $this->assertSession()->statusCodeEquals(403);
    $this->assertSession()->pageTextContains('Access denied');
  }

  /**
   * Tests the cacheability of the Basic Auth 401 response.
   *
   * @see \Drupal\basic_auth\Authentication\Provider\BasicAuth::challengeException()
   */
  public function testCacheabilityOf401Response(): void {
    $url = Url::fromRoute('router_test.11');

    $assert_response_cacheability = function ($expected_page_cache_header_value, $expected_dynamic_page_cache_header_sub_request_value) use ($url) {
      $this->drupalGet($url);
      $this->assertSession()->statusCodeEquals(401);
      $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', $expected_page_cache_header_value);
      $this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', "UNCACHEABLE (401, sub-request: $expected_dynamic_page_cache_header_sub_request_value)");
    };

    // 1. First request: cold caches, both Page Cache and Dynamic Page Cache for
    // the 401 sub-request are now primed.
    $assert_response_cacheability('MISS', 'MISS');
    // 2. Second request: Page Cache HIT, we don't even hit Dynamic Page Cache.
    // This is going to keep happening.
    $assert_response_cacheability('HIT', 'MISS');
    // 3. Third request: after clearing Page Cache, we now see that Dynamic Page
    // Cache has a HIT for the sub-request.
    $this->container->get('cache.page')->deleteAll();
    $assert_response_cacheability('MISS', 'HIT');
    // 4. Fourth request: warm caches.
    $assert_response_cacheability('HIT', 'HIT');

    // If the permissions of the 'anonymous' role change, it may no longer be
    // necessary to be authenticated to access this route. Therefore the cached
    // 401 responses should be invalidated.
    $this->grantPermissions(Role::load(Role::ANONYMOUS_ID), ['access content']);
    $assert_response_cacheability('MISS', 'MISS');
    $assert_response_cacheability('HIT', 'MISS');
    // Idem for when the 'system.site' config changes.
    $this->config('system.site')->save();
    $assert_response_cacheability('MISS', 'MISS');
    $assert_response_cacheability('HIT', 'MISS');
  }

  /**
   * Tests if the controller is called before authentication.
   *
   * @see https://www.drupal.org/node/2817727
   */
  public function testControllerNotCalledBeforeAuth(): void {
    $this->drupalGet('/basic_auth_test/state/modify');
    $this->assertSession()->statusCodeEquals(401);
    $this->drupalGet('/basic_auth_test/state/read');
    $this->assertSession()->statusCodeEquals(200);
    $this->assertSession()->pageTextContains('nope');

    $account = $this->drupalCreateUser();
    $this->basicAuthGet('/basic_auth_test/state/modify', $account->getAccountName(), $account->pass_raw);
    $this->assertSession()->statusCodeEquals(200);
    $this->assertSession()->pageTextContains('Done');

    $this->mink->resetSessions();
    $this->drupalGet('/basic_auth_test/state/read');
    $this->assertSession()->statusCodeEquals(200);
    $this->assertSession()->pageTextContains('yep');
  }

}
