1<?php
2
3namespace Drupal\Tests\Core\Command;
4
5use Drupal\Core\Database\Driver\sqlite\Install\Tasks;
6use Drupal\Core\Test\TestDatabase;
7use Drupal\Tests\BrowserTestBase;
8use GuzzleHttp\Client;
9use GuzzleHttp\Cookie\CookieJar;
10use PHPUnit\Framework\TestCase;
11use Symfony\Component\Process\PhpExecutableFinder;
12use Symfony\Component\Process\Process;
13
14/**
15 * Tests the quick-start commands.
16 *
17 * These tests are run in a separate process because they load Drupal code via
18 * an include.
19 *
20 * @runTestsInSeparateProcesses
21 * @preserveGlobalState disabled
22 * @requires extension pdo_sqlite
23 *
24 * @group Command
25 */
26class QuickStartTest extends TestCase {
27
28  /**
29   * The PHP executable path.
30   *
31   * @var string
32   */
33  protected $php;
34
35  /**
36   * A test database object.
37   *
38   * @var \Drupal\Core\Test\TestDatabase
39   */
40  protected $testDb;
41
42  /**
43   * The Drupal root directory.
44   *
45   * @var string
46   */
47  protected $root;
48
49  /**
50   * {@inheritdoc}
51   */
52  public function setUp(): void {
53    parent::setUp();
54    $php_executable_finder = new PhpExecutableFinder();
55    $this->php = $php_executable_finder->find();
56    $this->root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2);
57    chdir($this->root);
58    if (!is_writable("{$this->root}/sites/simpletest")) {
59      $this->markTestSkipped('This test requires a writable sites/simpletest directory');
60    }
61    // Get a lock and a valid site path.
62    $this->testDb = new TestDatabase();
63  }
64
65  /**
66   * {@inheritdoc}
67   */
68  public function tearDown(): void {
69    if ($this->testDb) {
70      $test_site_directory = $this->root . DIRECTORY_SEPARATOR . $this->testDb->getTestSitePath();
71      if (file_exists($test_site_directory)) {
72        // @todo use the tear down command from
73        //   https://www.drupal.org/project/drupal/issues/2926633
74        // Delete test site directory.
75        $this->fileUnmanagedDeleteRecursive($test_site_directory, [
76          BrowserTestBase::class,
77          'filePreDeleteCallback',
78        ]);
79      }
80    }
81    parent::tearDown();
82  }
83
84  /**
85   * Tests the quick-start command.
86   */
87  public function testQuickStartCommand() {
88    if (version_compare(phpversion(), \Drupal::MINIMUM_SUPPORTED_PHP) < 0) {
89      $this->markTestSkipped();
90    }
91    if (version_compare(\SQLite3::version()['versionString'], Tasks::SQLITE_MINIMUM_VERSION) < 0) {
92      $this->markTestSkipped();
93    }
94
95    // Install a site using the standard profile to ensure the one time login
96    // link generation works.
97
98    $install_command = [
99      $this->php,
100      'core/scripts/drupal',
101      'quick-start',
102      'standard',
103      "--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
104      '--suppress-login',
105    ];
106    $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
107    $process->setTimeout(500);
108    $process->start();
109    $guzzle = new Client();
110    $port = FALSE;
111    while ($process->isRunning()) {
112      if (preg_match('/127.0.0.1:(\d+)/', $process->getOutput(), $match)) {
113        $port = $match[1];
114        break;
115      }
116      // Wait for more output.
117      sleep(1);
118    }
119    // The progress bar uses STDERR to write messages.
120    $this->assertStringContainsString('Congratulations, you installed Drupal!', $process->getErrorOutput());
121    $this->assertNotFalse($port, "Web server running on port $port");
122
123    // Give the server a couple of seconds to be ready.
124    sleep(2);
125    $this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $process->getOutput());
126
127    // Generate a cookie so we can make a request against the installed site.
128    define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
129    chmod($this->testDb->getTestSitePath(), 0755);
130    $cookieJar = CookieJar::fromArray([
131      'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
132    ], '127.0.0.1');
133
134    $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
135    $content = (string) $response->getBody();
136    $this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
137
138    // Stop the web server.
139    $process->stop();
140  }
141
142  /**
143   * Tests that the installer throws a requirement error on older PHP versions.
144   */
145  public function testPhpRequirement() {
146    if (version_compare(phpversion(), \Drupal::MINIMUM_SUPPORTED_PHP) >= 0) {
147      $this->markTestSkipped();
148    }
149
150    $install_command = [
151      $this->php,
152      'core/scripts/drupal',
153      'quick-start',
154      'standard',
155      "--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
156      '--suppress-login',
157    ];
158    $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
159    $process->setTimeout(500);
160    $process->start();
161    while ($process->isRunning()) {
162      // Wait for more output.
163      sleep(1);
164    }
165
166    $error_output = $process->getErrorOutput();
167    $this->assertStringContainsString('Your PHP installation is too old.', $error_output);
168    $this->assertStringContainsString('Drupal requires at least PHP', $error_output);
169    $this->assertStringContainsString(\Drupal::MINIMUM_SUPPORTED_PHP, $error_output);
170
171    // Stop the web server.
172    $process->stop();
173  }
174
175  /**
176   * Tests the quick-start commands.
177   */
178  public function testQuickStartInstallAndServerCommands() {
179    if (version_compare(phpversion(), \Drupal::MINIMUM_SUPPORTED_PHP) < 0) {
180      $this->markTestSkipped();
181    }
182    if (version_compare(\SQLite3::version()['versionString'], Tasks::SQLITE_MINIMUM_VERSION) < 0) {
183      $this->markTestSkipped();
184    }
185
186    // Install a site.
187    $install_command = [
188      $this->php,
189      'core/scripts/drupal',
190      'install',
191      'testing',
192      "--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
193    ];
194    $install_process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
195    $install_process->setTimeout(500);
196    $result = $install_process->run();
197    // The progress bar uses STDERR to write messages.
198    $this->assertStringContainsString('Congratulations, you installed Drupal!', $install_process->getErrorOutput());
199    $this->assertSame(0, $result);
200
201    // Run the PHP built-in webserver.
202    $server_command = [
203      $this->php,
204      'core/scripts/drupal',
205      'server',
206      '--suppress-login',
207    ];
208    $server_process = new Process($server_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
209    $server_process->start();
210    $guzzle = new Client();
211    $port = FALSE;
212    while ($server_process->isRunning()) {
213      if (preg_match('/127.0.0.1:(\d+)/', $server_process->getOutput(), $match)) {
214        $port = $match[1];
215        break;
216      }
217      // Wait for more output.
218      sleep(1);
219    }
220    $this->assertEquals('', $server_process->getErrorOutput());
221    $this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $server_process->getOutput());
222    $this->assertNotFalse($port, "Web server running on port $port");
223
224    // Give the server a couple of seconds to be ready.
225    sleep(2);
226
227    // Generate a cookie so we can make a request against the installed site.
228    define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
229    chmod($this->testDb->getTestSitePath(), 0755);
230    $cookieJar = CookieJar::fromArray([
231      'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
232    ], '127.0.0.1');
233
234    $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
235    $content = (string) $response->getBody();
236    $this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
237
238    // Try to re-install over the top of an existing site.
239    $install_command = [
240      $this->php,
241      'core/scripts/drupal',
242      'install',
243      'testing',
244      "--site-name='Test another site {$this->testDb->getDatabasePrefix()}'",
245    ];
246    $install_process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
247    $install_process->setTimeout(500);
248    $result = $install_process->run();
249    $this->assertStringContainsString('Drupal is already installed.', $install_process->getOutput());
250    $this->assertSame(0, $result);
251
252    // Ensure the site name has not changed.
253    $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
254    $content = (string) $response->getBody();
255    $this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
256
257    // Stop the web server.
258    $server_process->stop();
259  }
260
261  /**
262   * Tests the install command with an invalid profile.
263   */
264  public function testQuickStartCommandProfileValidation() {
265    // Install a site using the standard profile to ensure the one time login
266    // link generation works.
267    $install_command = [
268      $this->php,
269      'core/scripts/drupal',
270      'quick-start',
271      'umami',
272      "--site-name='Test site {$this->testDb->getDatabasePrefix()}' --suppress-login",
273    ];
274    $process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
275    $process->run();
276    $this->assertStringContainsString('\'umami\' is not a valid install profile. Did you mean \'demo_umami\'?', $process->getErrorOutput());
277  }
278
279  /**
280   * Tests the server command when there is no installation.
281   */
282  public function testServerWithNoInstall() {
283    $server_command = [
284      $this->php,
285      'core/scripts/drupal',
286      'server',
287      '--suppress-login',
288    ];
289    $server_process = new Process($server_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
290    $server_process->run();
291    $this->assertStringContainsString('No installation found. Use the \'install\' command.', $server_process->getErrorOutput());
292  }
293
294  /**
295   * Deletes all files and directories in the specified path recursively.
296   *
297   * Note this method has no dependencies on Drupal core to ensure that the
298   * test site can be torn down even if something in the test site is broken.
299   *
300   * @param string $path
301   *   A string containing either a URI or a file or directory path.
302   * @param callable $callback
303   *   (optional) Callback function to run on each file prior to deleting it and
304   *   on each directory prior to traversing it. For example, can be used to
305   *   modify permissions.
306   *
307   * @return bool
308   *   TRUE for success or if path does not exist, FALSE in the event of an
309   *   error.
310   *
311   * @see \Drupal\Core\File\FileSystemInterface::deleteRecursive()
312   */
313  protected function fileUnmanagedDeleteRecursive($path, $callback = NULL) {
314    if (isset($callback)) {
315      call_user_func($callback, $path);
316    }
317    if (is_dir($path)) {
318      $dir = dir($path);
319      while (($entry = $dir->read()) !== FALSE) {
320        if ($entry == '.' || $entry == '..') {
321          continue;
322        }
323        $entry_path = $path . '/' . $entry;
324        $this->fileUnmanagedDeleteRecursive($entry_path, $callback);
325      }
326      $dir->close();
327
328      return rmdir($path);
329    }
330    return unlink($path);
331  }
332
333}
334