docs: Divi guides shortform/placeholders, hints name the write tools; test: Divi write end-to-end; version 0.3.0
- Hints (PostPresenter::DIVI_NOTE, outline, catalog) and server instructions (tree_rev rule) name the Divi write tools; guides divi-shortform and divi-placeholders, additions to divi-structure, divi-attrs, overview (tool groups, 'nur lesen'), workflows, content-types. - D7 wording and the S7 exception in readme, guides and spec 1.3/5.2; readme 0.3.0. - ToolNamingTest: every referenced guide topic exists; overview lists exactly the topics. - tests/Integration/divi-write-smoke.sh: all write tools via OAuth, editor without unfiltered_html with benign probes (S6), Divi's own save filters on the new fixture synthetic-divi-filters.html (S7); admin-render.php accepts the new wrap class. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -41,6 +41,6 @@ foreach ($lit_page->tabSlugs() as $lit_slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lit_ok = str_contains($lit_html, 'nav-tab-active') && str_contains($lit_html, 'class="wrap"');
|
||||
$lit_ok = str_contains($lit_html, 'nav-tab-active') && preg_match('/class="wrap[ "]/', $lit_html) === 1;
|
||||
echo $lit_slug . ($lit_ok ? ' ok ' : ' FAIL ') . strlen($lit_html) . "\n";
|
||||
}
|
||||
|
||||
Executable
+364
@@ -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"
|
||||
Reference in New Issue
Block a user