diff --git a/core/tests/Feature/InstallerRemovalFlowTest.php b/core/tests/Feature/InstallerRemovalFlowTest.php new file mode 100644 index 0000000000..242fa98089 --- /dev/null +++ b/core/tests/Feature/InstallerRemovalFlowTest.php @@ -0,0 +1,475 @@ + 'sqlite', 'database' => " . var_export($database, true) . ", 'prefix' => 'evo_'];\n"; + $environment = "DB_TYPE=sqlite\nDB_DATABASE=$database\nDB_PREFIX=evo_\n"; + foreach ([ + '.env' => $environment, + '.env.local' => $environment, + 'core/custom/.env' => $environment, + 'core/custom/define.php' => " $connection, + 'core/config/database/connections/default.php' => $connection, + 'core/config/database/connections/local.php' => $connection, + 'core/storage/cache/env.php' => " " . var_export($database, true) . "];\n", + 'manager/includes/config.inc.php' => " $content) { + is_dir(dirname("$root/$path")) || mkdir(dirname("$root/$path"), 0777, true); + file_put_contents("$root/$path", $content); + } +} + +/** Only what a process needs to run: the settings of the test run stay out of the site. */ +function installerFlowEnvironment(): array +{ + $kept = ['PATH', 'HOME', 'TMP', 'TEMP', 'TMPDIR', 'SYSTEMROOT', 'COMPOSER_HOME']; + $environment = []; + foreach (array_keys($_ENV + getenv()) as $name) { + if (!in_array(strtoupper($name), $kept, true)) { + $environment[$name] = false; + } + } + + return $environment; +} + +/** + * The site of this file: copied and served once, because a tree with its vendor takes seconds. + * + * @return array{root: string, url: string, server: Process, original: string, originalHash: string} + */ +function installerFlowSite(bool $stop = false): ?array +{ + static $site = null; + if ($stop) { + if ($site !== null) { + $site['server']->stop(1); + installerFlowDelete(dirname($site['root'])); + installerFlowDelete(dirname($site['original'])); + } + return $site = null; + } + if ($site !== null) { + return $site; + } + + $unique = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-installer-flow-' . bin2hex(random_bytes(8)); + $root = "$unique/site"; + + $original = "$unique-original/original.sqlite"; + mkdir(dirname($original), 0777, true); + (new PDO("sqlite:$original"))->exec('CREATE TABLE evo_site_content (id INTEGER PRIMARY KEY, pagetitle TEXT)'); + $local = dirname($original) . '/tree'; + installerFlowLocalConfiguration($local, $original); + + installerFlowCopy(dirname(__DIR__, 3), $root); + installerFlowCopy($local, $root); + foreach (['logs', 'cache', 'sessions', 'bootstrap', 'blade'] as $folder) { + is_dir("$root/core/storage/$folder") || mkdir("$root/core/storage/$folder", 0777, true); + } + is_dir("$root/assets/cache") || mkdir("$root/assets/cache", 0777, true); + + file_put_contents("$root/core/custom/.env", "DB_TYPE=sqlite\nDB_DATABASE=$root/core/database/flow.sqlite\nDB_PREFIX=evo_\n"); + file_put_contents("$root/flow-probe.php", <<<'PHP' +getExtension() === 'php') { + $files[] = $entry->getPathname(); + } + } + } +} +header('Content-Type: application/json'); +echo json_encode(['env' => $values, 'files' => $files]); +PHP); + + $socket = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($socket, false); + fclose($socket); + + // A conflict in the environment too: it has to go the way of the rest. + $_ENV['DB_DATABASE'] = $original; + $server = new Process( + [PHP_BINARY, '-d', 'opcache.enable=0', '-S', $address, '-t', $root], + $root, + installerFlowEnvironment(), + null, + null + ); + $server->start(); + unset($_ENV['DB_DATABASE']); + + for ($attempt = 0; $attempt < 100; $attempt++) { + $connection = @fsockopen('127.0.0.1', (int) substr(strrchr($address, ':'), 1), $code, $message, 0.1); + if ($connection !== false) { + fclose($connection); + break; + } + usleep(50000); + } + + return $site = [ + 'root' => $root, + 'url' => "http://$address", + 'server' => $server, + 'original' => $original, + 'originalHash' => hash_file('sha256', $original), + ]; +} + +/** + * Stops a test before its first write unless the site sees only the database of the fixture. + * The connection file the installer writes is the one file allowed to appear. + */ +function installerFlowAssertIsolated(array $site): void +{ + $root = $site['root']; + $seen = json_decode((string) file_get_contents($site['url'] . '/flow-probe.php'), true); + $expected = [ + 'DB_CONNECTION' => null, 'DB_TYPE' => 'sqlite', 'DB_HOST' => null, + 'DB_DATABASE' => "$root/core/database/flow.sqlite", 'DB_USERNAME' => null, 'DB_SOCKET' => null, + ]; + if (!is_array($seen) || $seen['env'] !== $expected) { + throw new RuntimeException('The site sees another connection: ' . json_encode($seen)); + } + $written = "$root/core/config/database/connections/default.php"; + $foreign = array_values(array_filter($seen['files'], static fn (string $file) => str_replace('\\', '/', $file) !== $written)); + if ($foreign !== []) { + throw new RuntimeException('Local configuration reached the site: ' . json_encode($foreign)); + } +} + +/** Back to a tree that was never installed, with the installer of the repository. */ +function installerFlowReset(): array +{ + $site = installerFlowSite(); + $root = $site['root']; + foreach ([ + 'core/.install', 'core/config/database/connections/default.php', 'core/database/flow.sqlite', + 'install.session.php', 'install', 'core/storage/bootstrap', 'core/storage/cache/env.php', + 'assets/cache/installProc.inc.php', + ] as $path) { + installerFlowDelete("$root/$path"); + } + mkdir("$root/core/storage/bootstrap", 0777, true); + installerFlowCopy(dirname(__DIR__, 3) . '/install', "$root/install", 'install'); + installerFlowAssertIsolated($site); + + return $site; +} + +function installerFlowBrowser(array $site): Client +{ + return new Client([ + 'base_uri' => $site['url'], + 'cookies' => new CookieJar(), + 'http_errors' => false, + 'timeout' => 300, + 'headers' => ['Accept-Language' => 'en', 'Referer' => $site['url'] . '/install/'], + ]); +} + +/** + * The fields form#install_form submits as it stands, and where it posts them. + * + * @return array{action: string, fields: array} + */ +function installerFlowForm(string $html): array +{ + $document = new DOMDocument(); + @$document->loadHTML($html); + $form = $document->getElementById('install_form'); + expect($form)->toBeInstanceOf(DOMElement::class, 'no installer form in: ' . substr(strip_tags($html), 0, 500)); + + $fields = []; + foreach ((new DOMXPath($document))->query('.//input|.//select|.//textarea', $form) as $field) { + $name = $field->getAttribute('name'); + $type = strtolower($field->getAttribute('type')); + if ($name === '' || $field->hasAttribute('disabled') + || (in_array($type, ['checkbox', 'radio'], true) && !$field->hasAttribute('checked'))) { + continue; + } + if ($field->nodeName === 'select') { + $value = ''; + foreach ($field->getElementsByTagName('option') as $index => $option) { + if ($index === 0 || $option->hasAttribute('selected')) { + $value = $option->getAttribute('value'); + } + } + } else { + $value = $field->nodeName === 'textarea' ? $field->textContent : $field->getAttribute('value'); + } + if (str_ends_with($name, '[]')) { + $fields[substr($name, 0, -2)][] = $value; + } else { + $fields[$name] = $value; + } + } + + return ['action' => $form->getAttribute('action'), 'fields' => $fields]; +} + +function installerFlowPost(Client $browser, string $path, array $fields): string +{ + $response = $browser->post($path, ['form_params' => $fields]); + expect($response->getStatusCode())->toBe(200, "$path answered " . $response->getStatusCode()); + + return (string) $response->getBody(); +} + +/** Submits the forms from $page on, each with $choices, up to the page of results. */ +function installerFlowFinish(Client $browser, string $page, array $choices): string +{ + for ($step = 0; $step < 8 && !str_contains($page, 'remove_installer.processor.php'); $step++) { + $form = installerFlowForm($page); + $page = installerFlowPost( + $browser, + '/install/' . $form['action'], + array_replace($form['fields'], $choices) + ); + } + expect($page)->toContain('remove_installer.processor.php')->not->toContain('class="notok"'); + + return $page; +} + +/** @return string the page of results */ +function installerFlowInstall(Client $browser): string +{ + $page = (string) $browser->get('/install/index.php')->getBody(); + $form = installerFlowForm($page); + $page = installerFlowPost($browser, '/install/' . $form['action'], ['language' => 'en'] + $form['fields']); + $form = installerFlowForm($page); + $page = installerFlowPost($browser, '/install/' . $form['action'], ['installmode' => '0'] + $form['fields']); + + $connection = [ + 'database_type' => 'sqlite', 'method' => 'sqlite', 'host' => '', 'uid' => '', 'pwd' => '', + 'database_name' => 'flow', 'tableprefix' => 'evo_', 'installMode' => '0', + ]; + $collations = installerFlowPost($browser, '/install/index.php?s=1&action=connection/collation', $connection); + preg_match('/