#!/usr/bin/env bash # End-to-end checks of the Etappe-1a tools (content, status, copies, revisions, # menus, WordPress settings, change log) through the OAuth-protected endpoint. # Runs INSIDE the DDEV web container: # cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh # Temporarily activates the installed Pods plugin (restored afterwards) and # restores every setting/menu location it changes. 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) PODS_WAS_ACTIVE=no wp plugin is-active pods && PODS_WAS_ACTIVE=yes ORIGINAL_DESCRIPTION=$(wp option get blogdescription) ORIGINAL_BLOGNAME=$(wp eval 'echo get_option("blogname");') ORIGINAL_LOCATIONS=$(wp eval 'echo wp_json_encode(get_nav_menu_locations());') CREATED_POSTS=() MENU_ID="" cleanup() { for id in "${CREATED_POSTS[@]}"; do wp post delete "$id" --force >/dev/null 2>&1 || true done [ -n "$MENU_ID" ] && wp menu delete "$MENU_ID" >/dev/null 2>&1 || true wp option update blogdescription "$ORIGINAL_DESCRIPTION" >/dev/null 2>&1 || true # Restore the stored (already escaped) form byte-exact, bypassing sanitize_option(). LIT_BLOGNAME="$ORIGINAL_BLOGNAME" wp eval \ 'global $wpdb; $wpdb->update($wpdb->options, ["option_value" => getenv("LIT_BLOGNAME")], ["option_name" => "blogname"]); wp_cache_delete("alloptions", "options"); wp_cache_delete("blogname", "options");' \ >/dev/null 2>&1 || true LIT_LOCATIONS="$ORIGINAL_LOCATIONS" wp eval \ 'set_theme_mod("nav_menu_locations", (array) json_decode(getenv("LIT_LOCATIONS"), true));' >/dev/null 2>&1 || true wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" delete >/dev/null 2>&1 || true [ "$PODS_WAS_ACTIVE" = "no" ] && wp plugin deactivate pods >/dev/null 2>&1 || true cleanup_app_passwords_for "$ADMIN_LOGIN" } trap cleanup EXIT wp plugin activate pods >/dev/null wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" create >/dev/null connect "$ADMIN_LOGIN" # 1. Tool list: direct tools visible with annotations, the rest only via meta-tools LIST=$(rpc tools/list '{}') for t in lit-content-types lit-content-query lit-content-get lit-content-create lit-content-update lit-post-publish; do echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t" done echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-post-trash")' >/dev/null \ && fail "lit-post-trash must only be reachable via execute-ability" echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-content-get") | .annotations.readOnlyHint == true' >/dev/null \ || fail "lit-content-get lacks readOnlyHint" pass "tools/list: direct content tools with annotations, indirect status tools" # 2. Schema detection incl. Pods TYPES=$(tool lit-content-types '{}') echo "$TYPES" | jq -e '.post_types[] | select(.name == "page")' >/dev/null || fail "content-types lacks page: $TYPES" PERSON_TYPE=$(tool lit-content-types '{"post_type":"lit_person"}') echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "role" and .required == true and .source == "pods")' >/dev/null \ || fail "Pods field role missing: $PERSON_TYPE" echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "level" and (.choices | length) == 2)' >/dev/null \ || fail "Pods choices missing: $PERSON_TYPE" pass "content-types lists post types and Pods fields" # 3. Create, read, update with rev lock CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke Seite","content":"

Hallo

