#!/usr/bin/env bash # End-to-end checks of Etappe 3a (Divi Library, global modules, node copy, {{link:ID}}) 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-library-smoke.sh # Creates only temporary data: draft/private pages and library entries whose titles start with # "LIT Smoke ", a role and a user without unfiltered_html. The EXIT trap removes all of it by # exact ID (posts with their revisions, change-log rows, user, role, app passwords) and restores the # options it changed. [Fixture]/[Test] content is only read (fixture files), never written. # Security probes (section 10) use benign markers only: data-probe attributes and an example.invalid URL. 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) STAGE3=$PLUGIN/tests/Fixtures/divi/stage3 RUN="$(date +%s)-$$" PREFIX="LIT Smoke $RUN" # Per-run names: cleanup never touches an account or role that existed before this run. EDITOR_LOGIN="lit-lib-editor-$RUN" EDITOR_ROLE="lit_lib_editor_${RUN//-/_}" EDITOR_CREATED=0 ROLE_CREATED=0 ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '__none__') ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__') CREATED_POSTS=() restore_roles() { if [ "$ORIGINAL_ROLES" = "__none__" ]; then wp option delete lit_mcp_allowed_roles >/dev/null 2>&1 || true else wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true fi } restore_exposure() { 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 } cleanup() { # Pages and library entries of this run whose ID a stopped run could not record (title prefix with the run token). local found found=$(LIT_PREFIX="$PREFIX " wp eval ' global $wpdb; echo implode(" ", $wpdb->get_col($wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type IN (%s, %s) AND post_title LIKE %s", "page", "et_pb_layout", $wpdb->esc_like((string) getenv("LIT_PREFIX")) . "%" )));' 2>/dev/null || true) for id in $found; do CREATED_POSTS+=("$id") done # Change-log rows: every row of the temporary user and every row about a post of this run. 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_unique(array_filter(explode(" ", (string) getenv("LIT_IDS")))) as $id) { foreach (["page", "et_pb_layout"] as $type) { $wpdb->delete($table, ["object_type" => $type, "object_id" => $id]); } }' >/dev/null 2>&1 || true # wp_delete_post(…, true) also deletes the revisions of each post. for id in "${CREATED_POSTS[@]}"; do wp post delete "$id" --force >/dev/null 2>&1 || true done cleanup_app_passwords_for "$ADMIN_LOGIN" if [ "$EDITOR_CREATED" = 1 ]; then cleanup_app_passwords_for "$EDITOR_LOGIN" wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true fi if [ "$ROLE_CREATED" = 1 ]; then wp role delete "$EDITOR_ROLE" >/dev/null 2>&1 || true fi restore_roles restore_exposure } trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT # Creates a page as the admin (no kses) from markup and prints its ID. create_page() { # $1 = markup, $2 = title suffix, $3 = status (default draft) LIT_CONTENT="$1" LIT_TITLE="$PREFIX $2" LIT_STATUS="${3:-draft}" wp --user="$ADMIN_LOGIN" eval ' $id = wp_insert_post(["post_type" => "page", "post_status" => getenv("LIT_STATUS"), "post_title" => getenv("LIT_TITLE"), "post_content" => wp_slash((string) getenv("LIT_CONTENT"))]); update_post_meta($id, "_et_pb_use_builder", "on"); update_post_meta($id, "_et_pb_use_divi_5", "on"); echo $id;' } # Exact stored bytes of a post (PHP, so no trailing newline is lost or added). content_of() { # $1 = post ID wp eval "echo (string) get_post($1)->post_content;" } content_hash() { # $1 = post ID wp eval "echo hash('sha256', (string) get_post($1)->post_content);" } outline() { # $1 = post ID -> the lit-divi-outline result tool lit-divi-outline "{\"post_id\":$1}" } rev_of() { # $1 = post ID -> tree_rev outline "$1" | jq -r '.tree_rev' } # "yes" when the stored content equals $2 with exactly the bytes $3 inserted at one position. inserted_once() { # $1 = post ID, $2 = content before, $3 = inserted bytes LIT_ID="$1" LIT_BEFORE="$2" LIT_NEEDLE="$3" wp eval ' $after = (string) get_post((int) getenv("LIT_ID"))->post_content; $before = (string) getenv("LIT_BEFORE"); $needle = (string) getenv("LIT_NEEDLE"); $ok = $needle !== "" && strlen($after) === strlen($before) + strlen($needle); for ($at = $ok ? strpos($after, $needle) : false, $ok = false; $at !== false; $at = strpos($after, $needle, $at + 1)) { if (substr($after, 0, $at) . substr($after, $at + strlen($needle)) === $before) { $ok = true; break; } } echo $ok ? "yes" : "no";' } # Divi's _et_* meta and the library terms of an entry, as sorted JSON (compared with the stage3 fixtures). entry_shape() { # $1 = post ID wp eval " \$meta = []; foreach (get_post_meta($1) as \$k => \$v) { if (str_starts_with(\$k, '_et_')) { \$meta[\$k] = \$v; } } ksort(\$meta); \$terms = []; foreach (['layout_type', 'module_width', 'scope'] as \$t) { \$terms[\$t] = wp_get_object_terms($1, \$t, ['fields' => 'slugs']); } echo wp_json_encode(['meta' => \$meta, 'terms' => \$terms]);" } fixture_shape() { # $1 = stage3 fixture name jq -cS '{meta: (.meta | with_entries(select(.key | startswith("_et_")))), terms: {layout_type: [.terms.layout_type[]?.slug], module_width: [.terms.module_width[]?.slug], scope: [.terms.scope[]?.slug]}}' \ "$STAGE3/$1.json" } # Byte snippets from Divi's own stage3 saves (fixtures were written by the Visual Builder). snippet() { # $1 = what -> prints the bytes LIT_DIR="$STAGE3" LIT_WHAT="$1" wp eval ' $dir = getenv("LIT_DIR"); $read = static fn (string $f): string => (string) file_get_contents("$dir/$f.html"); $unframe = static fn (string $c): string => substr($c, strlen(""), -strlen("")); $page = $read("page--globale-instanzen"); $from = strpos($page, "", $from) + 5 - $from); $globalSection = substr($page, strlen(""), strpos($page, "\n\n") + strlen("\n\n") - strlen("")); $text = $unframe($read("library-global--globales-modul")); switch (getenv("LIT_WHAT")) { case "source": // the fixture page without global instances: the global module as a plain text module echo str_replace([$globalSection, $instance], ["", $text], $page); break; case "target": // the section "Modul Abschnitt" with only the local button echo str_replace([$globalSection, $instance . "\n\n"], ["", ""], substr($page, 0, strpos($page, ""; break; case "section-entry": echo str_replace("\r\n", "\n", $read("library--library-abschnitt")); break; case "section": echo $unframe(str_replace("\r\n", "\n", $read("library--library-abschnitt"))); break; case "module-entry": echo $read("library-global--globales-modul"); break; case "instance": echo $instance; break; }' } [ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme" # 0. Temporary pages built from Divi's own stage3 bytes SOURCE_ID=$(create_page "$(snippet source)" "Quelle"); CREATED_POSTS+=("$SOURCE_ID") TARGET_ID=$(create_page "$(snippet target)" "Ziel"); CREATED_POSTS+=("$TARGET_ID") SECTION_BYTES=$(snippet section) content_of "$SOURCE_ID" | grep -qF "$SECTION_BYTES" || fail "setup: source page lacks the library section bytes" if content_of "$SOURCE_ID" | grep -q 'globalModule'; then fail "setup: source page still holds a global instance"; fi SOURCE_HASH=$(content_hash "$SOURCE_ID") connect "$ADMIN_LOGIN" # 1. Exposure: lit-library-insert direct, the other new tools indirect, 22 direct lit tools LIST=$(rpc tools/list '{}') echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert") | .annotations.readOnlyHint == false' >/dev/null \ || fail "lit-library-insert is not a direct write tool" for t in lit-library-list lit-library-save lit-divi-node-copy lit-divi-dynamic-sources; do if echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$t must be indirect"; fi done DIRECT='["lit-content-create","lit-content-get","lit-content-query","lit-content-types","lit-content-update","lit-divi-catalog", "lit-divi-delete","lit-divi-design-system","lit-divi-insert","lit-divi-move","lit-divi-node-get","lit-divi-outline","lit-divi-update", "lit-guide","lit-library-insert","lit-media-download","lit-media-preview","lit-media-search","lit-media-upload-from-url", "lit-page-render-summary","lit-post-publish","lit-site-status"]' echo "$LIST" | jq -e --argjson want "$DIRECT" '[.result.tools[].name | select(startswith("lit-"))] | sort == ($want | sort) and length == 22' >/dev/null \ || fail "direct lit tools: $(echo "$LIST" | jq -c '[.result.tools[].name]')" for a in lit/library-list lit/library-save lit/divi-node-copy lit/divi-dynamic-sources; do tool mcp-adapter-get-ability-info "$(jq -cn --arg a "$a" '{ability_name: $a}')" | jq -e 'has("__error") | not' >/dev/null \ || fail "$a is not reachable as an indirect ability" done pass "tools/list: lit-library-insert direct, library-list/-save, node-copy, dynamic-sources indirect, 22 direct tools" # 2. Save a section as normal entry: bytes, terms and meta as Divi's own entry, no Divi flags, source untouched REV=$(rev_of "$SOURCE_ID") SAVED=$(ability lit/library-save "$(jq -cn --argjson id "$SOURCE_ID" --arg rev "$REV" --arg t "$PREFIX Abschnitt" \ '{post_id: $id, tree_rev: $rev, id: "s1", title: $t}')") LIB_ID=$(echo "$SAVED" | jq -r '.library_id // empty') [ -n "$LIB_ID" ] || fail "library-save: $SAVED" CREATED_POSTS+=("$LIB_ID") [ "$(content_of "$LIB_ID")" = "$(snippet section-entry)" ] || fail "normal entry bytes differ from Divi's entry: $(content_of "$LIB_ID")" [ "$(entry_shape "$LIB_ID" | jq -cS .)" = "$(fixture_shape library--library-abschnitt)" ] \ || fail "normal entry terms/meta: $(entry_shape "$LIB_ID")" [ -z "$(wp post meta get "$LIB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "library entry got Divi flags" [ "$(wp post get "$LIB_ID" --field=post_status)" = "publish" ] || fail "library entry is not published" echo "$SAVED" | jq -e --arg r "$(rev_of "$LIB_ID")" '.tree_rev == $r' >/dev/null || fail "library-save tree_rev: $SAVED" [ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "library-save changed the source page" pass "lit/library-save normal: bytes, terms and meta like Divi's entry, no Divi flags, source unchanged" # 3. Save the text module as global entry; library-list shows both entries GSAVED=$(ability lit/library-save "$(jq -cn --argjson id "$SOURCE_ID" --arg rev "$REV" --arg t "$PREFIX Globales Modul" \ '{post_id: $id, tree_rev: $rev, id: "s0.r0.c0.m0", title: $t, scope: "global"}')") GLOB_ID=$(echo "$GSAVED" | jq -r '.library_id // empty') [ -n "$GLOB_ID" ] || fail "global library-save: $GSAVED" CREATED_POSTS+=("$GLOB_ID") [ "$(content_of "$GLOB_ID")" = "$(snippet module-entry)" ] || fail "global entry bytes differ from Divi's entry: $(content_of "$GLOB_ID")" [ "$(entry_shape "$GLOB_ID" | jq -cS .)" = "$(fixture_shape library-global--globales-modul)" ] \ || fail "global entry terms/meta: $(entry_shape "$GLOB_ID")" [ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "global library-save changed the source page" LISTED=$(ability lit/library-list "$(jq -cn --arg s "$PREFIX" '{search: $s}')") echo "$LISTED" | jq -e --argjson l "$LIB_ID" --argjson g "$GLOB_ID" ' (.items | map(select(.id == $l and .scope == "normal" and .layout_type == "section")) | length == 1) and (.items | map(select(.id == $g and .scope == "global" and .layout_type == "module")) | length == 1)' >/dev/null \ || fail "library-list: $LISTED" ability lit/library-list "{\"id\":$GLOB_ID}" | jq -e '.outline | length > 0' >/dev/null || fail "library-list detail of $GLOB_ID" pass "lit/library-save global: bytes, terms and meta like Divi's entry; lit/library-list shows both" # A normal section entry with the CRLF bytes of Divi's library dialog (stage3 fixture as-is, finding 9). CRLF_ID=$(LIT_FILE="$STAGE3/library--library-abschnitt.html" LIT_TITLE="$PREFIX CRLF" wp --user="$ADMIN_LOGIN" eval ' $id = wp_insert_post(["post_type" => "et_pb_layout", "post_status" => "publish", "post_title" => getenv("LIT_TITLE"), "post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]); wp_set_object_terms($id, "section", "layout_type"); wp_set_object_terms($id, "regular", "module_width"); wp_set_object_terms($id, "non_global", "scope"); update_post_meta($id, "_et_pb_template_type", "section"); update_post_meta($id, "_et_pb_built_for_post_type", "page"); echo $id;') CREATED_POSTS+=("$CRLF_ID") content_of "$CRLF_ID" | grep -q $'\r' || fail "setup: the CRLF entry holds no CR" # 4. Insert the global entry as instance, the CRLF entry as copy: bytes as the Visual Builder writes them BEFORE=$(content_of "$TARGET_ID") INST=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(rev_of "$TARGET_ID")" --argjson lib "$GLOB_ID" \ '{post_id: $id, tree_rev: $rev, library_id: $lib, parent: "s0.r0.c0", index: 0}')") echo "$INST" | jq -e '.ok == true and .changed[0].id == "s0.r0.c0.g0"' >/dev/null || fail "library-insert global: $INST" EXPECTED_INSTANCE=$(snippet instance | sed "s/\"globalModule\":\"1059\"/\"globalModule\":\"$GLOB_ID\"/") [ "$(inserted_once "$TARGET_ID" "$BEFORE" "$EXPECTED_INSTANCE")" = "yes" ] \ || fail "instance bytes differ from the Visual Builder's: $(content_of "$TARGET_ID")" outline "$TARGET_ID" | jq -e --arg g "$GLOB_ID" '.outline | contains($g)' >/dev/null || fail "outline does not name the instance" BEFORE=$(content_of "$TARGET_ID") COPY=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$INST" | jq -r .tree_rev)" --argjson lib "$CRLF_ID" \ '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')") echo "$COPY" | jq -e '.ok == true and .changed[0].id == "s1"' >/dev/null || fail "library-insert copy: $COPY" [ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "copy bytes differ from the entry's section" if content_of "$TARGET_ID" | grep -q $'\r'; then fail "copy brought CRLF into the page"; fi pass "lit-library-insert: instance and copy byte-exact in the Visual Builder's format" # 5. Global edit: dry run lists the pages, confirm changes the entry with a revision, page bytes unchanged PRIVATE_ID=$(create_page "$EXPECTED_INSTANCE" "Privat" private) CREATED_POSTS+=("$PRIVATE_ID") TARGET_HASH=$(content_hash "$TARGET_ID") PRIVATE_HASH=$(content_hash "$PRIVATE_ID") GLOB_HASH=$(content_hash "$GLOB_ID") GREV=$(rev_of "$GLOB_ID") CHANGE='[{"id":"m0","set":{"content":"

