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