#!/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 '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, "") - $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" => "", "after" => ""]]]) . ")$"; $text = ["content" => ["innerContent" => ["desktop" => ["value" => "

x

"]]]]; $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: "

Hallo Welt

"}, {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: "

Hallo Welt

"}, {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":"

Text

"}]}]}') 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 '^, # 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), "", $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" finish