Files

785 lines
34 KiB
Python

"""Slack Web API mock for the Slack app tests (spec section 3.3).
OpenObserve reaches it through ``O2_SLACK_API_BASE_URL=http://127.0.0.1:<port>/api``; only
server-side calls come here. Browser navigations to ``slack.com/oauth/v2/authorize`` and
``slack.com/openid/connect/authorize`` are intercepted by the tests, which fold what Slack would
know into the mock ``code``:
code = "mock." + base64url(json({"nonce": ..., "state": ..., "team_id": ..., ...}))
``oauth.v2.access`` and ``openid.connect.token`` decode it, so a test decides the workspace, the
channel of the incoming webhook, the Slack user and the ``nonce`` the id_token carries. A code that
is not ``mock.``-prefixed gets the defaults (team ``T0MOCK``, user ``U0MOCK``).
Run it standalone::
python3 tests/api-testing/helpers/slack_mock.py --port 5099
Control endpoints (JSON):
- ``GET /_mock/health`` liveness
- ``GET /_mock/requests[?method=m]`` every recorded call: method, form, bearer, at, response (ok calls), error
- ``POST /_mock/reset`` forget calls, apps, tokens and failures; reseed the team
- ``POST /_mock/fail`` ``{"method", "error", "times"?, "http_status"?, "retry_after"?}``
- ``POST /_mock/mode`` ``{"method", "http_status", "error"?, "retry_after"?}``: every call of ``method``
(``*`` for all) fails that way until ``{"method", "clear": true}`` or a reset, like a Slack outage or a 429 storm
- ``POST /_mock/apps`` register an app: ``{"app_id", "client_id", "client_secret", "signing_secret"?}``
- ``POST /_mock/teams`` add or replace a team: ``{"team_id", "team_name", "channels"?, "members"?}``
- ``POST /_mock/config_tokens`` ``{"refresh_token", "team_id"}``: ``tooling.tokens.rotate`` answers that team for the
token and its successors (an unregistered token answers ``T0MOCK``)
- ``POST /_mock/uninstall_events`` ``{"settle": s}``: ``apps.uninstall`` first POSTs Slack's signed ``tokens_revoked``
and ``app_uninstalled`` events to the app's HTTP request URL, as live Slack does, then waits ``s`` seconds
- ``GET /_mock/code?claims=<json>`` the ``mock.`` code for those claims
- ``GET /_mock/state`` apps, issued bot tokens (redacted), refresh tokens used
Incoming webhooks are served at ``POST /services/<team>/<id>`` and recorded as method
``incoming_webhook``. Uploads from ``files.getUploadURLExternal`` go to ``POST /upload/<file_id>``.
Blocks are refused as Slack refuses them (LIVE-RESULTS B-LIVE-5): more than 50 per message or 100 per view, a
repeated ``block_id``, an ``action_id`` twice in one block (in a view: twice anywhere in it). Messages answer
``invalid_blocks``, ``views.publish`` answers ``invalid_arguments``, both with ``response_metadata.messages``.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import itertools
import json
import logging
import sys
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from urllib.error import HTTPError
from urllib.request import Request, urlopen
log = logging.getLogger("slack-mock")
DEFAULT_TEAM = "T0MOCK"
DEFAULT_TEAM_NAME = "Mock Workspace"
DEFAULT_USER = "U0MOCK"
SLACK_ISSUER = "https://slack.com"
MAX_MESSAGE_BLOCKS = 50
MAX_VIEW_BLOCKS = 100
class SlackError(Exception):
"""An ``ok: false`` answer with Slack's error code."""
def __init__(self, error: str, http_status: int = 200, retry_after: int | None = None,
messages: list[str] | None = None):
super().__init__(error)
self.error = error
self.http_status = http_status
self.retry_after = retry_after
self.messages = messages
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def unb64url(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def mock_code(**claims: Any) -> str:
"""The OAuth or Sign in with Slack code that carries ``claims`` to the mock."""
return "mock." + b64url(json.dumps(claims, separators=(",", ":")).encode())
def decode_code(code: str) -> dict[str, Any]:
if not code.startswith("mock."):
return {}
try:
value = json.loads(unb64url(code[len("mock."):]))
except (ValueError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def id_token(claims: dict[str, Any]) -> str:
"""An unsigned JWT; O2 reads the claims of a token it fetched from Slack over TLS."""
header = b64url(b'{"alg":"none","typ":"JWT"}')
return f"{header}.{b64url(json.dumps(claims).encode())}.mock-signature"
def default_team(team_id: str = DEFAULT_TEAM, team_name: str = DEFAULT_TEAM_NAME) -> dict[str, Any]:
"""A workspace with recorded-shape channels and members (ids and names anonymized)."""
return {
"team_id": team_id,
"team_name": team_name,
"channels": [
{"id": "C0ALERTS", "name": "alerts", "is_private": False, "is_member": True, "is_shared": False},
{"id": "C0GENERAL", "name": "general", "is_private": False, "is_member": False, "is_shared": False},
{"id": "C0OPS", "name": "ops-private", "is_private": True, "is_member": True, "is_shared": False},
{"id": "C0SHARED", "name": "vendor-shared", "is_private": False, "is_member": True, "is_shared": True},
],
"members": [
member("U0DANA", team_id, "dana", "dana@acme.test"),
member("U0LEE", team_id, "lee", "lee@acme.test"),
member("U0GUEST", team_id, "guest", "guest@vendor.test", is_restricted=True),
member(DEFAULT_USER, team_id, "admin", None),
{**member("U0BOT", team_id, "o2bot", None), "is_bot": True},
],
}
def member(user_id: str, team_id: str, name: str, email: str | None, **flags: Any) -> dict[str, Any]:
profile: dict[str, Any] = {"real_name": name.title(), "display_name": name}
if email:
profile["email"] = email
return {
"id": user_id,
"team_id": team_id,
"name": name,
"deleted": False,
"is_bot": False,
"is_app_user": False,
"is_restricted": False,
"is_ultra_restricted": False,
"is_email_confirmed": bool(email),
"profile": profile,
**flags,
}
class MockState:
"""Everything the mock remembers; one lock guards it all."""
def __init__(self, base_url: str):
self.base_url = base_url.rstrip("/")
self.lock = threading.Lock()
self.ids = itertools.count(1)
self.reset()
def reset(self) -> None:
self.calls: list[dict[str, Any]] = []
self.failures: dict[str, list[dict[str, Any]]] = {}
self.apps: dict[str, dict[str, Any]] = {}
self.teams: dict[str, dict[str, Any]] = {DEFAULT_TEAM: default_team()}
self.bot_tokens: dict[str, dict[str, Any]] = {}
self.config_tokens: set[str] = set()
self.used_refresh_tokens: set[str] = set()
self.token_teams: dict[str, str] = {}
self.revoked: set[str] = set()
self.uninstall_settle: float | None = None
self.outbox: list[tuple[str, str, bytes]] = []
self.files: set[str] = set()
self.modes: dict[str, dict[str, Any]] = {}
def next_id(self, prefix: str) -> str:
return f"{prefix}{next(self.ids):06d}"
def record(self, method: str, form: dict[str, str], bearer: str | None) -> None:
self.calls.append({"method": method, "form": form, "bearer": bearer, "at": time.time()})
def take_failure(self, method: str) -> None:
mode = self.modes.get(method) or self.modes.get("*")
if mode is not None:
raise SlackError(mode.get("error", "service_unavailable"), mode["http_status"], mode.get("retry_after"))
queue = self.failures.get(method) or []
if not queue:
return
failure = queue[0]
failure["times"] -= 1
if failure["times"] <= 0:
queue.pop(0)
raise SlackError(failure["error"], failure.get("http_status", 200), failure.get("retry_after"))
def app_by_client(self, client_id: str) -> dict[str, Any] | None:
return next((a for a in self.apps.values() if a["client_id"] == client_id), None)
def check_client(self, client_id: str, client_secret: str) -> dict[str, Any] | None:
app = self.app_by_client(client_id)
if app is not None and app["client_secret"] != client_secret:
raise SlackError("invalid_client")
return app
def team(self, team_id: str) -> dict[str, Any]:
return self.teams.setdefault(team_id, default_team(team_id, f"Workspace {team_id}"))
def bot(self, bearer: str | None) -> dict[str, Any]:
if not bearer or bearer in self.revoked:
raise SlackError("token_revoked" if bearer in self.revoked else "not_authed")
grant = self.bot_tokens.get(bearer)
if grant is None:
raise SlackError("invalid_auth")
return grant
def config_token(self, bearer: str | None) -> None:
if not bearer or bearer not in self.config_tokens:
raise SlackError("invalid_auth")
Handler = Any
def action_ids(block: dict[str, Any], at: str) -> list[tuple[str, str]]:
"""Each ``action_id`` of one block with its json-pointer, the accessory's too."""
found = [(e["action_id"], f"{at}/elements/{j}/action_id") for j, e in enumerate(block.get("elements") or [])
if isinstance(e, dict) and "action_id" in e]
accessory = block.get("accessory")
if isinstance(accessory, dict) and "action_id" in accessory:
found.append((accessory["action_id"], f"{at}/accessory/action_id"))
return found
def repeated(found: list[tuple[str, str]], what: str) -> list[str]:
seen: set[str] = set()
errors = []
for value, pointer in found:
if value in seen:
errors.append(f'[ERROR] `{what}` "{value}" already exists [json-pointer:{pointer}]')
seen.add(value)
return errors
def block_errors(blocks: Any, pointer: str, max_blocks: int, view: bool) -> list[str]:
"""What Slack says about a block list; a view's ``action_id`` must be unique in the whole view."""
if not isinstance(blocks, list):
return []
errors = []
if len(blocks) > max_blocks:
errors.append(f"[ERROR] no more than {max_blocks} items allowed [json-pointer:{pointer}]")
listed = [(i, b) for i, b in enumerate(blocks) if isinstance(b, dict)]
errors += repeated([(b["block_id"], f"{pointer}/{i}/block_id") for i, b in listed if "block_id" in b], "block_id")
if view:
return errors + repeated([a for i, b in listed for a in action_ids(b, f"{pointer}/{i}")], "action_id")
for i, b in listed:
errors += repeated(action_ids(b, f"{pointer}/{i}"), "action_id")
return errors
def check_message_blocks(form: dict[str, str]) -> None:
try:
blocks = json.loads(form.get("blocks") or "null")
except ValueError:
raise SlackError("invalid_blocks_format") from None
errors = block_errors(blocks, "blocks", MAX_MESSAGE_BLOCKS, view=False)
if errors:
raise SlackError("invalid_blocks", messages=errors)
def m_oauth_v2_access(st: MockState, form: dict[str, str], _bearer: str | None) -> dict[str, Any]:
claims = decode_code(form.get("code", ""))
if not form.get("code") or claims.get("error"):
raise SlackError(claims.get("error") or "invalid_code")
app = st.check_client(form.get("client_id", ""), form.get("client_secret", ""))
team_id = claims.get("team_id", DEFAULT_TEAM)
team = st.team(team_id)
if "team_name" in claims:
team["team_name"] = claims["team_name"]
app_id = claims.get("app_id") or (app["app_id"] if app else "A0CLOUD")
token = f"xoxb-mock-{team_id}-{st.next_id('')}"
st.bot_tokens[token] = {"team_id": team_id, "app_id": app_id, "user_id": claims.get("bot_user_id", "U0BOT")}
body: dict[str, Any] = {
"access_token": token,
"token_type": "bot",
"scope": "app_mentions:read,chat:write,im:history",
"bot_user_id": claims.get("bot_user_id", "U0BOT"),
"app_id": app_id,
"team": {"id": team_id, "name": team["team_name"]},
"enterprise": {"id": claims["enterprise_id"], "name": "Mock Grid"} if claims.get("enterprise_id") else None,
"is_enterprise_install": bool(claims.get("is_enterprise_install")),
"authed_user": {"id": claims.get("user_id", DEFAULT_USER)},
}
if claims.get("channel_id"):
hook = st.next_id("B")
body["incoming_webhook"] = {
"channel": claims.get("channel", "#alerts"),
"channel_id": claims["channel_id"],
"configuration_url": f"https://{team_id.lower()}.slack.com/services/{hook}",
"url": f"{st.base_url}/services/{team_id}/{hook}",
}
if claims.get("rotation"):
body["refresh_token"] = f"xoxe-1-mock-{st.next_id('')}"
body["expires_in"] = 43200
return body
def m_openid_connect_token(st: MockState, form: dict[str, str], _bearer: str | None) -> dict[str, Any]:
claims = decode_code(form.get("code", ""))
if not form.get("code") or claims.get("error"):
raise SlackError(claims.get("error") or "invalid_code")
st.check_client(form.get("client_id", ""), form.get("client_secret", ""))
team_id = claims.get("team_id", DEFAULT_TEAM)
user_id = claims.get("user_id", DEFAULT_USER)
token_claims = {
"iss": SLACK_ISSUER,
"sub": user_id,
"aud": form.get("client_id", ""),
"exp": int(time.time()) + 300,
"iat": int(time.time()),
"nonce": claims.get("nonce", ""),
"email": claims.get("email", "dana@acme.test"),
"email_verified": True,
"https://slack.com/user_id": user_id,
"https://slack.com/team_id": team_id,
"https://slack.com/team_name": st.team(team_id)["team_name"],
}
if claims.get("enterprise_id"):
token_claims["https://slack.com/enterprise_id"] = claims["enterprise_id"]
user_token = f"xoxp-mock-{st.next_id('')}"
return {"access_token": user_token, "token_type": "Bearer", "id_token": id_token(token_claims)}
def m_auth_test(st: MockState, _form: dict[str, str], bearer: str | None) -> dict[str, Any]:
if bearer and bearer.startswith("xoxp-mock-") and bearer not in st.revoked:
return {"url": "https://mock.slack.com/", "team": DEFAULT_TEAM_NAME, "team_id": DEFAULT_TEAM, "user_id": DEFAULT_USER}
grant = st.bot(bearer)
team = st.team(grant["team_id"])
return {
"url": f"https://{grant['team_id'].lower()}.slack.com/",
"team": team["team_name"],
"team_id": grant["team_id"],
"user_id": grant["user_id"],
"bot_id": "B0BOT",
"is_enterprise_install": False,
}
def m_auth_revoke(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
token = bearer or form.get("token")
if not token:
raise SlackError("not_authed")
st.revoked.add(token)
return {"revoked": True}
def m_tooling_tokens_rotate(st: MockState, form: dict[str, str], _bearer: str | None) -> dict[str, Any]:
refresh = form.get("refresh_token", "")
if not refresh or "expired" in refresh or refresh in st.used_refresh_tokens:
raise SlackError("invalid_refresh_token")
st.used_refresh_tokens.add(refresh)
token = f"xoxe.xoxp-1-mock-{st.next_id('')}"
st.config_tokens.add(token)
team_id = st.token_teams.get(refresh, DEFAULT_TEAM)
successor = f"xoxe-1-mock-{st.next_id('')}"
st.token_teams[successor] = team_id
now = int(time.time())
return {
"token": token,
"refresh_token": successor,
"team_id": team_id,
"user_id": DEFAULT_USER,
"iat": now,
"exp": now + 43200,
}
def m_apps_manifest_create(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.config_token(bearer)
try:
manifest = json.loads(form.get("manifest", ""))
except json.JSONDecodeError as e:
raise SlackError("invalid_manifest") from e
app_id = st.next_id("A")
app = {
"app_id": app_id,
"client_id": f"{next(st.ids)}.{next(st.ids)}",
"client_secret": f"secret-{app_id}",
"signing_secret": f"signing-{app_id}",
"manifest": manifest,
}
st.apps[app_id] = app
return {
"app_id": app_id,
"credentials": {
"client_id": app["client_id"],
"client_secret": app["client_secret"],
"verification_token": f"verify-{app_id}",
"signing_secret": app["signing_secret"],
},
"oauth_authorize_url": f"https://slack.com/oauth/v2/authorize?client_id={app['client_id']}&scope=commands",
}
def m_apps_manifest_update(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.config_token(bearer)
app = st.apps.get(form.get("app_id", ""))
if app is None:
raise SlackError("app_not_found")
app["manifest"] = json.loads(form.get("manifest", "{}"))
return {"app_id": app["app_id"], "permissions_updated": False}
def m_apps_manifest_delete(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.config_token(bearer)
if st.apps.pop(form.get("app_id", ""), None) is None:
raise SlackError("app_not_found")
return {}
def m_apps_uninstall(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
grant = st.bot(bearer)
st.check_client(form.get("client_id", ""), form.get("client_secret", ""))
for token, g in st.bot_tokens.items():
if g["app_id"] == grant["app_id"] and g["team_id"] == grant["team_id"]:
st.revoked.add(token)
app = st.apps.get(grant["app_id"])
url = ((app or {}).get("manifest") or {}).get("settings", {}).get("event_subscriptions", {}).get("request_url")
if st.uninstall_settle is not None and url:
for event in uninstall_events(grant):
st.outbox.append((url, app["signing_secret"], event))
return {}
def uninstall_events(grant: dict[str, Any]) -> list[bytes]:
"""``tokens_revoked`` for the bot, then ``app_uninstalled``: what live Slack sent on ``apps.uninstall``."""
events = [
{"type": "tokens_revoked", "tokens": {"oauth": [], "bot": [grant["user_id"]]}},
{"type": "app_uninstalled"},
]
now = time.time()
return [json.dumps({
"token": "ignored", "team_id": grant["team_id"], "api_app_id": grant["app_id"],
"event": {**event, "event_ts": f"{now:.6f}"}, "type": "event_callback",
"event_id": "Ev" + uuid.uuid4().hex[:10].upper(), "event_time": int(now),
}).encode() for event in events]
def deliver(url: str, secret: str, body: bytes) -> int:
stamp = str(int(time.time()))
mac = hmac.new(secret.encode(), b"v0:" + stamp.encode() + b":" + body, hashlib.sha256).hexdigest()
headers = {"Content-Type": "application/json", "X-Slack-Request-Timestamp": stamp, "X-Slack-Signature": "v0=" + mac}
try:
with urlopen(Request(url, data=body, headers=headers, method="POST"), timeout=10) as resp:
return resp.status
except HTTPError as e:
return e.code
except OSError as e:
log.warning("event delivery to %s failed: %s", url, e)
return 0
def m_apps_connections_open(_st: MockState, _form: dict[str, str], bearer: str | None) -> dict[str, Any]:
if not bearer or not bearer.startswith("xapp-"):
raise SlackError("invalid_auth")
# Live Slack puts a 64-hex hash in the socket URL's app_id, never the A… app id.
app_hash = hashlib.sha256(bearer.encode()).hexdigest()
return {"url": f"wss://wss-primary.slack.com/link/?ticket={uuid.uuid4()}&app_id={app_hash}"}
def page(items: list[dict[str, Any]], form: dict[str, str]) -> tuple[list[dict[str, Any]], str]:
start = int(form.get("cursor") or 0)
limit = max(1, int(form.get("limit") or 100))
chunk = items[start:start + limit]
nxt = str(start + limit) if start + limit < len(items) else ""
return chunk, nxt
def m_conversations_list(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
grant = st.bot(bearer)
chunk, nxt = page(st.team(grant["team_id"])["channels"], form)
return {"channels": chunk, "response_metadata": {"next_cursor": nxt}}
def m_users_list(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
grant = st.bot(bearer)
chunk, nxt = page(st.team(grant["team_id"])["members"], form)
return {"members": chunk, "response_metadata": {"next_cursor": nxt}}
def m_users_info(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
grant = st.bot(bearer)
user = next((m for m in st.team(grant["team_id"])["members"] if m["id"] == form.get("user")), None)
if user is None:
raise SlackError("user_not_found")
return {"user": user}
def m_users_lookup_by_email(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
grant = st.bot(bearer)
email = (form.get("email") or "").lower()
user = next((m for m in st.team(grant["team_id"])["members"]
if (m["profile"].get("email") or "").lower() == email), None)
if user is None:
raise SlackError("users_not_found")
return {"user": user}
def m_conversations_info(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
grant = st.bot(bearer)
channel = next((c for c in st.team(grant["team_id"])["channels"] if c["id"] == form.get("channel")), None)
if channel is None:
raise SlackError("channel_not_found")
return {"channel": channel}
def m_chat_post_message(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
check_message_blocks(form)
if not form.get("channel"):
raise SlackError("channel_not_found")
ts = f"{time.time():.6f}"
# Posting to a user id lands in the DM, and Slack answers with the DM's own channel id.
channel = "D" + form["channel"][1:] if form["channel"].startswith("U") else form["channel"]
return {"channel": channel, "ts": ts, "message": {"text": form.get("text", ""), "ts": ts}}
def m_chat_update(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
check_message_blocks(form)
return {"channel": form.get("channel", ""), "ts": form.get("ts", ""), "text": form.get("text", "")}
def m_chat_post_ephemeral(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
check_message_blocks(form)
return {"message_ts": f"{time.time():.6f}"}
def m_agents_sessions_set_status(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
if form.get("status") not in ("active", "processing", "suspended", "closed"):
raise SlackError("invalid_status")
if not form.get("thread_ts"):
raise SlackError("thread_ts_required")
return {"status": form["status"], "agent_status": form["status"]}
def m_chat_start_stream(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
if not form.get("channel") or not form.get("thread_ts"):
raise SlackError("invalid_arguments")
return {"channel": form["channel"], "ts": f"{time.time():.6f}"}
def m_chat_append_stream(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
return {"channel": form.get("channel", ""), "ts": form.get("ts", "")}
def m_chat_stop_stream(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
check_message_blocks(form)
return {"channel": form.get("channel", ""), "ts": form.get("ts", "")}
def m_chat_delete(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
if not form.get("channel") or not form.get("ts"):
raise SlackError("message_not_found")
return {"channel": form["channel"], "ts": form["ts"]}
def m_views_publish(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
try:
view = json.loads(form.get("view") or "{}")
except ValueError:
raise SlackError("invalid_arguments") from None
errors = block_errors(view.get("blocks"), "view/blocks", MAX_VIEW_BLOCKS, view=True)
if errors:
raise SlackError("invalid_arguments", messages=errors)
return {"view": {"id": st.next_id("V"), "type": "home", "user_id": form.get("user_id", "")}}
def m_files_get_upload_url_external(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
file_id = st.next_id("F")
st.files.add(file_id)
return {"upload_url": f"{st.base_url}/upload/{file_id}", "file_id": file_id, "filename": form.get("filename", "")}
def m_files_complete_upload_external(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
files = json.loads(form.get("files", "[]"))
return {"files": [{"id": f.get("id", ""), "title": f.get("title", "")} for f in files]}
def m_files_delete(st: MockState, form: dict[str, str], bearer: str | None) -> dict[str, Any]:
st.bot(bearer)
if form.get("file") not in st.files:
raise SlackError("file_not_found")
st.files.discard(form["file"])
return {}
METHODS: dict[str, Handler] = {
"oauth.v2.access": m_oauth_v2_access,
"openid.connect.token": m_openid_connect_token,
"auth.test": m_auth_test,
"auth.revoke": m_auth_revoke,
"tooling.tokens.rotate": m_tooling_tokens_rotate,
"apps.manifest.create": m_apps_manifest_create,
"apps.manifest.update": m_apps_manifest_update,
"apps.manifest.delete": m_apps_manifest_delete,
"apps.uninstall": m_apps_uninstall,
"apps.connections.open": m_apps_connections_open,
"conversations.list": m_conversations_list,
"conversations.info": m_conversations_info,
"users.list": m_users_list,
"users.info": m_users_info,
"users.lookupByEmail": m_users_lookup_by_email,
"chat.postMessage": m_chat_post_message,
"chat.update": m_chat_update,
"chat.postEphemeral": m_chat_post_ephemeral,
"chat.startStream": m_chat_start_stream,
"chat.appendStream": m_chat_append_stream,
"chat.stopStream": m_chat_stop_stream,
"chat.delete": m_chat_delete,
"agents.sessions.setStatus": m_agents_sessions_set_status,
"views.publish": m_views_publish,
"files.getUploadURLExternal": m_files_get_upload_url_external,
"files.completeUploadExternal": m_files_complete_upload_external,
"files.delete": m_files_delete,
}
class SlackMockHandler(BaseHTTPRequestHandler):
server_version = "SlackMock/1"
state: MockState
def log_message(self, fmt: str, *args: Any) -> None:
log.debug(fmt, *args)
def do_GET(self) -> None: # noqa: N802
url = urlparse(self.path)
query = {k: v[-1] for k, v in parse_qs(url.query).items()}
if url.path == "/_mock/health":
return self.reply(200, {"ok": True})
if url.path == "/_mock/requests":
with self.state.lock:
calls = [c for c in self.state.calls if "method" not in query or c["method"] == query["method"]]
return self.reply(200, {"ok": True, "requests": calls})
if url.path == "/_mock/code":
return self.reply(200, {"ok": True, "code": mock_code(**json.loads(query.get("claims", "{}")))})
if url.path == "/_mock/state":
with self.state.lock:
snapshot = {
"apps": list(self.state.apps.values()),
"bot_tokens": len(self.state.bot_tokens),
"revoked": len(self.state.revoked),
"used_refresh_tokens": len(self.state.used_refresh_tokens),
}
return self.reply(200, {"ok": True, **snapshot})
return self.reply(404, {"ok": False, "error": "unknown_path"})
def do_POST(self) -> None: # noqa: N802
url = urlparse(self.path)
raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
if url.path.startswith("/_mock/"):
return self.control(url.path, json.loads(raw or b"{}"))
if url.path.startswith("/services/") or url.path.startswith("/upload/"):
kind = "incoming_webhook" if url.path.startswith("/services/") else "file_upload"
with self.state.lock:
self.state.record(kind, {"path": url.path, "body": raw.decode(errors="replace")}, None)
return self.reply(200, "ok")
if not url.path.startswith("/api/"):
return self.reply(404, {"ok": False, "error": "unknown_path"})
self.slack_method(url.path[len("/api/"):], raw)
def slack_method(self, method: str, raw: bytes) -> None:
form = self.parse_form(raw)
bearer = self.bearer() or form.get("token")
with self.state.lock:
self.state.record(method, form, bearer)
handler = METHODS.get(method)
try:
if handler is None:
raise SlackError("unknown_method")
self.state.take_failure(method)
body = handler(self.state, form, bearer)
self.state.calls[-1]["response"] = body
except SlackError as e:
self.state.calls[-1]["error"] = e.error
if e.http_status != 200:
headers = {"Retry-After": str(e.retry_after)} if e.retry_after is not None else {}
return self.reply(e.http_status, {"ok": False, "error": e.error}, headers)
metadata = {"response_metadata": {"messages": e.messages}} if e.messages else {}
return self.reply(200, {"ok": False, "error": e.error, **metadata})
outbox, self.state.outbox = self.state.outbox, []
settle = self.state.uninstall_settle or 0
if outbox:
self.deliver_events(outbox, settle)
self.reply(200, {"ok": True, **{k: v for k, v in body.items() if v is not None}})
def deliver_events(self, outbox: list[tuple[str, str, bytes]], settle: float) -> None:
"""Outside the lock, since O2 may call the mock while it handles an event."""
for url, secret, event in outbox:
status = deliver(url, secret, event)
with self.state.lock:
self.state.record("event_delivery", {"url": url, "body": event.decode(), "status": str(status)}, None)
time.sleep(settle)
def control(self, path: str, body: dict[str, Any]) -> None:
st = self.state
with st.lock:
if path == "/_mock/reset":
st.reset()
elif path == "/_mock/fail":
failure = {"times": 1, **body}
st.failures.setdefault(body["method"], []).append(failure)
elif path == "/_mock/mode":
if body.get("clear"):
st.modes.pop(body["method"], None)
else:
st.modes[body["method"]] = body
elif path == "/_mock/apps":
st.apps[body["app_id"]] = {"signing_secret": "", "manifest": {}, **body}
elif path == "/_mock/uninstall_events":
st.uninstall_settle = float(body.get("settle", 0))
elif path == "/_mock/teams":
team = default_team(body["team_id"], body.get("team_name", body["team_id"]))
st.teams[body["team_id"]] = {**team, **body}
elif path == "/_mock/config_tokens":
st.token_teams[body["refresh_token"]] = body["team_id"]
else:
return self.reply(404, {"ok": False, "error": "unknown_path"})
self.reply(200, {"ok": True})
def parse_form(self, raw: bytes) -> dict[str, str]:
ctype = self.headers.get("Content-Type", "")
if ctype.startswith("application/json"):
value = json.loads(raw or b"{}")
return {k: v if isinstance(v, str) else json.dumps(v) for k, v in value.items()}
return {k: v[-1] for k, v in parse_qs(raw.decode(), keep_blank_values=True).items()}
def bearer(self) -> str | None:
auth = self.headers.get("Authorization", "")
return auth[len("Bearer "):] if auth.startswith("Bearer ") else None
def reply(self, status: int, body: Any, headers: dict[str, str] | None = None) -> None:
data = body.encode() if isinstance(body, str) else json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "text/plain" if isinstance(body, str) else "application/json")
self.send_header("Content-Length", str(len(data)))
for k, v in (headers or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(data)
def make_server(host: str = "127.0.0.1", port: int = 0) -> ThreadingHTTPServer:
"""A server bound to ``host:port`` (0 picks a free port); call ``serve_forever`` to run it."""
server = ThreadingHTTPServer((host, port), SlackMockHandler)
state = MockState(f"http://{host}:{server.server_address[1]}")
server.RequestHandlerClass = type("BoundSlackMockHandler", (SlackMockHandler,), {"state": state})
return server
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=5099)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s slack-mock %(message)s")
server = make_server(args.host, args.port)
sys.stderr.write(f"slack mock listening on http://{args.host}:{server.server_address[1]}/api\n")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())