diff --git a/core/functions/nodes.php b/core/functions/nodes.php index dda5218a17..b47b8568f6 100644 --- a/core/functions/nodes.php +++ b/core/functions/nodes.php @@ -1,5 +1,40 @@ '; // manage order-by - if (!isset($_SESSION['tree_sortby']) && !isset($_SESSION['tree_sortdir'])) { - // This is the first startup, set default sort order - $_SESSION['tree_sortby'] = 'menuindex'; - $_SESSION['tree_sortdir'] = 'ASC'; - } + // Session values may come from the request or from stored user settings: never trust them in SQL + $_SESSION['tree_sortby'] = normalizeTreeSortBy($_SESSION['tree_sortby'] ?? null); + $_SESSION['tree_sortdir'] = normalizeTreeSortDir($_SESSION['tree_sortdir'] ?? null); $sc = evo()->getDatabase()->getFullTableName('site_content'); @@ -45,7 +78,7 @@ function makeHTML($indent, $parent, $expandAll, $hereid = '') $sortby = $sc . '.' . $_SESSION['tree_sortby']; }; - $orderBy = $sortby . ' ' . ($_SESSION['tree_sortdir'] ?? 'ASC'); + $orderBy = $sortby . ' ' . $_SESSION['tree_sortdir']; // get document groups for current user if (isset($_SESSION['mgrDocgroups']) && is_array($_SESSION['mgrDocgroups'])) { diff --git a/core/src/Core.php b/core/src/Core.php index bc1c18f535..e65d768f97 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -5220,22 +5220,26 @@ public function getDocumentChildrenTVars($parentid = 0, $tvidnames = [], $publis foreach ($_ as $i => $v) { if ($v === 'value') { unset($_[$i]); - } else { + } elseif (preg_match('/^(\w+|\*)$/D', $v)) { $_[$i] = 'tv.' . $v; + } else { + unset($_[$i]); } } - $fields = implode(',', $_); + $fields = $_ ? implode(',', $_) : 'tv.*'; } else { $fields = "tv.*"; } - if ($tvsort != '') { - $tvsort = 'tv.' . implode(',tv.', array_filter(array_map('trim', explode(',', $tvsort)))); - } + $tvsortdir = strtoupper(trim((string)$tvsortdir)) === 'DESC' ? 'DESC' : 'ASC'; + $tvsort = array_filter(array_map('trim', explode(',', (string)$tvsort)), function ($v) { + return preg_match('/^\w+$/D', $v); + }); + $tvsort = $tvsort ? 'tv.' . implode(',tv.', $tvsort) : ''; if ($tvidnames === "*") { $query = "tv.id<>0"; } else { - $query = (is_numeric($tvidnames[0]) ? "tv.id" : "tv.name") . " IN ('" . implode("','", $tvidnames) . "')"; + $query = (is_numeric($tvidnames[0]) ? "tv.id" : "tv.name") . " IN ('" . implode("','", $this->db->escape($tvidnames)) . "')"; } foreach ($docs as $doc) { @@ -5410,18 +5414,24 @@ public function getTemplateVars($idnames = [], $fields = '*', $docid = '', $publ if (\is_scalar($fields)) { $fields = explode(',', $fields); } - $fields = array_filter(array_map('trim', $fields), function ($value) { - return $value !== 'value'; - }); + $fields = array_values(array_filter(array_map('trim', $fields), function ($value) { + return $value !== 'value' && preg_match('/^(\w+|\*)$/D', $value); + })); + if (!$fields) { + $fields = ['*']; + } } else { $fields = ['*']; } - $sort = ($sort == '') ? '' : $table . '.' . implode(',' . $table . '.', array_filter(array_map('trim', explode(',', $sort)))); + $sort = array_filter(array_map('trim', explode(',', (string)$sort)), function ($v) { + return preg_match('/^\w+(\s+(ASC|DESC))?$/iD', $v); + }); + $sort = $sort ? $table . '.' . implode(',' . $table . '.', $sort) : ''; if ($idnames === '*') { $query = '' . $table . '.id<>0'; } else { - $query = (is_numeric($idnames[0]) ? '' . $table . '.id' : '' . $table . '.name') . " IN ('" . implode("','", $idnames) . "')"; + $query = (is_numeric($idnames[0]) ? '' . $table . '.id' : '' . $table . '.name') . " IN ('" . implode("','", $this->getDatabase()->escape($idnames)) . "')"; } $rs = SiteTmplvar::query() diff --git a/core/src/Database.php b/core/src/Database.php index 13db6829dc..4a98e285ff 100644 --- a/core/src/Database.php +++ b/core/src/Database.php @@ -343,7 +343,7 @@ protected function prepareFields($data, $ignoreAlias = false) $tmp = []; foreach ($data as $alias => $field) { $tmp[] = ($alias !== $field && !\is_int($alias) && $ignoreAlias === false) ? - ($field . ' as `' . $alias . '`') : $field; + ($field . ' as `' . str_replace('`', '``', (string)$alias) . '`') : $field; } $data = implode(',', $tmp); @@ -355,6 +355,23 @@ protected function prepareFields($data, $ignoreAlias = false) return $this->replacePrefixPlaceholderInTableName($data); } + /** + * Quotes a column name/alias for the active driver, doubling embedded quote characters + * so an untrusted array key cannot break out of the identifier. + * + * @param string|int $name + * @return string + */ + protected function quoteIdentifier($name) + { + $name = (string)$name; + if ($this->getConfig('driver') === 'pgsql') { + return '"' . str_replace('"', '""', $name) . '"'; + } + + return '`' . str_replace('`', '``', $name) . '`'; + } + /** * @param string|array $data * @param bool $hasArray @@ -628,19 +645,13 @@ public function insert($fields, $intotable, $fromfields = "*", $fromtable = "", $this->query("INSERT INTO {$intotable} {$fields}"); } else { if (empty($fromtable)) { - switch ($this->getConfig('driver')) { - case 'pgsql': - $fields = "(\"" . implode("\", \"", array_keys($fields)) . "\") VALUES('" . implode("', '", - array_values($fields)) . "')"; - break; - default: - $fields = "(`" . implode("`, `", array_keys($fields)) . "`) VALUES('" . implode("', '", - array_values($fields)) . "')"; - break; - } + $columns = implode(', ', array_map([$this, 'quoteIdentifier'], array_keys($fields))); + $fields = "(" . $columns . ") VALUES('" . implode("', '", array_values($fields)) . "')"; $this->query("INSERT INTO {$intotable} {$fields}"); } else { - $fields = "(" . implode(",", array_keys($fields)) . ")"; + $fields = "(" . implode(",", array_map(function ($column) { + return preg_match('/^[\w.]+$/', (string)$column) ? $column : $this->quoteIdentifier($column); + }, array_keys($fields))) . ")"; $where = trim($where); $limit = trim($limit); if ($where !== '' && stripos($where, 'WHERE') !== 0) { @@ -691,14 +702,7 @@ public function update($fields, $table, $where = "") } else { $f = "'" . $value . "'"; } - switch ($this->getConfig('driver')) { - case 'pgsql': - $fields[$key] = "\"{$key}\" = " . $f; - break; - default: - $fields[$key] = "`{$key}` = " . $f; - break; - } + $fields[$key] = $this->quoteIdentifier($key) . ' = ' . $f; } $fields = implode(',', $fields); diff --git a/core/src/Models/SiteContent.php b/core/src/Models/SiteContent.php index 46415ffc07..57c4036dfb 100644 --- a/core/src/Models/SiteContent.php +++ b/core/src/Models/SiteContent.php @@ -95,6 +95,12 @@ class SiteContent extends Eloquent\Model const CHILDREN_RELATION_NAME = 'children'; + /** + * Upper bound for the filters of one tvFilter() call and for the sort terms of one tvOrderBy() + * call: every one of them costs the database a join or a sort key, so an unbounded list is a DoS. + */ + const MAX_TV_QUERY_TERMS = 20; + /** * ClosureTable model instance. * @@ -2224,6 +2230,10 @@ public function scopeTvFilter($query, $filters = '', $outerSep = ';', $innerSep { $prefix = evo()->getDatabase()->getConfig('prefix'); $filters = explode($outerSep, trim($filters)); + if (count($filters) > self::MAX_TV_QUERY_TERMS) { + // Fail closed, like a malformed filter: dropping the surplus would widen the result set + return $query->whereRaw('1 = 0'); + } foreach ($filters as $filter) { if (empty($filter)) break; $parts = explode($innerSep, $filter, 5); @@ -2232,6 +2242,14 @@ public function scopeTvFilter($query, $filters = '', $outerSep = ';', $innerSep $op = $parts[2]; $value = !empty($parts[3]) ? $parts[3] : ''; $cast = !empty($parts[4]) ? $parts[4] : ''; + // The name, operator and cast end up in raw SQL below, so refuse anything that is not plain + if (!preg_match('/^[\w\-]+$/D', (string)$tvname) + || !preg_match('/^(=|!=|<>|<=|>=|<|>|[a-z_\-!]+)$/iD', (string)$op) + || !preg_match('/^([A-Za-z]+(\(\d+(,\d+)?\))?)?$/D', (string)$cast)) { + // Fail closed: dropping a malformed filter would widen the result set + $query = $query->whereRaw('1 = 0'); + continue; + } $field = 'tv_' . $tvname . '.value'; if ($type == 'tvd') { $field = \DB::Raw("IFNULL(`" . $prefix . "tv_" . $tvname . "`.`value`, `" . $prefix . "tvd_" . $tvname . "`.`default_text`)"); @@ -2263,6 +2281,9 @@ public function scopeTvFilter($query, $filters = '', $outerSep = ';', $innerSep case ($cast == 'UNSIGNED'): case ($cast == 'SIGNED'): case (strpos($cast, 'DECIMAL') !== false): + if (!is_numeric($value)) { + $value = 0; + } $numericCast = (in_array(evo()->getDatabase()->getConfig('driver'), ['sqlite', 'sqlite3'], true)) ? 'INTEGER' : $cast; @@ -2283,13 +2304,20 @@ public function scopeTvFilter($query, $filters = '', $outerSep = ';', $innerSep public function scopeTvOrderBy($query, $orderBy = '', $sep = ':') { $prefix = evo()->getDatabase()->getConfig('prefix'); - $orderBy = explode(',', trim($orderBy)); + $orderBy = array_slice(explode(',', trim($orderBy)), 0, self::MAX_TV_QUERY_TERMS); foreach ($orderBy as $parts) { if (empty(trim($parts))) return; $part = array_map('trim', explode(' ', trim($parts), 3)); $tvname = $part[0]; $sortDir = !empty($part[1]) ? $part[1] : 'desc'; $cast = !empty($part[2]) ? $part[2] : ''; + // The name, direction and cast end up in raw SQL below, so refuse anything that is not plain + $nameOnly = explode($sep, $tvname, 2)[0]; + if (!preg_match('/^[\w\-]+$/D', $nameOnly) + || !preg_match('/^(asc|desc)$/iD', $sortDir) + || !preg_match('/^([A-Za-z]+(\(\d+(,\d+)?\))?)?$/D', $cast)) { + continue; + } $driver = evo()->getDatabase()->getConfig('driver'); $castType = $cast; if (in_array($driver, ['sqlite', 'sqlite3'], true) && $castType !== '') { @@ -2418,6 +2446,8 @@ public static function tvList($docs, $tvList = []) public function scopeOrderByDate($query, $sortDir = 'desc') { + $sortDir = strtolower(trim((string)$sortDir)) === 'asc' ? 'ASC' : 'DESC'; + return $query->orderByRaw('CASE WHEN pub_date != 0 THEN pub_date ELSE createdon END ' . $sortDir); } diff --git a/core/tests/Fixtures/template-pinned-controller.php b/core/tests/Fixtures/template-pinned-controller.php new file mode 100644 index 0000000000..6b46bb3f45 --- /dev/null +++ b/core/tests/Fixtures/template-pinned-controller.php @@ -0,0 +1,91 @@ +newInstanceWithoutConstructor(); + $GLOBALS['evo'] = $app; + $app->instance('config', new Repository([ + 'cms' => ['settings' => ['ControllerNamespace' => substr(HomeController::class, 0, -strlen('HomeController'))]], + 'view' => [ + 'paths' => [$directory], + 'template_engines' => ['blade.php' => ['label' => 'Blade', 'processor' => 'blade']], + ], + ])); + $views = new class { + public array $found = []; + public array $calls = []; + public function exists($name) { $this->calls[] = $name; return in_array($name, $this->found, true); } + public function getExtensions() { return ['blade.php' => 'blade']; } + }; + $app->instance('view', $views); + $app->documentObject = ['id' => 7, 'template' => 2, 'templatealias' => 'home', 'content' => 'page']; + $app->documentContent = 'template'; + + try { + foreach (['blade.php', ''] as $extension) { + HomeController::$constructed = 0; + HomeController::$mainCalls = 0; + // Pinned files must work without resolving the alias through the view finder. + $views->found = $extension === '' ? ['home'] : []; + $views->calls = []; + $row = new SiteTemplate(); + $row->setRawAttributes(['id' => 2, 'templatesource' => 'file', 'templatefileextension' => $extension]); + $processor = new TemplateProcessor($app); + (new ReflectionProperty($processor, 'templateRows'))->setValue($processor, [2 => $row]); + + $assertSame('home', $processor->getBladeDocumentContent(), "[$extension] document content"); + $assertSame(1, HomeController::$constructed, "[$extension] controller constructed"); + $assertSame(1, HomeController::$mainCalls, "[$extension] controller main calls"); + $assertSame($extension === '' ? '' : $path, $processor->getDocumentViewPath(), "[$extension] view path"); + $assertSame($extension === '' + ? ['tpl-2_doc-7', 'doc-7', 'tpl-2', 'home'] + : ['tpl-2_doc-7', 'doc-7', 'tpl-2'], $views->calls, "[$extension] view probes"); + } + + $views->found = ['doc-7']; + $row->templatefileextension = 'blade.php'; + $processor = new TemplateProcessor($app); + (new ReflectionProperty($processor, 'templateRows'))->setValue($processor, [2 => $row]); + $assertSame('doc-7', $processor->getBladeDocumentContent(), 'doc-7 override content'); + $assertSame('', $processor->getDocumentViewPath(), 'doc-7 override view path'); + $assertSame(1, HomeController::$constructed, 'doc-7 override controller constructed'); + echo "Pinned and automatic template files run the controller once.\n"; + } finally { + unlink($path); + rmdir($directory); + unset($GLOBALS['evo']); + } +} diff --git a/core/tests/Unit/Console/PackageDiscoverBootstrapCacheTest.php b/core/tests/Unit/Console/PackageDiscoverBootstrapCacheTest.php index 74ca5014ee..041a07c332 100644 --- a/core/tests/Unit/Console/PackageDiscoverBootstrapCacheTest.php +++ b/core/tests/Unit/Console/PackageDiscoverBootstrapCacheTest.php @@ -2,7 +2,7 @@ test('package discovery invalidates bootstrap configuration after generating providers and aliases', function () { $fixture = dirname(__DIR__, 2) . '/Fixtures/discover-bootstrap-cache.php'; - exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($fixture) . ' 2>&1', $output, $status); + $output = evoRunPhp($fixture, [], $status); - expect($status)->toBe(0, implode("\n", $output)); + expect($status)->toBe(0, $output); }); diff --git a/core/tests/Unit/Security/ParserEvalHardeningTest.php b/core/tests/Unit/Security/ParserEvalHardeningTest.php index 0115594201..8b55b9dac2 100644 --- a/core/tests/Unit/Security/ParserEvalHardeningTest.php +++ b/core/tests/Unit/Security/ParserEvalHardeningTest.php @@ -31,7 +31,7 @@ if (!defined('IN_MANAGER_MODE')) { define('IN_MANAGER_MODE', false); } - $root = str_replace('\\', '/', dirname(__DIR__, 3)) . '/'; + $root = str_replace('\\', '/', dirname(__DIR__, 4)) . '/'; if (!defined('EVO_BASE_PATH')) { define('EVO_BASE_PATH', $root); } @@ -211,6 +211,8 @@ function parserHardeningCore(): Core $relative = 'assets/evo_atfile_' . bin2hex(random_bytes(6)) . '.txt'; $absolute = EVO_BASE_PATH . $relative; + // Another test may have pinned EVO_BASE_PATH to a tree without an assets directory + is_dir(dirname($absolute)) || mkdir(dirname($absolute), 0777, true); file_put_contents($absolute, 'included-body'); try { @@ -225,6 +227,8 @@ function parserHardeningCore(): Core $relative = 'assets/evo_atfile_' . bin2hex(random_bytes(6)) . '.txt'; $absolute = EVO_BASE_PATH . $relative; + // Another test may have pinned EVO_BASE_PATH to a tree without an assets directory + is_dir(dirname($absolute)) || mkdir(dirname($absolute), 0777, true); file_put_contents($absolute, 'roundtrip'); try { diff --git a/core/tests/Unit/Security/SqlIdentifierQuotingTest.php b/core/tests/Unit/Security/SqlIdentifierQuotingTest.php new file mode 100644 index 0000000000..55e37e711b --- /dev/null +++ b/core/tests/Unit/Security/SqlIdentifierQuotingTest.php @@ -0,0 +1,81 @@ +driverName : null; + } + + public function replacePrefixPlaceholderInTableName($sql) + { + return $sql; + } + + public function query($sql, $watchError = true) + { + $this->queries[] = $sql; + + return false; + } + + public function getInsertId($conn = null) + { + return 1; + } + }; +} + +test('update quotes hostile array keys as identifiers', function () { + $db = sqlCapturingDatabase(); + $db->update(['a` = (SELECT 1), `b' => 'x'], 't', 'id=1'); + + expect($db->queries[0])->toBe("UPDATE t SET `a`` = (SELECT 1), ``b` = 'x' WHERE id=1"); +}); + +test('insert quotes hostile array keys as identifiers', function () { + $db = sqlCapturingDatabase(); + $db->insert(['a`) VALUES (1); -- ' => 'x'], 't'); + + expect($db->queries[0])->toBe("INSERT INTO t (`a``) VALUES (1); -- `) VALUES('x')"); +}); + +test('insert-select quotes only non-plain column names', function () { + $db = sqlCapturingDatabase(); + $db->insert(['id' => 1, 'x`,(SELECT 1)' => 2], 't', '*', 'src'); + + expect($db->queries[0])->toContain('(id,`x``,(SELECT 1)`)'); +}); + +test('pgsql identifiers double the double quote', function () { + $db = sqlCapturingDatabase('pgsql'); + $db->update(['a" = 1, "b' => 'x'], 't'); + + expect($db->queries[0])->toBe("UPDATE t SET \"a\"\" = 1, \"\"b\" = 'x' "); +}); + +test('select quotes hostile aliases', function () { + $db = sqlCapturingDatabase(); + $db->select(['x` , (SELECT 1) as `y' => 'col'], 't'); + + expect($db->queries[0])->toContain('col as `x`` , (SELECT 1) as ``y`'); +}); + +test('orderByDate only accepts asc or desc', function () { + $builder = Mockery::mock(Illuminate\Database\Eloquent\Builder::class); + $builder->shouldReceive('orderByRaw')->once()->with('CASE WHEN pub_date != 0 THEN pub_date ELSE createdon END DESC')->andReturnSelf(); + $builder->shouldReceive('orderByRaw')->once()->with('CASE WHEN pub_date != 0 THEN pub_date ELSE createdon END ASC')->andReturnSelf(); + + $model = new EvolutionCMS\Models\SiteContent(); + $model->scopeOrderByDate($builder, 'desc, (SELECT SLEEP(5))'); + $model->scopeOrderByDate($builder, 'ASC'); +}); diff --git a/core/tests/Unit/Security/TreeSortAllowlistTest.php b/core/tests/Unit/Security/TreeSortAllowlistTest.php new file mode 100644 index 0000000000..51698e1881 --- /dev/null +++ b/core/tests/Unit/Security/TreeSortAllowlistTest.php @@ -0,0 +1,106 @@ +toBe('pagetitle') + ->and(normalizeTreeSortBy('publishedon'))->toBe('publishedon') + ->and(normalizeTreeSortBy('id; DROP TABLE x'))->toBe('menuindex') + ->and(normalizeTreeSortBy('(SELECT SLEEP(5))'))->toBe('menuindex') + ->and(normalizeTreeSortBy('menuindex DESC, (SELECT 1)'))->toBe('menuindex') + ->and(normalizeTreeSortBy(['id']))->toBe('menuindex') + ->and(normalizeTreeSortBy(null))->toBe('menuindex'); +}); + +test('tree sort direction accepts only ASC or DESC', function () { + expect(normalizeTreeSortDir('DESC'))->toBe('DESC') + ->and(normalizeTreeSortDir('desc'))->toBe('DESC') + ->and(normalizeTreeSortDir('ASC'))->toBe('ASC') + ->and(normalizeTreeSortDir('ASC, (SELECT 1)'))->toBe('ASC') + ->and(normalizeTreeSortDir('DESC; --'))->toBe('ASC') + ->and(normalizeTreeSortDir(null))->toBe('ASC'); +}); + +function recordingTvQuery(): object +{ + defined('EVO_CLASS') || define('EVO_CLASS', \Illuminate\Container\Container::class); + defined('IN_MANAGER_MODE') || define('IN_MANAGER_MODE', false); + defined('IN_INSTALL_MODE') || define('IN_INSTALL_MODE', false); + defined('EVO_API_MODE') || define('EVO_API_MODE', true); + + global $evo; + $evo = new class { + public function getDatabase() + { + return new class { + public function getConfig($option = null) + { + return $option === 'prefix' ? 'evo_' : 'mysql'; + } + }; + } + }; + + return new class { + public array $raw = []; + + public function whereRaw($sql) + { + $this->raw[] = $sql; + + return $this; + } + + public function orderBy(...$args) + { + $this->raw[] = 'orderBy:' . json_encode($args); + + return $this; + } + + public function where(...$args) + { + $this->raw[] = 'where:' . json_encode($args); + + return $this; + } + }; +} + +test('a malformed tv filter matches nothing instead of being dropped', function () { + $q = recordingTvQuery(); + (new \EvolutionCMS\Models\SiteContent())->scopeTvFilter($q, 'tv:price:>:5:UNSIGNED) OR 1=1 --'); + + expect($q->raw)->toBe(['1 = 0']); + $GLOBALS['evo'] = null; +}); + +test('a tv filter with a plain non-numeric cast is still applied', function () { + $q = recordingTvQuery(); + (new \EvolutionCMS\Models\SiteContent())->scopeTvFilter($q, 'tv:name:=:abc:CHAR'); + + expect($q->raw)->toBe(['where:' . json_encode(['tv_name.value', '=', 'abc'])]); + $GLOBALS['evo'] = null; +}); + +test('a tv filter list over the limit matches nothing', function () { + $max = \EvolutionCMS\Models\SiteContent::MAX_TV_QUERY_TERMS; + $filters = fn (int $n) => implode(';', array_fill(0, $n, 'tv:name:=:abc')); + + $q = recordingTvQuery(); + (new \EvolutionCMS\Models\SiteContent())->scopeTvFilter($q, $filters($max)); + expect($q->raw)->toHaveCount($max); + + $q = recordingTvQuery(); + (new \EvolutionCMS\Models\SiteContent())->scopeTvFilter($q, $filters($max + 1)); + expect($q->raw)->toBe(['1 = 0']); + $GLOBALS['evo'] = null; +}); + +test('tv sort terms over the limit are ignored', function () { + $max = \EvolutionCMS\Models\SiteContent::MAX_TV_QUERY_TERMS; + $terms = implode(',', array_map(fn ($i) => "tv{$i} asc", range(1, $max + 5))); + + $q = recordingTvQuery(); + (new \EvolutionCMS\Models\SiteContent())->scopeTvOrderBy($q, $terms); + expect($q->raw)->toHaveCount($max); + $GLOBALS['evo'] = null; +}); diff --git a/core/tests/Unit/TemplatePinnedControllerTest.php b/core/tests/Unit/TemplatePinnedControllerTest.php index a19d38226b..bf3b4add08 100644 --- a/core/tests/Unit/TemplatePinnedControllerTest.php +++ b/core/tests/Unit/TemplatePinnedControllerTest.php @@ -1,90 +1,8 @@ newInstanceWithoutConstructor(); - $GLOBALS['evo'] = $app; - $app->instance('config', new Repository([ - 'cms' => ['settings' => ['ControllerNamespace' => __NAMESPACE__ . '\\PinnedControllerFixtures\\']], - 'view' => [ - 'paths' => [$directory], - 'template_engines' => ['blade.php' => ['label' => 'Blade', 'processor' => 'blade']], - ], - ])); - $views = new class { - public array $found = []; - public array $calls = []; - public function exists($name) { $this->calls[] = $name; return in_array($name, $this->found, true); } - public function getExtensions() { return ['blade.php' => 'blade']; } - }; - $app->instance('view', $views); - $app->documentObject = ['id' => 7, 'template' => 2, 'templatealias' => 'home', 'content' => 'page']; - $app->documentContent = 'template'; - - try { - foreach (['blade.php', ''] as $extension) { - PinnedControllerFixtures\HomeController::$constructed = 0; - PinnedControllerFixtures\HomeController::$mainCalls = 0; - // Pinned files must work without resolving the alias through the view finder. - $views->found = $extension === '' ? ['home'] : []; - $views->calls = []; - $row = new SiteTemplate(); - $row->setRawAttributes(['id' => 2, 'templatesource' => 'file', 'templatefileextension' => $extension]); - $processor = new TemplateProcessor($app); - (new \ReflectionProperty($processor, 'templateRows'))->setValue($processor, [2 => $row]); - - self::assertSame('home', $processor->getBladeDocumentContent()); - self::assertSame(1, PinnedControllerFixtures\HomeController::$constructed); - self::assertSame(1, PinnedControllerFixtures\HomeController::$mainCalls); - self::assertSame($extension === '' ? '' : $path, $processor->getDocumentViewPath()); - self::assertSame($extension === '' - ? ['tpl-2_doc-7', 'doc-7', 'tpl-2', 'home'] - : ['tpl-2_doc-7', 'doc-7', 'tpl-2'], $views->calls); - } - - $views->found = ['doc-7']; - $row->templatefileextension = 'blade.php'; - $processor = new TemplateProcessor($app); - (new \ReflectionProperty($processor, 'templateRows'))->setValue($processor, [2 => $row]); - self::assertSame('doc-7', $processor->getBladeDocumentContent()); - self::assertSame('', $processor->getDocumentViewPath()); - self::assertSame(1, PinnedControllerFixtures\HomeController::$constructed); - } finally { - unlink($path); - rmdir($directory); - unset($GLOBALS['evo']); - } - } -} - -namespace Tests\Unit\PinnedControllerFixtures; - -class BaseController {} - -class HomeController extends BaseController -{ - public static int $constructed = 0; - public static int $mainCalls = 0; - - public function __construct() { ++self::$constructed; } - public function main() { ++self::$mainCalls; } -} + expect($status)->toBe(0, $output); +});