Files
WP-LIT-MCP/tests/Support/DiviFixtures.php
T

255 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 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
{
return SchemaFixture::managed($diviEnabled);
}
/** 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,
];
$select = static fn (): array => ['label' => 'x', 'type' => 'select', 'default' => ''];
$loop = ['before' => $text(), 'after' => $text(), 'loop_position' => $text()];
$date = ['date_format' => $select(), 'custom_date_format' => $text()];
$meta = $loop + $date + [
'select_loop_meta_key' => $select(),
'loop_meta_key' => $text(),
'enable_html' => ['label' => 'x', 'type' => 'yes_no_button', 'default' => 'off'],
];
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 options as Divi 5.15.1's get_options() returns them (research 4e §2): id = key = stored name.
'loop_post_title' => $option('loop_post_title', 'Loop', $loop),
'loop_post_date' => $option('loop_post_date', 'Loop', $loop + $date),
'loop_post_featured_image' => $option('loop_post_featured_image', 'Loop', [
'loop_position' => $text(), 'thumbnail_size' => $select(),
]),
'loop_post_meta_key_manual_custom_field' => $option(
'loop_post_meta_key_manual_custom_field',
'Loop Post Custom Fields',
$meta
),
'loop_product_title' => $option('loop_product_title', 'Loop Product', $loop),
'loop_term_name' => $option('loop_term_name', 'Loop Terms', $loop),
'loop_term_meta_key_manual_custom_field' => $option(
'loop_term_meta_key_manual_custom_field',
'Loop Term Custom Fields',
$meta
),
'loop_user_name' => $option('loop_user_name', 'Loop Users', $loop),
'loop_user_meta_key_manual_custom_field' => $option(
'loop_user_meta_key_manual_custom_field',
'Loop User Custom Fields',
$meta
),
'loop_menu_text' => $option('loop_menu_text', 'Loop Menus', $loop),
'loop_acf_team|||name' => $option('loop_acf_team|||name', 'Loop ACF team', $loop + ['acf_type' => $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;
}
}