Files
WP-LIT-MCP/tests/Integration/divi-write-smoke.sh
T
janniclas.loosenandClaude Opus 5.5 99eda25452 fix(security): Divi-markup refusal sees content as storage will (ruling S52)
Control characters removed by kses (wp_kses_no_null: C0, \\0 sequences)
or ControlChars (DEL) could hide a divi/* delimiter or an et_pb shortcode
from the raw-input check. The shared DiviMarkup helper checks the raw
and the normalized form and now guards every raw-content path:
content-create/-update content and media-update description (attachment
post_content). MediaImporter logs import failures with scheme, host,
port and path only; uninstall.php lists the removed user meta.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:50:31 +02:00

420 lines
29 KiB
Bash
Executable File
Raw Permalink 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.
#!/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"]')
ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__')
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
if [ "$ORIGINAL_EXPOSURE" = "__none__" ]; then
wp option delete lit_mcp_exposure >/dev/null 2>&1 || true
else
wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true
fi
}
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' 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"
echo "$CREATED" | jq -e '.edit_url | contains("et_fb=1")' >/dev/null || fail "content-create edit_url is not the builder: $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"
# 9. No way around the Divi pipeline (security audit M1, M2):
# 9a: raw content with Divi block markup is refused on create and update.
RAW=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Roh","content":"<!-- wp:divi/section --><!-- /wp:divi/section -->"}')
[ "$(error_code "$RAW")" = "validation_failed" ] || fail "9a: raw Divi markup accepted by content-create: $RAW"
PLAIN=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Klar","content":"<!-- wp:paragraph --><p>x</p><!-- /wp:paragraph -->"}')
PLAIN_ID=$(echo "$PLAIN" | jq -r '.changed[0].id // empty')
[ -n "$PLAIN_ID" ] || fail "9a: plain block markup refused: $PLAIN"
CREATED_POSTS+=("$PLAIN_ID")
PLAIN_REV=$(tool lit-content-get "{\"post_id\":$PLAIN_ID}" | jq -r '.rev')
RAW_UPDATE=$(tool lit-content-update "$(jq -cn --argjson id "$PLAIN_ID" --arg rev "$PLAIN_REV" '{post_id: $id, rev: $rev,
content: "<!--\n wp:divi/text {\"module\":{}} /-->"}')")
[ "$(error_code "$RAW_UPDATE")" = "validation_failed" ] || fail "9a: raw Divi markup accepted by content-update: $RAW_UPDATE"
pass "raw content with Divi markup refused (create and update); other block markup allowed"
# 9b: Divi group "nur lesen" stops content-create divi and lit/divi-insert (also via execute-ability).
wp option update lit_mcp_exposure '{"@readonly:divi":true}' --format=json >/dev/null
connect "$ADMIN_LOGIN"
GATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Gesperrt","divi":[{"type":"section"}]}')
[ "$(error_code "$GATED")" = "unsupported" ] || fail "9b: content-create divi with Divi read-only: $GATED"
UNGATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Ohne"}')
UNGATED_ID=$(echo "$UNGATED" | jq -r '.changed[0].id // empty')
[ -n "$UNGATED_ID" ] || fail "9b: content-create without divi must keep working: $UNGATED"
CREATED_POSTS+=("$UNGATED_ID")
VIA_EXECUTE=$(ability lit/divi-insert "{\"post_id\":$UNGATED_ID,\"tree_rev\":\"x\",\"nodes\":[{\"type\":\"section\"}]}")
echo "$VIA_EXECUTE" | jq -e 'has("__error")' >/dev/null || fail "9b: lit/divi-insert reachable via execute-ability: $VIA_EXECUTE"
echo "$VIA_EXECUTE" | grep -q 'not found' || fail "9b: expected ability not found: $VIA_EXECUTE"
pass "Divi read-only: content-create divi refused, content-create without divi works, divi-insert not executable"
# 9c: control characters cannot hide the markup (ruling S52) – as the user without unfiltered_html,
# on create, update and the attachment description.
wp option delete lit_mcp_exposure >/dev/null 2>&1 || true
[ "$ORIGINAL_EXPOSURE" = "__none__" ] || wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null
ATTACHMENT_ID=$(wp eval 'echo wp_insert_attachment(["post_title" => "LIT Divi Anhang", "post_mime_type" => "image/png", "post_status" => "inherit"]);')
CREATED_POSTS+=("$ATTACHMENT_ID")
connect "$EDITOR_LOGIN"
for hidden in '<!--\u0000 wp:divi/text {\"a\":1} /-->' '<!-- wp:di\u0000vi/text /-->' '<!-- wp:divi\u0001/text /-->' '[et_\u0000pb_text]x[/et_pb_text]'; do
NUL_CREATE=$(tool lit-content-create "$(jq -cn --argjson c "\"$hidden\"" '{post_type: "page", title: "LIT Divi NUL", content: $c}')")
[ "$(error_code "$NUL_CREATE")" = "validation_failed" ] || fail "9c: create accepted hidden Divi markup $hidden: $NUL_CREATE"
NUL_REV=$(tool lit-content-get "{\"post_id\":$PLAIN_ID}" | jq -r '.rev')
NUL_UPDATE=$(tool lit-content-update "$(jq -cn --argjson id "$PLAIN_ID" --arg rev "$NUL_REV" --argjson c "\"$hidden\"" '{post_id: $id, rev: $rev, content: $c}')")
[ "$(error_code "$NUL_UPDATE")" = "validation_failed" ] || fail "9c: update accepted hidden Divi markup $hidden: $NUL_UPDATE"
NUL_MEDIA=$(ability lit/media-update "$(jq -cn --argjson id "$ATTACHMENT_ID" --argjson c "\"$hidden\"" '{items: [{id: $id, description: $c}]}')")
[ "$(error_code "$NUL_MEDIA")" = "validation_failed" ] || fail "9c: media description accepted hidden Divi markup $hidden: $NUL_MEDIA"
done
if content_of "$PLAIN_ID" | grep -q 'wp:divi'; then fail "9c: Divi markup reached the stored content"; fi
if content_of "$ATTACHMENT_ID" | grep -q 'wp:divi'; then fail "9c: Divi markup reached the attachment description"; fi
pass "control characters inside the Divi delimiter or shortcode refused (create, update, media description)"
finish