LIT Smoke geändert

"}}]' DRY=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, changes: $c}')") [ "$(error_code "$DRY")" = "confirm_required" ] || fail "global edit without confirm: $DRY" echo "$DRY" | jq -r '.__error' | jq -e --argjson t "$TARGET_ID" --argjson p "$PRIVATE_ID" \ '(.affected | map(.post_id) | (index($t) != null and index($p) != null)) and .affected_count >= 2 and (has("blocked_ids") | not)' >/dev/null \ || fail "dry run: $DRY" [ "$(content_hash "$GLOB_ID")" = "$GLOB_HASH" ] || fail "dry run wrote something" REVISIONS=$(wp eval "echo count(wp_get_post_revisions($GLOB_ID));") DONE=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, confirm: true, changes: $c}')") echo "$DONE" | jq -e '.ok == true and .revision_id != null and .affected_count >= 2' >/dev/null || fail "confirmed global edit: $DONE" [ "$(wp eval "echo count(wp_get_post_revisions($GLOB_ID));")" -gt "$REVISIONS" ] || fail "confirmed global edit made no revision" [ "$(wp post get "$(echo "$DONE" | jq -r .revision_id)" --field=post_parent)" = "$GLOB_ID" ] || fail "revision_id is not a revision of $GLOB_ID" content_of "$GLOB_ID" | grep -q 'LIT Smoke geändert' || fail "the entry was not changed" [ -z "$(wp post meta get "$GLOB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "global edit set Divi flags" [ "$(content_hash "$TARGET_ID")" = "$TARGET_HASH" ] || fail "the global edit changed the page bytes" [ "$(content_hash "$PRIVATE_ID")" = "$PRIVATE_HASH" ] || fail "the global edit changed the private page bytes" # Divi does not render global modules outside a front-end request; the summary names the instance instead. tool lit-page-render-summary "{\"post_id\":$TARGET_ID}" | jq -e --arg g "Globales Modul $GLOB_ID " '.notes | map(contains($g)) | any' >/dev/null \ || fail "render summary does not note the global module" pass "global edit: dry run lists the pages, confirm changes the entry with a revision, pages keep their bytes" # 6. Copy a node between pages; a stale source_tree_rev writes nothing TREV=$(rev_of "$TARGET_ID") STALE=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \ '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: "abcdef012345"}')") [ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale source_tree_rev: $STALE" [ "$(rev_of "$TARGET_ID")" = "$TREV" ] || fail "stale node-copy wrote something" BEFORE=$(content_of "$TARGET_ID") NODE_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \ --arg srev "$(rev_of "$SOURCE_ID")" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: $srev}')") echo "$NODE_COPY" | jq -e '.ok == true and .revision_id != null' >/dev/null || fail "node-copy: $NODE_COPY" [ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "node-copy bytes differ from the source node" [ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "node-copy changed the source page" BEFORE=$(content_of "$TARGET_ID") LIB_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$NODE_COPY" | jq -r .tree_rev)" \ --argjson src "$CRLF_ID" --arg srev "$(rev_of "$CRLF_ID")" \ '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s0", source_tree_rev: $srev}')") echo "$LIB_COPY" | jq -e '.ok == true' >/dev/null || fail "node-copy from the CRLF entry: $LIB_COPY" [ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "node-copy from a library entry: bytes differ" if content_of "$TARGET_ID" | grep -q $'\r'; then fail "node-copy brought CRLF into the page"; fi NODE_COPY=$LIB_COPY pass "lit/divi-node-copy: stale source_tree_rev refused, copy byte-exact (CRLF unified from a library entry), source unchanged" # 7. {{link:ID}} in a button URL: Divi's post_link_url_page; a draft target renders only ?page_id= TREV=$(echo "$NODE_COPY" | jq -r '.tree_rev') LINKED=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --arg u "{{link:$SOURCE_ID}}" \ '{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "button", text: "Zur Quelle", url: $u}]}')") echo "$LINKED" | jq -e '.ok == true' >/dev/null || fail "{{link}} in a button: $LINKED" LINK_VALUE=$(LIT_ID="$TARGET_ID" wp eval ' $find = static function (array $blocks) use (&$find): ?string { foreach ($blocks as $b) { $text = $b["attrs"]["button"]["innerContent"]["desktop"]["value"]["text"] ?? null; if ($b["blockName"] === "divi/button" && $text === "Zur Quelle") { return (string) ($b["attrs"]["button"]["innerContent"]["desktop"]["value"]["linkUrl"] ?? ""); } $in = $find($b["innerBlocks"] ?? []); if ($in !== null) { return $in; } } return null; }; echo $find(parse_blocks((string) get_post((int) getenv("LIT_ID"))->post_content));') echo "$LINK_VALUE" | grep -qE "^\\\$variable\(\{\"type\":\"content\",\"value\":\{\"name\":\"post_link_url_page\",\"settings\":\{\"post_id\":\"$SOURCE_ID\"\}\}\}\)\\\$\$" \ || fail "{{link}} not stored as Divi's post_link_url_page: $LINK_VALUE" RENDERED=$(LIT_ID="$TARGET_ID" wp eval 'echo (new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render(get_post((int) getenv("LIT_ID")));') echo "$RENDERED" | grep -qE "href=\"[^\"]*\\?page_id=$SOURCE_ID\"" || fail "draft link target does not render as ?page_id=$SOURCE_ID" if echo "$RENDERED" | grep -q "$PREFIX Quelle"; then fail "the rendered link reveals the draft's title"; fi TEXT_LINK=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$LINKED" | jq -r .tree_rev)" --arg u "{{link:$SOURCE_ID}}" \ '{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "heading", text: $u}]}')") [ "$(error_code "$TEXT_LINK")" = "validation_failed" ] || fail "{{link}} in a text field: $TEXT_LINK" pass "{{link:ID}} stored as post_link_url_page, draft target renders only ?page_id=, refused in text fields" # 8. Carry-overs: guide "all", site-status compatibility, dynamic sources for page tool lit-guide '{"topic":"all"}' | jq -e '.content | contains("divi-library") and contains("lit-library-insert")' >/dev/null || fail "guide all" STATUS=$(tool lit-site-status '{}') echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .active == true and .problems == []' >/dev/null \ || fail "Divi compatibility findings: $STATUS" SOURCES=$(ability lit/divi-dynamic-sources '{"post_type":"page"}') echo "$SOURCES" | jq -e '.post_type == "page" and (.sources | length > 0) and (.sources | map(select(.name == "post_title" and (.placeholder // "" | startswith("{{post:")))) | length == 1) and (.sources | map(select(.name == "post_link_url_page" and .placeholder == "{{link:}}")) | length == 1) and (.sources | map(select(.name | startswith("loop_"))) | length == 0) and ([.fields[].key] | map(select(startswith("_"))) | length == 0)' >/dev/null || fail "dynamic sources: $SOURCES" pass "lit-guide all, lit-site-status without Divi findings, lit/divi-dynamic-sources for page" # 9. Probe page (admin-authored, benign markers): interactions, Lottie source, custom HTML PROBE_ID=$(LIT_TITLE="$PREFIX Probe" wp --user="$ADMIN_LOGIN" eval ' $v = static fn ($x): array => ["desktop" => ["value" => $x]]; $wrap = static fn (array $module): array => ["blockName" => "divi/section", "attrs" => [], "innerContent" => [null], "innerBlocks" => [["blockName" => "divi/row", "attrs" => [], "innerContent" => [null], "innerBlocks" => [[ "blockName" => "divi/column", "attrs" => ["module" => ["decoration" => ["sizing" => $v(["flexType" => "24_24"])]]], "innerContent" => [null], "innerBlocks" => [$module]]]]]]; $module = static fn (string $name, array $attrs): array => ["blockName" => $name, "attrs" => $attrs, "innerBlocks" => [], "innerContent" => []]; $text = ["innerContent" => $v("

Probe

")]; $sections = [ $wrap($module("divi/text", ["content" => $text, "module" => ["decoration" => ["interactions" => $v(["interactions" => [[ "id" => "probe", "trigger" => "click", "effect" => "addAttribute", "attributeName" => "data-probe", "attributeValue" => "1"]]])]]])), $wrap($module("divi/lottie", ["lottie" => ["innerContent" => $v(["src" => "https://example.invalid/data-probe.json"])]])), $wrap($module("divi/text", ["content" => $text, "module" => ["advanced" => ["html" => $v(["htmlBefore" => "Probe"])]]])), ]; $id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), "post_content" => wp_slash(serialize_block(["blockName" => "divi/placeholder", "attrs" => [], "innerBlocks" => $sections, "innerContent" => array_fill(0, count($sections), null)]))]); update_post_meta($id, "_et_pb_use_builder", "on"); update_post_meta($id, "_et_pb_use_divi_5", "on"); echo $id;') CREATED_POSTS+=("$PROBE_ID") PROBE_REV=$(rev_of "$PROBE_ID") PROBE_ENTRIES=() for s in s0 s1 s2; do P=$(ability lit/library-save "$(jq -cn --argjson id "$PROBE_ID" --arg rev "$PROBE_REV" --arg s "$s" --arg t "$PREFIX Probe $s" \ '{post_id: $id, tree_rev: $rev, id: $s, title: $t}')") PID=$(echo "$P" | jq -r '.library_id // empty') [ -n "$PID" ] || fail "admin library-save of probe $s: $P" CREATED_POSTS+=("$PID") PROBE_ENTRIES+=("$PID") done pass "admin (unfiltered_html) may save interactions, Lottie source and custom HTML to the library" # 10. Temporary editor without unfiltered_html and without edit_private_pages if wp user get "$EDITOR_LOGIN" >/dev/null 2>&1 || wp role exists "$EDITOR_ROLE" >/dev/null 2>&1; then fail "user $EDITOR_LOGIN or role $EDITOR_ROLE exists already" fi wp role create "$EDITOR_ROLE" "LIT Smoke Bibliothek" --clone=editor >/dev/null ROLE_CREATED=1 wp cap remove "$EDITOR_ROLE" unfiltered_html edit_private_pages >/dev/null wp option update lit_mcp_allowed_roles "[\"administrator\",\"$EDITOR_ROLE\"]" --format=json >/dev/null wp user create "$EDITOR_LOGIN" "$EDITOR_LOGIN@example.invalid" --role="$EDITOR_ROLE" >/dev/null EDITOR_CREATED=1 [ "$(LIT_LOGIN="$EDITOR_LOGIN" LIT_ID="$PRIVATE_ID" wp eval ' $u = get_user_by("login", getenv("LIT_LOGIN")); echo (user_can($u, "unfiltered_html") ? "html " : "") . (user_can($u, "edit_post", (int) getenv("LIT_ID")) ? "private" : "");')" = "" ] \ || fail "the editor must lack unfiltered_html and edit_post on the private page" connect "$EDITOR_LOGIN" # 10a. P8a: the private page blocks the confirmed global edit; the dry run names it in blocked_ids GLOB_HASH=$(content_hash "$GLOB_ID") GREV=$(rev_of "$GLOB_ID") CHANGE='[{"id":"m0","set":{"content":"

LIT Smoke Redakteur

"}}]' DRY=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, changes: $c}')") [ "$(error_code "$DRY")" = "confirm_required" ] || fail "editor dry run: $DRY" echo "$DRY" | jq -r '.__error' | jq -e --argjson p "$PRIVATE_ID" '.blocked_ids == [$p]' >/dev/null || fail "dry run lacks blocked_ids: $DRY" REFUSED=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, confirm: true, changes: $c}')") [ "$(error_code "$REFUSED")" = "forbidden" ] || fail "confirmed global edit with a blocked private page: $REFUSED" echo "$REFUSED" | grep -q "$PRIVATE_ID" || fail "refusal does not name the private page ID: $REFUSED" if echo "$REFUSED" | grep -q "$PREFIX Privat"; then fail "refusal reveals the private page's title"; fi [ "$(content_hash "$GLOB_ID")" = "$GLOB_HASH" ] || fail "refused global edit wrote something" pass "P8a: editor without rights on a private page gets blocked_ids in the dry run and forbidden on confirm" # 10b. Control: the editor may copy benign nodes and entries TREV=$(rev_of "$TARGET_ID") OK_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \ --arg srev "$(rev_of "$SOURCE_ID")" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: $srev}')") echo "$OK_COPY" | jq -e '.ok == true' >/dev/null || fail "editor node-copy of a benign section: $OK_COPY" OK_INSERT=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$OK_COPY" | jq -r .tree_rev)" --argjson lib "$LIB_ID" \ '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')") echo "$OK_INSERT" | jq -e '.ok == true' >/dev/null || fail "editor library-insert copy of a benign entry: $OK_INSERT" pass "editor without unfiltered_html copies benign nodes and entries" # 10c. Interactions, Lottie source and custom HTML: refused by library-save, library-insert copy and node-copy TARGET_HASH=$(content_hash "$TARGET_ID") TREV=$(rev_of "$TARGET_ID") PROBE_REV=$(rev_of "$PROBE_ID") PROBE_PATHS=(decoration.interactions lottie.innerContent advanced.html) refused() { # $1 = result, $2 = context, $3 = path the refusal must name [ "$(error_code "$1")" = "validation_failed" ] || fail "$2 not refused: $1" echo "$1" | grep -q 'Divi-Builder' && echo "$1" | grep -qF "$3" || fail "$2 refused for another reason: $1" } i=0 for s in s0 s1 s2; do p=${PROBE_PATHS[$i]} refused "$(ability lit/library-save "$(jq -cn --argjson id "$PROBE_ID" --arg rev "$PROBE_REV" --arg s "$s" --arg t "$PREFIX Probe Redakteur $s" \ '{post_id: $id, tree_rev: $rev, id: $s, title: $t}')")" "library-save $s" "$p" refused "$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson lib "${PROBE_ENTRIES[$i]}" \ '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')")" "library-insert copy $s" "$p" refused "$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$PROBE_ID" --arg s "$s" \ --arg srev "$PROBE_REV" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: $s, source_tree_rev: $srev}')")" "node-copy $s" "$p" i=$((i + 1)) done [ "$(content_hash "$TARGET_ID")" = "$TARGET_HASH" ] || fail "a refused probe wrote to the target page" [ "$(wp post list --post_type=et_pb_layout --post_status=any --s="$PREFIX Probe Redakteur" --format=count)" = "0" ] \ || fail "a refused library-save created an entry" if content_of "$TARGET_ID" | grep -q 'data-probe'; then fail "a probe marker reached the target page"; fi pass "editor: interactions, Lottie source and custom HTML refused by library-save, library-insert copy and node-copy" # 11. Group "Divi-Bibliothek" disabled: library IDs not_found in the Divi read tools, library tools gone connect "$ADMIN_LOGIN" [ "$(error_code "$(outline "$LIB_ID")")" = "" ] || fail "library entry not readable before disabling the group" wp option update lit_mcp_exposure '{"@group:divi-library":"disabled"}' --format=json >/dev/null connect "$ADMIN_LOGIN" LIST=$(rpc tools/list '{}') if echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null; then fail "lit-library-insert listed with the group disabled"; fi for a in lit/library-list lit/library-save lit/library-insert; do GONE=$(ability "$a" '{}') echo "$GONE" | jq -e 'has("__error")' >/dev/null && echo "$GONE" | grep -q 'not found' || fail "$a still executable: $GONE" done for id in "$LIB_ID" "$GLOB_ID"; do [ "$(error_code "$(outline "$id")")" = "not_found" ] || fail "outline of library entry $id with the group disabled" [ "$(error_code "$(tool lit-divi-node-get "{\"post_id\":$id,\"id\":\"s0\"}")")" = "not_found" ] || fail "node-get of $id with the group disabled" done [ "$(error_code "$(outline "$TARGET_ID")")" = "" ] || fail "pages must stay readable with the library group disabled" restore_exposure connect "$ADMIN_LOGIN" rpc tools/list '{}' | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null || fail "exposure not restored" pass "group Divi-Bibliothek disabled: library IDs not_found in outline/node-get, library tools gone; option restored" finish