"}') PAGE_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty') [ -n "$PAGE_ID" ] || fail "content-create: $CREATED" CREATED_POSTS+=("$PAGE_ID") [ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "new page is not a draft" REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') STALE=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"000000000000\",\"title\":\"x\"}") [ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale rev accepted: $STALE" UPDATED=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"$REV\",\"content\":\"

Neu

\"}") echo "$UPDATED" | jq -e '.ok == true and .log_id != null' >/dev/null || fail "content-update: $UPDATED" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "update changed the status" pass "content create/get/update, rev lock, status unchanged" # 4. Pods entries: required field enforced, values written through Pods MISSING=$(tool lit-content-create '{"post_type":"lit_person","title":"Ohne Funktion"}') [ "$(error_code "$MISSING")" = "validation_failed" ] || fail "missing required Pods field accepted: $MISSING" PERSON=$(tool lit-content-create '{"post_type":"lit_person","title":"Anna","fields":{"role":"Leitung","level":"senior"}}') PERSON_ID=$(echo "$PERSON" | jq -r '.changed[0].id // empty') [ -n "$PERSON_ID" ] || fail "Pods entry: $PERSON" CREATED_POSTS+=("$PERSON_ID") tool lit-content-get "{\"post_id\":$PERSON_ID}" | jq -e '.fields.role == "Leitung" and .fields.level == "senior"' >/dev/null \ || fail "Pods values not readable" pass "Pods entry with validated fields" # 5. Status tools tool lit-post-publish "{\"post_id\":$PAGE_ID}" | jq -e '.changed[0].action == "published"' >/dev/null || fail "publish" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "page not published" ability lit/post-unpublish "{\"post_id\":$PAGE_ID}" | jq -e '.ok == true' >/dev/null || fail "unpublish" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "page not back to draft" FUTURE=$(date -d '+2 days' '+%Y-%m-%d 10:00') ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"$FUTURE\"}" | jq -e '.ok == true' >/dev/null || fail "schedule" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "future" ] || fail "page not scheduled" PAST=$(ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"2020-01-01 10:00\"}") [ "$(error_code "$PAST")" = "validation_failed" ] || fail "past schedule accepted: $PAST" tool lit-post-publish "{\"post_id\":$PAGE_ID}" >/dev/null [ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "scheduled page did not publish now" PRIVATE=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke Privat"}') PRIVATE_ID=$(echo "$PRIVATE" | jq -r '.changed[0].id // empty') [ -n "$PRIVATE_ID" ] || fail "content-create (private): $PRIVATE" CREATED_POSTS+=("$PRIVATE_ID") wp post update "$PRIVATE_ID" --post_status=private >/dev/null SCHEDULED_PRIVATE=$(ability lit/post-schedule "{\"post_id\":$PRIVATE_ID,\"date\":\"$FUTURE\"}") echo "$SCHEDULED_PRIVATE" | jq -e '.ok == true and (.warnings | any(contains("ÖFFENTLICH")))' >/dev/null \ || fail "scheduling a private page does not warn: $SCHEDULED_PRIVATE" pass "publish/unpublish/schedule, past dates rejected, private schedule warns" # 6. Copy and replace (lit-content-update, lit/post-replace-with-copy: rev required) COPY=$(ability lit/post-duplicate "{\"post_id\":$PAGE_ID}") COPY_ID=$(echo "$COPY" | jq -r '.changed[0].id // empty') [ -n "$COPY_ID" ] || fail "duplicate: $COPY" CREATED_POSTS+=("$COPY_ID") COPY_REV=$(echo "$COPY" | jq -r '.rev') tool lit-content-update "{\"post_id\":$COPY_ID,\"rev\":\"$COPY_REV\",\"content\":\"

Aus Kopie

\"}" >/dev/null ORIGINAL_REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') NOCONFIRM=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"rev\":\"$ORIGINAL_REV\"}") [ "$(error_code "$NOCONFIRM")" = "confirm_required" ] || fail "replace without confirm: $NOCONFIRM" REPLACED=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"rev\":\"$ORIGINAL_REV\",\"confirm\":true}") echo "$REPLACED" | jq -e '.revision_id != null and .backup_id != null' >/dev/null || fail "replace-with-copy: $REPLACED" BACKUP_ID=$(echo "$REPLACED" | jq -r '.backup_id') CREATED_POSTS+=("$BACKUP_ID") [ "$(wp post get "$PAGE_ID" --field=post_content)" = "

Aus Kopie

