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']); } }