Files
intotheeast-com-content/plugins/api/tests/Unit/Controllers/PagesControllerHeaderToArrayTest.php
T

102 lines
3.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Page\Header;
use Grav\Plugin\Api\Controllers\PagesController;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Yaml\Yaml;
/**
* Regression coverage for getgrav/grav-plugin-admin2#31 — frontmatter polluted
* with NUL-prefixed keys after saving from Admin 2.0 (Expert mode).
*
* Flex pages (Grav's default since 1.7) return a Header/Data object from
* header(), not a stdClass. The update path used to read the current header
* with `(array) $page->header()`, which leaks the object's protected
* properties as NUL-prefixed keys ("\0*\0items", "\0*\0nestedSeparator").
* Those keys were then merged with the incoming edit and persisted, so every
* save nested the real fields one level deeper inside a growing "\0*\0items"
* wrapper. headerToArray() routes through jsonSerialize() instead and keeps the
* keys clean.
*/
#[CoversClass(PagesController::class)]
class PagesControllerHeaderToArrayTest extends TestCase
{
private function headerToArray($header): array
{
$ref = new ReflectionClass(PagesController::class);
$instance = $ref->newInstanceWithoutConstructor();
return $ref->getMethod('headerToArray')->invoke($instance, $header);
}
#[Test]
public function flex_header_object_yields_clean_keys(): void
{
$header = new Header([
'title' => 'page to filter out',
'access' => ['site.restricted' => true],
]);
$result = $this->headerToArray($header);
$this->assertSame(['title', 'access'], array_keys($result));
foreach (array_keys($result) as $key) {
$this->assertStringNotContainsString("\0", (string) $key, 'No NUL-prefixed keys may leak');
}
$this->assertStringNotContainsString('items', Yaml::dump($result));
$this->assertStringNotContainsString('nestedSeparator', Yaml::dump($result));
}
#[Test]
public function stdclass_header_from_legacy_pages_round_trips(): void
{
$header = (object) ['title' => 'Hello', 'taxonomy' => (object) ['tag' => ['a', 'b']]];
$result = $this->headerToArray($header);
$this->assertSame('Hello', $result['title']);
$this->assertSame(['a', 'b'], $result['taxonomy']['tag']);
}
#[Test]
public function null_and_array_headers_are_passed_through(): void
{
$this->assertSame([], $this->headerToArray(null));
$this->assertSame(['title' => 'x'], $this->headerToArray(['title' => 'x']));
}
#[Test]
public function repeated_saves_do_not_compound_pollution(): void
{
// Simulate the read-merge-write loop the update endpoint runs: read the
// current header off the page object, merge an incoming Expert-mode
// payload, then store it back. Three iterations must stay clean — the
// bug nested the real fields one level deeper on every save.
$stored = ['title' => 'Original', 'access' => ['site.restricted' => true]];
for ($i = 0; $i < 3; $i++) {
$headerObject = new Header($stored);
$existing = $this->headerToArray($headerObject);
$incoming = ['title' => 'Edited ' . $i, 'access' => ['site.restricted' => true]];
// mirrors mergePatch + (object) cast + Header re-wrap on save
$merged = array_replace($existing, $incoming);
$stored = $merged;
$dump = Yaml::dump($merged, 10, 2);
$this->assertStringNotContainsString("\0", $dump);
$this->assertStringNotContainsString('*0items', $dump);
$this->assertSame(['title', 'access'], array_keys($merged));
}
$this->assertSame('Edited 2', $stored['title']);
}
}