From aaa7b5bc655c37887aef3528fcbf4663bc1ac939 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Thu, 11 Jun 2026 00:03:51 -0400 Subject: [PATCH] Modernize library for 4.x: security fixes, strict types, tooling updates Bug and security fixes (each with a regression test): - Cap placeholder expansion at 25 passes / 1 MiB to prevent memory exhaustion from circular references with surrounding text - Ignore HTTP_* keys in $_SERVER for ${env.*} expansion, since those originate from client-supplied request headers in a web context - Expand falsy environment variables (e.g. VAR=0) correctly by checking getenv() against false instead of truthiness - Preserve non-string types (bool, int, float) when expanding via reference data; expandPropertyWithReferenceData() now returns mixed - Expand single-placeholder strings once instead of twice, eliminating duplicate logger/stringifier side effects - Restore the original value if preg_replace_callback() fails Modernization (BC breaks documented in RELEASE.md): - Add declare(strict_types=1) and full type declarations everywhere - Make StringifierInterface::stringifyArray() an instance method - Require array type for expandArrayProperties() $reference_array Tooling and CI: - Update PHPUnit ^9 to ^10.5 || ^11 || ^12 || ^13; migrate config and convert tests to attributes with static data providers - Add phpstan (level 5) to require-dev and the composer test script - Drop abandoned greg-1-anderson/composer-test-scenarios - Run CI on main and *.x branches; add PHP 8.5 to the matrix, a composer audit step, checkout@v6, and Dependabot config - Reach 100% line/method/class coverage; clean up env-var fixtures between tests - Fix README example syntax error, .gitignore malformed line, stale RELEASE.md script references; add .editorconfig and composer metadata Co-Authored-By: Claude Fable 5 --- .editorconfig | 15 +++ .github/dependabot.yml | 10 ++ .github/workflows/php.yml | 12 ++- .gitignore | 7 +- README.md | 6 +- RELEASE.md | 25 ++++- composer.json | 34 +++--- phpstan.neon.dist | 5 + phpunit.xml.dist | 18 ++-- src/Expander.php | 142 +++++++++++++------------ src/Stringifier.php | 9 +- src/StringifierInterface.php | 6 +- tests/src/ExpanderTest.php | 194 ++++++++++++++++++++++++++++++---- tests/src/StringifierTest.php | 38 +++++++ 14 files changed, 396 insertions(+), 125 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/dependabot.yml create mode 100644 phpstan.neon.dist create mode 100644 tests/src/StringifierTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4179788 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{yml,yaml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2eff6b9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 0438f06..90964a0 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, "*.x" ] pull_request: - branches: [ main ] + branches: [ main, "*.x" ] permissions: contents: read @@ -25,9 +25,12 @@ jobs: - os: "ubuntu-latest" php: "8.4" coverage: "pcov" + - os: "ubuntu-latest" + php: "8.5" + coverage: "none" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: @@ -41,6 +44,9 @@ jobs: - name: Install dependencies run: composer -n update --prefer-dist -o + - name: Security audit + run: composer audit + - name: Run test suite if: matrix.coverage == 'none' run: composer run-script test diff --git a/.gitignore b/.gitignore index b335bd2..0e3df6a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,9 @@ */Entity/*~ .idea -.phpunit.result.cachecomposer.lock +.phpunit.result.cache +.phpunit.cache/ +composer.lock +.DS_Store + +build/.phpcs-cache diff --git a/README.md b/README.md index f5fb399..76e7a71 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ $array = [ $expander = new Expander(); // Optionally set a logger. $expander->setLogger(new Psr\Log\NullLogger()); -// Optionally set a Stringfier, used to convert array placeholders into strings. Defaults to using implode() with `,` delimeter. +// Optionally set a Stringifier, used to convert array placeholders into strings. Defaults to using implode() with `,` delimiter. // @see StringifierInterface. $expander->setStringifier(new Grasmash\Expander\Stringifier()); @@ -74,13 +74,13 @@ $expander->setStringifier(new Grasmash\Expander\Stringifier()); $expanded = $expander->expandArrayProperties($array); // Parse an array, expanding references using both internal and supplementary values. -$reference_properties = 'book' => ['sequel' => 'Dune Messiah']; +$reference_properties = ['book' => ['sequel' => 'Dune Messiah']]; // Set an environmental variable. putenv("test=gomjabbar"); $expanded = $expander->expandArrayProperties($array, $reference_properties); print_r($expanded); -```` +``` Resultant array: diff --git a/RELEASE.md b/RELEASE.md index 2549d0c..e6fef1b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,10 +2,29 @@ ### Execute tests - ./scripts/run-tests.sh + composer test + +This runs linting (`composer lint`), unit tests (`composer unit`), coding +standards checks (`composer cs`), and static analysis (`composer stan`). To quickly fix PHPCS issues: - ./scripts/clean-code.sh - + composer cbf + +## 4.0 upgrade notes +- PHP 8.2+ is required. +- All source files now declare `strict_types=1`. +- `StringifierInterface::stringifyArray()` is now an instance method rather + than a static method. Custom `StringifierInterface` implementations and any + callers of `Stringifier::stringifyArray()` as a static method must update. +- `Expander::expandArrayProperties()` now requires `$reference_array` to be an + array. +- `Expander::expandPropertyWithReferenceData()` returns `mixed` instead of + `?string`, so non-string values (booleans, integers, floats) retain their + types when expanded via reference data. +- `${env.*}` placeholders no longer read `HTTP_*` keys from `$_SERVER`, since + those originate from client-supplied request headers in a web context. +- Environment variables with falsy values (e.g. `0`) now expand correctly. +- Expansion of a single string is capped at 25 passes and 1 MiB to prevent + runaway growth from circular references with surrounding text. diff --git a/composer.json b/composer.json index 5f8a862..a5149b9 100644 --- a/composer.json +++ b/composer.json @@ -1,11 +1,24 @@ { "name": "grasmash/expander", - "description": "Expands internal property references in PHP arrays file.", + "description": "Expands internal property references in PHP arrays.", "type": "library", + "keywords": [ + "array", + "configuration", + "dot-notation", + "expansion", + "placeholder", + "yaml" + ], + "homepage": "https://github.com/grasmash/expander", + "support": { + "issues": "https://github.com/grasmash/expander/issues", + "source": "https://github.com/grasmash/expander" + }, "require": { "php": ">=8.2", "dflydev/dot-access-data": "^3.0.0", - "psr/log": "^2 | ^3" + "psr/log": "^2 || ^3" }, "license": "MIT", "authors": [ @@ -25,14 +38,15 @@ } }, "require-dev": { - "greg-1-anderson/composer-test-scenarios": "^1", - "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.3" + "php-coveralls/php-coveralls": "^2.8", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5 || ^11 || ^12 || ^13", + "squizlabs/php_codesniffer": "^3.13" }, "scripts": { "cs": "phpcs", "cbf": "phpcbf", + "stan": "phpstan", "unit": "phpunit", "lint": [ "find src -name '*.php' -print0 | xargs -0 -n1 php -l", @@ -41,7 +55,8 @@ "test": [ "@lint", "@unit", - "@cs" + "@cs", + "@stan" ], "coverage": "php -d pcov.enabled=1 vendor/bin/phpunit tests/src --coverage-clover build/logs/clover.xml", "coveralls": [ @@ -51,10 +66,5 @@ "config": { "optimize-autoloader": true, "sort-packages": true - }, - "extra": { - "branch-alias": { - "dev-master": "4.x-dev" - } } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..376faab --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,5 @@ +parameters: + level: 5 + paths: + - src + - tests/src diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 207baef..e57951e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,18 +1,14 @@ - - - - src - - - - - + - + tests/src - + + + src + + diff --git a/src/Expander.php b/src/Expander.php index 0d984ee..40f8cef 100644 --- a/src/Expander.php +++ b/src/Expander.php @@ -1,5 +1,7 @@ 'x${b}', 'b' => 'y${a}']. */ - protected StringifierInterface $stringifier; + protected const MAX_ITERATIONS = 25; + /** - * @var \Psr\Log\LoggerInterface + * Maximum length of an expanded string value. Circular references with + * surrounding text double the value length on every pass; this caps that + * growth before it exhausts memory. */ + protected const MAX_LENGTH = 1048576; + + protected StringifierInterface $stringifier; protected LoggerInterface $logger; public function __construct() @@ -28,33 +37,21 @@ public function __construct() $this->setStringifier(new Stringifier()); } - /** - * @return \Grasmash\Expander\StringifierInterface - */ public function getStringifier(): StringifierInterface { return $this->stringifier; } - /** - * @param \Grasmash\Expander\StringifierInterface $stringifier - */ - public function setStringifier(StringifierInterface $stringifier) + public function setStringifier(StringifierInterface $stringifier): void { $this->stringifier = $stringifier; } - /** - * @return \Psr\Log\LoggerInterface - */ public function getLogger(): LoggerInterface { return $this->logger; } - /** - * @param \Psr\Log\LoggerInterface $logger - */ public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; @@ -67,12 +64,14 @@ public function setLogger(LoggerInterface $logger): void * * @param array $array * An array containing properties to expand. + * @param array $reference_array + * An optional array of supplemental values used for property expansion. * * @return array * The modified array in which placeholders have been replaced with * values. */ - public function expandArrayProperties(array $array, $reference_array = []): array + public function expandArrayProperties(array $array, array $reference_array = []): array { $data = new Data($array); if ($reference_array) { @@ -104,7 +103,7 @@ protected function doExpandArrayProperties( array $array, string $parent_keys = '', ?Data $reference_data = null - ) { + ): void { foreach ($array as $key => $value) { // Boundary condition(s). if ($value === null || is_bool($value)) { @@ -115,7 +114,7 @@ protected function doExpandArrayProperties( $this->doExpandArrayProperties($data, $value, $parent_keys . "$key.", $reference_data); } else { // Base case. - $this->expandStringProperties($data, $parent_keys, $reference_data, $value, $key); + $this->expandStringProperties($data, $parent_keys, $reference_data, (string) $value, (string) $key); } } } @@ -135,8 +134,6 @@ protected function doExpandArrayProperties( * The unexpanded property value. * @param string $key * The immediate key of the property. - * - * @return mixed */ protected function expandStringProperties( Data $data, @@ -146,25 +143,31 @@ protected function expandStringProperties( string $key ): mixed { $pattern = '/\$\{([^\$}]+)\}/'; + $iterations = 0; // We loop through all placeholders in a given string. // E.g., '${placeholder1} ${placeholder2}' requires two replacements. - while (str_contains((string) $value, '${')) { + while (is_string($value) && str_contains($value, '${')) { + if (++$iterations > static::MAX_ITERATIONS || strlen($value) > static::MAX_LENGTH) { + $this->log("Aborting expansion of $key. Value may contain circular references."); + break; + } $original_value = $value; - $value = preg_replace_callback( - $pattern, - function ($matches) use ($data, $reference_data) { - return $this->expandStringPropertiesCallback($matches, $data, $reference_data); - }, - $value, - -1, - $count - ); - - // If the value was just a _single_ property reference, we have the opportunity to preserve the data type. - if ($count === 1) { - preg_match($pattern, $original_value, $matches); - if ($matches[0] === $original_value) { - $value = $this->expandStringPropertiesCallback($matches, $data, $reference_data); + // If the value is a _single_ property reference, expand it + // directly so that the replacement's data type is preserved. + if (preg_match($pattern, $value, $matches) && $matches[0] === $value) { + $value = $this->expandStringPropertiesCallback($matches, $data, $reference_data); + } else { + $value = $this->replacePlaceholders( + $pattern, + function ($matches) use ($data, $reference_data) { + return (string) $this->expandStringPropertiesCallback($matches, $data, $reference_data); + }, + $value + ); + // A null value indicates a PCRE error. + if ($value === null) { + $this->log("Aborting expansion of $key: " . preg_last_error_msg()); + return $original_value; } } @@ -185,6 +188,16 @@ function ($matches) use ($data, $reference_data) { return $value; } + /** + * Replaces all placeholders in a string. + * + * Returns null on a PCRE error, per preg_replace_callback(). + */ + protected function replacePlaceholders(string $pattern, callable $callback, string $subject): ?string + { + return preg_replace_callback($pattern, $callback, $subject); + } + /** * Expansion callback used by preg_replace_callback() in expandProperty(). * @@ -195,8 +208,6 @@ function ($matches) use ($data, $reference_data) { * @param Data|null $reference_data * A reference data object. This is not operated upon but is used as a * reference to provide supplemental values for property expansion. - * - * @return mixed */ public function expandStringPropertiesCallback( array $matches, @@ -220,28 +231,25 @@ public function expandStringPropertiesCallback( } } - /** - * Searches both the subject data and the reference data for value. - * - * @param string $property_name - * The name of the value for which to search. - * @param string $unexpanded_value - * The original, unexpanded value, containing the placeholder. - * @param Data $data - * A data object containing the complete array being operated upon. - * @param Data|null $reference_data - * A reference data object. This is not operated upon but is used as a - * reference to provide supplemental values for property expansion. - * - * @return string|null The expanded string. - * The expanded string. - */ + /** + * Searches both the subject data and the reference data for value. + * + * @param string $property_name + * The name of the value for which to search. + * @param string $unexpanded_value + * The original, unexpanded value, containing the placeholder. + * @param Data $data + * A data object containing the complete array being operated upon. + * @param Data|null $reference_data + * A reference data object. This is not operated upon but is used as a + * reference to provide supplemental values for property expansion. + */ public function expandPropertyWithReferenceData( string $property_name, string $unexpanded_value, Data $data, ?Data $reference_data - ): ?string { + ): mixed { $expanded_value = $this->expandProperty( $property_name, $unexpanded_value, @@ -249,7 +257,7 @@ public function expandPropertyWithReferenceData( ); // If the string was not changed using the subject data, try using // the reference data. - if ($expanded_value === $unexpanded_value) { + if ($expanded_value === $unexpanded_value && $reference_data !== null) { $expanded_value = $this->expandProperty( $property_name, $unexpanded_value, @@ -269,18 +277,18 @@ public function expandPropertyWithReferenceData( * The original, unexpanded value, containing the placeholder. * @param Data $data * A data object containing possible replacement values. - * - * @return mixed */ public function expandProperty(string $property_name, string $unexpanded_value, Data $data): mixed { if (str_starts_with($property_name, "env.") && !$data->has($property_name)) { $env_key = substr($property_name, 4); - if (isset($_SERVER[$env_key])) { + // Skip HTTP_* keys in $_SERVER: in a web context these come from + // client-supplied request headers, not the environment. + if (isset($_SERVER[$env_key]) && !str_starts_with($env_key, 'HTTP_')) { $data->set($property_name, $_SERVER[$env_key]); - } elseif (getenv($env_key)) { - $data->set($property_name, getenv($env_key)); + } elseif (($env_value = getenv($env_key)) !== false) { + $data->set($property_name, $env_value); } } @@ -292,7 +300,7 @@ public function expandProperty(string $property_name, string $unexpanded_value, if (is_array($expanded_value)) { return $this->getStringifier()->stringifyArray($expanded_value); } - $this->log("Expanding property \${'$property_name'} => $expanded_value."); + $this->log("Expanding property \${'$property_name'} => " . var_export($expanded_value, true) . "."); return $expanded_value; } } @@ -303,8 +311,8 @@ public function expandProperty(string $property_name, string $unexpanded_value, * @param string $message * The message to log. */ - public function log(string $message) + public function log(string $message): void { - $this->getLogger()?->debug($message); + $this->getLogger()->debug($message); } } diff --git a/src/Stringifier.php b/src/Stringifier.php index f46fc1d..91c8929 100644 --- a/src/Stringifier.php +++ b/src/Stringifier.php @@ -1,15 +1,16 @@ envVarFixtures as $key) { + putenv($key); + unset($_SERVER[$key]); + } + $this->envVarFixtures = []; + parent::tearDown(); + } /** * Tests Expander::expandArrayProperties(). - * - * @param array $array - * @param array $reference_array - * - * @dataProvider providerSourceData */ - public function testExpandArrayProperties(array $array, array $reference_array) + #[DataProvider('providerSourceData')] + public function testExpandArrayProperties(array $array, array $reference_array): void { $expander = new Expander(); @@ -31,23 +48,33 @@ public function testExpandArrayProperties(array $array, array $reference_array) $this->assertEquals('Dune by Frank Herbert', $expanded['summary']); $this->assertEquals('${book.media.1}, hardcover', $expanded['available-products']); $this->assertEquals('Dune', $expanded['product-name']); - $this->assertEquals(Stringifier::stringifyArray($array['inline-array']), $expanded['expand-array']); + $this->assertEquals('one,two,three', $expanded['expand-array']); + $this->assertEquals('${not.real.property}', $expanded['publisher']); + $this->assertEquals('${book.expanded_to_null}', $expanded['test_expanded_to_null']); - $this->assertEquals(true, $expanded['boolean-value']); + $this->assertTrue($expanded['boolean-value']); $this->assertIsBool($expanded['boolean-value']); - $this->assertEquals(true, $expanded['expand-boolean']); + $this->assertTrue($expanded['expand-boolean']); $this->assertIsBool($expanded['expand-boolean']); + $this->assertSame(5, $expanded['expand-int']); + $this->assertSame(99.99, $expanded['expand-float']); + $expanded = $expander->expandArrayProperties($array, $reference_array); $this->assertEquals('Dune Messiah, and others.', $expanded['sequels']); $this->assertEquals('Dune Messiah', $expanded['book']['nested-reference']); + $this->assertNull($expanded['test_expanded_to_null']); + // Types must be preserved in reference-data mode too. + $this->assertTrue($expanded['expand-boolean']); + $this->assertSame(5, $expanded['expand-int']); + $this->assertSame(99.99, $expanded['expand-float']); } /** * @return array * An array of values to test. */ - public function providerSourceData(): array + public static function providerSourceData(): array { return [ [ @@ -85,6 +112,10 @@ public function providerSourceData(): array 'product-name' => '${${type}.title}', 'boolean-value' => true, 'expand-boolean' => '${boolean-value}', + 'int-value' => 5, + 'expand-int' => '${int-value}', + 'float-value' => 99.99, + 'expand-float' => '${float-value}', 'null-value' => null, 'inline-array' => [ 0 => 'one', @@ -107,10 +138,9 @@ public function providerSourceData(): array /** * Tests Expander::expandProperty(). - * - * @dataProvider providerTestExpandProperty */ - public function testExpandProperty(array $array, $property_name, $unexpanded_string, $expected) + #[DataProvider('providerTestExpandProperty')] + public function testExpandProperty(array $array, string $property_name, string $unexpanded_string, mixed $expected): void { $data = new Data($array); $expander = new Expander(); @@ -122,7 +152,7 @@ public function testExpandProperty(array $array, $property_name, $unexpanded_str /** * @return array */ - public function providerTestExpandProperty(): array + public static function providerTestExpandProperty(): array { return [ [ ['author' => 'Frank Herbert'], 'author', '${author}', 'Frank Herbert' ], @@ -130,13 +160,139 @@ public function providerTestExpandProperty(): array ]; } - /** - * @param $key - * @param $value - */ - protected function setEnvVarFixture($key, $value) + /** + * Tests the getenv() fallback when the variable is absent from $_SERVER. + */ + public function testExpandEnvPropertyGetenvFallback(): void + { + putenv('getenv_only=fallback_value'); + $this->envVarFixtures[] = 'getenv_only'; + $this->assertArrayNotHasKey('getenv_only', $_SERVER); + + $expander = new Expander(); + $expanded = $expander->expandArrayProperties(['env-fallback' => '${env.getenv_only}']); + $this->assertEquals('fallback_value', $expanded['env-fallback']); + } + + /** + * Tests that falsy (but set) environment variables like "0" expand. + */ + public function testExpandEnvPropertyFalsyValue(): void + { + putenv('falsy_env=0'); + $this->envVarFixtures[] = 'falsy_env'; + $this->assertArrayNotHasKey('falsy_env', $_SERVER); + + $expander = new Expander(); + $expanded = $expander->expandArrayProperties(['timeout' => '${env.falsy_env}']); + $this->assertEquals('0', $expanded['timeout']); + } + + /** + * Tests that HTTP_* keys in $_SERVER (client-controlled request headers) + * are never used for ${env.*} expansion. + */ + public function testExpandEnvPropertyIgnoresHttpHeaders(): void + { + $_SERVER['HTTP_X_INJECTED'] = 'attacker-value'; + $this->envVarFixtures[] = 'HTTP_X_INJECTED'; + + $expander = new Expander(); + $expanded = $expander->expandArrayProperties(['header' => '${env.HTTP_X_INJECTED}']); + $this->assertEquals('${env.HTTP_X_INJECTED}', $expanded['header']); + } + + /** + * Tests that circular references terminate rather than looping forever. + */ + public function testCircularReferencesTerminate(): void + { + $expander = new Expander(); + + $expanded = $expander->expandArrayProperties(['a' => '${a}']); + $this->assertEquals('${a}', $expanded['a']); + + $expanded = $expander->expandArrayProperties(['a' => '${b}', 'b' => '${a}']); + $this->assertEquals('${a}', $expanded['a']); + $this->assertEquals('${a}', $expanded['b']); + + $expanded = $expander->expandArrayProperties(['a' => '${b}', 'b' => '${c}', 'c' => '${a}']); + $this->assertEquals('${a}', $expanded['a']); + $this->assertEquals('${a}', $expanded['b']); + $this->assertEquals('${a}', $expanded['c']); + } + + /** + * Tests that mutually recursive placeholders with surrounding text do not + * grow unboundedly (previously caused memory exhaustion). + */ + public function testCircularReferencesWithTextTerminate(): void + { + $expander = new Expander(); + $expanded = $expander->expandArrayProperties(['a' => 'x${b}', 'b' => 'y${a}']); + $this->assertIsString($expanded['a']); + $this->assertIsString($expanded['b']); + } + + /** + * Tests that a PCRE failure during replacement aborts expansion and + * preserves the original value. + */ + public function testPcreErrorAbortsExpansion(): void + { + $expander = new class extends Expander { + protected function replacePlaceholders(string $pattern, callable $callback, string $subject): ?string + { + return null; + } + }; + $logger = $this->createMock(LoggerInterface::class); + $expander->setLogger($logger); + $logger->expects($this->once()) + ->method('debug') + ->with($this->stringContains('Aborting expansion of a')); + + $expanded = $expander->expandArrayProperties(['a' => 'text ${b} more', 'b' => 'val']); + $this->assertSame('text ${b} more', $expanded['a']); + } + + /** + * Tests logger and stringifier accessors and their use during expansion. + */ + public function testSettersAndGetters(): void + { + $expander = new Expander(); + $logger = $this->createMock(LoggerInterface::class); + $stringifier = $this->createMock(StringifierInterface::class); + + $expander->setLogger($logger); + $expander->setStringifier($stringifier); + $this->assertSame($logger, $expander->getLogger()); + $this->assertSame($stringifier, $expander->getStringifier()); + + $logger->expects($this->atLeastOnce()) + ->method('debug') + ->with($this->stringContains('not.real.property')); + $stringifier->expects($this->once()) + ->method('stringifyArray') + ->with(['one', 'two']) + ->willReturn('one, two'); + + $expanded = $expander->expandArrayProperties([ + 'missing' => '${not.real.property}', + 'list' => ['one', 'two'], + 'joined' => '${list}', + ]); + $this->assertSame('one, two', $expanded['joined']); + } + + /** + * Sets an environment variable fixture, registered for tearDown cleanup. + */ + protected function setEnvVarFixture(string $key, string $value): void { putenv("$key=$value"); $_SERVER[$key] = $value; + $this->envVarFixtures[] = $key; } } diff --git a/tests/src/StringifierTest.php b/tests/src/StringifierTest.php new file mode 100644 index 0000000..2897ce5 --- /dev/null +++ b/tests/src/StringifierTest.php @@ -0,0 +1,38 @@ +assertInstanceOf(StringifierInterface::class, new Stringifier()); + } + + #[DataProvider('providerStringifyArray')] + public function testStringifyArray(array $array, string $expected): void + { + $this->assertSame($expected, (new Stringifier())->stringifyArray($array)); + } + + /** + * @return array + */ + public static function providerStringifyArray(): array + { + return [ + 'empty array' => [[], ''], + 'single element' => [['one'], 'one'], + 'multiple elements' => [['one', 'two', 'three'], 'one,two,three'], + 'non-sequential keys' => [[5 => 'a', 9 => 'b'], 'a,b'], + 'mixed scalar types' => [[1, true, null, 'x'], '1,1,,x'], + ]; + } +}