Files
unraid-mcp/tests/mcporter/test-destructive.sh
Jacob Magar 7bb9d93bd5 chore: reorganize test scripts, add destructive action tests, fix rclone bug
- Move scripts/test-tools.sh and scripts/test-actions.sh → tests/mcporter/
  - Fix PROJECT_DIR path in test-tools.sh (SCRIPT_DIR/.. → SCRIPT_DIR/../..)
- Add tests/mcporter/test-destructive.sh: 2 live + 13 skipped destructive tests
  - stdio transport (no running server required)
  - notifications:delete (create→list→delete), keys:delete (create→delete→verify)
  - 3 new skips: createDockerFolder/updateSshSettings/createRCloneRemote not in API
  - Requires --confirm flag; dry-run by default
- Add tests/mcporter/README.md documenting both scripts and coverage
- Rewrite docs/DESTRUCTIVE_ACTIONS.md: merge test guide, all 15 actions with commands
- Delete docs/test-actions.md (merged into tests/mcporter/README.md)
- Fix rclone.py create_remote: send "parameters" not "config" (API field name)
- Update README.md and CLAUDE.md: 11 tools/~104 actions, new script paths
- Add AGENTS.md and GEMINI.md symlinks to CLAUDE.md
- Bump version 0.4.3 → 0.4.4

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-13 22:35:52 -04:00

