@@ -0,0 +1,364 @@
#!/usr/bin/env bash
# End-to-end checks of the Etappe-2b Divi write tools through the OAuth-protected endpoint.
# Runs INSIDE the DDEV web container (Divi 5 must be the active theme):
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh
# Creates draft pages, a temporary role/user without unfiltered_html, and removes everything again
# (pages with their revisions, change-log rows of the temporary user, role, user, app passwords).
# Security probes (section 7, ruling S6) use benign markers only: a data-probe attribute and the
# literal text alert(1); the check is that no tag, event handler, javascript: URL or data-probe
# attribute reaches the stored bytes, the render summary or the rendered page.
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
ADMIN_LOGIN = $( wp user list --role= administrator --field= user_login | head -1)
EDITOR_LOGIN = lit-smoke-editor
FIXTURES = $PLUGIN /tests/Fixtures/divi
ORIGINAL_ROLES = $( wp option get lit_mcp_allowed_roles --format= json 2>/dev/null || echo '["administrator"]' )
CREATED_POSTS = ( )
cleanup( ) {
for id in " ${ CREATED_POSTS [@] } " ; do
wp post delete " $id " --force >/dev/null 2>& 1 || true
done
cleanup_app_passwords_for " $ADMIN_LOGIN "
cleanup_app_passwords_for " $EDITOR_LOGIN "
# Change-log rows of this run: every row of the temporary user and every row about a test page.
LIT_LOGIN = " $EDITOR_LOGIN " LIT_IDS = " ${ CREATED_POSTS [*] } " wp eval '
global $wpdb;
$table = $wpdb->prefix . "lit_mcp_log";
$u = get_user_by("login", getenv("LIT_LOGIN"));
if ($u) { $wpdb->delete($table, ["user_id" => $u->ID]); }
foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) {
$wpdb->delete($table, ["object_type" => "page", "object_id" => $id]);
}' >/dev/null 2>& 1 || true
wp user delete " $EDITOR_LOGIN " --yes >/dev/null 2>& 1 || true
wp role delete lit_smoke_editor >/dev/null 2>& 1 || true
wp option update lit_mcp_allowed_roles " $ORIGINAL_ROLES " --format= json >/dev/null 2>& 1 || true
}
trap cleanup EXIT
create_page( ) { # $1 = fixture file ("" = empty page), $2 = title -> prints the ID
LIT_FILE = " ${ 1 : + $FIXTURES / $1 } " LIT_TITLE = " $2 " wp --user= " $ADMIN_LOGIN " eval '
$file = (string) getenv("LIT_FILE");
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
"post_content" => $file === "" ? "" : wp_slash((string) file_get_contents($file))]);
update_post_meta($id, "_et_pb_use_builder", "on");
update_post_meta($id, "_et_pb_use_divi_5", "on");
echo $id;'
}
content_of( ) { # $1 = post ID
wp post get " $1 " --field= post_content
}
# Whether the stored content still contains the exact bytes of the fixture section "Leistungen".
keeps_leistungen( ) { # $1 = post ID
LIT_ID = " $1 " LIT_FILE = " $FIXTURES /converted-landing.html " wp eval '
$fixture = (string) file_get_contents(getenv("LIT_FILE"));
$start = strpos($fixture, "<!-- wp:divi/section {\"module\":{\"meta\":{\"adminLabel\":{\"desktop\":{\"value\":\"Leistungen\"");
$section = substr($fixture, $start, strrpos($fixture, "<!-- /wp:divi/placeholder -->") - $start);
echo str_contains((string) get_post((int) getenv("LIT_ID"))->post_content, $section) ? "yes" : "no";'
}
# Prints every dangerous finding (one per line) in the stored content and in the rendered
# content area of a post: script tags, event-handler or data-probe attributes, javascript: URLs.
# Stored: every string value of every block attribute (decoded by parse_blocks, so "\u003c" is
# "<") and the markup between the blocks; rendered: the HTML of the content area.
probe_findings( ) { # $1 = post ID
LIT_ID = " $1 " wp --user= " $ADMIN_LOGIN " eval '
$post = get_post((int) getenv("LIT_ID"));
$stored = [];
$walk = static function ($value) use (&$walk, &$stored): void {
if (is_array($value)) {
array_walk($value, static fn ($v) => $walk($v));
} elseif (is_string($value)) {
$stored[] = $value;
// $variable({...})$ references: their settings (before/after) are JSON inside the string.
if (preg_match("/^\\\$variable\\((.*)\\)\\\$$/s", $value, $ref) === 1) {
$walk(json_decode($ref[1], true));
}
}
};
$blocks = static function (array $list) use (&$blocks, $walk, &$stored): void {
foreach ($list as $block) {
$walk($block["attrs"] ?? []);
$stored[] = (string) ($block["innerHTML"] ?? "");
$blocks($block["innerBlocks"] ?? []);
}
};
$blocks(parse_blocks((string) $post->post_content));
$sources = [
"stored" => $stored,
"rendered" => [(new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render($post)],
];
$rules = [
"script tag" => "/<\s*script/i",
"event handler" => "/<[^>]*\son[a-z]+\s*=/i",
"data-probe attribute" => "/<[^>]*\sdata-probe\b/i",
"javascript: URL" => "/javascript\s*:/i",
];
foreach ($sources as $where => $strings) {
foreach ($rules as $what => $pattern) {
foreach ($strings as $html) {
if (preg_match($pattern, $html) === 1) {
echo "$where: $what in " . substr($html, 0, 120) . "\n";
break;
}
}
}
}'
}
assert_clean( ) { # $1 = post ID, $2 = context
local findings
findings = $( probe_findings " $1 " )
[ -z " $findings " ] || fail " $2 : $findings "
}
[ " $( wp eval 'echo get_template();' ) " = "Divi" ] || fail "Divi must be the active theme"
PAGE_ID = $( create_page converted-landing.html "LIT Divi Schreiben" )
CREATED_POSTS += ( " $PAGE_ID " )
assert_clean " $PAGE_ID " "fixture baseline"
# The checker itself must see probes in attribute strings and in $variable settings.
PROBE_ID = $( LIT_TITLE = "LIT Divi Probe" wp --user= " $ADMIN_LOGIN " eval '
$v = "\$variable(" . wp_json_encode(["type" => "content", "value" => ["name" => "post_title",
"settings" => ["before" => "<script>alert(1)</script>", "after" => ""]]]) . ")$";
$text = ["content" => ["innerContent" => ["desktop" => ["value" => "<p><b data-probe=\"1\" onclick=\"alert(1)\">x</b></p>"]]]];
$head = ["title" => ["innerContent" => ["desktop" => ["value" => $v]]]];
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
"post_content" => wp_slash(serialize_block(["blockName" => "divi/text", "attrs" => $text, "innerBlocks" => [],
"innerHTML" => "", "innerContent" => []]) . serialize_block(["blockName" => "divi/heading", "attrs" => $head,
"innerBlocks" => [], "innerHTML" => "", "innerContent" => []]))]);
echo $id;' )
CREATED_POSTS += ( " $PROBE_ID " )
PROBE_SELFTEST = $( probe_findings " $PROBE_ID " )
for finding in "stored: script tag" "stored: event handler" "stored: data-probe attribute" ; do
echo " $PROBE_SELFTEST " | grep -q " $finding " || fail " probe checker misses \" $finding \": $PROBE_SELFTEST "
done
wp post delete " $PROBE_ID " --force >/dev/null
connect " $ADMIN_LOGIN "
# 1. Direct write tools with correct annotations
LIST = $( rpc tools/list '{}' )
for t in lit-divi-insert lit-divi-update lit-divi-move lit-divi-delete; do
echo " $LIST " | jq -e --arg t " $t " '.result.tools[] | select(.name == $t)' >/dev/null || fail " tools/list lacks $t "
done
echo " $LIST " | jq -e '.result.tools[] | select(.name == "lit-divi-delete") | .annotations.destructiveHint == true' >/dev/null \
|| fail "lit-divi-delete lacks destructiveHint"
if echo " $LIST " | jq -e '.result.tools[] | select(.name == "lit-divi-replace-text" or .name == "lit-divi-duplicate-node")' >/dev/null; then
fail "lit-divi-replace-text and lit-divi-duplicate-node must be indirect"
fi
GUIDE = $( tool lit-guide '{"topic":"divi-shortform"}' )
echo " $GUIDE " | jq -e '.content | contains("lit-divi-insert")' >/dev/null || fail " guide divi-shortform: $GUIDE "
echo " $INIT_RESPONSE " | grep -q 'tree_rev' || fail "server instructions lack the tree_rev rule"
pass "tools/list: Divi write tools direct, duplicate/replace-text indirect; guide and instructions"
# 2. Insert a whole section in short form with placeholders
TREE_REV = $( tool lit-divi-outline " {\"post_id\": $PAGE_ID } " | jq -r '.tree_rev' )
INSERT = $( tool lit-divi-insert " $( jq -cn --argjson id " $PAGE_ID " --arg rev " $TREE_REV " '{post_id: $id, tree_rev: $rev, before: "s1",
nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [
[{type: "heading", text: "{{post:title}}", level: "h2"}],
[{type: "text", content: "<p>Hallo <strong>Welt</strong></p>"},
{type: "button", text: "Mehr erfahren", url: "javascript:alert(1)", label: "Neu – CTA"}]]}]}]}' ) " )
[ " $( error_code " $INSERT " ) " = "validation_failed" ] || fail " javascript: URL accepted: $INSERT "
[ " $( tool lit-divi-outline " {\"post_id\": $PAGE_ID } " | jq -r '.tree_rev' ) " = " $TREE_REV " ] || fail "refused insert wrote something"
INSERT = $( tool lit-divi-insert " $( jq -cn --argjson id " $PAGE_ID " --arg rev " $TREE_REV " '{post_id: $id, tree_rev: $rev, before: "s1",
nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [
[{type: "heading", text: "{{post:title}}", level: "h2"}],
[{type: "text", content: "<p>Hallo <strong>Welt</strong></p>"},
{type: "button", text: "Mehr erfahren", url: "/mehr", label: "Neu – CTA"}]]}]}]}' ) " )
echo " $INSERT " | jq -e '.ok == true and .changed[0].id == "s1" and .revision_id != null and .log_id != null' >/dev/null \
|| fail " divi-insert: $INSERT "
TREE_REV = $( echo " $INSERT " | jq -r '.tree_rev' )
[ " $( keeps_leistungen " $PAGE_ID " ) " = "yes" ] || fail "insert changed the bytes of an untouched section"
SUMMARY = $( tool lit-page-render-summary " {\"post_id\": $PAGE_ID } " )
echo " $SUMMARY " | jq -e '.headings[] | select(.level == 2 and .text == "LIT Divi Schreiben")' >/dev/null \
|| fail " {{post:title}} not rendered: $SUMMARY "
pass "lit-divi-insert: javascript: URL refused, section with placeholders, untouched bytes kept, rendered"
# 3. Lock and all-or-nothing
STALE = $( tool lit-divi-update " {\"post_id\": $PAGE_ID ,\"tree_rev\":\"000000000000\",\"changes\":[{\"id\":\"s0\",\"label\":\"x\"}]} " )
[ " $( error_code " $STALE " ) " = "rev_mismatch" ] || fail " stale tree_rev accepted: $STALE "
BAD = $( tool lit-divi-update " $( jq -cn --argjson id " $PAGE_ID " --arg rev " $TREE_REV " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt"}}, {id: "s1.r0.c0.m0", attrs: {"title.decoration.fnt": {desktop: "x"}}}]}' ) " )
[ " $( error_code " $BAD " ) " = "invalid_attr_path" ] || fail " invalid path accepted: $BAD "
[ " $( tool lit-divi-outline " {\"post_id\": $PAGE_ID } " | jq -r '.tree_rev' ) " = " $TREE_REV " ] || fail "failed update wrote something"
pass "tree_rev lock and invalid_attr_path leave the page unchanged"
# 4. Update, move, delete
UPDATE = $( tool lit-divi-update " $( jq -cn --argjson id " $PAGE_ID " --arg rev " $TREE_REV " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt anfragen"}, attrs: {"button.decoration.background": {desktop: {color: "{{color:secondary}}"}}}}]}' ) " )
echo " $UPDATE " | jq -e '.ok == true and .changed[0].action == "updated"' >/dev/null || fail " divi-update: $UPDATE "
TREE_REV = $( echo " $UPDATE " | jq -r '.tree_rev' )
MOVE = $( tool lit-divi-move " {\"post_id\": $PAGE_ID ,\"tree_rev\":\" $TREE_REV \",\"id\":\"s1.r0.c1.m1\",\"parent\":\"s1.r0.c0\"} " )
echo " $MOVE " | jq -e '.changed[0].id == "s1.r0.c0.m1"' >/dev/null || fail " divi-move: $MOVE "
TREE_REV = $( echo " $MOVE " | jq -r '.tree_rev' )
ASK = $( tool lit-divi-delete " {\"post_id\": $PAGE_ID ,\"tree_rev\":\" $TREE_REV \",\"ids\":[\"s1.r0.c0.m1\"]} " )
[ " $( error_code " $ASK " ) " = "confirm_required" ] || fail " delete without confirm: $ASK "
DELETE = $( tool lit-divi-delete " {\"post_id\": $PAGE_ID ,\"tree_rev\":\" $TREE_REV \",\"ids\":[\"s1.r0.c0.m1\"],\"confirm\":true} " )
echo " $DELETE " | jq -e '.changed[0].action == "deleted"' >/dev/null || fail " divi-delete: $DELETE "
TREE_REV = $( echo " $DELETE " | jq -r '.tree_rev' )
[ " $( keeps_leistungen " $PAGE_ID " ) " = "yes" ] || fail "update/move/delete changed an untouched section"
pass "lit-divi-update, lit-divi-move, lit-divi-delete"
# 5. Duplicate and replace text (indirect)
DUP = $( ability lit/divi-duplicate-node " {\"post_id\": $PAGE_ID ,\"tree_rev\":\" $TREE_REV \",\"id\":\"s1\"} " )
echo " $DUP " | jq -e '.changed[0].id == "s2" and (.changed[0].label | endswith("(Kopie)"))' >/dev/null || fail " duplicate: $DUP "
DRY = $( ability lit/divi-replace-text '{"search":"Köln","replace":"Bonn","limit":100}' )
TARGET = $( echo " $DRY " | jq -c --argjson id " $PAGE_ID " '[.matches[] | select(.post_id == $id) | {post_id, tree_rev}]' )
[ " $TARGET " != "[]" ] || fail " dry run found nothing: $DRY "
if content_of " $PAGE_ID " | grep -q 'Bonn' ; then fail "dry run changed content" ; fi
RUN = $( ability lit/divi-replace-text " $( jq -cn --argjson t " $TARGET " '{search: "Köln", replace: "Bonn", dry_run: false, confirm: true, targets: $t}' ) " )
echo " $RUN " | jq -e '.results[0].ok == true and .results[0].count >= 1' >/dev/null || fail " replace-text: $RUN "
content_of " $PAGE_ID " | grep -q 'Bonn' || fail "replace-text wrote nothing"
pass "lit/divi-duplicate-node and lit/divi-replace-text (dry run, confirmed run)"
# 6. New Divi page in one call; first section on an empty Divi page (ruling S9)
CREATED = $( tool lit-content-create '{"post_type":"page","title":"LIT Divi Neu","divi":[{"type":"section","label":"Start","modules":[{"type":"heading","text":"Neu hier"},{"type":"text","content":"<p>Text</p>"}]}]}' )
NEW_ID = $( echo " $CREATED " | jq -r '.changed[0].id // empty' )
[ -n " $NEW_ID " ] || fail " content-create with divi: $CREATED "
CREATED_POSTS += ( " $NEW_ID " )
echo " $CREATED " | jq -e '.tree_rev != null' >/dev/null || fail " content-create lacks tree_rev: $CREATED "
[ " $( wp post meta get " $NEW_ID " _et_pb_use_divi_5) " = "on" ] || fail "Divi flag missing"
[ " $( wp post get " $NEW_ID " --field= post_status) " = "draft" ] || fail "new Divi page is not a draft"
tool lit-page-render-summary " {\"post_id\": $NEW_ID } " | jq -e '.headings[0].text == "Neu hier"' >/dev/null || fail "new page not rendered"
EMPTY_ID = $( create_page "" "LIT Divi Leer" )
CREATED_POSTS += ( " $EMPTY_ID " )
EMPTY = $( tool lit-divi-outline " {\"post_id\": $EMPTY_ID } " )
echo " $EMPTY " | jq -e '.hints[0] | contains("lit-divi-insert")' >/dev/null || fail " empty-page hint: $EMPTY "
FIRST = $( tool lit-divi-insert " $( jq -cn --argjson id " $EMPTY_ID " --arg rev " $( echo " $EMPTY " | jq -r '.tree_rev' ) " '{post_id: $id, tree_rev: $rev,
nodes: [{type: "section", label: "Start", modules: [{type: "heading", text: "Erster Abschnitt"}]}]}' ) " )
echo " $FIRST " | jq -e '.changed[0].id == "s0"' >/dev/null || fail " insert into empty page: $FIRST "
content_of " $EMPTY_ID " | grep -q '^<!-- wp:divi/placeholder --><!-- wp:divi/section ' || fail "first section not wrapped in divi/placeholder"
pass "lit-content-create with Divi short form; first section of an empty page wrapped like the builder"
# 7. A user without unfiltered_html on the admin-authored page (rulings D7, S6): untouched bytes
# identical, nothing dangerous stored or rendered.
wp role create lit_smoke_editor "LIT Smoke Editor" --clone= editor >/dev/null
wp cap remove lit_smoke_editor unfiltered_html >/dev/null
wp option update lit_mcp_allowed_roles '["administrator","lit_smoke_editor"]' --format= json >/dev/null
wp user create " $EDITOR_LOGIN " lit-smoke-editor@example.invalid --role= lit_smoke_editor >/dev/null
[ " $( LIT_LOGIN = " $EDITOR_LOGIN " wp eval 'echo user_can(get_user_by("login", getenv("LIT_LOGIN")), "unfiltered_html") ? "yes" : "no";' ) " = "no" ] \
|| fail "the editor must not have unfiltered_html"
connect " $EDITOR_LOGIN "
TREE_REV = $( tool lit-divi-outline " {\"post_id\": $PAGE_ID } " | jq -r '.tree_rev' )
# 7a. Rich text with markup: kses for the editor
EDIT = $( tool lit-divi-update " $( jq -cn --argjson id " $PAGE_ID " --arg rev " $TREE_REV " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m1", set: {content: "<p>Neu vom Redakteur</p><script>alert(1)</script><img src=\"x\" onerror=\"alert(1)\"><a href=\"javascript:alert(1)\">Link</a>"}}]}' ) " )
echo " $EDIT " | jq -e '.ok == true' >/dev/null || fail " editor update: $EDIT "
TREE_REV = $( echo " $EDIT " | jq -r '.tree_rev' )
[ " $( keeps_leistungen " $PAGE_ID " ) " = "yes" ] || fail "kses re-serialized untouched blocks of an editor save"
assert_clean " $PAGE_ID " "7a rich text"
# 7b. Whole-value $variable with markup in before: re-serialized with cleaned settings, or refused.
# before/after take the HTML wp_kses_post allows (data-* attributes included), so the data-probe
# marker is only used in text fields (7c/7d), where no attribute may ever appear.
VAR = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"<script>alert(1)</script><img src=\"x\" onerror=\"alert(1)\"><a href=\"javascript:alert(1)\">x</a>","after":"<svg onload=\"alert(1)\"></svg>"}}})$'
WHOLE = $( tool lit-divi-update " $( jq -cn --argjson id " $PAGE_ID " --arg rev " $TREE_REV " --arg v " $VAR " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m0", set: {text: $v}}, {id: "s0.r0.c0.m1", set: {content: $v}}]}' ) " )
if echo " $WHOLE " | jq -e '.ok == true' >/dev/null; then
TREE_REV = $( echo " $WHOLE " | jq -r '.tree_rev' )
else
[ " $( error_code " $WHOLE " ) " = "validation_failed" ] || fail " whole-value variable: $WHOLE "
fi
assert_clean " $PAGE_ID " " 7b whole-value \$variable ( ${ WHOLE : 0 : 60 } ) "
outcome( ) { echo " $1 " | jq -r 'if .ok == true then "saved (cleaned)" else "refused: " + ((.__error // "") | (try fromjson catch {}) | (.code // "?")) end' ; }
echo " info - 7b whole-value \$variable with markup in before/after: $( outcome " $WHOLE " ) "
# 7c. Embedded $variable inside rich text: refused, nothing written
PROBE_VAR = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"\"><b data-probe=1>","after":""}}})$'
EMBEDDED = $( tool lit-divi-update " $( jq -cn --argjson id " $PAGE_ID " --arg rev " $TREE_REV " --arg v " <p>Hallo $PROBE_VAR Welt</p> " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m1", set: {content: $v}}]}' ) " )
[ " $( error_code " $EMBEDDED " ) " = "validation_failed" ] || fail " embedded variable accepted: $EMBEDDED "
[ " $( tool lit-divi-outline " {\"post_id\": $PAGE_ID } " | jq -r '.tree_rev' ) " = " $TREE_REV " ] || fail "refused embedded variable wrote something"
# 7d. Text field with tag-split payloads, and a javascript: background image
SPLIT = $( tool lit-divi-update " $( jq -cn --argjson id " $PAGE_ID " --arg rev " $TREE_REV " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m0", set: {text: "<scr<script>ipt>alert(1)</scr</script>ipt> \"><<span data-probe=1>span data-probe=1>Titel"}},
{id: "s0.r0.c0.m2", set: {text: "<<b>b data-probe=1>Knopf</b>"}}]}' ) " )
if echo " $SPLIT " | jq -e '.ok == true' >/dev/null; then
TREE_REV = $( echo " $SPLIT " | jq -r '.tree_rev' )
else
[ " $( error_code " $SPLIT " ) " = "validation_failed" ] || fail " tag-split text: $SPLIT "
fi
BG = $( tool lit-divi-update " $( jq -cn --argjson id " $PAGE_ID " --arg rev " $TREE_REV " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0", attrs: {"module.decoration.background": {desktop: {image: {url: "javascript:alert(1)"}}}}}]}' ) " )
if echo " $BG " | jq -e '.ok == true' >/dev/null; then
TREE_REV = $( echo " $BG " | jq -r '.tree_rev' )
else
[ " $( error_code " $BG " ) " = "validation_failed" ] || [ " $( error_code " $BG " ) " = "invalid_attr_path" ] || fail " javascript: background: $BG "
fi
assert_clean " $PAGE_ID " "7d tag-split text / javascript: background"
echo " info - 7d tag-split text: $( outcome " $SPLIT " ) ; javascript: background image: $( outcome " $BG " ) "
SUMMARY = $( tool lit-page-render-summary " {\"post_id\": $PAGE_ID } " )
# The summary reports visible text (a probe shows up there only as harmless literal text, e.g. an
# entity-escaped "<script>" from sanitize_text_field); its link and image targets must be clean.
echo " $SUMMARY " | jq -e '[.links[].href, .images[].src] | map(select(. != null and test("^\\s*(javascript|data|vbscript)\\s*:"; "i"))) | length == 0' >/dev/null \
|| fail " render summary shows a dangerous link or image: $SUMMARY "
[ " $( keeps_leistungen " $PAGE_ID " ) " = "yes" ] || fail "editor saves changed an untouched section"
pass "user without unfiltered_html: untouched bytes identical; no script, handler, javascript: or data-probe stored or rendered"
# 8. Divi's own save filters (ruling S7). On a page with custom attributes or elementType Divi's
# AttributeSecurity/HtmlSecurity (wp_insert_post_data, every user, never removed) parse and
# re-serialize the WHOLE page with serialize_blocks() on every save.
# 8a: the page as Divi itself stored it (already in serialize_blocks() form): our write changes
# only the edited node, no warning.
# 8b: the same page in another valid byte form (empty blocks as <!-- wp:x --><!-- /wp:x -->,
# e.g. imported without Divi's filters): Divi normalizes untouched blocks too – the bytes
# change beyond the edit, the answer carries CHANGED_ON_SAVE, tree_rev is the stored state,
# custom fields survive (Divi 5.2.1 renders the custom attribute; the text module's wrapper
# ignores elementType), the page renders, and the next write is byte-stable again.
connect " $ADMIN_LOGIN "
FILTER_ID = $( create_page synthetic-divi-filters.html "LIT Divi Filter" )
CREATED_POSTS += ( " $FILTER_ID " )
node_bytes( ) { # $1 = post ID, $2 = admin label -> the stored opener of that block
LIT_ID = " $1 " LIT_LABEL = " $2 " wp eval '
$c = (string) get_post((int) getenv("LIT_ID"))->post_content;
$needle = "\"value\":\"" . getenv("LIT_LABEL") . "\"";
$at = strpos($c, $needle);
$start = strrpos(substr($c, 0, $at), "<!-- wp:");
echo substr($c, $start, strpos($c, "-->", $at) + 3 - $start);'
}
ARTICLE_BEFORE = $( node_bytes " $FILTER_ID " "Eigene Felder – Artikel" )
TREE_REV = $( tool lit-divi-outline " {\"post_id\": $FILTER_ID } " | jq -r '.tree_rev' )
CANONICAL = $( tool lit-divi-update " $( jq -cn --argjson id " $FILTER_ID " --arg rev " $TREE_REV " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m2", set: {text: "Schreiben"}}]}' ) " )
echo " $CANONICAL " | jq -e '.ok == true and (.warnings | any(contains("beim Speichern verändert")) | not)' >/dev/null \
|| fail " 8a: write on a Divi-stored page: $CANONICAL "
[ " $( node_bytes " $FILTER_ID " "Eigene Felder – Artikel" ) " = " $ARTICLE_BEFORE " ] || fail "8a: untouched block changed"
pass "Divi save filters, page as Divi stored it: only the edited node changes, no warning"
LIT_ID = " $FILTER_ID " LIT_FILE = " $FIXTURES /synthetic-divi-filters.html " wp eval '
global $wpdb;
$id = (int) getenv("LIT_ID");
$wpdb->update($wpdb->posts, ["post_content" => (string) file_get_contents(getenv("LIT_FILE"))], ["ID" => $id]);
clean_post_cache($id);'
ARTICLE_BEFORE = $( node_bytes " $FILTER_ID " "Eigene Felder – Artikel" )
TREE_REV = $( tool lit-divi-outline " {\"post_id\": $FILTER_ID } " | jq -r '.tree_rev' )
FILTERED = $( tool lit-divi-update " $( jq -cn --argjson id " $FILTER_ID " --arg rev " $TREE_REV " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m1", set: {text: "Eigene Felder bleiben erhalten"}}]}' ) " )
echo " $FILTERED " | jq -e '.ok == true' >/dev/null || fail " 8b: update on a page with custom fields: $FILTERED "
echo " $FILTERED " | jq -e '.warnings | any(contains("beim Speichern verändert"))' >/dev/null \
|| fail " 8b: CHANGED_ON_SAVE missing (Divi no longer normalizes – update the S7 documentation): $FILTERED "
[ " $( node_bytes " $FILTER_ID " "Eigene Felder – Artikel" ) " != " $ARTICLE_BEFORE " ] \
|| fail "8b: untouched block kept its bytes (Divi no longer normalizes – update the S7 documentation)"
OUTLINE = $( tool lit-divi-outline " {\"post_id\": $FILTER_ID } " )
[ " $( echo " $OUTLINE " | jq -r '.tree_rev' ) " = " $( echo " $FILTERED " | jq -r '.tree_rev' ) " ] \
|| fail " 8b: tree_rev of the answer is not the stored state: $FILTERED "
NODE = $( tool lit-divi-node-get " {\"post_id\": $FILTER_ID ,\"id\":\"s0.r0.c0.m0\"} " )
echo " $NODE " | jq -e 'tostring | contains("article") and contains("data-track")' >/dev/null \
|| fail " 8b: elementType or custom attribute lost: $NODE "
RENDERED = $( LIT_ID = " $FILTER_ID " wp --user= " $ADMIN_LOGIN " eval \
'echo (new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render(get_post((int) getenv("LIT_ID")));' )
echo " $RENDERED " | grep -q 'data-track="hero"' || fail "8b: custom attribute not rendered"
echo " $RENDERED " | grep -q 'Eigene Felder bleiben erhalten' || fail "8b: update not rendered"
AGAIN = $( tool lit-divi-update " $( jq -cn --argjson id " $FILTER_ID " --arg rev " $( echo " $OUTLINE " | jq -r '.tree_rev' ) " '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m2", set: {text: "Nochmal"}}]}' ) " )
echo " $AGAIN " | jq -e '.ok == true and (.warnings | any(contains("beim Speichern verändert")) | not)' >/dev/null \
|| fail " 8b: follow-up write with the new tree_rev: $AGAIN "
pass "Divi save filters, other byte form: normalized with CHANGED_ON_SAVE, tree_rev = stored state, page valid, then stable"
echo "ALL CHECKS PASSED"