mirror of
https://github.com/spantaleev/matrix-docker-ansible-deploy.git
synced 2026-08-29 20:13:13 +00:00
They were hard-wrapped at 80 characters, broke mid-parenthesis, and spent lines restating what the code below them does. Rewrapped at natural boundaries instead, with the narration dropped and only the reasons, gotchas and surprises kept. Section dividers stay - they delineate long plays rather than narrate them. Comments only; no scenario behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
180 lines
6.9 KiB
Python
180 lines
6.9 KiB
Python
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
|
|
#
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
"""A stand-in homeserver for Molecule scenarios.
|
|
|
|
Most components talk to a homeserver while starting up and exit if it is unreachable,
|
|
so a scenario cannot get them running without one. A real Synapse for every role would
|
|
dominate the run time and drag in Postgres, and the scenarios are not testing Synapse.
|
|
|
|
This answers the handful of endpoints components touch during startup, with the blandest
|
|
plausible response in each case. Deliberately permissive: an unknown path returns `{}` with
|
|
a 200 rather than a 404, because the goal is to get the component past its startup checks.
|
|
|
|
What it is NOT: an authentication check, a room state machine, or anything a scenario should
|
|
assert *about*. Assert on what the role rendered and what the component reports about itself.
|
|
A scenario that needs this stub to behave like a real homeserver has outgrown what these
|
|
tests are for.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
SERVER_NAME = os.environ.get("STUB_SERVER_NAME", "molecule.local")
|
|
PORT = int(os.environ.get("STUB_PORT", "8008"))
|
|
|
|
# Rooms reported as already joined. Components that resolve a room mapping at startup
|
|
# (matrix-alertmanager-receiver, for one) fail if the rooms they were configured with
|
|
# are missing, so a scenario passes its own room IDs in.
|
|
JOINED_ROOMS = [r for r in os.environ.get("STUB_JOINED_ROOMS", "").split(",") if r]
|
|
|
|
USER_ID = os.environ.get("STUB_USER_ID", f"@stub:{SERVER_NAME}")
|
|
|
|
# Longest a /sync call is held open. Long-polling clients ask for a 30s timeout and
|
|
# immediately ask again when the call returns, so answering instantly spins them into a hot
|
|
# loop that eats the test machine. Honouring the requested timeout, capped here, keeps an
|
|
# idle bot idle.
|
|
SYNC_MAX_HOLD_SECONDS = 30
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def _send(self, payload, status=200):
|
|
body = json.dumps(payload).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _route(self):
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path
|
|
|
|
# A client that syncs (every bot here does) needs a `next_batch` back or
|
|
# the response will not deserialize, and it needs the call to block for
|
|
# the timeout it asked for or it will hammer this stub. Nothing is ever
|
|
# reported: an idle bot is what a scenario wants.
|
|
if path.endswith("/sync"):
|
|
requested_ms = parse_qs(parsed.query).get("timeout", ["0"])[0]
|
|
try:
|
|
hold = min(int(requested_ms) / 1000.0, SYNC_MAX_HOLD_SECONDS)
|
|
except ValueError:
|
|
hold = 0
|
|
if hold > 0:
|
|
time.sleep(hold)
|
|
return {"next_batch": "molecule-stub-batch"}
|
|
|
|
# Before the generic `/upload` below: this one is the end-to-end
|
|
# encryption key upload, and the client insists on the key counts.
|
|
if path.endswith("/keys/upload"):
|
|
return {"one_time_key_counts": {}}
|
|
|
|
# Media. A bot that sets its own avatar asks for the upload limits first
|
|
# and refuses to proceed without them, then uploads and expects an MXC
|
|
# URI back.
|
|
if path.endswith("/media/config") or path.endswith("/media/v3/config"):
|
|
return {"m.upload.size": 10485760}
|
|
|
|
if path.endswith("/upload"):
|
|
return {"content_uri": f"mxc://{SERVER_NAME}/molecule-stub-media"}
|
|
|
|
# Sync filters are uploaded before the first sync and referenced by id.
|
|
if path.endswith("/filter"):
|
|
return {"filter_id": "molecule-stub-filter"}
|
|
|
|
if path.endswith("/joined_rooms"):
|
|
return {"joined_rooms": JOINED_ROOMS}
|
|
|
|
if path.endswith("/whoami"):
|
|
return {"user_id": USER_ID, "device_id": "STUBDEVICE"}
|
|
|
|
if path.endswith("/versions"):
|
|
return {
|
|
"versions": ["v1.1", "v1.2", "v1.3", "v1.4", "v1.5", "v1.6"],
|
|
"unstable_features": {},
|
|
}
|
|
|
|
if path.endswith("/capabilities"):
|
|
return {"capabilities": {}}
|
|
|
|
# Where bots authenticating with a username and password log in, rather than as an
|
|
# appservice with a token. Matched loosely on purpose, because clients differ on the
|
|
# API version prefix, and a login falling through to the catch-all `{}` below looks
|
|
# to the client like bad credentials.
|
|
if path.endswith("/login"):
|
|
return {
|
|
"user_id": USER_ID,
|
|
"access_token": "stub_access_token",
|
|
"device_id": "STUBDEVICE",
|
|
"home_server": SERVER_NAME,
|
|
}
|
|
|
|
if path.endswith("/createRoom"):
|
|
return {"room_id": f"!stub-room:{SERVER_NAME}"}
|
|
|
|
if re.search(r"/rooms/[^/]+/join$", path) or path.endswith("/join"):
|
|
return {"room_id": f"!stub-room:{SERVER_NAME}"}
|
|
|
|
if "/send/" in path or "/state/" in path:
|
|
return {"event_id": f"$stub-event:{SERVER_NAME}"}
|
|
|
|
if path.endswith("/register"):
|
|
return {
|
|
"user_id": USER_ID,
|
|
"access_token": "stub_access_token",
|
|
"device_id": "STUBDEVICE",
|
|
"home_server": SERVER_NAME,
|
|
}
|
|
|
|
if path.endswith("/profile") or "/profile/" in path:
|
|
return {"displayname": "stub"}
|
|
|
|
if path.startswith("/_matrix/key/"):
|
|
return {"server_name": SERVER_NAME, "verify_keys": {}, "old_verify_keys": {}}
|
|
|
|
if path.startswith("/.well-known/matrix/client"):
|
|
return {"m.homeserver": {"base_url": f"http://{SERVER_NAME}:{PORT}"}}
|
|
|
|
if path.startswith("/.well-known/matrix/server"):
|
|
return {"m.server": f"{SERVER_NAME}:{PORT}"}
|
|
|
|
if path.endswith("/health") or path.endswith("/_matrix/federation/v1/version"):
|
|
return {"server": {"name": "molecule-stub", "version": "0"}}
|
|
|
|
# Anything unrecognised: an empty object, so a component probing an endpoint
|
|
# not listed here still gets past it.
|
|
return {}
|
|
|
|
def do_GET(self):
|
|
self._send(self._route())
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
if length:
|
|
self.rfile.read(length)
|
|
self._send(self._route())
|
|
|
|
def do_PUT(self):
|
|
self.do_POST()
|
|
|
|
def do_DELETE(self):
|
|
self._send({})
|
|
|
|
def log_message(self, fmt, *args):
|
|
# Quiet by default. STUB_VERBOSE=1 when a scenario will not start and you need
|
|
# to see what the component is actually asking for.
|
|
if os.environ.get("STUB_VERBOSE"):
|
|
sys.stderr.write("stub: " + (fmt % args) + "\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|