335 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# test-destructive.sh — Safe destructive action tests for unraid-mcp
#
# Tests all 15 destructive actions using create→destroy and no-op patterns.
# Actions with global blast radius (no safe isolation) are skipped.
#
# Transport: stdio — spawns uv run unraid-mcp-server per call; no running server needed.
#
# Usage:
# ./tests/mcporter/test-destructive.sh [--confirm]
#
# Options:
# --confirm REQUIRED to execute destructive tests; without it, dry-runs only
#
# Exit codes:
# 0 — all executable tests passed (or dry-run)
# 1 — one or more tests failed
# 2 — prerequisite check failed
set -uo pipefail
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
readonly SCRIPT_NAME="$(basename -- "${BASH_SOURCE[0]}")"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
readonly PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd -P)"
CONFIRM=false
PASS=0; FAIL=0; SKIP=0
declare -a FAILED_TESTS=()
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--confirm) CONFIRM=true; shift ;;
-h|--help)
printf 'Usage: %s [--confirm]\n' "${SCRIPT_NAME}"
exit 0
;;
*) printf '[ERROR] Unknown argument: %s\n' "$1" >&2; exit 2 ;;
esac
done
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
section() { echo ""; echo -e "${CYAN}${BOLD}━━━ $1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; }
pass_test() {
printf " %-60s${GREEN}PASS${NC}\n" "$1"
((PASS++)) || true
}
fail_test() {
local label="$1" reason="$2"
printf " %-60s${RED}FAIL${NC}\n" "${label}"
printf " %s\n" "${reason}"
((FAIL++)) || true
FAILED_TESTS+=("${label}")
}
skip_test() {
printf " %-60s${YELLOW}SKIP${NC} (%s)\n" "$1" "$2"
((SKIP++)) || true
}
dry_run() {
printf " %-60s${CYAN}DRY-RUN${NC}\n" "$1"
((SKIP++)) || true
}
mcall() {
local tool="$1" args="$2"
mcporter call \
--stdio "uv run --project ${PROJECT_DIR} unraid-mcp-server" \
--tool "$tool" \
--args "$args" \
--output json \
2>/dev/null
}
extract() {
# extract <json> <python-expression>
python3 -c "import json,sys; d=json.loads('''$1'''); print($2)" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# Connectivity check
# ---------------------------------------------------------------------------
echo ""
echo -e "${BOLD}Unraid MCP Destructive Action Test Suite${NC}"
echo -e "Transport: ${CYAN}stdio (uv run unraid-mcp-server)${NC}"
echo -e "Mode: $(${CONFIRM} && echo "${RED}LIVE — destructive actions will execute${NC}" || echo "${YELLOW}DRY-RUN — pass --confirm to execute${NC}")"
echo ""
# ---------------------------------------------------------------------------
# docker: remove — skipped (two-machine problem)
# ---------------------------------------------------------------------------
section "docker: remove"
skip_test "docker: remove" "requires a pre-existing stopped container on the Unraid server — can't provision via local docker"
# ---------------------------------------------------------------------------
# docker: delete_entries — create folder → delete via MCP
# ---------------------------------------------------------------------------
section "docker: delete_entries"
skip_test "docker: delete_entries" "createDockerFolder mutation not available in this Unraid API version (HTTP 400)"
# ---------------------------------------------------------------------------
# docker: update_all — mock/safety audit only
# ---------------------------------------------------------------------------
section "docker: update_all"
skip_test "docker: update_all" "global blast radius — restarts all containers; safety audit only"
# ---------------------------------------------------------------------------
# docker: reset_template_mappings — mock/safety audit only
# ---------------------------------------------------------------------------
section "docker: reset_template_mappings"
skip_test "docker: reset_template_mappings" "wipes all template mappings globally; safety audit only"
# ---------------------------------------------------------------------------
# vm: force_stop — requires manual test VM setup
# ---------------------------------------------------------------------------
section "vm: force_stop"
skip_test "vm: force_stop" "requires pre-created Alpine test VM (no persistent disk)"
# ---------------------------------------------------------------------------
# vm: reset — requires manual test VM setup
# ---------------------------------------------------------------------------
section "vm: reset"
skip_test "vm: reset" "requires pre-created Alpine test VM (no persistent disk)"
# ---------------------------------------------------------------------------
# notifications: delete — create notification → delete via MCP
# ---------------------------------------------------------------------------
section "notifications: delete"
test_notifications_delete() {
local label="notifications: delete"
# Create the notification
local create_raw
create_raw="$(mcall unraid_notifications \
'{"action":"create","title":"mcp-test-delete","subject":"MCP destructive test","description":"Safe to delete","importance":"INFO"}')"
local create_ok
create_ok="$(python3 -c "import json,sys; d=json.loads('''${create_raw}'''); print(d.get('success', False))" 2>/dev/null)"
if [[ "${create_ok}" != "True" ]]; then
fail_test "${label}" "create notification failed: ${create_raw}"
return
fi
# The create response ID doesn't match the stored filename — list and find by title
local list_raw nid
list_raw="$(mcall unraid_notifications '{"action":"list","notification_type":"UNREAD"}')"
nid="$(python3 -c "
import json,sys
d = json.loads('''${list_raw}''')
notifs = d.get('notifications', [])
match = next((n['id'] for n in notifs if n.get('title') == 'mcp-test-delete'), '')
print(match)
" 2>/dev/null)"
if [[ -z "${nid}" ]]; then
fail_test "${label}" "created notification not found in UNREAD list"
return
fi
local del_raw
del_raw="$(mcall unraid_notifications \
"{\"action\":\"delete\",\"notification_id\":\"${nid}\",\"notification_type\":\"UNREAD\",\"confirm\":true}")"
# success=true OR deleteNotification key present (raw GraphQL response) both indicate success
local success
success="$(python3 -c "
import json,sys
d=json.loads('''${del_raw}''')
ok = d.get('success', False) or ('deleteNotification' in d)
print(ok)
" 2>/dev/null)"
if [[ "${success}" != "True" ]]; then
# Leak: notification created but not deleted — archive it so it doesn't clutter the feed
mcall unraid_notifications "{\"action\":\"archive\",\"notification_id\":\"${nid}\"}" &>/dev/null || true
fail_test "${label}" "delete did not return success=true: ${del_raw} (notification archived as fallback cleanup)"
return
fi
pass_test "${label}"
}
if ${CONFIRM}; then
test_notifications_delete
else
dry_run "notifications: delete [create notification → mcall unraid_notifications delete]"
fi
# ---------------------------------------------------------------------------
# notifications: delete_archived — bulk wipe; skip (hard to isolate)
# ---------------------------------------------------------------------------
section "notifications: delete_archived"
skip_test "notifications: delete_archived" "bulk wipe of ALL archived notifications; run manually on shart if needed"
# ---------------------------------------------------------------------------
# rclone: delete_remote — create local:/tmp remote → delete via MCP
# ---------------------------------------------------------------------------
section "rclone: delete_remote"
skip_test "rclone: delete_remote" "createRCloneRemote broken server-side on this Unraid version (url slash error)"
# ---------------------------------------------------------------------------
# keys: delete — create test key → delete via MCP
# ---------------------------------------------------------------------------
section "keys: delete"
test_keys_delete() {
local label="keys: delete"
# Guard: abort if test key already exists (don't delete a real key)
# Note: API key names cannot contain hyphens — use "mcp test key"
local existing_keys
existing_keys="$(mcall unraid_keys '{"action":"list"}')"
if python3 -c "
import json,sys
d = json.loads('''${existing_keys}''')
keys = d.get('keys', d.get('apiKeys', []))
sys.exit(1 if any(k.get('name') == 'mcp test key' for k in keys) else 0)
" 2>/dev/null; then
: # not found, safe to proceed
else
fail_test "${label}" "a key named 'mcp test key' already exists — refusing to proceed"
return
fi
local create_raw
create_raw="$(mcall unraid_keys \
'{"action":"create","name":"mcp test key","roles":["VIEWER"]}')"
local kid
kid="$(python3 -c "import json,sys; d=json.loads('''${create_raw}'''); print(d.get('key',{}).get('id',''))" 2>/dev/null)"
if [[ -z "${kid}" ]]; then
fail_test "${label}" "create key did not return an ID"
return
fi
local del_raw
del_raw="$(mcall unraid_keys "{\"action\":\"delete\",\"key_id\":\"${kid}\",\"confirm\":true}")"
local success
success="$(python3 -c "import json,sys; d=json.loads('''${del_raw}'''); print(d.get('success', False))" 2>/dev/null)"
if [[ "${success}" != "True" ]]; then
fail_test "${label}" "delete did not return success=true: ${del_raw}"
return
fi
# Verify gone
local list_raw
list_raw="$(mcall unraid_keys '{"action":"list"}')"
if python3 -c "
import json,sys
d = json.loads('''${list_raw}''')
keys = d.get('keys', d.get('apiKeys', []))
sys.exit(0 if not any(k.get('id') == '${kid}' for k in keys) else 1)
" 2>/dev/null; then
pass_test "${label}"
else
fail_test "${label}" "key still present in list after delete"
fi
}
if ${CONFIRM}; then
test_keys_delete
else
dry_run "keys: delete [create test key → mcall unraid_keys delete]"
fi
# ---------------------------------------------------------------------------
# storage: flash_backup — requires dedicated test remote
# ---------------------------------------------------------------------------
section "storage: flash_backup"
skip_test "storage: flash_backup" "requires dedicated test remote pre-configured and isolated destination"
# ---------------------------------------------------------------------------
# settings: configure_ups — mock/safety audit only
# ---------------------------------------------------------------------------
section "settings: configure_ups"
skip_test "settings: configure_ups" "wrong config breaks UPS monitoring; safety audit only"
# ---------------------------------------------------------------------------
# settings: setup_remote_access — mock/safety audit only
# ---------------------------------------------------------------------------
section "settings: setup_remote_access"
skip_test "settings: setup_remote_access" "misconfiguration can lock out remote access; safety audit only"
# ---------------------------------------------------------------------------
# settings: enable_dynamic_remote_access — shart only, toggle false → restore
# ---------------------------------------------------------------------------
section "settings: enable_dynamic_remote_access"
skip_test "settings: enable_dynamic_remote_access" "run manually on shart (10.1.0.3) only — see docs/DESTRUCTIVE_ACTIONS.md"
# ---------------------------------------------------------------------------
# info: update_ssh — read current values, re-apply same (no-op)
# ---------------------------------------------------------------------------
section "info: update_ssh"
skip_test "info: update_ssh" "updateSshSettings mutation not available in this Unraid API version (HTTP 400)"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
TOTAL=$((PASS + FAIL + SKIP))
echo ""
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}Results: ${GREEN}${PASS} passed${NC} ${RED}${FAIL} failed${NC} ${YELLOW}${SKIP} skipped${NC} (${TOTAL} total)"
if [[ ${#FAILED_TESTS[@]} -gt 0 ]]; then
echo ""
echo -e "${RED}${BOLD}Failed tests:${NC}"
for t in "${FAILED_TESTS[@]}"; do
echo -e " ${RED}${NC} ${t}"
done
fi
echo ""
if ! ${CONFIRM}; then
echo -e "${YELLOW}Dry-run complete. Pass --confirm to execute destructive tests.${NC}"
fi
[[ ${FAIL} -eq 0 ]] && exit 0 || exit 1