diff --git a/AGENTS.md b/AGENTS.md index 5013f8d..c17738b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ Split packages: - `stellarwp/foundation-identifier` - `stellarwp/foundation-pipeline` - `stellarwp/foundation-shutdown` +- `stellarwp/foundation-view` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` - `stellarwp/foundation-docs` @@ -49,6 +50,10 @@ Feature-local interfaces should live in a `Contracts/` folder inside the feature Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`. +Design public contracts as the smallest stable capabilities consumers need so applications can replace the supplied implementation without inheriting unrelated backend assumptions. Do not put filesystem, database, transport, framework, or other implementation-specific behavior on a general contract merely because the default concrete class supports it. Use a separate capability contract when only some implementations provide optional behavior, and bind each supported contract to the default implementation in the package provider. Follow interface segregation and dependency inversion: application code should be able to supply a substantially different implementation without implementing meaningless methods or extending Foundation internals. + +Keep convenience methods and implementation machinery on the concrete class unless they form a genuine reusable capability. Do not expose private helpers as public API speculatively. Prefer composition, and extract a focused collaborator when another real implementation needs to share the same policy or behavior. + Avoid `use ... as ...` import aliases unless they resolve a real class-name collision or ambiguity. Prefer importing the class by its actual short name. The standing exception is `use lucatume\DI52\Container as C;`, which may be used for concise container factory callbacks. Exceptions should live in an `Exceptions/` folder. Put shared package exceptions at the package root, for example `src/Database/Exceptions/DatabaseException.php`; put feature-only exceptions under that feature's `Exceptions/` folder only when they are not shared outside that feature. diff --git a/README.md b/README.md index 509ed2c..1b2451f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ See the [Foundation documentation](https://foundation.stellarwp.com/) for instal | [stellarwp/foundation-lock-redis](https://github.com/stellarwp/foundation-lock-redis) | Multiple processes or servers coordinate through dedicated Redis | Runtime | | [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) | A WordPress application needs queries, migrations, or database-backed locks | Runtime | | [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) | Services need injectable ULID generation and validation | Runtime | +| [stellarwp/foundation-view](https://github.com/stellarwp/foundation-view) | Services need scoped PHP template rendering without global state | Runtime | | [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) | A shipped WordPress plugin exposes WP-CLI commands | Runtime | | [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) | Developers need Foundation generators or monorepo maintenance commands | Development | | [stellarwp/foundation-docs](https://github.com/stellarwp/foundation-docs) | Contributors maintain or deploy the Foundation documentation site | Documentation | diff --git a/composer.json b/composer.json index 3b245f7..4cdb81d 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-shutdown": "self.version", + "stellarwp/foundation-view": "self.version", "stellarwp/foundation-wpcli": "self.version" }, "minimum-stability": "dev", @@ -61,6 +62,7 @@ "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", "StellarWP\\Foundation\\Shutdown\\": "src/Shutdown/", + "StellarWP\\Foundation\\View\\": "src/View/", "StellarWP\\Foundation\\WPCli\\": "src/WPCli/" }, "exclude-from-classmap": [ diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs index 85c9be3..2025cd0 100644 --- a/src/Docs/astro.config.mjs +++ b/src/Docs/astro.config.mjs @@ -46,6 +46,7 @@ export default defineConfig({ { slug: 'components/identifier' }, { slug: 'components/pipeline' }, { slug: 'components/shutdown' }, + { slug: 'components/view' }, { slug: 'components/wp-cli' }, ], }, diff --git a/src/Docs/src/content/docs/components/view.mdx b/src/Docs/src/content/docs/components/view.mdx new file mode 100644 index 0000000..941b66c --- /dev/null +++ b/src/Docs/src/content/docs/components/view.mdx @@ -0,0 +1,285 @@ +--- +title: View +description: Render trusted PHP templates to strings from configured or runtime-selected directories. +sidebar: + order: 9 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation View provides a small contract for rendering named views and a default `PhpView` implementation that renders trusted PHP templates from an explicit directory. It keeps markup in view files while application services remain responsible for selecting the view and preparing its data. + +`PhpView` uses ordinary PHP templates without introducing custom template syntax. It captures their output and returns it as a string instead of echoing it automatically. A configured renderer can also create an immutable renderer for another trusted directory at runtime. + +## Installation + +Install the split package: + +```shell +composer require stellarwp/foundation-view +``` + +### Prepare the application + +Foundation View uses the shared application configuration and provider architecture established in these guides: + + + + + + + +## Configuration + +### Choose the default view directory + +Create a `views/` directory at the application root. In the root `config.php`, provide its absolute path: + +```php title="config.php" + [ + 'directory' => __DIR__ . '/views', + ], +]; +``` + +`view.directory` is required and must identify an existing, readable directory. Foundation resolves it to its canonical path before rendering. + +### Register the view provider + +In `src/App.php`, add `ViewProvider` before feature providers that consume the `View` contract: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\View\ViewProvider; +use YourPlugin\Admin; + +/** @var list> */ +private const array PROVIDERS = [ + ViewProvider::class, + Admin\Provider::class, +]; +``` + +`ViewProvider` binds one shared `PhpView` instance to `StellarWP\Foundation\View\Contracts\View` and `StellarWP\Foundation\View\Contracts\DirectoryAwareView`. + +## Usage + +### Create a view before rendering it + +View names are relative to the configured directory and omit the `.php` extension. For the name `admin/product-summary`, first create `views/admin/product-summary.php`: + +```php title="product-summary.php" + +
+

+

+
+``` + +:::note[Document every view variable] +Add a typed `@var` annotation, including a short description, for every value the view expects. These annotations define the template's input contract, allow PHPStan to analyze the file without undefined-variable errors, and provide type-aware completion in supported IDEs. +::: + +:::caution[Escape output in the view] +Foundation passes data into trusted PHP files but does not escape it automatically. Escape each value for its HTML, attribute, URL, or JavaScript context when the view outputs it. Do not render user-uploaded PHP templates. +::: + +### Render the view from a service + +In `src/Admin/Product_Summary_Notice.php`, inject the `View` contract and return or echo the rendered string at the application boundary: + +```php title="Product_Summary_Notice.php" +products->count(); + + echo $this->view->render( + 'admin/product-summary', + [ + 'title' => __( 'Product catalog', 'your-plugin' ), + 'summary' => sprintf( + /* translators: %d: number of products. */ + _n( '%d product is available.', '%d products are available.', $count, 'your-plugin' ), + $count + ), + ] + ); + } +} +``` + +Register the WordPress callback from `src/Admin/Provider.php`: + +```php title="Provider.php" +container->callback( Product_Summary_Notice::class, 'display' ) + ); + } +} +``` + +### Select another directory at runtime + +Inject `DirectoryAwareView` instead of the base `View` contract when a service must select another trusted template root, such as a theme override directory: + +```php title="Receipt_Renderer.php" +view->withDirectory( $trusted_template_directory ); + + return $renderer->render( + 'email/receipt', + [ 'receipt' => $receipt ] + ); + } +} +``` + +`withDirectory()` returns a new renderer. It does not mutate the shared renderer or affect other services using the configured directory. + +:::caution[Do not accept a directory from request input] +Runtime directory selection is for trusted application paths. Never pass a URL parameter, form value, REST field, or other untrusted input to `withDirectory()`. +::: + +### Supply another renderer + +The base `View` contract requires only named rendering. A renderer that does not use PHP files or directories can implement it without supporting `withDirectory()`: + +```php title="Json_View.php" + $name, + 'data' => $data, + ], + JSON_THROW_ON_ERROR + ); + } +} +``` + +Bind the replacement from the application's feature provider instead of registering `ViewProvider`: + +```php title="Provider.php" +use StellarWP\Foundation\View\Contracts\View; + +public function register(): void { + $this->container->singleton( View::class, Json_View::class ); +} +``` + +Use a separate capability contract when a custom renderer supports optional behavior such as runtime directory selection. Application services that only call `render()` should continue depending on `View`. + +### Handle missing views + +The renderer throws `ViewNotFoundException` when a view is missing, unreadable, or resolves outside the selected directory. Empty names, absolute paths, null bytes, and parent traversal such as `../private` are rejected with `InvalidArgumentException`. + +Exceptions thrown by the view itself are propagated after Foundation removes any removable buffers opened while rendering. A view may use balanced buffers of its own, but it must not clean, flush, close, or replace Foundation's rendering buffer. Invalid buffer state is rejected instead of returning incomplete output. Let application-level error handling record or present those failures rather than returning a partial template. + +:::danger[Do not create non-removable output buffers] +A PHP template must not start a buffer without `PHP_OUTPUT_HANDLER_REMOVABLE`. PHP cannot close such a buffer before the process ends, so no in-process PHP renderer can restore the request's output-buffer stack afterward. Treat view files as trusted application code and keep any buffers they open balanced and removable. +::: + +## Testing + +Place small PHP view fixtures under the test data directory. For example, create `tests/_data/views/message.php`: + +```php title="message.php" +

