- DiviFixtures: markup(), givenPost(), schema(), designSystem(), catalog(), dynamicContentRegistry(), maliciousRefs() - moved verbatim out of DiviPageLoaderTest, DesignSystemTest, ModuleCatalogTest, DynamicContentMapTest and VariableRefTest (its malicious() provider now returns maliciousRefs()). - ComposeFixture moves to tests/Support. - DiviWriterFixture: modules(), designReader(), composition(), a recording saver(), log() and writer(&$saved, &$failures, unfiltered, loader) for the five tests that wired the Divi write stack by hand. No test cases or assertions changed (1397 / 5834). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
232 lines
12 KiB
PHP
232 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Support;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
/**
|
||
* Shared Divi test data: page markup, the stubbed post of the page tests, the sample design
|
||
* system and module catalog (real Divi 5.2.1 shapes), the dynamic content registry and the
|
||
* malicious $variable rows the Compose tests feed into every write path.
|
||
*/
|
||
final class DiviFixtures
|
||
{
|
||
/** A Divi page markup file from tests/Fixtures/divi. */
|
||
public static function markup(string $name): string
|
||
{
|
||
return (string) file_get_contents(dirname(__DIR__) . '/Fixtures/divi/' . $name);
|
||
}
|
||
|
||
/** Stubs everything PostGuard::editable() needs for a post with $content. */
|
||
public static function givenPost(string $content, array $fields = []): WP_Post
|
||
{
|
||
$post = new WP_Post(array_merge(
|
||
['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Start', 'post_content' => $content],
|
||
$fields
|
||
));
|
||
Functions\when('get_post')->justReturn($post);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
|
||
Functions\when('add_query_arg')->alias(static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value);
|
||
|
||
return $post;
|
||
}
|
||
|
||
public static function schema(bool $diviEnabled = true): ContentSchema
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(true);
|
||
$schema->allows('diviEnabled')->andReturn($diviEnabled);
|
||
|
||
return $schema;
|
||
}
|
||
|
||
/** Shapes as GlobalData / GlobalPreset return them (Divi 5.2.1, verified in DDEV). */
|
||
public static function designSystem(): DesignSystem
|
||
{
|
||
return DesignSystem::fromDivi(
|
||
[
|
||
'gcid-primary-color' => ['color' => '#2ea3f2', 'label' => 'Primärfarbe', 'status' => 'active'],
|
||
'gcid-body-color' => ['color' => '#666666', 'label' => 'Fließtextfarbe', 'status' => 'active'],
|
||
'gcid-a1b2' => ['color' => '#0b3d59', 'label' => 'Nachtblau', 'status' => 'active'],
|
||
'gcid-old' => ['color' => '#ff0000', 'label' => 'Alt', 'status' => 'inactive'],
|
||
],
|
||
[
|
||
'numbers' => ['gvid-r1' => ['id' => 'gvid-r1', 'label' => 'Radius', 'value' => '8px', 'status' => 'active']],
|
||
'strings' => ['gvid-s1' => ['id' => 'gvid-s1', 'label' => 'Telefon', 'value' => '0221 123', 'status' => 'active']],
|
||
// Divi returns every variable type as stdClass (verified in DDEV).
|
||
'fonts' => (object) [
|
||
'--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift', 'value' => 'Open Sans', 'status' => 'active'],
|
||
'--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext', 'value' => 'Lato', 'status' => 'active'],
|
||
],
|
||
'links' => new \stdClass(),
|
||
],
|
||
[
|
||
'module' => ['divi/button' => ['default' => 'p-1', 'items' => [
|
||
'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'],
|
||
'p-2' => ['id' => 'p-2', 'name' => 'CTA primär', 'moduleName' => 'divi/button'],
|
||
]]],
|
||
'group' => ['divi/font' => ['default' => '', 'items' => [
|
||
'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'],
|
||
]]],
|
||
],
|
||
['body_font_size' => '16']
|
||
);
|
||
}
|
||
|
||
/** Real Divi 5.2.1 metadata (tests/Integration/divi-metadata-fixture.php). */
|
||
public static function catalog(): ModuleCatalog
|
||
{
|
||
$sample = json_decode(
|
||
(string) file_get_contents(dirname(__DIR__) . '/Fixtures/divi/metadata-sample.json'),
|
||
true
|
||
);
|
||
|
||
return new ModuleCatalog(
|
||
$sample['metadata'],
|
||
static fn (string $name): array => (array) ($sample['defaults'][$name] ?? [])
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Shape of DynamicContentOptions::get_options($pageId, 'edit') in Divi 5.2.1 (excerpt of a
|
||
* live dump for a page: no post_categories/post_tags – pages have no such taxonomies).
|
||
*
|
||
* @return array<string, array<string, mixed>>
|
||
*/
|
||
public static function dynamicContentRegistry(): array
|
||
{
|
||
$text = static fn (): array => ['label' => 'x', 'type' => 'text', 'default' => ''];
|
||
$option = static fn (string $id, string $group, array $fields): array => [
|
||
'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
|
||
];
|
||
|
||
return [
|
||
'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]),
|
||
'post_excerpt' => $option('post_excerpt', 'Default', [
|
||
'before' => $text(), 'after' => $text(), 'words' => $text(), 'read_more_label' => $text(),
|
||
]),
|
||
'post_date' => $option('post_date', 'Default', [
|
||
'before' => $text(),
|
||
'after' => $text(),
|
||
'date_format' => ['label' => 'x', 'type' => 'select', 'default' => 'default'],
|
||
'custom_date_format' => $text(),
|
||
]),
|
||
'post_author' => $option('post_author', 'Default', [
|
||
'before' => $text(),
|
||
'after' => $text(),
|
||
'name_format' => ['type' => 'select'],
|
||
'link' => ['type' => 'yes_no_button'],
|
||
]),
|
||
'site_title' => $option('site_title', 'Default', ['before' => $text(), 'after' => $text()]),
|
||
'post_link_url' => $option('post_link_url', 'Default', []),
|
||
'post_link_url_page' => $option('post_link_url_page', 'Default', ['post_id' => ['type' => 'select_post']]),
|
||
'post_featured_image' => $option('post_featured_image', 'Default', []),
|
||
'post_meta_key' => $option('post_meta_key', 'Standard', [
|
||
'before' => $text(),
|
||
'after' => $text(),
|
||
'select_meta_key' => ['type' => 'select'],
|
||
'meta_key' => $text(),
|
||
'enable_html' => ['type' => 'yes_no_button'],
|
||
]),
|
||
'loop_post_title' => $option('loop_post_title', 'Loop', [
|
||
'before' => $text(), 'after' => $text(), 'loop_position' => $text(),
|
||
]),
|
||
'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [
|
||
'loop_meta_key' => $text(),
|
||
]),
|
||
// Odd entries a third-party filter could add.
|
||
'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]),
|
||
'no_fields' => ['id' => 'no_fields', 'type' => 'text'],
|
||
7 => $option('7', 'Default', []),
|
||
'not_an_array' => 'x',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Hand-crafted attacks (preflight §4 "Malicious $variable tests").
|
||
*
|
||
* @return array<string, array{string, string}> Value => part of the German error.
|
||
*/
|
||
public static function maliciousRefs(): array
|
||
{
|
||
$end = self::escape('0029') . '$';
|
||
|
||
return [
|
||
'escaped end marker in before' => [self::content('post_title', '{"before":"x' . $end . ' <b>y</b>","after":""}'), 'allein im Wert'],
|
||
'escaped end marker in after' => [self::content('post_title', '{"before":"","after":"' . $end . '"}'), 'allein im Wert'],
|
||
'escaped end marker in date format' => [self::content('post_date', '{"custom_date_format":"d' . $end . '"}'), 'allein im Wert'],
|
||
'escaped end marker in name' => [self::content('post_title' . $end, '{}'), 'Unbekannter Name'],
|
||
'select with markup' => [self::content('post_date', '{"date_format":"<b>d</b>"}'), 'date_format'],
|
||
'type evil' => ['$variable({"type":"evil","value":{"name":"gcid-a1b2","settings":{}}})$', 'Typ'],
|
||
'unknown gcid' => ['$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$', 'gcid-nope'],
|
||
'unknown gvid' => [self::content('gvid-nope', '{}'), 'gvid-nope'],
|
||
'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'],
|
||
'color gcid as content' => [self::content('gcid-a1b2', '{}'), 'gcid-a1b2'],
|
||
'name with paren' => ['$variable({"type":"color","value":{"name":"gcid-a1b2)","settings":{}}})$', 'Name'],
|
||
'name with style breakout' => [
|
||
'$variable({"type":"color","value":{"name":"gcid-a1b2;}</style><script>alert(1)</script>","settings":{}}})$',
|
||
'Name',
|
||
],
|
||
'settings on a global variable' => [self::content('gvid-s1', '{"before":"<b>x</b>"}'), 'before'],
|
||
'settings on a theme font' => [self::content('--et_global_heading_font', '{"after":""}'), 'after'],
|
||
'unknown settings key' => [self::content('post_title', '{"before":"","foo":"1"}'), 'foo'],
|
||
'setting the option lacks' => [self::content('post_featured_image', '{"before":"x"}'), 'before'],
|
||
'enable_html' => [self::content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'],
|
||
'color setting not numeric' => [
|
||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"1;}</style>"}}})$',
|
||
'opacity',
|
||
],
|
||
'protected meta key' => [self::content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'],
|
||
'doubled meta prefix' => [
|
||
self::content('custom_meta_custom_meta__edit_lock', '{}'),
|
||
'custom_meta_custom_meta__edit_lock',
|
||
],
|
||
'loop content outside a loop' => [self::content('loop_post_title', '{}'), 'loop_post_title'],
|
||
'unknown dynamic content' => [self::content('post_meta_key', '{}'), 'post_meta_key'],
|
||
'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'],
|
||
'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'],
|
||
'greedy smuggling' => ['$variable({"a":"})$<script>alert(1)</script>"})$', 'allein im Wert'],
|
||
'nested reference in before' => [self::content('post_title', '{"before":"$variable(x)$","after":""}'), 'allein im Wert'],
|
||
// S28: a marker the sanitizer rebuilds inside a cleaned setting (html(), text()).
|
||
'tag-split marker in before' => [self::content('post_title', '{"before":"$variable<x>(y","after":""}'), 'allein im Wert'],
|
||
'NUL-split marker in after' => [
|
||
self::content('post_title', '{"before":"","after":"$variable' . self::escape('0000') . '(y"}'),
|
||
'allein im Wert',
|
||
],
|
||
'octet-split marker in date format' => [self::content('post_date', '{"custom_date_format":"$variable%41(y"}'), 'allein im Wert'],
|
||
// S32: text() keeps control characters (like sanitize_text_field); VariableRef strips them.
|
||
'NUL-split marker in date format' => [
|
||
self::content('post_date', '{"custom_date_format":"$variable' . self::escape('0000') . '(y"}'),
|
||
'allein im Wert',
|
||
],
|
||
'DEL-split marker in date format' => [
|
||
self::content('post_date', '{"custom_date_format":"$variable' . self::escape('007f') . '(y"}'),
|
||
'allein im Wert',
|
||
],
|
||
'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'],
|
||
'broken json' => ['$variable(kaputt)$', 'JSON'],
|
||
'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'],
|
||
'embedded in text' => ['<p>Hi ' . self::content('post_title', '{"before":"","after":""}') . '</p>', 'allein im Wert'],
|
||
];
|
||
}
|
||
|
||
private static function content(string $name, string $settings): string
|
||
{
|
||
return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$';
|
||
}
|
||
|
||
/** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */
|
||
private static function escape(string $hex): string
|
||
{
|
||
return chr(92) . 'u' . $hex;
|
||
}
|
||
}
|