diff --git a/.gitea/workflows/e2e-api.yml b/.gitea/workflows/e2e-api.yml
index 75d4f67cf..8d28b5ca1 100644
--- a/.gitea/workflows/e2e-api.yml
+++ b/.gitea/workflows/e2e-api.yml
@@ -290,6 +290,15 @@ jobs:
echo "ADMIN_TOKEN=${E2E_ADMIN_TOKEN}" >> "$GITHUB_ENV"
echo "MOLECULE_ADMIN_TOKEN=${E2E_ADMIN_TOKEN}" >> "$GITHUB_ENV"
echo "Admin token configured for the e2e platform (ADMIN_TOKEN + MOLECULE_ADMIN_TOKEN)."
+ # Channels e2e test seam (core#2332 P1.10). These env-gated overrides
+ # let the LIVE Slack-webhook send path + Telegram discover path target
+ # the local mock upstreams that tests/e2e/test_channels_e2e.sh binds,
+ # so the outbound serialize+POST is provable in CI (was unit-mock-only).
+ # Inert in prod/staging — those deploys never set these. The fixed
+ # loopback ports MUST match the script's E2E_CHANNELS_*_PORT defaults.
+ echo "MOLECULE_CHANNELS_TEST_WEBHOOK_BASE=http://127.0.0.1:18099/" >> "$GITHUB_ENV"
+ echo "MOLECULE_CHANNELS_TEST_TELEGRAM_API_BASE=http://127.0.0.1:18098" >> "$GITHUB_ENV"
+ echo "Channels test seam configured (webhook+telegram mock bases on fixed loopback ports)."
- name: Build platform
if: needs.detect-changes.outputs.api == 'true'
working-directory: workspace-server
@@ -430,6 +439,20 @@ jobs:
- name: Run notify-with-attachments E2E
if: needs.detect-changes.outputs.api == 'true'
run: bash tests/e2e/test_notify_attachments_e2e.sh
+ - name: "Run channels + data-prune E2E (REQUIRE-LIVE: mock upstream proves send+discover, purge proves prune)"
+ # core#2332 P1.10. Stands up a local mock upstream, points the LIVE
+ # Slack-webhook send + Telegram discover paths at it via the
+ # production-inert test seam configured above, and asserts the mock
+ # RECEIVED the serialized payload (send) + round-tripped the bot/chat
+ # (discover). Then exercises the RFC #734 data-prune: DELETE
+ # ?purge=true removes the target's durable child data while a sibling
+ # survives. E2E_REQUIRE_LIVE=1 ⇒ a missing/regressed seam is RED, not a
+ # silent skip. The platform inherits the MOLECULE_CHANNELS_TEST_* bases
+ # from $GITHUB_ENV; the script's mock ports match them (18099/18098).
+ if: needs.detect-changes.outputs.api == 'true'
+ env:
+ E2E_REQUIRE_LIVE: '1'
+ run: bash tests/e2e/test_channels_e2e.sh
- name: "Run priority-runtimes E2E (REQUIRE-LIVE: mock validates the runtime plumbing end-to-end)"
# E2E_REQUIRE_LIVE=1 is ON: the run MUST validate >=1 runtime end-to-end
# or it exits NON-zero (RED). This is now SAFE because the `mock` arm can
diff --git a/tests/e2e/test_channels_e2e.sh b/tests/e2e/test_channels_e2e.sh
new file mode 100755
index 000000000..36435bdb5
--- /dev/null
+++ b/tests/e2e/test_channels_e2e.sh
@@ -0,0 +1,468 @@
+#!/usr/bin/env bash
+# GATING E2E for the social-channels outbound + discover + data-prune paths
+# (core#2332 P1.10). Closes two coverage gaps that were previously only
+# unit-mocked, so a regression in any of them goes RED in the required
+# `E2E API Smoke Test` lane instead of slipping through:
+#
+# (1) Channel SEND end-to-end. Every adapter's SendMessage was only ever
+# asserted by unit tests that reconstruct the payload by hand and POST
+# it themselves (see internal/channels/lark_test.go's "we can't change
+# the prefix const" comment) — nothing proved that a message submitted
+# through the LIVE platform API actually serializes and POSTs to a
+# provider endpoint. Here we stand up a local mock-upstream, point a
+# Slack Incoming-Webhook channel at it, send via
+# POST /channels/:id/send, and assert the MOCK RECEIVED the correctly
+# serialized {"text":"..."} body. Real serialize+POST, real HTTP stack,
+# no real Slack account.
+#
+# (2) Channel DISCOVER (POST /channels/discover). Had no test at all. We
+# point the Telegram discover path at a mock Bot API that serves
+# getMe + getUpdates and assert the discovered bot username + chat
+# round-trip back through the handler.
+#
+# (3) Workspace data-prune (RFC #734). The user-requested permanent delete
+# with ?purge=true prunes a workspace's durable child data (channels,
+# secrets, config, …). We create prunable data on a target workspace
+# AND a sibling, purge the target, then assert the target's child rows
+# are GONE while the sibling's SURVIVE.
+#
+# ── Test seam (production-inert) ────────────────────────────────────────
+# Adapters pin their outbound host to the real vendor (hooks.slack.com /
+# api.telegram.org). Two env-gated overrides — set ONLY by this lane, never
+# in any prod/staging deploy — let the live send/discover path target a
+# local mock so the round-trip is provable in CI:
+#
+# MOLECULE_CHANNELS_TEST_WEBHOOK_BASE (Slack webhook accept-prefix)
+# MOLECULE_CHANNELS_TEST_TELEGRAM_API_BASE (Telegram Bot API base)
+#
+# These must be present in the PLATFORM process env (the workflow exports
+# them via $GITHUB_ENV before "Start platform"), pointing at the fixed
+# loopback ports this script binds its mocks on. If they are absent the
+# platform rejects the mock URLs; under E2E_REQUIRE_LIVE=1 that is a hard
+# RED (the seam regressed / the workflow wiring broke), otherwise a LOUD
+# SKIP for ad-hoc local runs that didn't export them.
+#
+# NEVER fail-open: a missing assertion target fails the script.
+#
+# Required env (defaults shown):
+# BASE http://127.0.0.1:8080
+# MOLECULE_ADMIN_TOKEN (admin bearer; matches the platform's ADMIN_TOKEN)
+# E2E_CHANNELS_WEBHOOK_PORT 18099 (mock Slack webhook upstream)
+# E2E_CHANNELS_TELEGRAM_PORT 18098 (mock Telegram Bot API upstream)
+# E2E_REQUIRE_LIVE 0 (1 = seam-absent is RED, not skip)
+
+set -uo pipefail
+
+# shellcheck disable=SC1091
+source "$(dirname "$0")/_lib.sh" # sets BASE default + admin/token helpers
+
+WEBHOOK_PORT="${E2E_CHANNELS_WEBHOOK_PORT:-18099}"
+TELEGRAM_PORT="${E2E_CHANNELS_TELEGRAM_PORT:-18098}"
+REQUIRE_LIVE="${E2E_REQUIRE_LIVE:-0}"
+
+# The base prefixes the PLATFORM must have been started with. We assert the
+# adapter accepted a URL under these — proving the platform's env matches.
+WEBHOOK_BASE="http://127.0.0.1:${WEBHOOK_PORT}/"
+TELEGRAM_BASE="http://127.0.0.1:${TELEGRAM_PORT}"
+
+PASS=0
+FAIL=0
+WORK_DIR="$(mktemp -d)"
+WS_TARGET=""
+WS_SIBLING=""
+WS_TARGET_TOK=""
+WS_SIBLING_TOK=""
+MOCK_PID=""
+
+ADMIN_BEARER="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
+ADMIN_AUTH=()
+[ -n "$ADMIN_BEARER" ] && ADMIN_AUTH=(-H "Authorization: Bearer $ADMIN_BEARER")
+
+pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
+fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && echo " $2"; FAIL=$((FAIL + 1)); }
+
+# loud_skip records a SKIP and exits according to E2E_REQUIRE_LIVE. NEVER
+# silently passes — it either hard-fails (require-live) or exits 0 with a
+# loud banner (ad-hoc local). Mirrors the require-live gate pattern used by
+# test_priority_runtimes_e2e.sh.
+loud_skip() {
+ local reason="$1"
+ echo
+ echo "============================================================"
+ if [ "$REQUIRE_LIVE" = "1" ]; then
+ echo "E2E_REQUIRE_LIVE=1 but channels e2e seam is unavailable:"
+ echo " $reason"
+ echo "This is a HARD FAILURE — the platform was not started with the"
+ echo "channels test seam env (MOLECULE_CHANNELS_TEST_WEBHOOK_BASE /"
+ echo "MOLECULE_CHANNELS_TEST_TELEGRAM_API_BASE) on the fixed loopback"
+ echo "ports, or the seam regressed. Fix the workflow wiring or the seam."
+ echo "============================================================"
+ cleanup
+ exit 1
+ fi
+ echo "SKIP (loud): $reason"
+ echo "Set MOLECULE_CHANNELS_TEST_WEBHOOK_BASE=$WEBHOOK_BASE and"
+ echo "MOLECULE_CHANNELS_TEST_TELEGRAM_API_BASE=$TELEGRAM_BASE in the"
+ echo "PLATFORM env before starting it, then re-run. (CI sets these.)"
+ echo "============================================================"
+ cleanup
+ exit 0
+}
+
+cleanup() {
+ set +e
+ if [ -n "$MOCK_PID" ]; then
+ kill "$MOCK_PID" 2>/dev/null
+ wait "$MOCK_PID" 2>/dev/null
+ fi
+ # Hard-purge any workspaces we created so repeat runs are deterministic.
+ for pair in "$WS_TARGET|$WS_TARGET_TOK|e2e-chan-target" \
+ "$WS_SIBLING|$WS_SIBLING_TOK|e2e-chan-sibling"; do
+ local wid tok name
+ wid="${pair%%|*}"; pair="${pair#*|}"
+ tok="${pair%%|*}"; name="${pair#*|}"
+ [ -z "$wid" ] && continue
+ local auth=("${ADMIN_AUTH[@]}")
+ [ -n "$tok" ] && auth=(-H "Authorization: Bearer $tok")
+ curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true&purge=true" \
+ -H "X-Confirm-Name: $name" "${auth[@]}" >/dev/null 2>&1
+ done
+ rm -rf "$WORK_DIR" 2>/dev/null
+}
+trap cleanup EXIT INT TERM
+
+# ── mock upstream ───────────────────────────────────────────────────────
+# One Python process serves BOTH mocks (different ports). It records the
+# Slack webhook request body to $WORK_DIR/slack_body.json and answers the
+# Telegram getMe/getUpdates calls with a deterministic bot+chat fixture.
+start_mock() {
+ cat > "$WORK_DIR/mock.py" <<'PY'
+import json
+import os
+import sys
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+WORK_DIR = os.environ["MOCK_WORK_DIR"]
+WEBHOOK_PORT = int(os.environ["MOCK_WEBHOOK_PORT"])
+TELEGRAM_PORT = int(os.environ["MOCK_TELEGRAM_PORT"])
+
+BOT_USERNAME = "e2e_mock_bot"
+CHAT_ID = -1009876543210
+CHAT_NAME = "E2E Mock Group"
+
+
+class SlackHandler(BaseHTTPRequestHandler):
+ def log_message(self, *a): # silence
+ pass
+
+ def do_POST(self):
+ n = int(self.headers.get("Content-Length", "0") or "0")
+ body = self.rfile.read(n)
+ # Persist EXACTLY what the live Slack send path POSTed so the bash
+ # side can assert the serialized payload.
+ with open(os.path.join(WORK_DIR, "slack_body.json"), "wb") as f:
+ f.write(body)
+ with open(os.path.join(WORK_DIR, "slack_meta.json"), "w") as f:
+ json.dump({"path": self.path,
+ "content_type": self.headers.get("Content-Type", "")}, f)
+ # Real Slack Incoming Webhooks reply 200 "ok".
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b"ok")
+
+
+class TelegramHandler(BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def _send(self, obj):
+ payload = json.dumps(obj).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(payload)))
+ self.end_headers()
+ self.wfile.write(payload)
+
+ def _route(self):
+ # tgbotapi calls /bot/
+ method = self.path.rsplit("/", 1)[-1]
+ if method == "getMe":
+ return self._send({"ok": True, "result": {
+ "id": 4242, "is_bot": True, "first_name": "E2E Mock",
+ "username": BOT_USERNAME, "can_read_all_group_messages": True}})
+ if method == "setMyCommands":
+ return self._send({"ok": True, "result": True})
+ if method == "deleteWebhook":
+ return self._send({"ok": True, "result": True})
+ if method == "getUpdates":
+ # One my_chat_member update so the bot "discovers" a group.
+ return self._send({"ok": True, "result": [{
+ "update_id": 1,
+ "my_chat_member": {
+ "chat": {"id": CHAT_ID, "title": CHAT_NAME, "type": "supergroup"},
+ "from": {"id": 1, "is_bot": False, "first_name": "Op"},
+ "date": 0,
+ "old_chat_member": {"user": {"id": 4242, "is_bot": True,
+ "first_name": "E2E Mock"},
+ "status": "left"},
+ "new_chat_member": {"user": {"id": 4242, "is_bot": True,
+ "first_name": "E2E Mock"},
+ "status": "member"},
+ }}]})
+ # Default OK for any other bot method tgbotapi may probe.
+ return self._send({"ok": True, "result": True})
+
+ def do_POST(self):
+ n = int(self.headers.get("Content-Length", "0") or "0")
+ if n:
+ self.rfile.read(n)
+ self._route()
+
+ def do_GET(self):
+ self._route()
+
+
+def serve(port, handler):
+ ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever()
+
+
+t = threading.Thread(target=serve, args=(TELEGRAM_PORT, TelegramHandler), daemon=True)
+t.start()
+serve(WEBHOOK_PORT, SlackHandler)
+PY
+ MOCK_WORK_DIR="$WORK_DIR" MOCK_WEBHOOK_PORT="$WEBHOOK_PORT" \
+ MOCK_TELEGRAM_PORT="$TELEGRAM_PORT" \
+ python3 "$WORK_DIR/mock.py" &
+ MOCK_PID=$!
+ # Wait for both ports to accept connections (fail loudly if they never do).
+ local up=0
+ for _ in $(seq 1 50); do
+ if curl -s -o /dev/null "http://127.0.0.1:${WEBHOOK_PORT}/" \
+ && curl -s -o /dev/null "http://127.0.0.1:${TELEGRAM_PORT}/botX/getMe"; then
+ up=1; break
+ fi
+ sleep 0.1
+ done
+ if [ "$up" != "1" ]; then
+ echo "FATAL: mock upstream did not come up on ports $WEBHOOK_PORT/$TELEGRAM_PORT" >&2
+ cleanup
+ exit 2
+ fi
+}
+
+json_field() { python3 -c "import sys,json; print(json.load(sys.stdin).get('$1',''))"; }
+
+create_external_ws() {
+ local name="$1" resp wid
+ resp=$(curl -s -X POST "$BASE/workspaces" "${ADMIN_AUTH[@]}" \
+ -H "Content-Type: application/json" \
+ -d "{\"name\":\"$name\",\"runtime\":\"external\",\"external\":true,\"tier\":1}")
+ wid=$(printf '%s' "$resp" | json_field id)
+ if [ -z "$wid" ]; then
+ echo "FATAL: could not create workspace $name: $resp" >&2
+ cleanup
+ exit 1
+ fi
+ local tok
+ tok=$(printf '%s' "$resp" | e2e_extract_token)
+ [ -z "$tok" ] && tok=$(e2e_mint_workspace_token "$wid" 2>/dev/null || true)
+ printf '%s\t%s\n' "$wid" "$tok"
+}
+
+# ════════════════════════════════════════════════════════════════════════
+echo "=== Channels + data-prune E2E (core#2332 P1.10) ==="
+echo "BASE=$BASE webhook_mock=$WEBHOOK_BASE telegram_mock=$TELEGRAM_BASE"
+
+if ! curl -sf "$BASE/health" >/dev/null 2>&1; then
+ echo "FATAL: platform not reachable at $BASE/health" >&2
+ exit 2
+fi
+
+start_mock
+
+# ── workspaces ──────────────────────────────────────────────────────────
+IFS=$'\t' read -r WS_TARGET WS_TARGET_TOK < <(create_external_ws "e2e-chan-target-$$")
+IFS=$'\t' read -r WS_SIBLING WS_SIBLING_TOK < <(create_external_ws "e2e-chan-sibling-$$")
+echo "target=$WS_TARGET sibling=$WS_SIBLING"
+
+WS_AUTH=("${ADMIN_AUTH[@]}")
+[ -n "$WS_TARGET_TOK" ] && WS_AUTH=(-H "Authorization: Bearer $WS_TARGET_TOK")
+SIB_AUTH=("${ADMIN_AUTH[@]}")
+[ -n "$WS_SIBLING_TOK" ] && SIB_AUTH=(-H "Authorization: Bearer $WS_SIBLING_TOK")
+
+# ── (1) SEND end-to-end via a Slack Incoming-Webhook channel ────────────
+echo
+echo "--- (1) channel SEND → mock upstream receives serialized payload ---"
+
+# Create a slack channel whose webhook_url points at our mock. If the
+# platform wasn't started with the webhook test-base, ValidateConfig
+# rejects this URL → loud_skip / RED. chat_id is required by SendOutbound.
+SLACK_CFG=$(python3 -c "import json,sys; print(json.dumps({
+ 'webhook_url': sys.argv[1] + 'services/T000/B000/e2e',
+ 'chat_id': 'mock-chat'}))" "$WEBHOOK_BASE")
+CREATE=$(curl -s -X POST "$BASE/workspaces/$WS_TARGET/channels" "${WS_AUTH[@]}" \
+ -H "Content-Type: application/json" \
+ -d "{\"channel_type\":\"slack\",\"config\":$SLACK_CFG,\"enabled\":true}")
+CH_ID=$(printf '%s' "$CREATE" | json_field id)
+if [ -z "$CH_ID" ]; then
+ case "$CREATE" in
+ *"invalid channel config"*)
+ loud_skip "platform rejected mock webhook_url (MOLECULE_CHANNELS_TEST_WEBHOOK_BASE not set on platform): $CREATE" ;;
+ *)
+ fail "create slack channel" "$CREATE" ;;
+ esac
+else
+ pass "create slack channel pointed at mock upstream (id=$CH_ID)"
+
+ SEND_TEXT="hello from e2e $$"
+ # Send route: wsAuth.POST /workspaces/:id/channels/:channelId/send (the
+ # handler keys off :channelId; :id scopes the workspace bearer).
+ SEND=$(curl -s -w $'\n%{http_code}' -X POST \
+ "$BASE/workspaces/$WS_TARGET/channels/$CH_ID/send" "${WS_AUTH[@]}" \
+ -H "Content-Type: application/json" \
+ -d "{\"text\":\"$SEND_TEXT\"}")
+ SEND_CODE=$(printf '%s' "$SEND" | tail -n1)
+ if [ "$SEND_CODE" = "200" ]; then
+ pass "POST /channels/:id/send returned 200"
+ else
+ fail "POST /channels/:id/send" "code=$SEND_CODE body=$(printf '%s' "$SEND" | sed '$d')"
+ fi
+
+ # Give the async-free SendOutbound a beat to land at the mock.
+ RECEIVED=""
+ for _ in $(seq 1 30); do
+ if [ -s "$WORK_DIR/slack_body.json" ]; then RECEIVED=1; break; fi
+ sleep 0.1
+ done
+ if [ -n "$RECEIVED" ]; then
+ pass "mock upstream RECEIVED an outbound POST"
+ GOT_TEXT=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('text',''))" \
+ "$WORK_DIR/slack_body.json" 2>/dev/null || true)
+ if [ "$GOT_TEXT" = "$SEND_TEXT" ]; then
+ pass "mock received correctly-serialized {\"text\":...} payload (text matches end-to-end)"
+ else
+ fail "serialized payload mismatch" "want=[$SEND_TEXT] got=[$GOT_TEXT] raw=$(cat "$WORK_DIR/slack_body.json")"
+ fi
+ else
+ fail "mock upstream never received the outbound POST" "send path did not serialize+POST to the configured endpoint"
+ fi
+fi
+
+# ── (2) DISCOVER via the Telegram mock Bot API ──────────────────────────
+echo
+echo "--- (2) POST /channels/discover (telegram) → mock Bot API ---"
+# A token matching the telegramTokenRegex (\d+:[A-Za-z0-9_-]{30,}).
+DISC_TOKEN="424242:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+DISC=$(curl -s -w $'\n%{http_code}' -X POST "$BASE/channels/discover" \
+ "${ADMIN_AUTH[@]}" -H "Content-Type: application/json" \
+ -d "{\"channel_type\":\"telegram\",\"bot_token\":\"$DISC_TOKEN\",\"workspace_id\":\"$WS_TARGET\"}")
+DISC_CODE=$(printf '%s' "$DISC" | tail -n1)
+DISC_BODY=$(printf '%s' "$DISC" | sed '$d')
+if [ "$DISC_CODE" = "200" ]; then
+ pass "POST /channels/discover returned 200"
+ if printf '%s' "$DISC_BODY" | grep -qF '"bot_username":"e2e_mock_bot"'; then
+ pass "discover round-tripped the mock bot username"
+ else
+ fail "discover bot_username" "$DISC_BODY"
+ fi
+ if printf '%s' "$DISC_BODY" | grep -qF '"chat_id":"-1009876543210"'; then
+ pass "discover round-tripped the mock chat id"
+ else
+ fail "discover chat list" "$DISC_BODY"
+ fi
+else
+ case "$DISC_BODY" in
+ *"Cannot reach Telegram"*|*"Invalid bot token"*|*"Failed to connect"*)
+ # Platform reached the REAL api.telegram.org (seam not set) → can't prove.
+ loud_skip "discover hit real Telegram, not the mock (MOLECULE_CHANNELS_TEST_TELEGRAM_API_BASE not set on platform): code=$DISC_CODE $DISC_BODY" ;;
+ *)
+ fail "POST /channels/discover" "code=$DISC_CODE body=$DISC_BODY" ;;
+ esac
+fi
+
+# ── (3) Data-prune (RFC #734): purge removes prunable data, sibling survives
+echo
+echo "--- (3) data-prune: purge target's child data, sibling survives ---"
+
+# Seed prunable child data on BOTH workspaces: a channel (already on target)
+# + a secret on each. We assert via GET /channels which lists workspace_channels.
+seed_secret() {
+ local wid="$1"; shift
+ curl -s -o /dev/null -X POST "$BASE/workspaces/$wid/secrets" "$@" \
+ -H "Content-Type: application/json" \
+ -d '{"key":"E2E_PRUNE_PROBE","value":"v"}'
+}
+seed_secret "$WS_TARGET" "${WS_AUTH[@]}"
+# Sibling gets its OWN channel so we can prove its rows survive the target purge.
+SIB_SLACK_CFG=$(python3 -c "import json,sys; print(json.dumps({
+ 'webhook_url': sys.argv[1] + 'services/T111/B111/sib',
+ 'chat_id': 'sib-chat'}))" "$WEBHOOK_BASE")
+SIB_CH=$(curl -s -X POST "$BASE/workspaces/$WS_SIBLING/channels" "${SIB_AUTH[@]}" \
+ -H "Content-Type: application/json" \
+ -d "{\"channel_type\":\"slack\",\"config\":$SIB_SLACK_CFG,\"enabled\":true}")
+SIB_CH_ID=$(printf '%s' "$SIB_CH" | json_field id)
+
+# Pre-purge: confirm both workspaces have >=1 channel row.
+TGT_CH_PRE=$(curl -s "$BASE/workspaces/$WS_TARGET/channels" "${WS_AUTH[@]}")
+SIB_CH_PRE=$(curl -s "$BASE/workspaces/$WS_SIBLING/channels" "${SIB_AUTH[@]}")
+TGT_PRE_N=$(printf '%s' "$TGT_CH_PRE" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0)
+SIB_PRE_N=$(printf '%s' "$SIB_CH_PRE" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0)
+if [ "${TGT_PRE_N:-0}" -ge 1 ] && [ "${SIB_PRE_N:-0}" -ge 1 ]; then
+ pass "pre-purge: target ($TGT_PRE_N) and sibling ($SIB_PRE_N) both have channel data"
+else
+ fail "pre-purge seed" "target=$TGT_PRE_N sibling=$SIB_PRE_N (need >=1 each)"
+fi
+
+# Permanent delete WITH purge — the RFC #734 prune of durable child data.
+# DELETE /workspaces/:id is AdminAuth-gated (router.go:167); Tier-2b rejects a
+# workspace bearer when ADMIN_TOKEN is set, so this MUST use the admin bearer.
+# X-Confirm-Name must equal the workspace name (the destructive-delete guard).
+PURGE_AUTH=("${ADMIN_AUTH[@]}")
+[ ${#PURGE_AUTH[@]} -eq 0 ] && [ -n "$WS_TARGET_TOK" ] && PURGE_AUTH=(-H "Authorization: Bearer $WS_TARGET_TOK")
+PURGE=$(curl -s -w $'\n%{http_code}' -X DELETE \
+ "$BASE/workspaces/$WS_TARGET?confirm=true&purge=true" \
+ -H "X-Confirm-Name: e2e-chan-target-$$" "${PURGE_AUTH[@]}")
+PURGE_CODE=$(printf '%s' "$PURGE" | tail -n1)
+PURGE_BODY=$(printf '%s' "$PURGE" | sed '$d')
+if [ "$PURGE_CODE" = "200" ] && printf '%s' "$PURGE_BODY" | grep -qF '"status":"purged"'; then
+ pass "DELETE ?purge=true returned purged"
+else
+ fail "DELETE ?purge=true" "code=$PURGE_CODE body=$PURGE_BODY"
+fi
+# Target was purged → its token is revoked; query its channels with admin
+# bearer. The purge hard-deletes workspace_channels rows for the target.
+TGT_CH_POST=$(curl -s "$BASE/workspaces/$WS_TARGET/channels" "${ADMIN_AUTH[@]}")
+TGT_POST_N=$(printf '%s' "$TGT_CH_POST" | python3 -c "import sys,json
+try:
+ d=json.load(sys.stdin); print(len(d) if isinstance(d,list) else -1)
+except Exception:
+ print(-1)" 2>/dev/null || echo -1)
+if [ "${TGT_POST_N:-1}" = "0" ]; then
+ pass "post-purge: target's prunable channel data is GONE (0 rows)"
+else
+ fail "prune did not remove target channel data" "post-purge target rows=$TGT_POST_N body=$(printf '%s' "$TGT_CH_POST" | head -c 200)"
+fi
+WS_TARGET="" # purged; don't re-delete in cleanup
+
+# Sibling (NON-prunable relative to the target purge) must be untouched.
+SIB_CH_POST=$(curl -s "$BASE/workspaces/$WS_SIBLING/channels" "${SIB_AUTH[@]}")
+SIB_POST_N=$(printf '%s' "$SIB_CH_POST" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo -1)
+if [ "${SIB_POST_N:-0}" -ge 1 ] && printf '%s' "$SIB_CH_POST" | grep -qF "$SIB_CH_ID"; then
+ pass "post-purge: sibling's non-prunable data SURVIVED ($SIB_POST_N rows, channel $SIB_CH_ID intact)"
+else
+ fail "purge over-reached: sibling data did not survive" "sibling rows=$SIB_POST_N body=$(printf '%s' "$SIB_CH_POST" | head -c 200)"
+fi
+
+# ── verdict ─────────────────────────────────────────────────────────────
+echo
+echo "=== channels+prune e2e: $PASS passed, $FAIL failed ==="
+if [ "$FAIL" -ne 0 ]; then
+ exit 1
+fi
+# Guard against a vacuous green: every section must have produced asserts.
+if [ "$PASS" -lt 9 ]; then
+ echo "FATAL: only $PASS assertions ran — expected >=9 (send + discover + prune). Refusing to report green." >&2
+ exit 1
+fi
+echo "ALL CHANNELS + PRUNE E2E CHECKS PASSED"
diff --git a/workspace-server/internal/channels/slack.go b/workspace-server/internal/channels/slack.go
index 2b78c77cf..f78bb7efa 100644
--- a/workspace-server/internal/channels/slack.go
+++ b/workspace-server/internal/channels/slack.go
@@ -21,6 +21,27 @@ const (
var slackHTTPClient = &http.Client{Timeout: slackHTTPTimeout}
+// slackWebhookAccepted reports whether a Slack Incoming Webhook URL is allowed
+// as a send destination. Production accepts only the real hooks.slack.com host.
+//
+// TEST SEAM (gating e2e): when MOLECULE_CHANNELS_TEST_WEBHOOK_BASE is set, a
+// URL with that prefix is ALSO accepted so tests/e2e/test_channels_e2e.sh can
+// point the live Slack send path at a local mock-upstream and assert the mock
+// actually received the serialized {"text":...} payload end-to-end (the unit
+// tests can only assert the body shape — see lark_test.go's prefix-gate
+// workaround comment). The env var is NEVER set in any production/staging
+// deploy; channelsTestWebhookBase() returns "" there and only the real
+// hooks.slack.com prefix passes, so this changes no production behaviour.
+func slackWebhookAccepted(u string) bool {
+ if strings.HasPrefix(u, slackWebhookPrefix) {
+ return true
+ }
+ if base := channelsTestWebhookBase(); base != "" && strings.HasPrefix(u, base) {
+ return true
+ }
+ return false
+}
+
// SlackAdapter implements ChannelAdapter for Slack Incoming Webhooks.
//
// Outbound messages are sent via Slack Incoming Webhooks (the simple,
@@ -98,7 +119,7 @@ func (s *SlackAdapter) ValidateConfig(config map[string]interface{}) error {
return fmt.Errorf("bot_token mode requires channel_id")
}
}
- if webhookURL != "" && !strings.HasPrefix(webhookURL, slackWebhookPrefix) {
+ if webhookURL != "" && !slackWebhookAccepted(webhookURL) {
return fmt.Errorf("invalid Slack webhook URL")
}
return nil
@@ -197,7 +218,7 @@ func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string
if webhookURL == "" {
return fmt.Errorf("webhook_url not configured")
}
- if !strings.HasPrefix(webhookURL, slackWebhookPrefix) {
+ if !slackWebhookAccepted(webhookURL) {
return fmt.Errorf("invalid Slack webhook URL")
}
diff --git a/workspace-server/internal/channels/telegram.go b/workspace-server/internal/channels/telegram.go
index 11e59ee57..dc3ff11ca 100644
--- a/workspace-server/internal/channels/telegram.go
+++ b/workspace-server/internal/channels/telegram.go
@@ -148,7 +148,18 @@ func (t *TelegramAdapter) DiscoverChats(ctx context.Context, botToken string) (*
return nil, errors.New("invalid bot token format")
}
- bot, err := tgbotapi.NewBotAPI(botToken)
+ // TEST SEAM: when MOLECULE_CHANNELS_TEST_TELEGRAM_API_BASE is set (only in
+ // the gating channels e2e — never in prod/staging), build the bot client
+ // against a local mock API base instead of api.telegram.org so
+ // POST /channels/discover can be proven end-to-end. The format string is
+ // "/bot%s/%s" (token, method), matching tgbotapi.APIEndpoint.
+ var bot *tgbotapi.BotAPI
+ var err error
+ if apiBase := channelsTestTelegramAPIBase(); apiBase != "" {
+ bot, err = tgbotapi.NewBotAPIWithAPIEndpoint(botToken, apiBase+"/bot%s/%s")
+ } else {
+ bot, err = tgbotapi.NewBotAPI(botToken)
+ }
if err != nil {
return nil, fmt.Errorf("invalid bot token: %w", err)
}
diff --git a/workspace-server/internal/channels/testseam.go b/workspace-server/internal/channels/testseam.go
new file mode 100644
index 000000000..f7c8e1f34
--- /dev/null
+++ b/workspace-server/internal/channels/testseam.go
@@ -0,0 +1,47 @@
+package channels
+
+import "os"
+
+// Test seams for the GATING channels e2e (tests/e2e/test_channels_e2e.sh).
+//
+// Every adapter pins its outbound destination to the real vendor host
+// (hooks.slack.com, discord.com, api.telegram.org) in both ValidateConfig and
+// SendMessage. That host pin is correct for production, but it means a real
+// end-to-end test cannot point the LIVE send/discover path at a local mock
+// upstream — so today the outbound serialize+POST is only ever asserted by
+// unit tests that reconstruct the payload by hand (see lark_test.go's
+// "we can't change the prefix const" comment) and never proven through the
+// running platform.
+//
+// These two env-gated overrides close that gap WITHOUT changing any
+// production behaviour:
+//
+// - MOLECULE_CHANNELS_TEST_WEBHOOK_BASE — when set, Slack Incoming Webhook
+// URLs with this prefix are accepted as send destinations (in addition to
+// the real hooks.slack.com host). Lets the e2e create a slack channel whose
+// webhook_url points at a local httptest mock and assert the mock RECEIVED
+// the serialized {"text":...} payload.
+//
+// - MOLECULE_CHANNELS_TEST_TELEGRAM_API_BASE — when set, TelegramAdapter.
+// DiscoverChats builds its bot client against this API base instead of
+// api.telegram.org, so POST /channels/discover can be exercised against a
+// mock that serves getMe/getUpdates and the e2e can assert the discovered
+// chats round-trip.
+//
+// Both vars are NEVER set in any production or staging deploy. The helpers
+// return "" there, so the real vendor-host pins are the only thing that
+// passes — production behaviour is byte-for-byte unchanged. Reading os.Getenv
+// on each call (not caching) keeps the seam honest: a process that never sets
+// the var can never accidentally enable it.
+
+// channelsTestWebhookBase returns the test-only accepted webhook base prefix,
+// or "" in production. See package doc above.
+func channelsTestWebhookBase() string {
+ return os.Getenv("MOLECULE_CHANNELS_TEST_WEBHOOK_BASE")
+}
+
+// channelsTestTelegramAPIBase returns the test-only Telegram Bot API base
+// (a printf format string "/bot%s/%s"), or "" in production.
+func channelsTestTelegramAPIBase() string {
+ return os.Getenv("MOLECULE_CHANNELS_TEST_TELEGRAM_API_BASE")
+}