Files
WP-LIT-MCP/tests/Integration/divi-library-smoke.sh
T
janniclas.loosenandClaude Opus 5.5 25446d292d test: divi-library smoke hygiene (M-14)
Per-run editor login and role, deleted only when this run created them (and
refused when they exist); the allowed-roles option is deleted again when it
was absent; the run-token fallback covers pages too; readOnlyHint must be
false, not just missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 04:25:53 +02:00

474 lines
31 KiB
Bash
Executable File

#!/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 <run>", 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("<!-- wp:divi/placeholder -->"), -strlen("<!-- /wp:divi/placeholder -->"));
$page = $read("page--globale-instanzen");
$from = strpos($page, "<!-- wp:divi/global-layout {\"globalModule\":\"1059\"");
$instance = substr($page, $from, strpos($page, " /-->", $from) + 5 - $from);
$globalSection = substr($page, strlen("<!-- wp:divi/placeholder -->"),
strpos($page, "<!-- /wp:divi/global-layout -->\n\n") + strlen("<!-- /wp:divi/global-layout -->\n\n") - strlen("<!-- wp:divi/placeholder -->"));
$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, "<!-- wp:divi/section {\"module\":{\"meta\":{\"adminLabel\":{\"desktop\":{\"value\":\"Library Abschnitt\""))) . "<!-- /wp:divi/placeholder -->"; 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 "<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column {\"module\":{\"decoration\":{\"sizing\":{\"desktop\":{\"value\":{\"flexType\":\"24_24\"}}}}}} -->$EXPECTED_INSTANCE<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->" "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":"<p>LIT Smoke geändert</p>"}}]'
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:<ID>}}")) | 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("<p>Probe</p>")];
$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" => "<b data-probe=\"1\">Probe</b>"])]]])),
];
$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":"<p>LIT Smoke Redakteur</p>"}}]'
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