+``` + +Render the fixture with the concrete class: + +```php +use StellarWP\Foundation\View\PhpView; + +$view = new PhpView( codecept_data_dir( 'views' ) ); + +$this->assertSame( + '

Hello, Foundation

', + $view->render( + 'message', + [ 'message' => 'Hello, Foundation' ] + ) +); +``` + +Test feature services through the `View` contract when the rendered markup is part of their observable behavior. Use a temporary directory under `tests/_data/temp` for path-containment or runtime-directory tests that must create files. diff --git a/src/Docs/src/content/docs/start/what-is-foundation.md b/src/Docs/src/content/docs/start/what-is-foundation.md index a93c050..3179821 100644 --- a/src/Docs/src/content/docs/start/what-is-foundation.md +++ b/src/Docs/src/content/docs/start/what-is-foundation.md @@ -5,7 +5,7 @@ sidebar: order: 1 --- -Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, shutdown, or command conventions. +Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, shutdown, view, or command conventions. Foundation is primarily developed for internal Nexcess projects. Its packages are publicly available and designed to remain reusable, but the needs of Nexcess applications will primarily drive changes, priorities, and the project roadmap. diff --git a/src/View/.gitattributes b/src/View/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/View/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/View/.github/workflows/close-pull-request.yml b/src/View/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/View/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/View/.gitignore b/src/View/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/View/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/View/Contracts/DirectoryAwareView.php b/src/View/Contracts/DirectoryAwareView.php new file mode 100644 index 0000000..cdd2ddb --- /dev/null +++ b/src/View/Contracts/DirectoryAwareView.php @@ -0,0 +1,18 @@ + $data Values made available to the renderer. + * + * @throws Throwable When rendering fails. + */ + public function render(string $name, array $data = []): string; +} diff --git a/src/View/Exceptions/ViewNotFoundException.php b/src/View/Exceptions/ViewNotFoundException.php new file mode 100644 index 0000000..2d03e86 --- /dev/null +++ b/src/View/Exceptions/ViewNotFoundException.php @@ -0,0 +1,12 @@ +directory = $resolved; + } + + /** + * {@inheritDoc} + */ + public function withDirectory(string $directory): static { + return new self($directory); + } + + /** + * {@inheritDoc} + * + * @throws InvalidArgumentException When the view name is empty, absolute, or traverses parent directories. + * @throws RuntimeException When the view leaves output buffering in an invalid state. + * @throws ViewNotFoundException When the view does not exist, is unreadable, or resolves outside the configured directory. + * @throws \Throwable When the view itself throws. + */ + public function render(string $name, array $data = []): string { + $path = $this->resolve($name); + $bufferLevel = ob_get_level(); + $renderBufferLevel = $bufferLevel + 1; + $renderBufferTouched = false; + + ob_start(static function () use (&$renderBufferTouched): string { + $renderBufferTouched = true; + + return ''; + }); + + try { + self::renderFile($path, $data); + + if ($renderBufferTouched || ob_get_level() !== $renderBufferLevel) { + throw new RuntimeException(sprintf('The view "%s" must leave output buffering unchanged.', $name)); + } + + $output = ob_get_clean(); + + if ($output === false) { + throw new RuntimeException(sprintf('The output buffer for view "%s" could not be read.', $name)); + } + + return $output; + } finally { + self::discardBuffersAbove($bufferLevel); + } + } + + /** + * Render a PHP file in an isolated static scope with the supplied view data. + * + * @param array $foundationViewData + */ + private static function renderFile(string $foundationViewPath, array $foundationViewData): void { + extract($foundationViewData, EXTR_SKIP); + + require $foundationViewPath; + } + + /** + * Remove buffers opened while rendering without closing a caller-owned buffer. + * + * PHP cannot remove a buffer created without PHP_OUTPUT_HANDLER_REMOVABLE. + */ + private static function discardBuffersAbove(int $bufferLevel): void { + while (ob_get_level() > $bufferLevel) { + $status = ob_get_status(); + + if (($status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE) === 0 || ! ob_end_clean()) { + return; + } + } + } + + /** + * Resolve a relative view name to a readable PHP file inside the configured directory. + * + * @throws InvalidArgumentException When the view name is empty, absolute, or traverses parent directories. + * @throws ViewNotFoundException When the view cannot be safely resolved and read. + */ + private function resolve(string $name): string { + $this->validateName($name); + + $relative = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $name) . '.php'; + $path = realpath($this->directory . DIRECTORY_SEPARATOR . $relative); + + if ($path === false || ! is_file($path) || ! is_readable($path) || ! $this->contains($path)) { + throw new ViewNotFoundException(sprintf('The view "%s" could not be found in "%s".', $name, $this->directory)); + } + + return $path; + } + + /** + * Reject names that could select files outside the configured directory. + * + * @throws InvalidArgumentException When the name is empty, absolute, or contains a parent-directory segment. + */ + private function validateName(string $name): void { + if ( + trim($name) === '' + || str_contains($name, "\0") + || str_starts_with($name, '/') + || str_starts_with($name, '\\') + || preg_match('/^[A-Za-z]:[\\\\\/]/', $name) === 1 + ) { + throw new InvalidArgumentException('View names must be non-empty relative paths.'); + } + + $segments = preg_split('#[\\\\/]#', $name); + + if ($segments === false || in_array('..', $segments, true)) { + throw new InvalidArgumentException('View names cannot traverse parent directories.'); + } + } + + /** + * Determine whether a canonical file path remains inside the canonical view directory. + */ + private function contains(string $path): bool { + $directory = rtrim($this->directory, '/\\') . DIRECTORY_SEPARATOR; + + if (DIRECTORY_SEPARATOR === '\\') { + return str_starts_with(strtolower($path), strtolower($directory)); + } + + return str_starts_with($path, $directory); + } +} diff --git a/src/View/README.md b/src/View/README.md new file mode 100644 index 0000000..cdf7595 --- /dev/null +++ b/src/View/README.md @@ -0,0 +1,18 @@ +# Foundation View + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Foundation View renders trusted PHP templates to strings from a configured +directory and supports immutable runtime directory selection. + +## Installation + +```shell +composer require stellarwp/foundation-view +``` + +## Documentation + +See the [Foundation View documentation](https://foundation.stellarwp.com/components/view/) +for configuration, rendering, runtime directory selection, path safety, and testing. diff --git a/src/View/ViewProvider.php b/src/View/ViewProvider.php new file mode 100644 index 0000000..1ac238a --- /dev/null +++ b/src/View/ViewProvider.php @@ -0,0 +1,41 @@ +registerView(); + } + + /** + * @throws InvalidArgumentException When view.directory is not a non-empty string. + */ + private function registerView(): void { + $directory = $this->config->get('view.directory'); + + if (! is_string($directory) || trim($directory) === '') { + throw new InvalidArgumentException('The view.directory configuration value must be a non-empty string.'); + } + + $this->container->when(PhpView::class) + ->needs('$directory') + ->give($directory); + + $this->container->singleton(PhpView::class); + $this->container->singleton(View::class, static fn (C $c): PhpView => $c->get(PhpView::class)); + $this->container->singleton(DirectoryAwareView::class, static fn (C $c): PhpView => $c->get(PhpView::class)); + } +} diff --git a/src/View/composer.json b/src/View/composer.json new file mode 100644 index 0000000..14cc05c --- /dev/null +++ b/src/View/composer.json @@ -0,0 +1,24 @@ +{ + "name": "stellarwp/foundation-view", + "type": "library", + "description": "Render scoped PHP views to strings with immutable runtime directory selection.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^2.0" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\View\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/tests/Support/Fixtures/View/DelegatingDirectoryView.php b/tests/Support/Fixtures/View/DelegatingDirectoryView.php new file mode 100644 index 0000000..dc1cbe0 --- /dev/null +++ b/tests/Support/Fixtures/View/DelegatingDirectoryView.php @@ -0,0 +1,24 @@ +view->withDirectory($directory); + } + + public function render(string $name, array $data = []): string { + return $this->view->render($name, $data); + } +} diff --git a/tests/Support/Fixtures/View/JsonView.php b/tests/Support/Fixtures/View/JsonView.php new file mode 100644 index 0000000..83edfc4 --- /dev/null +++ b/tests/Support/Fixtures/View/JsonView.php @@ -0,0 +1,22 @@ + $name, + 'data' => $data, + ], JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Unit/View/PhpViewTest.php b/tests/Unit/View/PhpViewTest.php new file mode 100644 index 0000000..df33fa1 --- /dev/null +++ b/tests/Unit/View/PhpViewTest.php @@ -0,0 +1,216 @@ +data_dir('View/default')); + + $this->assertSame( + '

Hello, Foundation

' . PHP_EOL, + $view->render('greeting', [ + 'greeting' => 'Hello', + 'name' => 'Foundation', + ]) + ); + } + + public function test_it_escapes_template_data_in_the_template(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame( + '

<strong>Hello</strong>, Foundation

' . PHP_EOL, + $view->render('greeting', [ + 'greeting' => 'Hello', + 'name' => 'Foundation', + ]) + ); + } + + public function test_it_renders_a_view_from_a_nested_directory(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame('

Product summary

' . PHP_EOL, $view->render('admin/product-summary')); + } + + public function test_it_returns_a_new_renderer_for_a_runtime_directory_without_mutating_the_original(): void { + $view = new PhpView($this->data_dir('View/default')); + $runtimeView = $view->withDirectory($this->data_dir('View/runtime')); + + $this->assertNotSame($view, $runtimeView); + $this->assertSame('

Runtime directory

' . PHP_EOL, $runtimeView->render('greeting')); + $this->assertSame( + '

Hello, Foundation

' . PHP_EOL, + $view->render('greeting', ['greeting' => 'Hello', 'name' => 'Foundation']) + ); + } + + public function test_view_data_cannot_replace_the_resolved_view_path(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame( + 'internal-variable.php', + $view->render('internal-variable', [ + 'foundationViewPath' => $this->data_dir('View/outside.php'), + ]) + ); + } + + public function test_it_restores_the_output_buffer_when_a_view_throws(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('throws'); + $this->fail('Expected the view exception to be propagated.'); + } catch (RuntimeException $exception) { + $this->assertSame('View rendering failed.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_and_cleans_up_an_unclosed_view_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('unclosed-buffer'); + $this->fail('Expected unbalanced output buffering to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "unclosed-buffer" must leave output buffering unchanged.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_allows_balanced_buffers_owned_by_the_view(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame('Balanced view output.', $view->render('balanced-buffer')); + } + + public function test_it_does_not_close_a_caller_buffer_when_the_view_closes_its_rendering_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + ob_start(); + + try { + $view->render('closes-buffer'); + $this->fail('Expected an unexpectedly closed rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "closes-buffer" must leave output buffering unchanged.', $exception->getMessage()); + $this->assertSame($bufferLevel + 1, ob_get_level()); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_a_same_depth_replacement_for_its_rendering_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('replaces-buffer'); + $this->fail('Expected a replaced rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "replaces-buffer" must leave output buffering unchanged.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_flushing_its_rendering_buffer_without_leaking_output(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + ob_start(); + + try { + $view->render('flushes-buffer'); + $this->fail('Expected a flushed rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "flushes-buffer" must leave output buffering unchanged.', $exception->getMessage()); + $this->assertSame('', ob_get_contents()); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_an_invalid_view_directory(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must exist and be readable'); + + new PhpView($this->data_dir('View/missing')); + } + + public function test_it_reports_a_missing_view(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->expectException(ViewNotFoundException::class); + $this->expectExceptionMessage('The view "missing" could not be found'); + + $view->render('missing'); + } + + /** + * @dataProvider invalid_view_names + */ + #[\PHPUnit\Framework\Attributes\DataProvider('invalid_view_names')] + public function test_it_rejects_unsafe_view_names(string $name): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->expectException(InvalidArgumentException::class); + + $view->render($name); + } + + /** + * @return array + */ + public static function invalid_view_names(): array { + return [ + 'empty' => ['name' => ''], + 'null byte' => ['name' => "greeting\0ignored"], + 'absolute Unix' => ['name' => '/tmp/view'], + 'absolute Windows' => ['name' => 'C:\\tmp\\view'], + 'parent traversal' => ['name' => '../outside'], + 'nested traversal' => ['name' => 'nested/../../outside'], + ]; + } + + public function test_it_rejects_a_symlink_that_resolves_outside_the_view_directory(): void { + $directory = $this->prepare_temp_dir('view'); + $root = $directory . '/root'; + $outside = $directory . '/outside.php'; + + mkdir($root); + file_put_contents($outside, 'Outside'); + + if (! symlink($outside, $root . '/linked.php')) { + $this->markTestSkipped('The test environment cannot create symbolic links.'); + } + + $view = new PhpView($root); + + $this->expectException(ViewNotFoundException::class); + + $view->render('linked'); + } +} diff --git a/tests/Unit/View/ViewContractTest.php b/tests/Unit/View/ViewContractTest.php new file mode 100644 index 0000000..317fd38 --- /dev/null +++ b/tests/Unit/View/ViewContractTest.php @@ -0,0 +1,30 @@ +assertSame( + '{"view":"product-summary","data":{"count":3}}', + $view->render('product-summary', ['count' => 3]) + ); + } + + public function test_a_directory_aware_adapter_may_return_another_implementation(): void { + $view = new PhpView($this->data_dir('View/default')); + $adapter = new DelegatingDirectoryView($view); + $runtimeView = $adapter->withDirectory($this->data_dir('View/runtime')); + + $this->assertInstanceOf(PhpView::class, $runtimeView); + $this->assertNotSame($view, $runtimeView); + $this->assertSame('

Runtime directory

' . PHP_EOL, $runtimeView->render('greeting')); + } +} diff --git a/tests/Unit/View/ViewProviderTest.php b/tests/Unit/View/ViewProviderTest.php new file mode 100644 index 0000000..1018927 --- /dev/null +++ b/tests/Unit/View/ViewProviderTest.php @@ -0,0 +1,37 @@ +container->get(Dot::class)->set('view.directory', $this->data_dir('View/default')); + $this->container->register(ViewProvider::class); + + $view = $this->container->get(View::class); + + $this->assertInstanceOf(PhpView::class, $view); + $this->assertSame($view, $this->container->get(View::class)); + $this->assertSame($view, $this->container->get(DirectoryAwareView::class)); + $this->assertSame($view, $this->container->get(PhpView::class)); + $this->assertSame( + '

Hello, Foundation

' . PHP_EOL, + $view->render('greeting', ['greeting' => 'Hello', 'name' => 'Foundation']) + ); + } + + public function test_it_rejects_missing_view_configuration(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('view.directory configuration value must be a non-empty string'); + + $this->container->register(ViewProvider::class); + } +} diff --git a/tests/_data/View/default/admin/product-summary.php b/tests/_data/View/default/admin/product-summary.php new file mode 100644 index 0000000..ce78839 --- /dev/null +++ b/tests/_data/View/default/admin/product-summary.php @@ -0,0 +1 @@ +

Product summary

diff --git a/tests/_data/View/default/balanced-buffer.php b/tests/_data/View/default/balanced-buffer.php new file mode 100644 index 0000000..1f2e065 --- /dev/null +++ b/tests/_data/View/default/balanced-buffer.php @@ -0,0 +1,7 @@ +

,

diff --git a/tests/_data/View/default/internal-variable.php b/tests/_data/View/default/internal-variable.php new file mode 100644 index 0000000..43e0588 --- /dev/null +++ b/tests/_data/View/default/internal-variable.php @@ -0,0 +1,3 @@ +Runtime directory