docs: readme with site-wide effects, media guide; test: media/admin end-to-end; version 0.2.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 04:51:11 +02:00
co-authored by Claude Opus 5.5
parent 251934ce53
commit 7f58301a71
8 changed files with 415 additions and 4 deletions
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Renders every tab of "Einstellungen → LIT MCP" server-side as an administrator and
* prints one line per tab: "<slug> ok <bytes>" or "<slug> FAIL <reason>".
*
* Usage (inside DDEV): wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/admin-render.php
*
* No strict_types: wp eval-file runs this through eval().
*/
defined('WP_CLI') && WP_CLI || exit;
require_once ABSPATH . 'wp-admin/includes/admin.php';
$lit_admins = get_users(['role' => 'administrator', 'number' => 1]);
wp_set_current_user($lit_admins[0]->ID);
set_current_screen('settings_page_lit-mcp');
// wp eval-file runs without an HTTP request: WP_List_Table (used by the "Protokoll" tab)
// reads $_SERVER['HTTP_HOST']/['REQUEST_URI'] directly (no isset() guard in WordPress core),
// so a CLI run needs both set to render it, exactly as a real admin request would have them.
$_SERVER['HTTP_HOST'] = (string) wp_parse_url(home_url(), PHP_URL_HOST);
$lit_page = \LIT\MCP\Plugin::settingsPage();
if ($lit_page === null) {
echo "FAIL settings page not built\n";
exit(1);
}
foreach ($lit_page->tabSlugs() as $lit_slug) {
$_GET['tab'] = $lit_slug;
$_SERVER['REQUEST_URI'] = '/wp-admin/options-general.php?page=lit-mcp&tab=' . rawurlencode($lit_slug);
ob_start();
try {
$lit_page->render();
$lit_html = (string) ob_get_clean();
} catch (\Throwable $e) {
ob_end_clean();
echo $lit_slug . ' FAIL ' . $e->getMessage() . "\n";
continue;
}
$lit_ok = str_contains($lit_html, 'nav-tab-active') && str_contains($lit_html, 'class="wrap"');
echo $lit_slug . ($lit_ok ? ' ok ' : ' FAIL ') . strlen($lit_html) . "\n";
}
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# End-to-end checks of Etappe 1b: media tools, exposure policy, visibility for users
# without access, settings page rendering, change log.
# Runs INSIDE the DDEV web container:
# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/media-admin-smoke.sh
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)
ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || true)
CREATED_POSTS=()
# 1x1 PNG
PNG='iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
cleanup() {
for id in "${CREATED_POSTS[@]}"; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
if [ -n "$ORIGINAL_EXPOSURE" ]; then
wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true
else
wp option delete lit_mcp_exposure >/dev/null 2>&1 || true
fi
cleanup_app_passwords_for "$ADMIN_LOGIN"
cleanup_app_passwords_for lit-smoke-sub2
wp user delete lit-smoke-sub2 --yes >/dev/null 2>&1 || true
}
trap cleanup EXIT
connect "$ADMIN_LOGIN"
# 1. Direct media tools; image tool without outputSchema
LIST=$(rpc tools/list '{}')
for t in lit-media-search lit-media-preview lit-media-download lit-media-upload-from-url; 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-media-preview") | has("outputSchema") | not' >/dev/null \
|| fail "lit-media-preview must not declare an outputSchema"
pass "direct media tools listed"
# 2. Base64 upload, search, preview, metadata update, download
UPLOAD=$(ability lit/media-upload "{\"filename\":\"lit-smoke.png\",\"data\":\"$PNG\",\"title\":\"LIT Smoke Bild\"}")
MEDIA_ID=$(echo "$UPLOAD" | jq -r '.attachment.id // empty')
[ -n "$MEDIA_ID" ] || fail "media-upload: $UPLOAD"
CREATED_POSTS+=("$MEDIA_ID")
tool lit-media-search '{"search":"LIT Smoke","missing_alt":true}' \
| jq -e --argjson id "$MEDIA_ID" '.items[] | select(.id == $id)' >/dev/null || fail "missing_alt search"
PREVIEW=$(rpc tools/call "{\"name\":\"lit-media-preview\",\"arguments\":{\"id\":$MEDIA_ID}}")
echo "$PREVIEW" | jq -e '.result.content[0].type == "image" and (.result.content[0].data | length) > 0' >/dev/null \
|| fail "media-preview: $PREVIEW"
# media-update is all-or-nothing (spec/task 5): a valid single item succeeds and is stored...
UPDATE=$(ability lit/media-update "{\"items\":[{\"id\":$MEDIA_ID,\"alt\":\"Testbild\"}]}")
echo "$UPDATE" | jq -e '.results[0].ok == true' >/dev/null || fail "media-update: $UPDATE"
[ "$(wp post meta get "$MEDIA_ID" _wp_attachment_image_alt)" = "Testbild" ] || fail "alt text not stored"
# ...but a batch with one unknown id refuses the WHOLE batch (nothing partially applied).
BATCH_FAIL=$(ability lit/media-update "{\"items\":[{\"id\":$MEDIA_ID,\"alt\":\"geaendert\"},{\"id\":999999,\"alt\":\"x\"}]}")
[ "$(error_code "$BATCH_FAIL")" = "not_found" ] || fail "media-update batch with unknown id should refuse entirely: $BATCH_FAIL"
[ "$(wp post meta get "$MEDIA_ID" _wp_attachment_image_alt)" = "Testbild" ] \
|| fail "media-update batch refusal must not have changed anything"
ability lit/media-download "{\"id\":$MEDIA_ID}" | jq -e '.type == "resource" and .resource.mimeType == "image/png"' >/dev/null \
|| fail "media-download"
pass "upload, search, preview, update (single ok, mixed batch refused), download"
# 3. Import protection
SSRF=$(tool lit-media-upload-from-url '{"url":"http://127.0.0.1/wp-login.php"}')
[ "$(error_code "$SSRF")" = "validation_failed" ] || fail "internal URL accepted: $SSRF"
FAKE=$(ability lit/media-upload "{\"filename\":\"evil.png\",\"data\":\"$(printf '<?php echo 1;' | base64 -w0)\"}")
[ "$(error_code "$FAKE")" = "validation_failed" ] || fail "disguised PHP accepted: $FAKE"
EXTERNAL=$(tool lit-media-upload-from-url '{"url":"https://s.w.org/images/wmark.png","title":"LIT Smoke Extern"}')
EXTERNAL_ID=$(echo "$EXTERNAL" | jq -r '.attachment.id // empty')
if [ -n "$EXTERNAL_ID" ]; then
CREATED_POSTS+=("$EXTERNAL_ID")
pass "import from public URL"
else
echo "WARN - import from public URL failed (no internet in container?): $EXTERNAL"
fi
pass "internal URLs and disguised files rejected"
# 4. Usage check before delete
PAGE=$(tool lit-content-create "{\"post_type\":\"page\",\"title\":\"LIT Smoke Medien\",\"featured_image\":$MEDIA_ID}")
PAGE_ID=$(echo "$PAGE" | jq -r '.changed[0].id // empty')
[ -n "$PAGE_ID" ] || fail "page with featured image: $PAGE"
CREATED_POSTS+=("$PAGE_ID")
ability lit/media-get "{\"id\":$MEDIA_ID}" | jq -e '.usages[] | select(.where == "featured")' >/dev/null \
|| fail "usage not found"
NOCONFIRM=$(ability lit/media-delete "{\"id\":$MEDIA_ID}")
[ "$(error_code "$NOCONFIRM")" = "confirm_required" ] || fail "delete without confirm: $NOCONFIRM"
INUSE=$(ability lit/media-delete "{\"id\":$MEDIA_ID,\"confirm\":true}")
[ "$(error_code "$INUSE")" = "in_use" ] || fail "delete of used media without force: $INUSE"
ability lit/media-delete "{\"id\":$MEDIA_ID,\"confirm\":true,\"force\":true}" | jq -e '.ok == true' >/dev/null \
|| fail "forced delete"
pass "media delete: confirm, in_use, force"
# 5. Exposure policy
wp option update lit_mcp_exposure '{"lit/content-query":"indirect","lit/menu-list":"disabled"}' --format=json >/dev/null
LIST2=$(rpc tools/list '{}')
echo "$LIST2" | jq -e '.result.tools[] | select(.name == "lit-content-query")' >/dev/null \
&& fail "lit-content-query still direct"
ability lit/content-query '{"post_type":"page","limit":1}' | jq -e 'has("items")' >/dev/null \
|| fail "indirect content-query not executable"
ability lit/menu-list '{}' | jq -e 'has("__error")' >/dev/null || fail "disabled ability still executable"
wp option delete lit_mcp_exposure >/dev/null
pass "exposure policy: indirect and disabled"
# 6. Users without access see neither tools nor site details
wp user create lit-smoke-sub2 lit-smoke-sub2@example.invalid --role=subscriber >/dev/null
ADMIN_TOKEN=$TOKEN
ADMIN_SESSION=$SESSION
connect lit-smoke-sub2
rpc tools/list '{}' | jq -e '.result.tools | length == 0' >/dev/null || fail "subscriber sees tools"
echo "$INIT_RESPONSE" | grep -q 'nicht freigegeben' || fail "subscriber instructions lack the access hint"
echo "$INIT_RESPONSE" | grep -q 'Inhaltstypen' && fail "subscriber instructions leak site details"
TOKEN=$ADMIN_TOKEN
SESSION=$ADMIN_SESSION
pass "no tools and no site details for users without access"
# 7. Settings page renders every tab
RENDER=$(wp eval-file "$PLUGIN/tests/Integration/admin-render.php")
for tab in verbindung zugriff tools protokoll limits; do
echo "$RENDER" | grep -q "^$tab ok" || fail "settings tab $tab: $RENDER"
done
pass "settings page tabs render"
# 8. Change log
# Not `wp db query` here (as in wp-basis-smoke.sh): its write/read detection greps the raw SQL
# text for keywords like UPDATE/DELETE with no word boundary, so it misfires on the *values*
# 'lit/media-update' and 'lit/media-delete' and returns "Rows affected: -1" instead of the
# SELECT result. `wp eval` with a prepared $wpdb query sidesteps that entirely.
ROWS=$(wp eval '
global $wpdb;
$table = $wpdb->prefix . "lit_mcp_log";
echo (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE ability IN (%s, %s) AND client = %s"
. " AND created_at > (UTC_TIMESTAMP() - INTERVAL 10 MINUTE)",
"lit/media-upload",
"lit/media-delete",
"lit-mcp-integration-test"
));
')
[ "$ROWS" -ge 2 ] || fail "media changes not logged (found $ROWS)"
pass "media changes logged"
echo "ALL CHECKS PASSED"