From bb1c0cd851164aec1721be5c2ffc7ba3d11a10ea Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Thu, 5 Nov 2026 20:33:26 +0800 Subject: [PATCH] feat: add an orchestration layer for testing packages --- docs/.nav.yml | 1 + docs/changelogs/v4.8.0.md | 1 + docs/index.md | 1 + docs/testing/index.md | 1 + docs/testing/packages.md | 115 ++++ .../Orchestrator/AbstractOrchestrator.php | 447 ++++++++++++++ system/Test/Orchestrator/SparkResult.php | 28 + .../Orchestrator/ExtraFixtures/extra.txt | 1 + .../Orchestrator/ExtraFixtures/override.txt | 1 + .../app/Commands/OrchestratorProbe.php | 48 ++ .../Orchestrator/Fixtures/override.txt | 1 + .../Orchestrator/OrchestratorStub.php | 60 ++ .../Orchestrator/AbstractOrchestratorTest.php | 572 ++++++++++++++++++ 13 files changed, 1277 insertions(+) create mode 100644 docs/testing/packages.md create mode 100644 system/Test/Orchestrator/AbstractOrchestrator.php create mode 100644 system/Test/Orchestrator/SparkResult.php create mode 100644 tests/_support/Orchestrator/ExtraFixtures/extra.txt create mode 100644 tests/_support/Orchestrator/ExtraFixtures/override.txt create mode 100644 tests/_support/Orchestrator/Fixtures/app/Commands/OrchestratorProbe.php create mode 100644 tests/_support/Orchestrator/Fixtures/override.txt create mode 100644 tests/_support/Orchestrator/OrchestratorStub.php create mode 100644 tests/system/Test/Orchestrator/AbstractOrchestratorTest.php diff --git a/docs/.nav.yml b/docs/.nav.yml index 2247f0d7eda3..7dcfd8cb7091 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -329,6 +329,7 @@ nav: - testing/benchmark.md - testing/debugging.md - "Session Testing": testing/session_testing.md + - testing/packages.md - "Command Line Usage": - cli/index.md - cli/cli_overview.md diff --git a/docs/changelogs/v4.8.0.md b/docs/changelogs/v4.8.0.md index 74ab79e58e5d..f3fc0c33b0aa 100644 --- a/docs/changelogs/v4.8.0.md +++ b/docs/changelogs/v4.8.0.md @@ -249,6 +249,7 @@ Release Date: Unreleased - Added `assertSameSql()` to `CIUnitTestCase` to compare generated SQL while ignoring newlines in the actual SQL. - Added `FeatureTestTrait::withFiles()` and `MockUploadedFile` for testing routes that validate and move uploaded files. +- Added `CodeIgniter\Test\Orchestrator\AbstractOrchestrator` to build a throwaway application skeleton that packages test against. See [Testing Packages](../testing/packages.md) for details. ### Database diff --git a/docs/index.md b/docs/index.md index 6e2f26bd8dec..13ab82988fbd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -171,6 +171,7 @@ - [Benchmarking](testing/benchmark.md) - [Debugging Your Application](testing/debugging.md) - [Session Testing](testing/session_testing.md) + - [Testing Packages](testing/packages.md) - [Command Line Usage](cli/index.md) - [CLI Overview](cli/cli_overview.md) diff --git a/docs/testing/index.md b/docs/testing/index.md index f2c9c34706c0..fde7e870bd69 100644 --- a/docs/testing/index.md +++ b/docs/testing/index.md @@ -13,3 +13,4 @@ CodeIgniter ships with a number of tools to help you test and debug your applica - [Benchmarking](benchmark.md) - [Debugging Your Application](debugging.md) - [Session Testing](session_testing.md) +- [Testing Packages](packages.md) diff --git a/docs/testing/packages.md b/docs/testing/packages.md new file mode 100644 index 000000000000..0ff029ff661b --- /dev/null +++ b/docs/testing/packages.md @@ -0,0 +1,115 @@ +# Testing Packages + +!!! success "Available from version 4.8.0" + +A package that supplements CodeIgniter has no `app/`, `public/`, `writable/` or `spark` of its own. **AbstractOrchestrator** builds a throwaway application skeleton from the framework that your package is installed with, so that your tests run against real paths, configuration and commands instead of stubs. + +## Defining an Orchestrator + +Extend `CodeIgniter\Test\Orchestrator\AbstractOrchestrator` and implement `getPackageRoot()`: + +```php + 'http://localhost/']; + } +} +``` + +The package root is the directory holding `vendor/autoload.php`. The skeleton shares that autoloader, so your package and its dev dependencies are available inside it. + +The following methods can be overridden: + +- `getFixtures()` returns a list of directories that are mirrored onto the root of the skeleton, in order, after the framework files. A later directory overwrites an earlier one. Use them to add controllers, routes, configuration or a `Registrar`, for example `Fixtures/app/Config/Routes.php`. +- `getEnv()` returns settings for the skeleton's `.env`. Values are quoted, so they may contain spaces, `#` and quotes, but a value with a line break is refused. Names may only contain letters, digits, `.`, `_`, `-` and `\`, which covers forms such as `app.baseURL` and `Config\App.baseURL`. `CI_ENVIRONMENT` defaults to `development`, because `spark` does not run in the `testing` environment. +- `getFrameworkPath()` returns the framework that the skeleton is copied from, which is the one that provides the orchestrator. Expose it from your subclass if your tests need the framework's location. +- `afterBuild()` runs when the skeleton is complete, for example to create a database. +- `getPath()` returns the skeleton's location, `/build/orchestrator` by default. A token is appended to it so that parallel processes do not share a skeleton. The token is read from `ORCHESTRATOR_TEST_TOKEN`, or else from `TEST_TOKEN`, which parallel runners such as ParaTest set for each process. It may only contain letters, digits, hyphens and underscores: an invalid `ORCHESTRATOR_TEST_TOKEN` throws an exception, and an invalid `TEST_TOKEN` is ignored. Set `ORCHESTRATOR_TEST_TOKEN` to an empty string to opt out of `TEST_TOKEN`. + +!!! note "Note" + The skeleton is generated, so add its location to your `.gitignore`, which is `build/` unless `getPath()` is overridden. + +## Testing in the PHPUnit Process + +Call `boot()` from your PHPUnit bootstrap file. It builds the skeleton, defines the path constants against it, and loads the framework's `system/Test/bootstrap.php`: + +```php +boot(); +``` + +Point `bootstrap` at this file in your `phpunit.dist.xml`. Since it replaces Composer's autoloader as the bootstrap, it loads `vendor/autoload.php` from the package root itself, so that your orchestrator class can be found. Your tests then extend `CIUnitTestCase` as usual. + +!!! important "Important" + The path constants and the framework's static state last for the whole process, so one process hosts one skeleton. + +## Testing Through spark + +`spark()` runs the skeleton's `spark` in a separate process and returns a `SparkResult` with the `exitCode`, `stdout`, `stderr` and `timedOut` properties: + +```php +$orchestrator = new BlogOrchestrator(); +$orchestrator->build(); + +$result = $orchestrator->spark(['blog:publish', '--dry-run'], stdin: "yes\n", environment: ['APP_DEBUG' => '1']); + +$this->assertSame(0, $result->exitCode); +$this->assertStringContainsString('Published', $result->stdout); +``` + +`stdin` is written to the process, and `environment` adds to the inherited environment. A `null` value unsets a variable. Pass `timeout` in seconds to terminate a process that runs longer, in which case `timedOut` is `true`. + +!!! warning "Warning" + A terminated process usually reports `-1` as its `exitCode`, so check `timedOut` first. Only the `spark` process is terminated, so processes that it started itself may keep running. + +`stdin` is not a terminal, which affects interactive commands: + +- Each line of `stdin` answers the next prompt asked from `execute()`, such as `ask()`. `select()` and `multiselect()` fall back to numbered choices, so pass the number instead of key presses. +- The command runs as non-interactive, so `interact()` and the prompts for missing arguments are skipped. Pass those values as arguments and options instead. +- Some prompt labels are not written to `stdout`, so assert on the command's own output instead. + +!!! warning "Warning" + Once `stdin` runs out, the remaining prompts take their defaults instead of failing, so a missing answer does not stop the command. + +## Changing the Skeleton + +`write()` creates a file in the skeleton, and `delete()` removes a file or a directory tree from it. Both take a path relative to the skeleton, and refuse a path that would leave it, whether through `..` or a symlink. `build()` removes the previous skeleton before it creates a new one, and `clean()` removes the skeleton, for example from `tearDownAfterClass()`. A skeleton booted with `boot()` is read until the process ends, so it is left in place. + +## Worker Mode + +The skeleton has no [Worker Mode](../installation/worker_mode.md) files of its own, because `worker:install` generates them. Run it from `afterBuild()` to publish `public/frankenphp-worker.php` and the `Caddyfile` from the template of the framework that the skeleton is copied from: + +```php +protected function afterBuild(): void +{ + $this->spark(['worker:install']); +} +``` + +Your tests can then inspect or change the published files with `write()` and `delete()`. + +!!! note "Note" + The orchestrator does not run FrankenPHP. `spark()` waits for its process to exit, so it cannot host a server, and `boot()` sets up the regular per-process test environment instead of the worker's request loop. To send real requests to a worker, start `frankenphp run` yourself with the skeleton as its working directory, and stop it when your tests finish. diff --git a/system/Test/Orchestrator/AbstractOrchestrator.php b/system/Test/Orchestrator/AbstractOrchestrator.php new file mode 100644 index 000000000000..b1c8df4ec04d --- /dev/null +++ b/system/Test/Orchestrator/AbstractOrchestrator.php @@ -0,0 +1,447 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Orchestrator; + +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use RuntimeException; + +/** + * Base for the orchestrator of a throwaway application skeleton that a package tests against. + */ +abstract class AbstractOrchestrator +{ + /** + * Seconds that a timed-out process gets to exit on SIGTERM before it is killed. + */ + private const KILL_GRACE_PERIOD = 2.0; + + /** + * Absolute path of the skeleton, suffixed with `ORCHESTRATOR_TEST_TOKEN`, or else a valid `TEST_TOKEN`, so parallel runs do not collide. + */ + public function getPath(): string + { + $token = getenv('ORCHESTRATOR_TEST_TOKEN'); + + if (is_string($token) && ! $this->isValidToken($token)) { + throw new RuntimeException( + 'ORCHESTRATOR_TEST_TOKEN may only contain letters, digits, hyphens and underscores.', + ); + } + + if ($token === false) { + $token = getenv('TEST_TOKEN'); + $token = is_string($token) && $this->isValidToken($token) ? $token : ''; + } + + return sprintf( + '%s/build/orchestrator%s', + rtrim($this->getPackageRoot(), '\\/'), + is_string($token) && $token !== '' ? '-' . $token : '', + ); + } + + /** + * Builds the skeleton from the framework that this class ships with, replacing any previous one. + */ + final public function build(): void + { + $framework = $this->getFrameworkPath(); + $path = $this->getPath(); + + require_once $framework . '/system/Helpers/filesystem_helper.php'; + + $this->ensureUnlinkedParent(); + $this->removePath($path); + + foreach (['app', 'public', 'writable'] as $directory) { + directory_mirror($framework . '/' . $directory, $path . '/' . $directory); + } + + $this->failUnless(copy($framework . '/spark', $path . '/spark'), 'copy spark'); + $this->pointPathsAtTheFramework(); + $this->writeAutoloader(); + $this->writeEnv(); + + foreach ($this->getFixtures() as $fixtures) { + directory_mirror($fixtures, $path); + } + + $this->afterBuild(); + } + + /** + * Builds the skeleton, then boots the framework in this process against it for `CIUnitTestCase` tests. + * + * Call it from the PHPUnit bootstrap file. The path constants are process-wide, so a process hosts one skeleton. + */ + final public function boot(): void + { + $this->build(); + + $path = realpath($this->getPath()) . DIRECTORY_SEPARATOR; + $testPath = rtrim($this->getPackageRoot(), '\\/') . '/tests' . DIRECTORY_SEPARATOR; + $constants = [ + 'HOMEPATH' => $path, + 'CONFIGPATH' => $path . 'app/Config' . DIRECTORY_SEPARATOR, + 'PUBLICPATH' => $path . 'public' . DIRECTORY_SEPARATOR, + 'TESTPATH' => $testPath, + ]; + + if (is_dir($testPath . '_support')) { + $constants['SUPPORTPATH'] = $testPath . '_support' . DIRECTORY_SEPARATOR; + } + + foreach ($constants as $name => $value) { + if (! defined($name)) { + define($name, $value); + } + } + + require $this->getFrameworkPath() . '/system/Test/bootstrap.php'; + } + + /** + * Runs spark in the skeleton and returns its outcome. + * + * @param list $arguments + * @param array $environment Variables to set, or to unset when `null` + * @param float|null $timeout Seconds after which the process is terminated, or `null` to wait indefinitely + */ + public function spark(array $arguments, string $stdin = '', array $environment = [], ?float $timeout = null): SparkResult + { + $input = tmpfile(); + $stdout = tmpfile(); + $stderr = tmpfile(); + + if ($input === false || $stdout === false || $stderr === false) { + throw new RuntimeException('Cannot create the temporary streams for spark.'); + } + + fwrite($input, $stdin); + rewind($input); + + $process = proc_open( + [PHP_BINARY, $this->getPath() . '/spark', ...$arguments], + [0 => $input, 1 => $stdout, 2 => $stderr], + $pipes, + $this->getPath(), + array_filter([...getenv(), ...$environment], is_string(...)), + ); + + if ($process === false) { + throw new RuntimeException('Cannot start spark for the orchestrated application.'); + } + + [$exitCode, $timedOut] = $this->wait($process, $timeout); + + rewind($stdout); + rewind($stderr); + + return new SparkResult( + $exitCode, + (string) stream_get_contents($stdout), + (string) stream_get_contents($stderr), + $timedOut, + ); + } + + /** + * Writes a file of the skeleton, creating its directory when missing. + */ + public function write(string $path, string $contents): void + { + $file = $this->resolve($path); + $root = $this->getRealRoot(); + + if ($root === '') { + throw new RuntimeException( + sprintf('Cannot write %s before the orchestrated application is built.', $path), + ); + } + + $this->ensureInside($file, $root, $path); + + if (! is_dir(dirname($file))) { + $this->failUnless(mkdir(dirname($file), 0755, true), sprintf('create the directory of %s', $path)); + } + + $this->failUnless(file_put_contents($file, $contents) !== false, sprintf('write %s', $path)); + } + + /** + * Deletes a file or a directory tree of the skeleton, if present. + */ + public function delete(string $path): void + { + $target = $this->resolve($path); + $root = $this->getRealRoot(); + + if ($root === '') { + return; + } + + $this->ensureInside(dirname($target), $root, $path); + $this->removePath($target); + } + + /** + * Removes the skeleton, if present. + */ + final public function clean(): void + { + $this->ensureUnlinkedParent(); + $this->removePath($this->getPath()); + } + + /** + * Absolute path of the package under test, the directory holding its `vendor/autoload.php`. + */ + abstract protected function getPackageRoot(): string; + + /** + * Absolute paths of directories mirrored onto the skeleton, in order, after the framework files. + * + * @return list + */ + protected function getFixtures(): array + { + return []; + } + + /** + * Settings written to the skeleton's `.env`, keyed by name. + * + * @return array + */ + protected function getEnv(): array + { + return []; + } + + /** + * Runs at the end of `build()`, once the skeleton is complete. + */ + protected function afterBuild(): void + { + } + + /** + * Absolute path of the framework that the skeleton is copied from. + */ + protected function getFrameworkPath(): string + { + return dirname(__DIR__, 3); + } + + private function isValidToken(string $token): bool + { + return preg_match('/\A[A-Za-z0-9_-]*\z/', $token) === 1; + } + + private function resolve(string $path): string + { + $segments = preg_split('#[\\\\/]+#', $path, -1, PREG_SPLIT_NO_EMPTY); + + if ($segments === false || $segments === [] || in_array('..', $segments, true)) { + throw new RuntimeException( + sprintf('Cannot use "%s" as a path inside the orchestrated application.', $path), + ); + } + + return $this->getPath() . '/' . implode('/', $segments); + } + + /** + * @return string The resolved skeleton root, or an empty string when the skeleton does not exist + */ + private function getRealRoot(): string + { + $root = rtrim($this->getPath(), '\\/'); + + $this->ensureUnlinkedParent(); + + if (is_link($root)) { + throw new RuntimeException( + sprintf('Cannot use the symlinked %s as the orchestrated application.', $root), + ); + } + + $real = realpath($root); + + return $real === false ? '' : $real; + } + + private function ensureUnlinkedParent(): void + { + $parent = dirname(rtrim($this->getPath(), '\\/')); + + if (is_link($parent)) { + throw new RuntimeException( + sprintf('Cannot place the orchestrated application in the symlinked %s.', $parent), + ); + } + } + + private function ensureInside(string $path, string $root, string $given): void + { + while (! file_exists($path) && ! is_link($path) && dirname($path) !== $path) { + $path = dirname($path); + } + + $real = realpath($path); + + if ($real === false || ($real !== $root && ! str_starts_with($real, $root . DIRECTORY_SEPARATOR))) { + throw new RuntimeException(sprintf('Cannot reach %s outside the orchestrated application.', $given)); + } + } + + private function removePath(string $path): void + { + $path = rtrim($path, '\\/'); + + if (is_link($path) || is_file($path)) { + $this->removeEntry($path); + } elseif (is_dir($path)) { + $entries = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($entries as $entry) { + if ($entry->isDir() && ! $entry->isLink()) { + $this->failUnless(rmdir($entry->getPathname()), sprintf('remove %s', $entry->getPathname())); + } else { + $this->removeEntry($entry->getPathname()); + } + } + + $this->failUnless(rmdir($path), sprintf('remove %s', $path)); + } + } + + private function removeEntry(string $path): void + { + $this->failUnless( + PHP_OS_FAMILY === 'Windows' && is_dir($path) ? rmdir($path) : unlink($path), + sprintf('remove %s', $path), + ); + } + + /** + * Wait for the given process to finish, with an optional timeout. + * Returns the exit code and whether the process was terminated for exceeding the timeout. + * + * @param resource $process + * + * @return array{int, bool} + */ + private function wait(mixed $process, ?float $timeout): array + { + if ($timeout === null) { + return [proc_close($process), false]; + } + + $deadline = microtime(true) + $timeout; + $exitCode = null; + $timedOut = false; + $killAt = null; + + while ($exitCode === null) { + $status = proc_get_status($process); + + if (! $status['running']) { + $exitCode = $status['exitcode']; + + continue; + } + + $now = microtime(true); + + if (! $timedOut && $now >= $deadline) { + $timedOut = true; + $killAt = $now + self::KILL_GRACE_PERIOD; + proc_terminate($process); + } elseif ($killAt !== null && $now >= $killAt) { + $killAt = null; + proc_terminate($process, 9); + } + + usleep(1000); + } + + proc_close($process); + + return [$exitCode, $timedOut]; + } + + private function pointPathsAtTheFramework(): void + { + $paths = $this->getPath() . '/app/Config/Paths.php'; + $contents = file_get_contents($paths); + + $this->failUnless($contents !== false, 'read Paths.php'); + + $rewritten = str_replace( + "__DIR__ . '/../../system'", + var_export($this->getFrameworkPath() . '/system', true), + (string) $contents, + $count, + ); + + $this->failUnless($count === 1, 'find the system directory in Paths.php'); + $this->failUnless(write_file($paths, $rewritten), 'rewrite Paths.php'); + } + + private function writeAutoloader(): void + { + $this->failUnless(mkdir($this->getPath() . '/vendor', 0755, true), 'create vendor'); + $this->failUnless(write_file($this->getPath() . '/vendor/autoload.php', sprintf( + "getPackageRoot(), '\\/') . '/vendor/autoload.php', true), + )), 'write the autoloader shim'); + } + + private function writeEnv(): void + { + $contents = ''; + + foreach (['CI_ENVIRONMENT' => 'development', ...$this->getEnv()] as $name => $value) { + if (preg_match('/\A[A-Za-z0-9_.\\\\-]+\z/', $name) !== 1) { + throw new RuntimeException(sprintf( + 'Cannot write the .env setting "%s", whose name may only contain letters, digits, ".", "_", "-" and "\\".', + $name, + )); + } + + if (strpbrk($value, "\r\n") !== false) { + throw new RuntimeException( + sprintf('Cannot write the .env setting %s, whose value contains a line break.', $name), + ); + } + + $contents .= sprintf("%s = \"%s\"\n", $name, str_replace(['\\', '"'], ['\\\\', '\\"'], $value)); + } + + $this->failUnless(write_file($this->getPath() . '/.env', $contents), 'write .env'); + } + + private function failUnless(bool $succeeded, string $step): void + { + if (! $succeeded) { + throw new RuntimeException(sprintf('Cannot %s for the orchestrated application.', $step)); + } + } +} diff --git a/system/Test/Orchestrator/SparkResult.php b/system/Test/Orchestrator/SparkResult.php new file mode 100644 index 000000000000..67d72f2644a2 --- /dev/null +++ b/system/Test/Orchestrator/SparkResult.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Orchestrator; + +/** + * Outcome of a spark process run by an orchestrator. + */ +final readonly class SparkResult +{ + public function __construct( + public int $exitCode, + public string $stdout, + public string $stderr, + public bool $timedOut = false, + ) { + } +} diff --git a/tests/_support/Orchestrator/ExtraFixtures/extra.txt b/tests/_support/Orchestrator/ExtraFixtures/extra.txt new file mode 100644 index 000000000000..0f2287157f7c --- /dev/null +++ b/tests/_support/Orchestrator/ExtraFixtures/extra.txt @@ -0,0 +1 @@ +extra diff --git a/tests/_support/Orchestrator/ExtraFixtures/override.txt b/tests/_support/Orchestrator/ExtraFixtures/override.txt new file mode 100644 index 000000000000..e019be006cf3 --- /dev/null +++ b/tests/_support/Orchestrator/ExtraFixtures/override.txt @@ -0,0 +1 @@ +second diff --git a/tests/_support/Orchestrator/Fixtures/app/Commands/OrchestratorProbe.php b/tests/_support/Orchestrator/Fixtures/app/Commands/OrchestratorProbe.php new file mode 100644 index 000000000000..80c38a258ef1 --- /dev/null +++ b/tests/_support/Orchestrator/Fixtures/app/Commands/OrchestratorProbe.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\Commands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command( + name: 'orchestrator:probe', + description: 'Reports its stdin and environment, then fails.', + group: 'Tests', +)] +final class OrchestratorProbe extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + $stdin = stream_get_contents(STDIN); + + fwrite(STDOUT, sprintf('stdin=%s env=%s', $stdin, (string) getenv('ORCHESTRATOR_PROBE'))); + fwrite(STDERR, 'probe-error'); + + if (getenv('ORCHESTRATOR_STUBBORN') === '1') { + pcntl_signal(SIGTERM, SIG_IGN); + } + + if (getenv('ORCHESTRATOR_SLEEP') === '1' || getenv('ORCHESTRATOR_STUBBORN') === '1') { + sleep(30); + } + + if (getenv('ORCHESTRATOR_FLOOD') === '1') { + fwrite(STDOUT, str_repeat('o', 300_000)); + fwrite(STDERR, str_repeat('e', 300_000)); + } + + return 3; + } +} diff --git a/tests/_support/Orchestrator/Fixtures/override.txt b/tests/_support/Orchestrator/Fixtures/override.txt new file mode 100644 index 000000000000..9c59e24b8393 --- /dev/null +++ b/tests/_support/Orchestrator/Fixtures/override.txt @@ -0,0 +1 @@ +first diff --git a/tests/_support/Orchestrator/OrchestratorStub.php b/tests/_support/Orchestrator/OrchestratorStub.php new file mode 100644 index 000000000000..24c5a3618d13 --- /dev/null +++ b/tests/_support/Orchestrator/OrchestratorStub.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Orchestrator; + +use CodeIgniter\Test\Orchestrator\AbstractOrchestrator; + +final class OrchestratorStub extends AbstractOrchestrator +{ + public int $afterBuildCalls = 0; + + /** + * @param array $extraEnv + */ + public function __construct( + private readonly string $root, + private readonly string $skeleton = '', + private readonly array $extraEnv = [], + ) { + } + + public function getPath(): string + { + return $this->skeleton !== '' ? $this->skeleton : parent::getPath(); + } + + protected function getPackageRoot(): string + { + return $this->root; + } + + protected function getFixtures(): array + { + return [__DIR__ . '/Fixtures', __DIR__ . '/ExtraFixtures']; + } + + protected function getEnv(): array + { + return [ + 'logger.threshold' => '0', + 'orchestrator.quoted' => 'a b #c "d" \\e', + ...$this->extraEnv, + ]; + } + + protected function afterBuild(): void + { + $this->afterBuildCalls++; + } +} diff --git a/tests/system/Test/Orchestrator/AbstractOrchestratorTest.php b/tests/system/Test/Orchestrator/AbstractOrchestratorTest.php new file mode 100644 index 000000000000..d2e503aef491 --- /dev/null +++ b/tests/system/Test/Orchestrator/AbstractOrchestratorTest.php @@ -0,0 +1,572 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Orchestrator; + +use Closure; +use CodeIgniter\Config\DotEnv; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use RuntimeException; +use Tests\Support\Orchestrator\OrchestratorStub; + +/** + * @internal + */ +#[CoversClass(AbstractOrchestrator::class)] +#[CoversClass(SparkResult::class)] +#[Group('Others')] +final class AbstractOrchestratorTest extends CIUnitTestCase +{ + private static string $buildPath; + private static OrchestratorStub $orchestrator; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + self::$buildPath = sys_get_temp_dir() . '/ci4-orchestrator-' . bin2hex(random_bytes(4)); + mkdir(self::$buildPath . '/vendor', 0755, true); + file_put_contents( + self::$buildPath . '/vendor/autoload.php', + sprintf("build(); + } + + public static function tearDownAfterClass(): void + { + self::$orchestrator->clean(); + + helper('filesystem'); + delete_files(self::$buildPath, true, false, true); + rmdir(self::$buildPath); + + parent::tearDownAfterClass(); + } + + public function testBuildCreatesTheSkeleton(): void + { + foreach ([ + 'app/Config/Paths.php', + 'public/index.php', + 'spark', + '.env', + 'vendor/autoload.php', + 'writable', + ] as $entry) { + $this->assertFileExists(self::$orchestrator->getPath() . '/' . $entry); + } + } + + public function testBuildPointsPathsAtTheFramework(): void + { + $contents = (string) file_get_contents(self::$orchestrator->getPath() . '/app/Config/Paths.php'); + + $this->assertStringContainsString(var_export(rtrim(SYSTEMPATH, '\\/'), true), $contents); + $this->assertStringNotContainsString("__DIR__ . '/../../system'", $contents); + } + + public function testBuildWritesTheEnvFile(): void + { + $this->assertSame( + <<<'EOT' + CI_ENVIRONMENT = "development" + logger.threshold = "0" + orchestrator.quoted = "a b #c \"d\" \\e" + + EOT, + file_get_contents(self::$orchestrator->getPath() . '/.env'), + ); + } + + public function testEnvValuesRoundTripThroughDotEnv(): void + { + $dotEnv = new DotEnv(self::$orchestrator->getPath()); + $lines = file(self::$orchestrator->getPath() . '/.env', FILE_IGNORE_NEW_LINES); + $values = []; + + foreach ($lines as $line) { + [$name, $value] = $dotEnv->normaliseVariable($line); + $values[$name] = $value; + } + + $this->assertSame([ + 'CI_ENVIRONMENT' => 'development', + 'logger.threshold' => '0', + 'orchestrator.quoted' => 'a b #c "d" \\e', + ], $values); + } + + public function testBuildAcceptsConfigStyleEnvNames(): void + { + $skeleton = self::$buildPath . '/config-names'; + $env = ['Config\App.baseURL' => 'http://localhost/', 'database.tests-x.DB_Driver' => 'SQLite3']; + + (new OrchestratorStub(self::$buildPath, $skeleton, $env))->build(); + + $this->assertStringContainsString( + "Config\\App.baseURL = \"http://localhost/\"\n", + (string) file_get_contents($skeleton . '/.env'), + ); + + (new OrchestratorStub(self::$buildPath, $skeleton))->clean(); + } + + /** + * @param array $env + */ + #[DataProvider('provideBuildRefusesUnsafeEnvSettings')] + public function testBuildRefusesUnsafeEnvSettings(array $env): void + { + $skeleton = self::$buildPath . '/unsafe-env'; + $orchestrator = new OrchestratorStub(self::$buildPath, $skeleton, $env); + + try { + $orchestrator->build(); + $this->fail('The unsafe .env setting should have been refused.'); + } catch (RuntimeException) { + $this->assertFileDoesNotExist($skeleton . '/.env'); + } finally { + $orchestrator->clean(); + } + } + + /** + * @return iterable}> + */ + public static function provideBuildRefusesUnsafeEnvSettings(): iterable + { + yield 'line feed in value' => [['LABEL' => "x\nORCHESTRATOR_INJECTED=proof"]]; + + yield 'carriage return in value' => [['LABEL' => "x\rY"]]; + + yield 'line feed in name' => [["LABEL\nINJECTED" => 'x']]; + + yield 'assignment in name' => [['ORCHESTRATOR_INJECTED=proof #' => 'x']]; + + yield 'export prefix in name' => [['export LABEL' => 'x']]; + + yield 'quotes in name' => [['"LABEL"' => 'x']]; + } + + public function testBuildMirrorsFixturesInOrderAndRunsTheHook(): void + { + $this->assertFileExists(self::$orchestrator->getPath() . '/app/Commands/OrchestratorProbe.php'); + $this->assertFileExists(self::$orchestrator->getPath() . '/extra.txt'); + $this->assertSame("second\n", file_get_contents(self::$orchestrator->getPath() . '/override.txt')); + $this->assertGreaterThan(0, self::$orchestrator->afterBuildCalls); + } + + public function testBuildReplacesThePreviousSkeleton(): void + { + self::$orchestrator->write('stale.txt', 'old'); + self::$orchestrator->build(); + + $this->assertFileDoesNotExist(self::$orchestrator->getPath() . '/stale.txt'); + } + + public function testBuildAndCleanRefuseASymlinkedBuildDirectory(): void + { + $root = self::$buildPath . '/linked-build-root'; + $outside = self::$buildPath . '/outside-build-dir'; + + mkdir($outside . '/orchestrator', 0755, true); + file_put_contents($outside . '/orchestrator/keep', 'keep'); + mkdir($root); + + if (! @symlink($outside, $root . '/build')) { + $this->markTestSkipped('Symlinks are unavailable.'); + } + + $orchestrator = new OrchestratorStub($root); + + try { + $operations = [ + 'build' => $orchestrator->build(...), + 'clean' => $orchestrator->clean(...), + 'write' => static fn () => $orchestrator->write('keep', 'proof'), + 'delete' => static fn () => $orchestrator->delete('keep'), + ]; + + foreach ($operations as $method => $operation) { + try { + $operation(); + $this->fail(sprintf('%s() through a symlinked build directory should have been refused.', $method)); + } catch (RuntimeException) { + $this->assertSame('keep', file_get_contents($outside . '/orchestrator/keep')); + } + } + } finally { + unlink($root . '/build'); + } + } + + public function testDeleteAndBuildUnlinkNestedSymlinksWithoutFollowingThem(): void + { + $outside = self::$buildPath . '/outside-nested'; + mkdir($outside); + file_put_contents($outside . '/keep.txt', 'keep'); + + self::$orchestrator->write('tree/file.txt', 'x'); + + if (! @symlink($outside, self::$orchestrator->getPath() . '/tree/link')) { + $this->markTestSkipped('Symlinks are unavailable.'); + } + + self::$orchestrator->delete('tree'); + + $this->assertDirectoryDoesNotExist(self::$orchestrator->getPath() . '/tree'); + $this->assertFileExists($outside . '/keep.txt'); + + self::$orchestrator->write('tree/file.txt', 'x'); + symlink($outside, self::$orchestrator->getPath() . '/tree/link'); + + self::$orchestrator->build(); + + $this->assertDirectoryDoesNotExist(self::$orchestrator->getPath() . '/tree'); + $this->assertFileExists($outside . '/keep.txt'); + } + + public function testBuildReplacesASymlinkedSkeletonWithoutFollowingIt(): void + { + $root = self::$buildPath . '/linked'; + $outside = self::$buildPath . '/outside-build'; + $orchestrator = new OrchestratorStub($root); + + mkdir($outside); + file_put_contents($outside . '/keep.txt', 'keep'); + mkdir($root . '/build', 0755, true); + + if (! @symlink($outside, $orchestrator->getPath())) { + $this->markTestSkipped('Symlinks are unavailable.'); + } + + mkdir($root . '/vendor'); + file_put_contents( + $root . '/vendor/autoload.php', + sprintf("build(); + + $this->assertFileExists($outside . '/keep.txt'); + $this->assertFalse(is_link($orchestrator->getPath())); + $this->assertFileExists($orchestrator->getPath() . '/spark'); + } + + public function testBuildReplacesASymlinkedSkeletonGivenWithATrailingSlash(): void + { + $root = self::$buildPath . '/slashed-root'; + $outside = self::$buildPath . '/outside-slashed-build'; + + mkdir($outside); + file_put_contents($outside . '/keep.txt', 'keep'); + mkdir($root . '/vendor', 0755, true); + file_put_contents( + $root . '/vendor/autoload.php', + sprintf("markTestSkipped('Symlinks are unavailable.'); + } + + $orchestrator = new OrchestratorStub($root, $root . '/skeleton/'); + + $orchestrator->build(); + + $this->assertFileExists($outside . '/keep.txt'); + $this->assertFalse(is_link($root . '/skeleton')); + $this->assertFileExists($root . '/skeleton/spark'); + } + + /** + * @param array $tokens + */ + #[DataProvider('providePathIsSuffixedWithTheTestToken')] + public function testPathIsSuffixedWithTheTestToken(array $tokens, string $suffix): void + { + $this->withTokens($tokens, function () use ($suffix): void { + $this->assertSame(self::$buildPath . '/build/orchestrator' . $suffix, self::$orchestrator->getPath()); + }); + } + + /** + * @return iterable, string}> + */ + public static function providePathIsSuffixedWithTheTestToken(): iterable + { + yield 'no token' => [['ORCHESTRATOR_TEST_TOKEN' => null, 'TEST_TOKEN' => null], '']; + + yield 'own token' => [['ORCHESTRATOR_TEST_TOKEN' => '7', 'TEST_TOKEN' => null], '-7']; + + yield 'runner token' => [['ORCHESTRATOR_TEST_TOKEN' => null, 'TEST_TOKEN' => '4'], '-4']; + + yield 'own token wins' => [['ORCHESTRATOR_TEST_TOKEN' => '7', 'TEST_TOKEN' => '4'], '-7']; + + yield 'invalid runner token ignored' => [['ORCHESTRATOR_TEST_TOKEN' => null, 'TEST_TOKEN' => '../x'], '']; + } + + public function testPathRejectsATokenWithPathComponents(): void + { + $this->expectException(RuntimeException::class); + + $this->withTokens(['ORCHESTRATOR_TEST_TOKEN' => '../../elsewhere'], static function (): void { + self::$orchestrator->getPath(); + }); + } + + public function testWriteCreatesMissingDirectoriesAndDeleteRemovesThem(): void + { + self::$orchestrator->write('nested/dir/file.txt', 'hello'); + $this->assertSame('hello', file_get_contents(self::$orchestrator->getPath() . '/nested/dir/file.txt')); + + self::$orchestrator->delete('nested'); + $this->assertDirectoryDoesNotExist(self::$orchestrator->getPath() . '/nested'); + + self::$orchestrator->delete('nested'); + } + + public function testDeleteRemovesASymlinkWithoutFollowingIt(): void + { + $outside = self::$buildPath . '/outside'; + mkdir($outside); + file_put_contents($outside . '/keep.txt', 'keep'); + + if (! @symlink($outside, self::$orchestrator->getPath() . '/link')) { + $this->markTestSkipped('Symlinks are unavailable.'); + } + + self::$orchestrator->delete('link'); + + $this->assertFalse(is_link(self::$orchestrator->getPath() . '/link')); + $this->assertFileExists($outside . '/keep.txt'); + } + + public function testDeleteWithATrailingSlashRemovesTheSymlinkOnly(): void + { + $outside = self::$buildPath . '/outside-slash'; + mkdir($outside); + file_put_contents($outside . '/keep.txt', 'keep'); + + if (! @symlink($outside, self::$orchestrator->getPath() . '/slashed')) { + $this->markTestSkipped('Symlinks are unavailable.'); + } + + self::$orchestrator->delete('slashed/'); + + $this->assertFalse(is_link(self::$orchestrator->getPath() . '/slashed')); + $this->assertFileExists($outside . '/keep.txt'); + } + + public function testDeleteRefusesPathsThatLeaveTheSkeleton(): void + { + $outside = self::$buildPath . '/outside-escape'; + mkdir($outside); + file_put_contents($outside . '/keep.txt', 'keep'); + + if (! @symlink($outside, self::$orchestrator->getPath() . '/escape')) { + $this->markTestSkipped('Symlinks are unavailable.'); + } + + try { + foreach (['escape/keep.txt', '../outside-escape/keep.txt'] as $path) { + try { + self::$orchestrator->delete($path); + $this->fail(sprintf('Deleting %s should have been refused.', $path)); + } catch (RuntimeException) { + $this->assertFileExists($outside . '/keep.txt'); + } + } + } finally { + unlink(self::$orchestrator->getPath() . '/escape'); + } + } + + public function testWriteRefusesTraversal(): void + { + try { + self::$orchestrator->write('../../outside.txt', 'proof'); + $this->fail('Writing outside the skeleton should have been refused.'); + } catch (RuntimeException) { + $this->assertFileDoesNotExist(self::$buildPath . '/outside.txt'); + } + } + + public function testWriteRefusesSymlinksLeavingTheSkeleton(): void + { + $outside = self::$buildPath . '/outside-write'; + mkdir($outside); + + if (! @symlink($outside, self::$orchestrator->getPath() . '/wlink')) { + $this->markTestSkipped('Symlinks are unavailable.'); + } + + symlink($outside . '/missing.txt', self::$orchestrator->getPath() . '/dangling'); + + try { + foreach (['wlink/redirected.txt', 'dangling'] as $path) { + try { + self::$orchestrator->write($path, 'proof'); + $this->fail(sprintf('Writing %s should have been refused.', $path)); + } catch (RuntimeException) { + $this->assertSame([], array_values(array_diff((array) scandir($outside), ['.', '..']))); + } + } + } finally { + unlink(self::$orchestrator->getPath() . '/wlink'); + unlink(self::$orchestrator->getPath() . '/dangling'); + } + } + + public function testWriteAndDeleteRefuseASymlinkedSkeleton(): void + { + $outside = self::$buildPath . '/outside-root'; + mkdir($outside); + file_put_contents($outside . '/file.txt', 'keep'); + + if (! @symlink($outside, self::$buildPath . '/linked-skeleton')) { + $this->markTestSkipped('Symlinks are unavailable.'); + } + + $orchestrator = new OrchestratorStub(self::$buildPath, self::$buildPath . '/linked-skeleton'); + + try { + $orchestrator->delete('file.txt'); + $this->fail('Deleting through a symlinked skeleton should have been refused.'); + } catch (RuntimeException) { + $this->assertFileExists($outside . '/file.txt'); + } + + try { + $orchestrator->write('new.txt', 'proof'); + $this->fail('Writing through a symlinked skeleton should have been refused.'); + } catch (RuntimeException) { + $this->assertFileDoesNotExist($outside . '/new.txt'); + } finally { + unlink(self::$buildPath . '/linked-skeleton'); + } + } + + public function testCleanRemovesTheSkeletonAndToleratesItsAbsence(): void + { + $orchestrator = new OrchestratorStub(self::$buildPath, self::$buildPath . '/cleaned'); + $orchestrator->build(); + + $orchestrator->clean(); + $this->assertDirectoryDoesNotExist(self::$buildPath . '/cleaned'); + + $orchestrator->clean(); + $this->assertDirectoryDoesNotExist(self::$buildPath . '/cleaned'); + } + + public function testSparkRunsACommand(): void + { + $result = self::$orchestrator->spark(['list']); + + $this->assertSame(0, $result->exitCode); + $this->assertStringContainsString('orchestrator:probe', $result->stdout); + } + + public function testSparkForwardsStdinEnvironmentAndReportsFailure(): void + { + $result = self::$orchestrator->spark(['orchestrator:probe'], 'input', ['ORCHESTRATOR_PROBE' => 'set']); + + $this->assertSame(3, $result->exitCode); + $this->assertStringEndsWith('stdin=input env=set', $result->stdout); + $this->assertSame('probe-error', $result->stderr); + } + + public function testSparkUnsetsVariablesGivenAsNull(): void + { + putenv('ORCHESTRATOR_PROBE=inherited'); + + try { + $result = self::$orchestrator->spark(['orchestrator:probe'], '', ['ORCHESTRATOR_PROBE' => null]); + } finally { + putenv('ORCHESTRATOR_PROBE'); + } + + $this->assertStringEndsWith('stdin= env=', $result->stdout); + } + + public function testSparkTerminatesAProcessThatExceedsTheTimeout(): void + { + $started = microtime(true); + $result = self::$orchestrator->spark(['orchestrator:probe'], '', ['ORCHESTRATOR_SLEEP' => '1'], 1.0); + + $this->assertTrue($result->timedOut); + $this->assertLessThan(20, microtime(true) - $started); + } + + public function testSparkKillsAProcessThatIgnoresTermination(): void + { + if (! extension_loaded('pcntl')) { + $this->markTestSkipped('The pcntl extension is required to ignore SIGTERM.'); + } + + $started = microtime(true); + $result = self::$orchestrator->spark(['orchestrator:probe'], '', ['ORCHESTRATOR_STUBBORN' => '1'], 1.0); + + $this->assertTrue($result->timedOut); + $this->assertLessThan(20, microtime(true) - $started); + } + + public function testSparkDoesNotFlagAProcessThatFinishesWithinTheTimeout(): void + { + $result = self::$orchestrator->spark(['orchestrator:probe'], '', [], 60.0); + + $this->assertFalse($result->timedOut); + $this->assertSame(3, $result->exitCode); + } + + public function testSparkDrainsLargeOutputOfBothStreams(): void + { + $result = self::$orchestrator->spark( + ['orchestrator:probe'], + str_repeat('i', 200_000), + ['ORCHESTRATOR_FLOOD' => '1'], + ); + + $this->assertSame(3, $result->exitCode); + $this->assertStringEndsWith(str_repeat('o', 300_000), $result->stdout); + $this->assertStringEndsWith(str_repeat('e', 300_000), $result->stderr); + } + + /** + * @param array $tokens + * @param Closure(): void $test + */ + private function withTokens(array $tokens, Closure $test): void + { + $originals = []; + + foreach ($tokens as $name => $value) { + $originals[$name] = getenv($name); + putenv($value === null ? $name : $name . '=' . $value); + } + + try { + $test(); + } finally { + foreach ($originals as $name => $value) { + putenv($value === false ? $name : $name . '=' . $value); + } + } + } +}