coolify/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
Andras Bacsai 059164224c fix(bootstrap): add bounds check to extractBalancedBraceContent
Return null when startPos exceeds string length to prevent out-of-bounds
access. Add comprehensive test coverage for environment variable parsing
edge cases.
2026-03-02 12:24:40 +01:00

351 lines
12 KiB
PHP

<?php
use function PHPUnit\Framework\assertNotNull;
use function PHPUnit\Framework\assertNull;
// ─── Malformed Variables ───────────────────────────────────────────────────────
test('extractBalancedBraceContent handles empty variable name', function () {
$result = extractBalancedBraceContent('${}', 0);
assertNotNull($result);
expect($result['content'])->toBe('');
});
test('splitOnOperatorOutsideNested handles empty variable name with default', function () {
$split = splitOnOperatorOutsideNested(':-default');
assertNotNull($split);
expect($split['variable'])->toBe('')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('default');
});
test('extractBalancedBraceContent handles double opening brace', function () {
$result = extractBalancedBraceContent('${{VAR}}', 0);
assertNotNull($result);
expect($result['content'])->toBe('{VAR}');
});
test('extractBalancedBraceContent returns null for empty string', function () {
$result = extractBalancedBraceContent('', 0);
assertNull($result);
});
test('extractBalancedBraceContent returns null for just dollar sign', function () {
$result = extractBalancedBraceContent('$', 0);
assertNull($result);
});
test('extractBalancedBraceContent returns null for just opening brace', function () {
$result = extractBalancedBraceContent('{', 0);
assertNull($result);
});
test('extractBalancedBraceContent returns null for just closing brace', function () {
$result = extractBalancedBraceContent('}', 0);
assertNull($result);
});
test('extractBalancedBraceContent handles extra closing brace', function () {
$result = extractBalancedBraceContent('${VAR}}', 0);
assertNotNull($result);
expect($result['content'])->toBe('VAR');
});
test('extractBalancedBraceContent returns null for unclosed with no content', function () {
$result = extractBalancedBraceContent('${', 0);
assertNull($result);
});
test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () {
$result = extractBalancedBraceContent('${A:-${B:-${C}', 0);
assertNull($result);
});
test('replaceVariables handles empty braces gracefully', function () {
$result = replaceVariables('${}');
expect($result->value())->toBe('');
});
test('replaceVariables handles double braces gracefully', function () {
$result = replaceVariables('${{VAR}}');
expect($result->value())->toBe('{VAR}');
});
// ─── Edge Cases with Braces and Special Characters ─────────────────────────────
test('extractBalancedBraceContent finds consecutive variables', function () {
$str = '${A}${B}';
$first = extractBalancedBraceContent($str, 0);
assertNotNull($first);
expect($first['content'])->toBe('A');
$second = extractBalancedBraceContent($str, $first['end'] + 1);
assertNotNull($second);
expect($second['content'])->toBe('B');
});
test('splitOnOperatorOutsideNested handles URL with port in default', function () {
$split = splitOnOperatorOutsideNested('URL:-http://host:8080/path');
assertNotNull($split);
expect($split['variable'])->toBe('URL')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('http://host:8080/path');
});
test('splitOnOperatorOutsideNested handles equals sign in default', function () {
$split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar');
assertNotNull($split);
expect($split['variable'])->toBe('VAR')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('key=value&foo=bar');
});
test('splitOnOperatorOutsideNested handles dashes in default value', function () {
$split = splitOnOperatorOutsideNested('A:-value-with-dashes');
assertNotNull($split);
expect($split['variable'])->toBe('A')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('value-with-dashes');
});
test('splitOnOperatorOutsideNested handles question mark in default value', function () {
$split = splitOnOperatorOutsideNested('A:-what?');
assertNotNull($split);
expect($split['variable'])->toBe('A')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('what?');
});
test('extractBalancedBraceContent handles variable with digits', function () {
$result = extractBalancedBraceContent('${VAR123}', 0);
assertNotNull($result);
expect($result['content'])->toBe('VAR123');
});
test('extractBalancedBraceContent handles long variable name', function () {
$longName = str_repeat('A', 200);
$result = extractBalancedBraceContent('${'.$longName.'}', 0);
assertNotNull($result);
expect($result['content'])->toBe($longName);
});
test('splitOnOperatorOutsideNested returns null for empty string', function () {
$split = splitOnOperatorOutsideNested('');
assertNull($split);
});
test('splitOnOperatorOutsideNested handles variable name with underscores', function () {
$split = splitOnOperatorOutsideNested('_MY_VAR_:-default');
assertNotNull($split);
expect($split['variable'])->toBe('_MY_VAR_')
->and($split['default'])->toBe('default');
});
test('extractBalancedBraceContent with startPos beyond string length', function () {
$result = extractBalancedBraceContent('${VAR}', 100);
assertNull($result);
});
test('extractBalancedBraceContent handles brace in middle of text', function () {
$result = extractBalancedBraceContent('prefix ${VAR} suffix', 0);
assertNotNull($result);
expect($result['content'])->toBe('VAR');
});
// ─── Deeply Nested Defaults ────────────────────────────────────────────────────
test('extractBalancedBraceContent handles four levels of nesting', function () {
$input = '${A:-${B:-${C:-${D}}}}';
$result = extractBalancedBraceContent($input, 0);
assertNotNull($result);
expect($result['content'])->toBe('A:-${B:-${C:-${D}}}');
});
test('splitOnOperatorOutsideNested handles four levels of nesting', function () {
$content = 'A:-${B:-${C:-${D}}}';
$split = splitOnOperatorOutsideNested($content);
assertNotNull($split);
expect($split['variable'])->toBe('A')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('${B:-${C:-${D}}}');
// Verify second level
$nested = extractBalancedBraceContent($split['default'], 0);
assertNotNull($nested);
$split2 = splitOnOperatorOutsideNested($nested['content']);
assertNotNull($split2);
expect($split2['variable'])->toBe('B')
->and($split2['default'])->toBe('${C:-${D}}');
});
test('multiple variables at same depth in default', function () {
$input = '${A:-${B}/${C}/${D}}';
$result = extractBalancedBraceContent($input, 0);
assertNotNull($result);
$split = splitOnOperatorOutsideNested($result['content']);
assertNotNull($split);
expect($split['default'])->toBe('${B}/${C}/${D}');
// Verify all three nested variables can be found
$default = $split['default'];
$vars = [];
$pos = 0;
while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
$vars[] = $nested['content'];
$pos = $nested['end'] + 1;
}
expect($vars)->toBe(['B', 'C', 'D']);
});
test('nested with mixed operators', function () {
$input = '${A:-${B:?required}}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['variable'])->toBe('A')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('${B:?required}');
// Inner variable uses :? operator
$nested = extractBalancedBraceContent($split['default'], 0);
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
expect($innerSplit['variable'])->toBe('B')
->and($innerSplit['operator'])->toBe(':?')
->and($innerSplit['default'])->toBe('required');
});
test('nested variable without default as default', function () {
$input = '${A:-${B}}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['default'])->toBe('${B}');
$nested = extractBalancedBraceContent($split['default'], 0);
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
assertNull($innerSplit);
expect($nested['content'])->toBe('B');
});
// ─── Backwards Compatibility ───────────────────────────────────────────────────
test('replaceVariables with brace format without dollar sign', function () {
$result = replaceVariables('{MY_VAR}');
expect($result->value())->toBe('MY_VAR');
});
test('replaceVariables with truncated brace format', function () {
$result = replaceVariables('{MY_VAR');
expect($result->value())->toBe('MY_VAR');
});
test('replaceVariables with plain string returns unchanged', function () {
$result = replaceVariables('plain_value');
expect($result->value())->toBe('plain_value');
});
test('replaceVariables preserves full content for variable with default', function () {
$result = replaceVariables('${DB_HOST:-localhost}');
expect($result->value())->toBe('DB_HOST:-localhost');
});
test('replaceVariables preserves nested content for variable with nested default', function () {
$result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}');
expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db');
});
test('replaceVariables with brace format containing default falls back gracefully', function () {
$result = replaceVariables('{VAR:-default}');
expect($result->value())->toBe('VAR:-default');
});
test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () {
$split = splitOnOperatorOutsideNested('VAR:-val-ue');
assertNotNull($split);
expect($split['operator'])->toBe(':-')
->and($split['variable'])->toBe('VAR')
->and($split['default'])->toBe('val-ue');
});
test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () {
$split = splitOnOperatorOutsideNested('VAR:?error?');
assertNotNull($split);
expect($split['operator'])->toBe(':?')
->and($split['variable'])->toBe('VAR')
->and($split['default'])->toBe('error?');
});
test('full round trip: extract, split, and resolve nested variables', function () {
$input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}';
// Step 1: Extract outer content
$result = extractBalancedBraceContent($input, 0);
assertNotNull($result);
expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
// Step 2: Split on outer operator
$split = splitOnOperatorOutsideNested($result['content']);
assertNotNull($split);
expect($split['variable'])->toBe('APP_URL')
->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
// Step 3: Find all nested variables in default
$default = $split['default'];
$nestedVars = [];
$pos = 0;
while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
$nestedVars[] = [
'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'],
'default' => $innerSplit !== null ? $innerSplit['default'] : null,
];
$pos = $nested['end'] + 1;
}
expect($nestedVars)->toHaveCount(2)
->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP')
->and($nestedVars[0]['default'])->toBeNull()
->and($nestedVars[1]['name'])->toBe('API_VERSION')
->and($nestedVars[1]['default'])->toBe('2');
});