Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
453 lines
31 KiB
Bash
Executable File
453 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)
|
|
EDITOR_LOGIN=lit-lib-editor
|
|
EDITOR_ROLE=lit_lib_editor
|
|
STAGE3=$PLUGIN/tests/Fixtures/divi/stage3
|
|
RUN="$(date +%s)-$$"
|
|
PREFIX="LIT Smoke $RUN"
|
|
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]')
|
|
ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__')
|
|
CREATED_POSTS=()
|
|
|
|
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() {
|
|
# 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 = %s AND post_title LIKE %s",
|
|
"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"
|
|
cleanup_app_passwords_for "$EDITOR_LOGIN"
|
|
wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
|
|
wp role delete "$EDITOR_ROLE" >/dev/null 2>&1 || true
|
|
wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true
|
|
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 != true' >/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
|
|
wp role create "$EDITOR_ROLE" "LIT Smoke Bibliothek" --clone=editor >/dev/null
|
|
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" lit-lib-editor@example.invalid --role="$EDITOR_ROLE" >/dev/null
|
|
[ "$(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
|