" ] || fail "original not replaced" [ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "replace changed the original's status" [ "$(wp post get "$COPY_ID" --field=post_status)" = "trash" ] || fail "copy not trashed" [ "$(wp post get "$BACKUP_ID" --field=post_status)" = "trash" ] || fail "backup of the original not trashed" pass "duplicate and replace-with-copy (status kept, copy trashed, backup taken)" # 7. Revisions (lit/revision-restore: rev required) REVISIONS=$(ability lit/revisions-list "{\"post_id\":$PAGE_ID}") OLDEST=$(echo "$REVISIONS" | jq -r '.items[-1].id // empty') [ -n "$OLDEST" ] || fail "revisions-list: $REVISIONS" PAGE_REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') ability lit/revision-restore "{\"revision_id\":$OLDEST,\"rev\":\"$PAGE_REV\",\"confirm\":true}" | jq -e '.ok == true' >/dev/null \ || fail "revision-restore" pass "revisions list and restore" # 8. Trash ability lit/post-trash "{\"post_id\":$PERSON_ID,\"confirm\":true}" | jq -e '.changed[0].action == "trashed"' >/dev/null \ || fail "post-trash" [ "$(wp post get "$PERSON_ID" --field=post_status)" = "trash" ] || fail "entry not in trash" PERSON_REV=$(tool lit-content-get "{\"post_id\":$PERSON_ID}" | jq -r '.rev') TRASHED=$(tool lit-content-update "{\"post_id\":$PERSON_ID,\"rev\":\"$PERSON_REV\",\"title\":\"x\"}") [ "$(error_code "$TRASHED")" = "validation_failed" ] || fail "update of a trashed entry accepted: $TRASHED" pass "post-trash moves to trash; trashed entries are not updated" # 8b. A missed schedule is never published as a side effect of a write MISSED=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke verpasst"}') MISSED_ID=$(echo "$MISSED" | jq -r '.changed[0].id // empty') [ -n "$MISSED_ID" ] || fail "content-create: $MISSED" CREATED_POSTS+=("$MISSED_ID") LIT_ID="$MISSED_ID" wp eval \ 'global $wpdb; $wpdb->update($wpdb->posts, ["post_status" => "future", "post_date" => "2020-01-01 10:00:00", "post_date_gmt" => "2020-01-01 09:00:00"], ["ID" => (int) getenv("LIT_ID")]); clean_post_cache((int) getenv("LIT_ID"));' \ >/dev/null MISSED_REV=$(tool lit-content-get "{\"post_id\":$MISSED_ID}" | jq -r '.rev') REFUSED=$(tool lit-content-update "{\"post_id\":$MISSED_ID,\"rev\":\"$MISSED_REV\",\"title\":\"x\"}") [ "$(error_code "$REFUSED")" = "validation_failed" ] || fail "missed schedule updated: $REFUSED" [ "$(wp post get "$MISSED_ID" --field=post_status)" = "future" ] || fail "missed schedule got published" pass "content-update refuses a missed schedule instead of publishing it" # 9. Menus (lit/menu-set-items: menu_rev from lit/menu-get required) MENU=$(ability lit/menu-create '{"name":"LIT Smoke Menü"}') MENU_ID=$(echo "$MENU" | jq -r '.menu_id // empty') [ -n "$MENU_ID" ] || fail "menu-create: $MENU" MENU_REV=$(ability lit/menu-get "{\"menu_id\":$MENU_ID}" | jq -r '.menu_rev // empty') [ -n "$MENU_REV" ] || fail "menu-get lacks menu_rev" NOREV=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"menu_rev\":\"000000000000\",\"items\":[]}") [ "$(error_code "$NOREV")" = "rev_mismatch" ] || fail "stale menu_rev accepted: $NOREV" SET=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"menu_rev\":\"$MENU_REV\",\"items\":[{\"type\":\"post\",\"object_id\":$PAGE_ID,\"children\":[{\"type\":\"custom\",\"url\":\"/kontakt\",\"title\":\"Kontakt\"}]}]}") echo "$SET" | jq -e '.ok == true and (.menu_rev | type) == "string"' >/dev/null || fail "menu-set-items (tree): $SET" STALE_MENU=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"menu_rev\":\"$MENU_REV\",\"items\":[]}") [ "$(error_code "$STALE_MENU")" = "rev_mismatch" ] || fail "outdated menu_rev accepted: $STALE_MENU" TREE=$(ability lit/menu-get "{\"menu_id\":$MENU_ID}") [ "$(echo "$TREE" | jq -r '.menu_rev')" = "$(echo "$SET" | jq -r '.menu_rev')" ] || fail "menu_rev of set-items differs from menu-get" CHILD_ID=$(echo "$TREE" | jq -r '.items[0].children[0].id // empty') [ "$(echo "$TREE" | jq -r '.items[0].children[0].title')" = "Kontakt" ] || fail "menu tree: $TREE" UNCHANGED_ARGS=$(echo "$TREE" | jq -c --argjson id "$MENU_ID" '{menu_id: $id, menu_rev: .menu_rev, items: .items}') UNCHANGED=$(ability lit/menu-set-items "$UNCHANGED_ARGS") echo "$UNCHANGED" | jq -e '.summary | contains("keine Änderung")' >/dev/null || fail "menu no-op: $UNCHANGED" MENU_REV=$(echo "$UNCHANGED" | jq -r '.menu_rev') REMOVE=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"menu_rev\":\"$MENU_REV\",\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}]}") [ "$(error_code "$REMOVE")" = "confirm_required" ] || fail "menu removal without confirm: $REMOVE" ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"menu_rev\":\"$MENU_REV\",\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}],\"confirm\":true}" \ | jq -e '(.previous_items[0].children | length) == 1 and (.items[0].children | length) == 0' >/dev/null \ || fail "menu removal with confirm" LOCATION=$(ability lit/menu-list '{}' | jq -r '.locations[0].location // empty') if [ -n "$LOCATION" ]; then ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":$MENU_ID}" | jq -e '.ok == true' >/dev/null \ || fail "menu-assign-location" UNASSIGN=$(ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":0}") [ "$(error_code "$UNASSIGN")" = "confirm_required" ] || fail "unassign without confirm: $UNASSIGN" ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":0,\"confirm\":true}" \ | jq -e '.summary | contains("LIT Smoke Menü")' >/dev/null || fail "unassign with confirm does not name the menu" fi TRASH_LINKED=$(ability lit/post-trash "{\"post_id\":$PAGE_ID,\"confirm\":true}") echo "$TRASH_LINKED" | jq -e '.changed[0].action == "trashed" and (.warnings | any(contains("LIT Smoke Menü")))' >/dev/null \ || fail "trashing a menu-linked page does not name the menu: $TRASH_LINKED" pass "menu create, menu_rev lock, tree, no-op resend, operations with confirm, location (unassign with confirm), trash warns about menu link" # 10. WordPress settings (whitelist) ability lit/wp-settings-get '{"group":"general"}' | jq -e '.settings[] | select(.key == "blogname")' >/dev/null \ || fail "wp-settings-get" BLOCKED=$(ability lit/wp-settings-update '{"settings":{"admin_email":"x@example.invalid"}}') [ "$(error_code "$BLOCKED")" = "validation_failed" ] || fail "blocked setting accepted: $BLOCKED" ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT Smoke Test"}}' \ | jq -e '.previous.blogdescription != null' >/dev/null || fail "wp-settings-update" [ "$(wp option get blogdescription)" = "LIT Smoke Test" ] || fail "blogdescription not updated" AMP='{"settings":{"blogname":"LIT Müller & Söhne","blogdescription":"LIT Smoke & Test"}}' ability lit/wp-settings-update "$AMP" | jq -e '.ok == true' >/dev/null || fail "settings with & (first call)" AGAIN=$(ability lit/wp-settings-update "$AMP") echo "$AGAIN" | jq -e '.ok == true and (.changed | length) == 0 and .previous.blogname == "LIT Müller & Söhne"' >/dev/null \ || fail "settings with & (second call): $AGAIN" ability lit/wp-settings-get '{"group":"general"}' | jq -e '.settings[] | select(.key == "blogname") | .value == "LIT Müller & Söhne"' >/dev/null \ || fail "wp-settings-get does not return blogname as typed" pass "WordPress settings whitelist; text with & can be set twice and reads back as typed" # 11. Change log with client name PREFIX=$(wp db prefix) ROWS=$(wp db query "SELECT COUNT(*) FROM ${PREFIX}lit_mcp_log WHERE ability = 'lit/content-create' AND client = 'lit-mcp-integration-test' AND created_at > (UTC_TIMESTAMP() - INTERVAL 10 MINUTE)" --skip-column-names) [ "$ROWS" -ge 2 ] || fail "change log rows missing (found $ROWS)" pass "change log records ability and client" echo "ALL CHECKS PASSED"