diff --git a/tests/e2e/test_workspace_abilities_e2e.sh b/tests/e2e/test_workspace_abilities_e2e.sh new file mode 100755 index 00000000..72a32c51 --- /dev/null +++ b/tests/e2e/test_workspace_abilities_e2e.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# E2E test: workspace broadcast and talk-to-user platform abilities. +# +# What this proves: +# 1. talk_to_user_enabled (default true) — POST /notify works out-of-the-box. +# 2. PATCH /workspaces/:id/abilities { talk_to_user_enabled: false } disables +# delivery: /notify → 403 with error="talk_to_user_disabled" + delegate hint. +# 3. Re-enabling talk_to_user_enabled restores delivery. +# 4. broadcast_enabled (default false) — POST /broadcast → 403 when disabled. +# 5. PATCH { broadcast_enabled: true } enables fan-out. +# 6. POST /broadcast delivers to all non-sender, non-removed workspaces: +# - Returns {"status":"sent","delivered":N} +# - Receiver's activity log has a broadcast_receive entry with the message. +# - Sender's activity log has a broadcast_sent entry. +# 7. The sender itself does NOT receive a broadcast_receive entry. +# +# Usage: tests/e2e/test_workspace_abilities_e2e.sh +# Prereqs: workspace-server on http://localhost:8080, MOLECULE_ENV != production + +set -euo pipefail + +source "$(dirname "$0")/_lib.sh" + +PASS=0 +FAIL=0 +SENDER_ID="" +RECEIVER_ID="" + +cleanup() { + for wid in "$SENDER_ID" "$RECEIVER_ID"; do + if [ -n "$wid" ]; then + curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true + fi + done +} +trap cleanup EXIT INT TERM + +assert() { + local label="$1" actual="$2" expected="$3" + if [ "$actual" = "$expected" ]; then + echo " PASS — $label" + PASS=$((PASS+1)) + else + echo " FAIL — $label" + echo " expected: $expected" + echo " actual: $actual" + FAIL=$((FAIL+1)) + fi +} + +assert_contains() { + local label="$1" haystack="$2" needle="$3" + if echo "$haystack" | grep -qF "$needle"; then + echo " PASS — $label" + PASS=$((PASS+1)) + else + echo " FAIL — $label" + echo " needle: $needle" + echo " haystack: $haystack" + FAIL=$((FAIL+1)) + fi +} + +assert_not_contains() { + local label="$1" haystack="$2" needle="$3" + if ! echo "$haystack" | grep -qF "$needle"; then + echo " PASS — $label" + PASS=$((PASS+1)) + else + echo " FAIL — $label (unexpected match)" + echo " needle: $needle" + echo " haystack: $haystack" + FAIL=$((FAIL+1)) + fi +} + +# ── Pre-sweep: remove any stale leftover workspaces from a prior aborted run ── +echo "=== Setup ===" +for NAME in "Abilities Sender" "Abilities Receiver"; do + PRIOR=$(curl -s "$BASE/workspaces" | python3 -c " +import json, sys +try: + print(' '.join(w['id'] for w in json.load(sys.stdin) if w.get('name') == '$NAME')) +except Exception: + pass +") + for _wid in $PRIOR; do + echo "Sweeping leftover '$NAME' workspace: $_wid" + curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true + done +done + +R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ + -d '{"name":"Abilities Sender","tier":1}') +SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true) +[ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; } +echo "Created sender workspace: $SENDER_ID" + +R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ + -d '{"name":"Abilities Receiver","tier":1}') +RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true) +[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; } +echo "Created receiver workspace: $RECEIVER_ID" + +# Mint workspace-scoped bearer tokens (test-only endpoint, disabled in prod). +SENDER_TOKEN=$(e2e_mint_test_token "$SENDER_ID") +[ -n "$SENDER_TOKEN" ] || { echo "Failed to mint sender token"; exit 1; } +SENDER_AUTH="Authorization: Bearer $SENDER_TOKEN" + +# Admin token — any live workspace bearer satisfies AdminAuth in local dev. +# In production-like envs, set MOLECULE_ADMIN_TOKEN. +ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}" +ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN" + +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "=== Part 1: talk_to_user ability ===" + +echo "" +echo "--- 1a: /notify works with default talk_to_user_enabled=true ---" +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \ + -H "Content-Type: application/json" -H "$SENDER_AUTH" \ + -d '{"message":"Hello from sender"}') +assert "POST /notify returns 200 when talk_to_user_enabled=true (default)" "$CODE" "200" + +echo "" +echo "--- 1b: Disable talk_to_user ---" +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \ + -H "Content-Type: application/json" -H "$ADMIN_AUTH" \ + -d '{"talk_to_user_enabled": false}') +assert "PATCH /abilities talk_to_user_enabled=false returns 200" "$CODE" "200" + +# Verify the flag is reflected in the workspace GET response. +WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH") +FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))') +assert "GET /workspaces/:id reflects talk_to_user_enabled=false" "$FLAG" "False" + +echo "" +echo "--- 1c: /notify blocked when talk_to_user disabled ---" +BODY=$(curl -s -w "" -X POST "$BASE/workspaces/$SENDER_ID/notify" \ + -H "Content-Type: application/json" -H "$SENDER_AUTH" \ + -d '{"message":"Should be blocked"}') +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \ + -H "Content-Type: application/json" -H "$SENDER_AUTH" \ + -d '{"message":"Should be blocked"}') +assert "POST /notify returns 403 when talk_to_user_enabled=false" "$CODE" "403" + +ERR=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("error",""))' 2>/dev/null || echo "") +assert_contains "403 body contains talk_to_user_disabled error code" "$ERR" "talk_to_user_disabled" + +HINT=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("hint",""))' 2>/dev/null || echo "") +assert_contains "403 body contains delegate_task hint" "$HINT" "delegate_task" + +echo "" +echo "--- 1d: Re-enable talk_to_user and verify /notify works again ---" +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \ + -H "Content-Type: application/json" -H "$ADMIN_AUTH" \ + -d '{"talk_to_user_enabled": true}') +assert "PATCH /abilities talk_to_user_enabled=true returns 200" "$CODE" "200" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \ + -H "Content-Type: application/json" -H "$SENDER_AUTH" \ + -d '{"message":"Re-enabled, should work"}') +assert "POST /notify returns 200 after re-enabling talk_to_user" "$CODE" "200" + +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "=== Part 2: broadcast ability ===" + +echo "" +echo "--- 2a: Broadcast blocked by default (broadcast_enabled=false) ---" +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \ + -H "Content-Type: application/json" -H "$SENDER_AUTH" \ + -d '{"message":"Should be blocked"}') +assert "POST /broadcast returns 403 when broadcast_enabled=false (default)" "$CODE" "403" + +echo "" +echo "--- 2b: Enable broadcast ---" +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \ + -H "Content-Type: application/json" -H "$ADMIN_AUTH" \ + -d '{"broadcast_enabled": true}') +assert "PATCH /abilities broadcast_enabled=true returns 200" "$CODE" "200" + +WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH") +FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))') +assert "GET /workspaces/:id reflects broadcast_enabled=true" "$FLAG" "True" + +echo "" +echo "--- 2c: Successful broadcast fan-out ---" +BCAST=$(curl -s -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \ + -H "Content-Type: application/json" -H "$SENDER_AUTH" \ + -d '{"message":"Org-wide notice: scheduled maintenance in 5 minutes."}') +BSTATUS=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("status",""))' 2>/dev/null || echo "") +BDELIVERED=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("delivered","-1"))' 2>/dev/null || echo "-1") +assert "POST /broadcast returns status=sent" "$BSTATUS" "sent" + +# delivered count must be >= 1 (the receiver workspace). +echo " INFO — broadcast delivered=$BDELIVERED" +if python3 -c "import sys; sys.exit(0 if int('$BDELIVERED') >= 1 else 1)" 2>/dev/null; then + echo " PASS — delivered count >= 1" + PASS=$((PASS+1)) +else + echo " FAIL — expected delivered >= 1, got $BDELIVERED" + FAIL=$((FAIL+1)) +fi + +echo "" +echo "--- 2d: Receiver activity log has broadcast_receive entry ---" +RECEIVER_TOKEN=$(e2e_mint_test_token "$RECEIVER_ID") +[ -n "$RECEIVER_TOKEN" ] || { echo "Failed to mint receiver token"; exit 1; } +RECEIVER_AUTH="Authorization: Bearer $RECEIVER_TOKEN" + +ACT=$(curl -s -H "$RECEIVER_AUTH" "$BASE/workspaces/$RECEIVER_ID/activity?source=agent&limit=20") +ROW=$(echo "$ACT" | python3 -c ' +import json, sys +rows = json.load(sys.stdin) or [] +for r in rows: + if r.get("activity_type") == "broadcast_receive": + print(json.dumps(r)) + break +') +[ -n "$ROW" ] || { + echo " FAIL — could not find broadcast_receive row in receiver activity" + FAIL=$((FAIL+1)) +} + +if [ -n "$ROW" ]; then + # Message is stored in summary field. + MSG=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("summary",""))') + assert_contains "broadcast_receive row summary has original message" "$MSG" "scheduled maintenance" + # Sender ID is stored in source_id field. + SRC=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("source_id",""))') + assert "broadcast_receive row source_id is sender workspace" "$SRC" "$SENDER_ID" +fi + +echo "" +echo "--- 2e: Sender activity log has broadcast_sent entry ---" +ACT_SENDER=$(curl -s -H "$SENDER_AUTH" "$BASE/workspaces/$SENDER_ID/activity?limit=20") +SENT_ROW=$(echo "$ACT_SENDER" | python3 -c ' +import json, sys +rows = json.load(sys.stdin) or [] +for r in rows: + if r.get("activity_type") == "broadcast_sent": + print(json.dumps(r)) + break +') +[ -n "$SENT_ROW" ] || { + echo " FAIL — could not find broadcast_sent row in sender activity" + FAIL=$((FAIL+1)) +} + +if [ -n "$SENT_ROW" ]; then + # Delivered count is baked into the summary field (no response_body for sender row). + SUMMARY=$(echo "$SENT_ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("summary",""))') + assert_contains "broadcast_sent summary mentions workspace count" "$SUMMARY" "workspace" +fi + +echo "" +echo "--- 2f: Sender does NOT receive a broadcast_receive entry ---" +SELF_RECV=$(echo "$ACT_SENDER" | python3 -c ' +import json, sys +rows = json.load(sys.stdin) or [] +for r in rows: + if r.get("activity_type") == "broadcast_receive": + print("found") + break +') +assert_not_contains "sender has no broadcast_receive in own activity log" "${SELF_RECV:-}" "found" + +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "--- 2g: Empty message is rejected ---" +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \ + -H "Content-Type: application/json" -H "$SENDER_AUTH" \ + -d '{"message":""}') +assert "POST /broadcast with empty message returns 400" "$CODE" "400" + +echo "" +echo "--- 2h: Partial PATCH does not clobber other flags ---" +# Set talk_to_user=false, then patch only broadcast — talk_to_user must stay false. +curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \ + -H "Content-Type: application/json" -H "$ADMIN_AUTH" \ + -d '{"talk_to_user_enabled": false}' +curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \ + -H "Content-Type: application/json" -H "$ADMIN_AUTH" \ + -d '{"broadcast_enabled": false}' +WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH") +TUF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))') +BEF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))') +assert "partial PATCH preserves talk_to_user_enabled=false" "$TUF" "False" +assert "partial PATCH sets broadcast_enabled=false" "$BEF" "False" + +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ]