test(harness): add docker-compose stack, mocks, and extend suite per test-improvement plan
Implements all phases of test-improvement.md: - docker-compose.yml: postgres + aiohttp mocks + prosody + optional tester, using exact template placeholder names (fixes latent fcm_api_url/del_api_url envsubst bug). - tests/mocks: single aiohttp backend (auth, FCM, delfile, avatar, file-share) with /__requests capture API and /__fcm_error for prune tests. - tests/Dockerfile + tests/requirements.txt: tester image. - conftest: mock_client fixture, --mock-url; VNCXmppClient now honors XMPP_HOST/PORT (slixmpp was SRV-resolving the JID domain); RESTInjector sends Host: <domain> (prosody routes HTTP by Host header). - test_08_image_patches: docker-exec grep of every patched upstream file. - test_09_http_sideeffects: FCM (1:1 + MUC), delfile, vcard-avatar, receipts. - test_10_smacks: enable/ack, resume-replay, hibernation-expiry (offline variant xfail until fork is replaced by upstream mod_smacks_offline). - test_11_smokes: filter_chatstates, idlecompat, http_altconnect, webpresence, admin-telnet non-loopback. - test_12_image_runtime: http_upload slot handshake, healthcheck exit-2, no-residual-placeholder config check. - test_06_postgres: real kick-row assertion via unregister IQ. - test_02_muc: vcard_muc get/set; test_03_vnctalk: muc_hook non-joined affiliate. - startup.sh + template: SMACKS_HIBERNATION_TIME as a proper global config var (default 300) so SMACKS expiry tests can lower it. - Makefile (make up/test/down/logs) and run-tests.sh updated. Validated end-to-end against the compose stack: 81 passed, 1 xfailed, 1 pre-existing vcard_fallback failure (unrelated), 3 skipped. Part-of: <http://gitlab.vnc.biz/uxf/vnctalk-prosody/-/merge_requests/3>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Makefile — convenience wrappers for the docker-compose test harness.
|
||||
#
|
||||
# make test build + up the stack, run the full suite in the tester container
|
||||
# make up build + start postgres/mocks/prosody (run pytest from the host)
|
||||
# make down tear the stack down
|
||||
# make logs tail prosody logs
|
||||
#
|
||||
# Host-run example (after `make up`):
|
||||
# XMPP_HOST=localhost XMPP_PORT=5222 XMPP_DOMAIN=example.com \
|
||||
# MUC_DOMAIN=conference.example.com XMPP_JID=user1@example.com XMPP_PASSWORD=pass1 \
|
||||
# XMPP_JID2=user2@example.com XMPP_PASSWORD2=pass2 \
|
||||
# REST_URL=http://localhost:5280/rest BOSH_URL=http://localhost:5280/http-bind \
|
||||
# WS_URL=ws://localhost:5280/xmpp-websocket ADMIN_TELNET_HOST=localhost \
|
||||
# PG_HOST=localhost PG_PORT=5434 PG_DB=prosody PG_USER=prosody PG_PASSWORD=prosody \
|
||||
# MOCK_URL=http://localhost:8092 SMACKS_HIBERNATION_TIME=10 \
|
||||
# pytest tests/ -v -c tests/pytest.ini
|
||||
|
||||
.PHONY: test up down logs
|
||||
|
||||
up:
|
||||
docker compose up -d --build postgres mocks prosody
|
||||
|
||||
test: up
|
||||
docker compose run --rm --rm tester pytest tests/ -v -c tests/pytest.ini
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
logs:
|
||||
docker compose logs -f prosody
|
||||
@@ -200,7 +200,7 @@ default_archive_policy = "roster"
|
||||
--------------------------------------------------------------------------------
|
||||
---- SMACKS config
|
||||
--------------------------------------------------------------------------------
|
||||
smacks_hibernation_time = 300;
|
||||
smacks_hibernation_time = ${SMACKS_HIBERNATION_TIME};
|
||||
smacks_enabled_s2s = false;
|
||||
smacks_max_unacked_stanzas = 5;
|
||||
smacks_max_ack_delay = 60;
|
||||
|
||||
@@ -38,6 +38,13 @@ if [ -z "$log_slow_events_threshold" ]; then
|
||||
fi
|
||||
export log_slow_events_threshold
|
||||
|
||||
# SMACKS hibernation window. Defaults to 300s (production). The compose test
|
||||
# harness lowers it (e.g. 10) so SMACKS parity tests can exercise expiry.
|
||||
if [ -z "$SMACKS_HIBERNATION_TIME" ]; then
|
||||
SMACKS_HIBERNATION_TIME="300"
|
||||
fi
|
||||
export SMACKS_HIBERNATION_TIME
|
||||
|
||||
if [ -z "$DEFAULT_JITSI_CONFERENCE" ]; then
|
||||
DEFAULT_JITSI_CONFERENCE="conference.jitsi.dev.vnc.de"
|
||||
export DEFAULT_JITSI_CONFERENCE
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Test harness for vnctalk-prosody end-to-end verification.
|
||||
#
|
||||
# Brings up Prosody (built from this repo's Dockerfile), a local PostgreSQL,
|
||||
# and a single aiohttp mock service that stands in for every external HTTP
|
||||
# backend (auth, FCM, file-share, avatar upload). The optional `tester`
|
||||
# service runs the pytest suite inside the compose network; for local dev the
|
||||
# host can run pytest directly against the published ports.
|
||||
#
|
||||
# docker compose up -d --build postgres mocks prosody
|
||||
# docker compose run --rm tester pytest tests/ -v -c tests/pytest.ini
|
||||
#
|
||||
# Env-var names below use the EXACT placeholders found in
|
||||
# config/prosody.cfg.lua.template (snake_case ${fcm_api_url} / ${del_api_url}).
|
||||
# test.sh historically passed camelCase fcmApiUrl/fcmDelUrl, which envsubst
|
||||
# silently leaves unsubstituted — see test-improvement.md §1.2.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: prosody
|
||||
POSTGRES_USER: prosody
|
||||
POSTGRES_PASSWORD: prosody
|
||||
ports: ["5434:5432"] # 5434 on host (5433 often taken by a local PG)
|
||||
volumes: [pgdata:/var/lib/postgresql/data]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U prosody"]
|
||||
interval: 2s
|
||||
retries: 30
|
||||
|
||||
mocks:
|
||||
build: ./tests/mocks
|
||||
ports: ["8092:8080"] # host inspection of /__requests (8090 often taken)
|
||||
environment:
|
||||
AUTH_USERS: "user1@example.com:pass1,user2@example.com:pass2,admin@example.com:adminpw"
|
||||
|
||||
prosody:
|
||||
build: .
|
||||
# The image declares USER prosody, but startup.sh writes certs and the
|
||||
# rendered config into /etc/prosody (root-owned). Production runs as root
|
||||
# (run_as_root = true); mirror that here so the harness is self-contained.
|
||||
user: "0:0"
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
mocks: { condition: service_started }
|
||||
ports:
|
||||
- "5222:5222" # C2S
|
||||
- "5280:5280" # HTTP (BOSH/WS/REST/admin)
|
||||
- "5582:5582" # admin telnet
|
||||
environment:
|
||||
prosodyDomain: example.com
|
||||
prosodyDBhost: postgres
|
||||
prosodyDBname: prosody
|
||||
prosodyDBuser: prosody
|
||||
prosodyDBpass: prosody
|
||||
hybridaAuthUrl: http://mocks:8080/auth
|
||||
fcm_api_url: http://mocks:8080/fcm/notify
|
||||
del_api_url: http://mocks:8080/delfile
|
||||
fcmApiKey: test-fcm-key
|
||||
fileShareBaseUrl: http://mocks:8080/share/
|
||||
fileShareSecret: test-share-secret
|
||||
avatarUploadUrl: http://mocks:8080/avatar/
|
||||
avatarUploadUser: avatar
|
||||
avatarUploadPass: avpw
|
||||
DEFAULT_JITSI_CONFERENCE: conference.jitsi.test
|
||||
log_slow_events_threshold: "1.5"
|
||||
# Lower SMACKS hibernation window so test_10 can exercise expiry without
|
||||
# waiting 5 minutes. startup.sh appends this to the rendered config when set.
|
||||
SMACKS_HIBERNATION_TIME: "10"
|
||||
healthcheck:
|
||||
test: ["CMD", "/vnc/config/healthcheck.sh"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
|
||||
tester:
|
||||
build: ./tests
|
||||
profiles: ["ci"] # only started with --profile ci
|
||||
depends_on: [prosody]
|
||||
environment:
|
||||
XMPP_HOST: prosody
|
||||
XMPP_PORT: "5222"
|
||||
XMPP_DOMAIN: example.com
|
||||
MUC_DOMAIN: conference.example.com
|
||||
XMPP_JID: user1@example.com
|
||||
XMPP_PASSWORD: pass1
|
||||
XMPP_JID2: user2@example.com
|
||||
XMPP_PASSWORD2: pass2
|
||||
REST_URL: http://prosody:5280/rest
|
||||
BOSH_URL: http://prosody:5280/http-bind
|
||||
WS_URL: ws://prosody:5280/xmpp-websocket
|
||||
ADMIN_TELNET_HOST: prosody
|
||||
PG_HOST: postgres
|
||||
PG_USER: prosody
|
||||
PG_PASSWORD: prosody
|
||||
PG_DB: prosody
|
||||
MOCK_URL: http://mocks:8080 # for side-effect assertions
|
||||
volumes:
|
||||
- ./tests:/tests
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
|
||||
export XMPP_HOST=xmpp.microlab.zimbra-vnc.de
|
||||
export XMPP_PORT=30522
|
||||
export XMPP_JID=lisa.porter@microlab.zimbra-vnc.de
|
||||
export XMPP_PASSWORD=q3tx65test
|
||||
export XMPP_JID2=richard.watson@microlab.zimbra-vnc.de
|
||||
export XMPP_PASSWORD2=q3tx65test
|
||||
export XMPP_DOMAIN=microlab.zimbra-vnc.de
|
||||
export BOSH_URL=https://xmpp.microlab.zimbra-vnc.de/http-bind
|
||||
export WS_URL=wss://xmpp.microlab.zimbra-vnc.de/xmpp-websocket
|
||||
export REST_URL=https://xmpprest.microlab.zimbra-vnc.de/rest
|
||||
export MUC_DOMAIN=conference.microlab.zimbra-vnc.de
|
||||
export ADMIN_TELNET_HOST=127.0.0.1
|
||||
export ADMIN_TELNET_PORT=5582
|
||||
export REST_USER=vnctalk
|
||||
export REST_PASSWORD=ohtai2Eicai4aiting7bec2am6Aih
|
||||
export PG_HOST=localhost
|
||||
export PG_PORT=14322
|
||||
export PG_DB=prosody
|
||||
export PG_USER=prosody
|
||||
export PG_PASSWORD=eS5Gi7ahzo3chaiXiel0ief
|
||||
|
||||
pytest tests/test_01_core.py -v -c tests/pytest.ini
|
||||
pytest tests/test_02_muc.py -v -c tests/pytest.ini
|
||||
pytest tests/test_03_vnctalk.py -v -c tests/pytest.ini
|
||||
pytest tests/test_04_infra.py -v -c tests/pytest.ini
|
||||
pytest tests/test_05_patches.py -v -c tests/pytest.ini
|
||||
pytest tests/test_06_postgres.py -v -c tests/pytest.ini
|
||||
pytest tests/test_07_module_load.py -v -c tests/pytest.ini
|
||||
pytest tests/test_08_image_patches.py -v -c tests/pytest.ini
|
||||
pytest tests/test_09_http_sideeffects.py -v -c tests/pytest.ini
|
||||
pytest tests/test_10_smacks.py -v -c tests/pytest.ini
|
||||
pytest tests/test_11_smokes.py -v -c tests/pytest.ini
|
||||
pytest tests/test_12_image_runtime.py -v -c tests/pytest.ini
|
||||
|
||||
# Or run the whole suite at once:
|
||||
# pytest tests/ -v -c tests/pytest.ini
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-alpine
|
||||
WORKDIR /tests
|
||||
COPY requirements.txt /tests/requirements.txt
|
||||
RUN apk add --no-cache build-base libffi-dev openssl-dev libxml2-dev libxslt-dev \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
COPY . /tests
|
||||
CMD ["pytest", "tests/", "-v", "-c", "tests/pytest.ini"]
|
||||
+73
-2
@@ -25,6 +25,11 @@ class VNCXmppClient(slixmpp.ClientXMPP):
|
||||
self.add_event_handler("session_start", self._on_session_start)
|
||||
self.add_event_handler("disconnected", self._on_disconnected)
|
||||
self._use_ssl = use_ssl
|
||||
# Honor the configured host/port: slixmpp's connect() otherwise does
|
||||
# SRV/A resolution on the JID domain, which breaks against a local
|
||||
# compose stack whose domain (e.g. example.com) doesn't resolve to it.
|
||||
self._connect_host = host or None
|
||||
self._connect_port = port
|
||||
|
||||
def _on_session_start(self, event):
|
||||
self.session_started_event.set()
|
||||
@@ -33,7 +38,7 @@ class VNCXmppClient(slixmpp.ClientXMPP):
|
||||
self.disconnected_event.set()
|
||||
|
||||
async def async_connect(self, timeout=30):
|
||||
await self.connect()
|
||||
await self.connect(self._connect_host, self._connect_port)
|
||||
await asyncio.wait_for(self.session_started_event.wait(), timeout=timeout)
|
||||
# announce presence so the server routes directed messages to this
|
||||
# resource; without it, chats to our bare JID go to offline storage
|
||||
@@ -249,13 +254,19 @@ class RESTInjector:
|
||||
because the /rest endpoint is typically protected by a reverse proxy.
|
||||
"""
|
||||
|
||||
def __init__(self, url, auth_user=None, auth_password=None):
|
||||
def __init__(self, url, auth_user=None, auth_password=None, host_header=None):
|
||||
self.url = url
|
||||
self.auth_user = auth_user
|
||||
self.auth_password = auth_password
|
||||
# Prosody routes HTTP by the Host header to a virtual host; mod_http_rest
|
||||
# lives on the XMPP domain's vhost, so requests to a generic host/port
|
||||
# (e.g. http://localhost:5280/rest) must carry Host: <xmpp domain>.
|
||||
self.host_header = host_header
|
||||
|
||||
def _headers(self):
|
||||
headers = {"Content-Type": "text/xml"}
|
||||
if self.host_header:
|
||||
headers["Host"] = self.host_header
|
||||
if self.auth_user and self.auth_password:
|
||||
import base64
|
||||
creds = base64.b64encode(
|
||||
@@ -365,6 +376,10 @@ def pytest_addoption(parser):
|
||||
"--pg-password", action="store", default=os.getenv("PG_PASSWORD", ""),
|
||||
help="PostgreSQL password"
|
||||
)
|
||||
parser.addoption(
|
||||
"--mock-url", action="store", default=os.getenv("MOCK_URL", ""),
|
||||
help="Base URL of the test mock service (for HTTP side-effect assertions)"
|
||||
)
|
||||
parser.addoption(
|
||||
"--verify-ssl", action="store_true", default=False,
|
||||
help="Verify TLS certificates (default: False, because test containers use self-signed certs)"
|
||||
@@ -398,6 +413,7 @@ def xmpp_config(request):
|
||||
"pg_db": request.config.getoption("--pg-db"),
|
||||
"pg_user": request.config.getoption("--pg-user"),
|
||||
"pg_password": request.config.getoption("--pg-password"),
|
||||
"mock_url": request.config.getoption("--mock-url"),
|
||||
"verify_ssl": request.config.getoption("--verify-ssl"),
|
||||
"skip_live": request.config.getoption("--skip-live"),
|
||||
}
|
||||
@@ -461,6 +477,7 @@ def rest_injector(xmpp_config):
|
||||
url,
|
||||
auth_user=xmpp_config.get("rest_user"),
|
||||
auth_password=xmpp_config.get("rest_password"),
|
||||
host_header=xmpp_config.get("domain"),
|
||||
)
|
||||
|
||||
|
||||
@@ -490,3 +507,57 @@ async def pg_connection(xmpp_config):
|
||||
yield conn
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class MockClient:
|
||||
"""Client for the tests/mocks aiohttp capture service.
|
||||
|
||||
Exposes reset()/captured()/wait_for() helpers so HTTP side-effect tests can
|
||||
assert deterministically on what Prosody POSTed/PUTed to the mock backends.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url, session):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = session
|
||||
|
||||
async def reset(self):
|
||||
async with self.session.delete(f"{self.base_url}/__requests") as resp:
|
||||
assert resp.status == 204, f"mock reset failed: {resp.status}"
|
||||
|
||||
async def captured(self, path=None):
|
||||
"""Return the captured request list, optionally filtered by path prefix."""
|
||||
url = f"{self.base_url}/__requests"
|
||||
if path:
|
||||
url += f"?path={path}"
|
||||
async with self.session.get(url) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def wait_for(self, path, count=1, timeout=5, interval=0.2):
|
||||
"""Poll until at least `count` captured requests match `path` prefix."""
|
||||
import time
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
items = await self.captured(path)
|
||||
if len(items) >= count:
|
||||
return items
|
||||
await asyncio.sleep(interval)
|
||||
return await self.captured(path)
|
||||
|
||||
async def mark_fcm_error(self, token):
|
||||
"""Tell the mock to return NotRegistered for the next notify of `token`."""
|
||||
async with self.session.get(f"{self.base_url}/__fcm_error", params={"token": token}) as resp:
|
||||
return await resp.json()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_client(xmpp_config):
|
||||
"""Yield a MockClient if MOCK_URL is configured, else skip."""
|
||||
base = xmpp_config.get("mock_url")
|
||||
if not base:
|
||||
pytest.skip("No MOCK_URL configured")
|
||||
import aiohttp
|
||||
session = aiohttp.ClientSession()
|
||||
try:
|
||||
yield MockClient(base, session)
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-alpine
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py /app/app.py
|
||||
EXPOSE 8080
|
||||
CMD ["python", "app.py"]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Single aiohttp mock standing in for every VNCtalk external HTTP backend.
|
||||
|
||||
Route groups:
|
||||
/auth GET mod_auth_http_async backend (Basic user@host:pw)
|
||||
/fcm/notify POST FCM push target (mod_vnc_fcm / mod_vnc_muc_fcm)
|
||||
/fcm/delete POST alias for the FCM prune target
|
||||
/delfile POST mod_vnc_delfile delete webhook
|
||||
/avatar/<tail> PUT mod_vnc_vcard_avatar / mod_vcard_muc upload target
|
||||
/share/<tail> PUT mod_http_upload_external file PUT target
|
||||
/__requests GET introspect captured requests (?path=<prefix>)
|
||||
/__requests DELETE reset capture log
|
||||
/__fcm_error GET mark a token to return {"error":"NotRegistered"}
|
||||
|
||||
Capture log entries: {method, path, headers, body, time}. The body is stored
|
||||
decoded when it is JSON, otherwise as text.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
AUTH_USERS = {}
|
||||
CAPTURED = []
|
||||
FCM_ERROR_TOKENS = set()
|
||||
|
||||
|
||||
def _parse_auth_users(raw):
|
||||
users = {}
|
||||
for entry in (raw or "").split(","):
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
if ":" not in entry:
|
||||
continue
|
||||
creds, pw = entry.rsplit(":", 1)
|
||||
users[creds] = pw # creds == "user@host"
|
||||
return users
|
||||
|
||||
|
||||
def _record(method, path, headers, body):
|
||||
CAPTURED.append({
|
||||
"method": method,
|
||||
"path": path,
|
||||
"headers": dict(headers),
|
||||
"body": body,
|
||||
"time": time.time(),
|
||||
})
|
||||
|
||||
|
||||
def _decode_body(raw_bytes, content_type):
|
||||
if raw_bytes is None:
|
||||
return None
|
||||
text = None
|
||||
if "charset=utf-8" in (content_type or "").lower() or "json" in (content_type or "").lower():
|
||||
try:
|
||||
text = raw_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = None
|
||||
if text is not None and "json" in (content_type or "").lower():
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
return text
|
||||
if text is not None:
|
||||
return text
|
||||
# binary (e.g. avatar image bytes) — keep as latin-1 round-trippable string
|
||||
try:
|
||||
return raw_bytes.decode("latin-1")
|
||||
except Exception:
|
||||
return repr(raw_bytes)
|
||||
|
||||
|
||||
async def auth(request):
|
||||
header = request.headers.get("Authorization", "")
|
||||
code = 401
|
||||
if header.startswith("Basic "):
|
||||
try:
|
||||
decoded = base64.b64decode(header[6:]).decode("utf-8")
|
||||
except Exception:
|
||||
decoded = ""
|
||||
# mod_auth_http_async sends "user@host:password"
|
||||
if ":" in decoded:
|
||||
creds, pw = decoded.rsplit(":", 1)
|
||||
if AUTH_USERS.get(creds) == pw:
|
||||
code = 200
|
||||
_record("GET", request.path, request.headers, None)
|
||||
return web.Response(status=code)
|
||||
|
||||
|
||||
async def fcm_notify(request):
|
||||
raw = await request.read()
|
||||
body = _decode_body(raw, request.headers.get("Content-Type"))
|
||||
_record("POST", request.path, request.headers, body)
|
||||
token = ""
|
||||
if isinstance(body, dict):
|
||||
token = body.get("to", "")
|
||||
if token and token in FCM_ERROR_TOKENS:
|
||||
FCM_ERROR_TOKENS.discard(token)
|
||||
return web.json_response({"message_id": "err", "results": [{"error": "NotRegistered"}]})
|
||||
return web.json_response({"message_id": "mock-" + str(int(time.time() * 1000))[-8:]})
|
||||
|
||||
|
||||
async def delfile(request):
|
||||
raw = await request.read()
|
||||
body = _decode_body(raw, request.headers.get("Content-Type"))
|
||||
_record("POST", request.path, request.headers, body)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
async def avatar_put(request):
|
||||
raw = await request.read()
|
||||
body = _decode_body(raw, request.headers.get("Content-Type"))
|
||||
_record("PUT", request.path, request.headers, body)
|
||||
return web.Response(status=201)
|
||||
|
||||
|
||||
async def share_put(request):
|
||||
raw = await request.read()
|
||||
body = _decode_body(raw, request.headers.get("Content-Type"))
|
||||
_record("PUT", request.path, request.headers, body)
|
||||
return web.Response(status=201)
|
||||
|
||||
|
||||
async def requests_get(request):
|
||||
path_filter = request.query.get("path")
|
||||
items = CAPTURED
|
||||
if path_filter:
|
||||
items = [c for c in CAPTURED if c["path"].startswith(path_filter)]
|
||||
return web.json_response(items)
|
||||
|
||||
|
||||
async def requests_delete(request):
|
||||
CAPTURED.clear()
|
||||
return web.Response(status=204)
|
||||
|
||||
|
||||
async def fcm_error(request):
|
||||
token = request.query.get("token", "")
|
||||
if token:
|
||||
FCM_ERROR_TOKENS.add(token)
|
||||
return web.json_response({"marked": token})
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def catch_all(request, handler):
|
||||
"""Record any unhandled route so tests can still assert side-effects."""
|
||||
try:
|
||||
resp = await handler(request)
|
||||
except web.HTTPNotFound:
|
||||
method = request.method
|
||||
raw = await request.read() if request.body_exists else b""
|
||||
_record(method, request.path, request.headers, _decode_body(raw, request.headers.get("Content-Type")))
|
||||
return web.Response(status=200)
|
||||
return resp
|
||||
|
||||
|
||||
def build_app():
|
||||
app = web.Application(middlewares=[catch_all])
|
||||
app.router.add_get("/auth", auth)
|
||||
app.router.add_post("/fcm/notify", fcm_notify)
|
||||
app.router.add_post("/fcm/delete", delfile) # alias
|
||||
app.router.add_post("/delfile", delfile)
|
||||
app.router.add_put("/avatar", avatar_put)
|
||||
app.router.add_put("/avatar/{tail:.*}", avatar_put)
|
||||
app.router.add_put("/share", share_put)
|
||||
app.router.add_put("/share/{tail:.*}", share_put)
|
||||
app.router.add_get("/__requests", requests_get)
|
||||
app.router.add_delete("/__requests", requests_delete)
|
||||
app.router.add_get("/__fcm_error", fcm_error)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
AUTH_USERS.update(_parse_auth_users(os.environ.get("AUTH_USERS", "")))
|
||||
web.run_app(build_app(), host="0.0.0.0", port=8080)
|
||||
@@ -0,0 +1 @@
|
||||
aiohttp>=3.9
|
||||
@@ -0,0 +1,7 @@
|
||||
aiohttp>=3.9
|
||||
slixmpp>=1.8
|
||||
pytest>=7
|
||||
pytest-asyncio>=0.21
|
||||
asyncpg>=0.29
|
||||
psycopg2-binary>=2.9
|
||||
lxml>=4.9
|
||||
@@ -346,3 +346,53 @@ class TestMuc:
|
||||
finally:
|
||||
await xmpp_client.leave_muc(room_jid, nick)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestVcardMuc:
|
||||
"""mod_vcard_muc: room vCard get/set persistence."""
|
||||
|
||||
async def test_muc_vcard_get_set(self, xmpp_client, xmpp_config):
|
||||
muc_domain = xmpp_config.get("muc_domain")
|
||||
if not muc_domain:
|
||||
pytest.skip("No MUC domain configured")
|
||||
|
||||
room_jid = f"vcardmuc_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
||||
nick = "owner"
|
||||
await xmpp_client.join_muc(room_jid, nick)
|
||||
await xmpp_client.configure_muc(room_jid)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
try:
|
||||
# A brand-new room has no vCard → item-not-found.
|
||||
iq = xmpp_client.make_iq_get(ito=room_jid)
|
||||
iq.append(ET.Element("{vcard-temp}vCard"))
|
||||
try:
|
||||
await iq.send(timeout=8)
|
||||
# Some builds return an empty vCard instead of an error; that's
|
||||
# also acceptable as the "no vCard yet" state.
|
||||
except slixmpp.exceptions.IqError as e:
|
||||
assert e.condition == "item-not-found", (
|
||||
f"expected item-not-found for empty room vCard, got {e.condition}"
|
||||
)
|
||||
|
||||
# Set a vCard with FN (owner affiliation is required to set).
|
||||
vcard = ET.Element("{vcard-temp}vCard")
|
||||
fn = ET.SubElement(vcard, "{vcard-temp}FN")
|
||||
fn.text = f"Room Display Name {uuid.uuid4().hex[:6]}"
|
||||
iq = xmpp_client.make_iq_set(ito=room_jid)
|
||||
iq.append(vcard)
|
||||
res = await iq.send(timeout=8)
|
||||
assert res["type"] == "result", f"vCard set failed: {res['type']}"
|
||||
|
||||
# Re-query and assert FN persisted.
|
||||
iq = xmpp_client.make_iq_get(ito=room_jid)
|
||||
iq.append(ET.Element("{vcard-temp}vCard"))
|
||||
res = await iq.send(timeout=8)
|
||||
fn_el = res.xml.find(".//{vcard-temp}FN")
|
||||
assert fn_el is not None and fn_el.text == fn.text, (
|
||||
f"room vCard FN did not persist; got {fn_el.text if fn_el is not None else None!r}"
|
||||
)
|
||||
finally:
|
||||
await xmpp_client.leave_muc(room_jid, nick)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
@@ -228,3 +228,49 @@ class TestVnctalkMUCExtensions:
|
||||
await xmpp_client.leave_muc(room_jid, nick1)
|
||||
await second_client.leave_muc(room_jid, nick2)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestVncMucHook:
|
||||
"""mod_vnc_muc_hook: notify an online, non-joined affiliated member."""
|
||||
|
||||
async def test_muc_hook_notifies_nonjoined_affiliate(
|
||||
self, xmpp_client, second_client, xmpp_config
|
||||
):
|
||||
muc_domain = xmpp_config.get("muc_domain")
|
||||
if not muc_domain:
|
||||
pytest.skip("No MUC domain configured")
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.xfail("Need --xmpp-jid2 with a distinct account")
|
||||
|
||||
room_jid = f"muchook_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
||||
nick1 = "owner"
|
||||
await xmpp_client.join_muc(room_jid, nick1)
|
||||
await xmpp_client.configure_muc(room_jid)
|
||||
await asyncio.sleep(0.5)
|
||||
# Grant the second client membership WITHOUT them joining the room.
|
||||
await xmpp_client.set_muc_affiliation(room_jid, second_client.boundjid.bare, "member")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
notified = asyncio.Event()
|
||||
|
||||
def on_message(msg):
|
||||
# mod_vnc_muc_hook sends either a muc#hook notification or a
|
||||
# mediated invite (when muc_notification_invite=true).
|
||||
if str(msg["from"]).startswith(room_jid):
|
||||
if msg.xml.find("{http://vnc.biz/xmpp/muc#hook}notification") is not None:
|
||||
notified.set()
|
||||
elif msg.xml.find("{http://jabber.org/protocol/muc#user}x") is not None:
|
||||
notified.set()
|
||||
|
||||
second_client.add_event_handler("message", on_message)
|
||||
try:
|
||||
msg = xmpp_client.make_message(mto=room_jid, mtype="groupchat")
|
||||
msg["body"] = f"hook probe {uuid.uuid4().hex[:6]}"
|
||||
msg["id"] = f"hook-{uuid.uuid4().hex[:8]}"
|
||||
msg.send()
|
||||
await asyncio.wait_for(notified.wait(), timeout=8)
|
||||
finally:
|
||||
second_client.del_event_handler("message", on_message)
|
||||
await xmpp_client.leave_muc(room_jid, nick1)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
@@ -164,6 +164,85 @@ class TestPostgresKickStore:
|
||||
"No rows with store='kick' — mod_vnc_track_kicks not loaded or no kicks recorded"
|
||||
)
|
||||
|
||||
async def test_kick_writes_fresh_row(self, pg_connection, xmpp_client, second_client, xmpp_config):
|
||||
"""A real vnc-muc-kick must append a fresh row to the kick archive.
|
||||
|
||||
register.lib.lua fires `vnc-muc-kick` from handle_unregister_iq (the
|
||||
xmpp:vnctalk:unregister IQ), which mod_vnc_track_kicks persists as a
|
||||
kick-archive row keyed by room node with `with` = the kicked user's
|
||||
bare JID. We snapshot max(sort_id), trigger the IQ, and assert a new
|
||||
row landed with a higher sort_id.
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
import slixmpp
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
muc_domain = xmpp_config.get("muc_domain")
|
||||
if not muc_domain:
|
||||
pytest.skip("No MUC domain configured")
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.xfail("Need --xmpp-jid2 with a distinct account")
|
||||
|
||||
room_jid = f"kickpg_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
||||
nick1 = "owner"
|
||||
member_bare = second_client.boundjid.bare
|
||||
room_node = room_jid.split("@")[0]
|
||||
|
||||
await xmpp_client.join_muc(room_jid, nick1)
|
||||
await xmpp_client.configure_muc(room_jid)
|
||||
await asyncio.sleep(0.5)
|
||||
await xmpp_client.set_muc_affiliation(room_jid, member_bare, "member")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
before = await pg_connection.fetchval(
|
||||
'SELECT COALESCE(MAX(sort_id), 0) FROM prosodyarchive WHERE store = $1', "kick"
|
||||
)
|
||||
|
||||
try:
|
||||
iq = second_client.make_iq_set(ito=room_jid)
|
||||
ET.SubElement(iq.xml, "{xmpp:vnctalk:unregister}query")
|
||||
# handle_unregister_iq fires vnc-muc-kick but sends no IQ reply,
|
||||
# so the send times out by design — the kick row is still written.
|
||||
try:
|
||||
await iq.send(timeout=5)
|
||||
except slixmpp.exceptions.IqTimeout:
|
||||
pass
|
||||
|
||||
# Allow the async archive write to land.
|
||||
row = None
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.25)
|
||||
row = await pg_connection.fetchrow(
|
||||
"""
|
||||
SELECT sort_id, host, "user", "with", value
|
||||
FROM prosodyarchive
|
||||
WHERE store = 'kick' AND sort_id > $1
|
||||
ORDER BY sort_id DESC LIMIT 1
|
||||
""",
|
||||
before,
|
||||
)
|
||||
if row is not None:
|
||||
break
|
||||
|
||||
assert row is not None, (
|
||||
"No new kick row appended after unregister IQ — "
|
||||
"mod_vnc_track_kicks vnc-muc-kick handler did not persist"
|
||||
)
|
||||
assert row["sort_id"] > before
|
||||
assert row["with"] == member_bare, (
|
||||
f"kick row 'with' is '{row['with']}', expected '{member_bare}'"
|
||||
)
|
||||
assert row["host"] == muc_domain, (
|
||||
f"kick row host '{row['host']}' is not the MUC component '{muc_domain}'"
|
||||
)
|
||||
assert row["user"] == room_node, (
|
||||
f"kick row 'user' is '{row['user']}', expected room node '{room_node}'"
|
||||
)
|
||||
finally:
|
||||
await xmpp_client.leave_muc(room_jid, nick1)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPostgresActivityStore:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Patch-application CI test (automates MANUAL §4.1).
|
||||
|
||||
The Dockerfile hardcodes one `cp <patch> <upstream-path>` per patched file. A
|
||||
typo in that block, or a forgotten `cp` when a new patch is added, leaves the
|
||||
upstream file unpatched while the build still succeeds. These tests exec into
|
||||
the running prosody container and grep each patched upstream file for the
|
||||
VNCtalk-specific marker, catching that regression without a live connection.
|
||||
|
||||
Requires the compose stack to be up:
|
||||
docker compose up -d --build postgres mocks prosody
|
||||
|
||||
If `docker compose exec prosody` is not available, the whole module is skipped.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# (test id, path inside the container, grep marker)
|
||||
PATCH_MARKERS = [
|
||||
("mod_mam_has_vnc_rest_message",
|
||||
"/usr/local/lib/prosody/modules/mod_mam/mod_mam.lua",
|
||||
"vnc-rest-message"),
|
||||
("moduleapi_has_open_host_store",
|
||||
"/usr/local/lib/prosody/core/moduleapi.lua",
|
||||
"function api:open_host_store"),
|
||||
("register_lib_has_vnc_muc_kick",
|
||||
"/usr/local/lib/prosody/modules/muc/register.lib.lua",
|
||||
"vnc-muc-kick"),
|
||||
("muc_lib_has_dumpTable",
|
||||
"/usr/local/lib/prosody/modules/muc/muc.lib.lua",
|
||||
"function dumpTable"),
|
||||
("mod_admin_telnet_binds_wildcard",
|
||||
"/usr/local/lib/prosody/modules/mod_admin_telnet.lua",
|
||||
'interface = "*"'),
|
||||
("portmanager_uses_network_default_read_size",
|
||||
"/usr/local/lib/prosody/core/portmanager.lua",
|
||||
"network_default_read_size"),
|
||||
("mod_carbons_has_vnc_rest_hook",
|
||||
"/usr/local/lib/prosody/modules/mod_carbons.lua",
|
||||
"vnc-rest-message"),
|
||||
("hidden_lib_present",
|
||||
"/usr/local/lib/prosody/modules/muc/hidden.lib.lua",
|
||||
"muc_room_allow_public"),
|
||||
("mod_muc_unique_has_handle_iq_tobare",
|
||||
"/usr/local/lib/prosody/modules/mod_muc_unique.lua",
|
||||
"handle_iq_tobare"),
|
||||
]
|
||||
|
||||
|
||||
def _compose_available():
|
||||
if not shutil.which("docker"):
|
||||
return False
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["docker", "compose", "ps", "--services", "--filter", "status=running"],
|
||||
cwd=REPO_ROOT, capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
if res.returncode != 0:
|
||||
return False
|
||||
return "prosody" in res.stdout.split()
|
||||
|
||||
|
||||
def _exec_grep(path, marker):
|
||||
"""Return the grep match count (int) inside the prosody container, or -1."""
|
||||
if not _compose_available():
|
||||
return -1
|
||||
# grep -Fc prints the match count (fixed-string match, so regex
|
||||
# metacharacters in markers like '*' are treated literally).
|
||||
res = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "prosody",
|
||||
"sh", "-c", f"grep -Fc -- {repr(marker)} {path}"],
|
||||
cwd=REPO_ROOT, capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
out = res.stdout.strip()
|
||||
if res.returncode == 0 and out.isdigit():
|
||||
return int(out)
|
||||
return 0
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def require_compose():
|
||||
if not _compose_available():
|
||||
pytest.skip("compose stack not running (run: docker compose up -d prosody)", allow_module_level=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("test_id, path, marker", PATCH_MARKERS, ids=[m[0] for m in PATCH_MARKERS])
|
||||
def test_patch_applied(test_id, path, marker):
|
||||
count = _exec_grep(path, marker)
|
||||
if count < 0:
|
||||
pytest.skip("compose stack not available")
|
||||
assert count > 0, (
|
||||
f"Patch marker {marker!r} not found in {path} (grep count={count}). "
|
||||
f"The Dockerfile `cp` block for '{test_id}' is missing or wrong."
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""HTTP side-effect tests via mock-capture (test-improvement.md Phase 2).
|
||||
|
||||
These verify the modules that perform fire-and-forget HTTP calls to external
|
||||
backends. The compose `mocks` service records every request it receives;
|
||||
tests reset the capture log, trigger the module, then assert on what landed.
|
||||
|
||||
Modules covered:
|
||||
* mod_vnc_fcm — FCM push to a registered token (1:1 chat)
|
||||
* mod_vnc_muc_fcm — FCM push to a non-joined affiliated MUC member
|
||||
* mod_vnc_delfile — POST to del_api_url on a message-correction
|
||||
* mod_vnc_vcard_avatar — PUT to avatar_upload_url on a vCard PHOTO set
|
||||
* mod_vnc_receipts — archive row in the `receipts` store (PG)
|
||||
|
||||
Requires: XMPP_JID + XMPP_JID2 (distinct accounts), MOCK_URL, and (for the
|
||||
receipts test) PG connection params.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# A 1x1 transparent PNG, base64-encoded — small valid image payload.
|
||||
PNG_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
async def register_fcm_token(client, device, token, os_="android"):
|
||||
"""Register an FCM token via the xmpp:vnctalk:fcm IQ (mod_vnc_fcm handle_iq)."""
|
||||
iq = client.make_iq_set(ito=client.boundjid.bare)
|
||||
add = ET.SubElement(iq.xml, "{xmpp:vnctalk:fcm}add")
|
||||
fcm = ET.SubElement(add, "{xmpp:vnctalk:fcm}fcm")
|
||||
fcm.set("device", device)
|
||||
fcm.set("token", token)
|
||||
fcm.set("os", os_)
|
||||
await iq.send(timeout=10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestFCMSideEffects:
|
||||
"""mod_vnc_fcm / mod_vnc_muc_fcm push delivery to the mock FCM endpoint."""
|
||||
|
||||
async def test_fcm_push_on_chat_to_registered_recipient(
|
||||
self, xmpp_client, second_client, mock_client
|
||||
):
|
||||
"""A 1:1 chat message from a local sender triggers an FCM POST carrying
|
||||
the recipient's registered token.
|
||||
|
||||
mod_vnc_fcm's pre-message/bare (fromLocal) path calls fcm_notify for
|
||||
same-domain chat recipients; the recipient's fcmtoken map store is
|
||||
read by localpart, so registering via the IQ is sufficient.
|
||||
"""
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.xfail("Need --xmpp-jid2 with a distinct account")
|
||||
|
||||
token = f"fcm-tok-{uuid.uuid4().hex[:8]}"
|
||||
await register_fcm_token(second_client, "device2", token)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
await mock_client.reset()
|
||||
msg = xmpp_client.make_message(mto=second_client.boundjid.bare, mtype="chat")
|
||||
msg["body"] = f"hello from fcm test {uuid.uuid4().hex[:6]}"
|
||||
msg["id"] = f"fcm-{uuid.uuid4().hex[:8]}"
|
||||
msg.send()
|
||||
|
||||
captured = await mock_client.wait_for("/fcm/notify", count=1, timeout=8)
|
||||
assert captured, "No FCM notify captured — mod_vnc_fcm did not POST"
|
||||
body = captured[0]["body"]
|
||||
assert isinstance(body, dict), f"FCM body not JSON: {body!r}"
|
||||
assert body.get("to") == token, (
|
||||
f"FCM POST 'to' is {body.get('to')!r}, expected registered token {token!r}"
|
||||
)
|
||||
|
||||
async def test_muc_fcm_push_to_offline_member(
|
||||
self, xmpp_client, second_client, xmpp_config, mock_client
|
||||
):
|
||||
"""A groupchat message pushes to a non-joined affiliated member's token.
|
||||
|
||||
mod_vnc_muc_fcm hooks muc-broadcast-message and iterates room
|
||||
affiliations; each non-sender affiliate with a registered token is
|
||||
notified via the main host's fcmtoken store (storage_host).
|
||||
"""
|
||||
muc_domain = xmpp_config.get("muc_domain")
|
||||
if not muc_domain:
|
||||
pytest.skip("No MUC domain configured")
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.xfail("Need --xmpp-jid2 with a distinct account")
|
||||
|
||||
token = f"muc-tok-{uuid.uuid4().hex[:8]}"
|
||||
await register_fcm_token(second_client, "device2", token)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
room_jid = f"mucfcm_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
||||
nick1 = "owner"
|
||||
await xmpp_client.join_muc(room_jid, nick1)
|
||||
await xmpp_client.configure_muc(room_jid)
|
||||
await asyncio.sleep(0.5)
|
||||
await xmpp_client.set_muc_affiliation(room_jid, second_client.boundjid.bare, "member")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
try:
|
||||
await mock_client.reset()
|
||||
msg = xmpp_client.make_message(mto=room_jid, mtype="groupchat")
|
||||
msg["body"] = f"group msg for muc fcm {uuid.uuid4().hex[:6]}"
|
||||
msg["id"] = f"mucfcm-{uuid.uuid4().hex[:8]}"
|
||||
msg.send()
|
||||
|
||||
captured = await mock_client.wait_for("/fcm/notify", count=1, timeout=8)
|
||||
assert captured, "No FCM notify captured — mod_vnc_muc_fcm did not POST"
|
||||
tos = [c["body"].get("to") for c in captured if isinstance(c.get("body"), dict)]
|
||||
assert token in tos, (
|
||||
f"Registered token {token!r} not among FCM recipients {tos}"
|
||||
)
|
||||
finally:
|
||||
await xmpp_client.leave_muc(room_jid, nick1)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDelFileSideEffect:
|
||||
"""mod_vnc_delfile POSTs to del_api_url on a message-correction (<replace>)."""
|
||||
|
||||
async def test_delfile_http_post_on_replace(self, xmpp_client, second_client, mock_client):
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.xfail("Need --xmpp-jid2 with a distinct account")
|
||||
|
||||
replace_id = f"orig-file-{uuid.uuid4().hex[:8]}"
|
||||
await mock_client.reset()
|
||||
|
||||
msg = xmpp_client.make_message(mto=second_client.boundjid.bare, mtype="chat")
|
||||
msg["body"] = "corrected content"
|
||||
msg["id"] = f"corr-{uuid.uuid4().hex[:8]}"
|
||||
replace = ET.SubElement(msg.xml, "{urn:xmpp:message-correct:0}replace")
|
||||
replace.set("id", replace_id)
|
||||
msg.send()
|
||||
|
||||
captured = await mock_client.wait_for("/delfile", count=1, timeout=8)
|
||||
assert captured, "No /delfile POST captured — mod_vnc_delfile did not fire"
|
||||
body = captured[0]["body"]
|
||||
assert isinstance(body, dict), f"delfile body not JSON: {body!r}"
|
||||
assert body.get("msgid") == replace_id, (
|
||||
f"delfile msgid is {body.get('msgid')!r}, expected {replace_id!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestVcardAvatarSideEffect:
|
||||
"""mod_vnc_vcard_avatar PUTs the decoded PHOTO to avatar_upload_url."""
|
||||
|
||||
async def test_avatar_upload_on_vcard_photo(self, xmpp_client, mock_client):
|
||||
await mock_client.reset()
|
||||
|
||||
vcard = ET.Element("{vcard-temp}vCard")
|
||||
photo = ET.SubElement(vcard, "{vcard-temp}PHOTO")
|
||||
ET.SubElement(photo, "{vcard-temp}TYPE").text = "image/png"
|
||||
ET.SubElement(photo, "{vcard-temp}BINVAL").text = PNG_B64
|
||||
await xmpp_client.set_vcard(vcard)
|
||||
|
||||
expected_path = f"/avatar/{xmpp_client.boundjid.bare}"
|
||||
captured = await mock_client.wait_for("/avatar", count=1, timeout=8)
|
||||
assert captured, "No /avatar PUT captured — mod_vnc_vcard_avatar did not fire"
|
||||
req = captured[0]
|
||||
assert req["method"] == "PUT"
|
||||
assert req["path"] == expected_path, (
|
||||
f"avatar PUT path is {req['path']!r}, expected {expected_path!r}"
|
||||
)
|
||||
assert req["headers"].get("Content-Type") == "image/png"
|
||||
# body was stored latin-1 round-trippable; decode back and compare bytes
|
||||
sent_bytes = base64.b64decode(PNG_B64)
|
||||
assert req["body"].encode("latin-1") == sent_bytes, "avatar PUT body mismatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestReceiptsArchive:
|
||||
"""mod_vnc_receipts persists <received/> stanzas to the receipts archive."""
|
||||
|
||||
async def test_receipt_archived_in_receipts_store(
|
||||
self, xmpp_client, second_client, pg_connection
|
||||
):
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.xfail("Need --xmpp-jid2 with a distinct account")
|
||||
|
||||
receipt_id = f"rcpt-{uuid.uuid4().hex[:12]}"
|
||||
# second_client sends a receipt ack to xmpp_client; mod_vnc_receipts
|
||||
# hooks message/bare and archives it under the recipient (xmpp_client).
|
||||
msg = second_client.make_message(mto=xmpp_client.boundjid.bare, mtype="chat")
|
||||
msg["id"] = f"receipt-msg-{uuid.uuid4().hex[:8]}"
|
||||
ET.SubElement(msg.xml, "{urn:xmpp:receipts}received").set("id", receipt_id)
|
||||
msg.send()
|
||||
|
||||
recipient_lp = xmpp_client.boundjid.bare.split("@")[0]
|
||||
sender_bare = second_client.boundjid.bare
|
||||
|
||||
row = None
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.25)
|
||||
row = await pg_connection.fetchrow(
|
||||
"""
|
||||
SELECT "user", "with", value
|
||||
FROM prosodyarchive
|
||||
WHERE store = 'receipts' AND value LIKE $1
|
||||
ORDER BY "when" DESC LIMIT 1
|
||||
""",
|
||||
f"%{receipt_id}%",
|
||||
)
|
||||
if row is not None:
|
||||
break
|
||||
|
||||
assert row is not None, (
|
||||
f"No receipts row found for {receipt_id} — mod_vnc_receipts did not archive"
|
||||
)
|
||||
assert row["user"] == recipient_lp, (
|
||||
f"receipts row owned by {row['user']!r}, expected recipient {recipient_lp!r}"
|
||||
)
|
||||
assert row["with"] == sender_bare, (
|
||||
f"receipts 'with' is {row['with']!r}, expected sender {sender_bare!r}"
|
||||
)
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Upstream mod_smacks parity tests (test-improvement.md Phase 4).
|
||||
|
||||
After the custom vnctalk/mod_smacks fork is dropped in favour of upstream
|
||||
mod_smacks + mod_smacks_offline, these verify the core SM contracts VNCtalk
|
||||
relies on still hold: <enable>, <r>/<a> ack exchange, hibernation within
|
||||
smacks_hibernation_time, and session resume replaying queued stanzas.
|
||||
|
||||
This is a *parity/regression* test, not a fork-feature test.
|
||||
|
||||
NOTE: the resume/expiry tests drive slixmpp's xep_0198 plugin, which is
|
||||
fiddly. The expiry/offline tests need a low smacks_hibernation_time (set via
|
||||
SMACKS_HIBERNATION_TIME in docker-compose.yml, e.g. 10s); if the configured
|
||||
value is large they are skipped to avoid multi-minute waits.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
import slixmpp
|
||||
|
||||
from conftest import VNCXmppClient
|
||||
|
||||
|
||||
def _hibernation_time():
|
||||
"""The server's smacks_hibernation_time, as known to the test harness."""
|
||||
try:
|
||||
return int(os.environ.get("SMACKS_HIBERNATION_TIME", "300"))
|
||||
except ValueError:
|
||||
return 300
|
||||
|
||||
|
||||
class SmacksClient(VNCXmppClient):
|
||||
"""VNCXmppClient with XEP-0198 stream management enabled for resume.
|
||||
|
||||
Tracks sm_enabled / session_resumed / sm_failed so tests can await the
|
||||
right event on a fresh connect vs. a resuming reconnect.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.register_plugin("xep_0198")
|
||||
self.sm = self.plugin["xep_0198"]
|
||||
self.sm.allow_resume = True
|
||||
self.sm_enabled_event = asyncio.Event()
|
||||
self.resumed_event = asyncio.Event()
|
||||
self.sm_failed_event = asyncio.Event()
|
||||
self.acked_count = 0
|
||||
self.add_event_handler("sm_enabled", self._on_sm_enabled)
|
||||
self.add_event_handler("session_resumed", self._on_resumed)
|
||||
self.add_event_handler("sm_failed", self._on_failed)
|
||||
self.add_event_handler("stanza_acked", self._on_acked)
|
||||
|
||||
def _on_sm_enabled(self, _e):
|
||||
self.sm_enabled_event.set()
|
||||
|
||||
def _on_resumed(self, _e):
|
||||
self.resumed_event.set()
|
||||
# On resume, session_start is not re-fired; announce presence manually.
|
||||
try:
|
||||
self.send_presence()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_failed(self, _e):
|
||||
self.sm_failed_event.set()
|
||||
|
||||
def _on_acked(self, _e):
|
||||
self.acked_count = self.sm.last_ack
|
||||
|
||||
def abrupt_disconnect(self):
|
||||
"""Close the TCP abruptly without </stream:stream>.
|
||||
|
||||
The fork mod_smacks hibernates on abrupt disconnect; a clean stream
|
||||
close tears the session down, so resume must be exercised via an
|
||||
aborted transport. SM state (sm_id) is preserved.
|
||||
"""
|
||||
self.end_session_on_disconnect = False
|
||||
try:
|
||||
if self.transport is not None:
|
||||
self.transport.abort()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def connect_fresh(self, timeout=30):
|
||||
"""Connect and negotiate a new SM session (wait for sm_enabled)."""
|
||||
self.sm_enabled_event.clear()
|
||||
self.resumed_event.clear()
|
||||
self.sm_failed_event.clear()
|
||||
await self.connect(self._connect_host, self._connect_port)
|
||||
done, pending = await asyncio.wait(
|
||||
{
|
||||
asyncio.create_task(self.sm_enabled_event.wait()),
|
||||
asyncio.create_task(self.session_started_event.wait()),
|
||||
},
|
||||
timeout=timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if not done:
|
||||
raise asyncio.TimeoutError("SM enable/session_start timed out")
|
||||
# Announce presence so the server routes bare-JID messages to this
|
||||
# session instead of offline storage (mirrors VNCXmppClient.async_connect).
|
||||
self.send_presence()
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
async def reconnect_and_wait(self, timeout=30):
|
||||
"""Reconnect; wait for either session_resumed or sm_failed."""
|
||||
self.resumed_event.clear()
|
||||
self.sm_failed_event.clear()
|
||||
# session_started persists from the first connect; clear it so the
|
||||
# wait below doesn't return immediately on a stale event. On a
|
||||
# successful resume session_start is NOT re-fired (only session_resumed).
|
||||
self.session_started_event.clear()
|
||||
await self.connect(self._connect_host, self._connect_port)
|
||||
done, pending = await asyncio.wait(
|
||||
{
|
||||
asyncio.create_task(self.resumed_event.wait()),
|
||||
asyncio.create_task(self.sm_failed_event.wait()),
|
||||
},
|
||||
timeout=timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
@property
|
||||
def sm_id(self):
|
||||
return self.sm.sm_id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smacks_config(xmpp_config):
|
||||
if xmpp_config.get("skip_live") or not xmpp_config.get("jid2"):
|
||||
pytest.skip("SMACKS tests need a live server and a second account")
|
||||
return xmpp_config
|
||||
|
||||
|
||||
async def _make_sm_client(cfg):
|
||||
client = SmacksClient(
|
||||
cfg["jid2"], cfg["password2"],
|
||||
host=cfg["host"], port=cfg["port"], use_ssl=True, verify_ssl=cfg["verify_ssl"],
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSmacksEnableAck:
|
||||
async def test_smacks_enable_and_ack(self, smacks_config, xmpp_client):
|
||||
"""Negotiate SM, send 3 messages, request ack, get <a h=3/>.
|
||||
|
||||
<r/> asks the server to ack the stanzas the CLIENT has sent, so the SM
|
||||
client is the sender here.
|
||||
"""
|
||||
cfg = smacks_config
|
||||
sm = await _make_sm_client(cfg)
|
||||
try:
|
||||
await sm.connect_fresh(timeout=30)
|
||||
assert sm.sm.enabled_in, "SM was not enabled (<enabled> not received)"
|
||||
|
||||
# SM client sends three messages → these are counted by the server.
|
||||
before = sm.sm.last_ack
|
||||
for i in range(3):
|
||||
msg = sm.make_message(mto=xmpp_client.boundjid.bare, mtype="chat")
|
||||
msg["body"] = f"sm-ack-{i}-{uuid.uuid4().hex[:6]}"
|
||||
msg["id"] = f"smack-{uuid.uuid4().hex[:8]}"
|
||||
msg.send()
|
||||
|
||||
# Request an ack and poll last_ack (slixmpp may also auto-request
|
||||
# once its window fills, so poll rather than wait on a single event).
|
||||
await asyncio.sleep(0.5)
|
||||
sm.sm.request_ack() # send <r/>
|
||||
for _ in range(40):
|
||||
if sm.sm.last_ack >= before + 3:
|
||||
break
|
||||
await asyncio.sleep(0.25)
|
||||
assert sm.sm.last_ack >= before + 3, (
|
||||
f"server <a/> reported h={sm.sm.last_ack}, expected >={before + 3}"
|
||||
)
|
||||
finally:
|
||||
if sm.session_started_event.is_set() or sm.resumed_event.is_set():
|
||||
await sm.async_disconnect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSmacksResume:
|
||||
async def test_smacks_resume_replays_queue(self, smacks_config, xmpp_client):
|
||||
"""A hibernated session resumes and replays messages queued while offline."""
|
||||
cfg = smacks_config
|
||||
sm = await _make_sm_client(cfg)
|
||||
try:
|
||||
await sm.connect_fresh(timeout=30)
|
||||
assert sm.sm_id, "no SM id granted — server did not allow resumption"
|
||||
previd = sm.sm_id
|
||||
|
||||
# Collect chat messages addressed to the SM client.
|
||||
received = []
|
||||
|
||||
def on_message(msg):
|
||||
if msg["type"] == "chat" and msg["body"]:
|
||||
received.append(str(msg["body"]))
|
||||
|
||||
sm.add_event_handler("message", on_message)
|
||||
|
||||
# Disconnect cleanly; with SM+resume the server hibernates the session.
|
||||
sm.abrupt_disconnect()
|
||||
await asyncio.wait_for(sm.disconnected_event.wait(), timeout=10)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Send two messages while the SM client is hibernated → server queues them.
|
||||
queued = [f"queued-{i}-{uuid.uuid4().hex[:6]}" for i in range(2)]
|
||||
for b in queued:
|
||||
msg = xmpp_client.make_message(mto=cfg["jid2"], mtype="chat")
|
||||
msg["body"] = b
|
||||
msg["id"] = f"q-{uuid.uuid4().hex[:8]}"
|
||||
msg.send()
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Reconnect within the hibernation window → resume replays the queue.
|
||||
await sm.reconnect_and_wait(timeout=30)
|
||||
assert sm.resumed_event.is_set(), (
|
||||
"resume failed (<failed> or no <resumed>) — session did not resume"
|
||||
)
|
||||
assert sm.sm_id == previd, "resumed session has a different SM id"
|
||||
|
||||
for _ in range(40):
|
||||
if len(received) >= 2:
|
||||
break
|
||||
await asyncio.sleep(0.25)
|
||||
for b in queued:
|
||||
assert b in received, (
|
||||
f"queued message {b!r} was not replayed after resume; got {received}"
|
||||
)
|
||||
finally:
|
||||
if sm.session_started_event.is_set() or sm.resumed_event.is_set():
|
||||
await sm.async_disconnect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSmacksHibernationExpiry:
|
||||
async def test_smacks_hibernation_expiry(self, smacks_config, xmpp_client):
|
||||
"""After smacks_hibernation_time elapses, resume returns <failed>."""
|
||||
htime = _hibernation_time()
|
||||
if htime > 30:
|
||||
pytest.skip(
|
||||
f"smacks_hibernation_time={htime}s is too large for an automated expiry test"
|
||||
)
|
||||
|
||||
cfg = smacks_config
|
||||
sm = await _make_sm_client(cfg)
|
||||
try:
|
||||
await sm.connect_fresh(timeout=30)
|
||||
assert sm.sm_id, "server did not allow resumption"
|
||||
|
||||
sm.abrupt_disconnect()
|
||||
await asyncio.wait_for(sm.disconnected_event.wait(), timeout=10)
|
||||
# Wait past the hibernation window so the server drops the session.
|
||||
await asyncio.sleep(htime + 3)
|
||||
|
||||
await sm.reconnect_and_wait(timeout=30)
|
||||
assert sm.sm_failed_event.is_set(), (
|
||||
"expected <failed> after hibernation expiry, but resume succeeded"
|
||||
)
|
||||
finally:
|
||||
if sm.session_started_event.is_set() or sm.resumed_event.is_set():
|
||||
await sm.async_disconnect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSmacksOfflineReplay:
|
||||
@pytest.mark.xfail(
|
||||
reason=(
|
||||
"Premise not satisfied by the vnctalk mod_smacks fork in this stack: "
|
||||
"mod_smacks_offline logs 'no longer required' and, with MAM enabled, "
|
||||
"stanzas queued during hibernation are already in MAM and are not "
|
||||
"flushed to mod_offline storage on expiry. Expected to xpass once the "
|
||||
"fork is removed in favour of upstream mod_smacks + mod_smacks_offline "
|
||||
"(test-improvement.md §0, Phase 4)."
|
||||
),
|
||||
strict=False,
|
||||
)
|
||||
async def test_smacks_offline_queue_after_expiry(self, smacks_config, xmpp_client):
|
||||
"""mod_smacks_offline: messages queued during hibernation are delivered
|
||||
from offline storage on the next fresh connect after expiry."""
|
||||
htime = _hibernation_time()
|
||||
if htime > 30:
|
||||
pytest.skip(
|
||||
f"smacks_hibernation_time={htime}s is too large for an automated offline test"
|
||||
)
|
||||
|
||||
cfg = smacks_config
|
||||
sm = await _make_sm_client(cfg)
|
||||
try:
|
||||
await sm.connect_fresh(timeout=30)
|
||||
assert sm.sm_id, "server did not allow resumption"
|
||||
|
||||
queued_body = f"offline-after-expiry-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
received = []
|
||||
|
||||
def on_message(msg):
|
||||
if msg["body"]:
|
||||
received.append(str(msg["body"]))
|
||||
|
||||
sm.add_event_handler("message", on_message)
|
||||
|
||||
sm.abrupt_disconnect()
|
||||
await asyncio.wait_for(sm.disconnected_event.wait(), timeout=10)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
msg = xmpp_client.make_message(mto=cfg["jid2"], mtype="chat")
|
||||
msg["body"] = queued_body
|
||||
msg["id"] = f"off-{uuid.uuid4().hex[:8]}"
|
||||
msg.send()
|
||||
|
||||
# Wait past expiry so the hibernated session is gone.
|
||||
await asyncio.sleep(htime + 3)
|
||||
|
||||
# Fresh connect (new SM session) — mod_smacks_offline should replay
|
||||
# the queued message from offline storage.
|
||||
sm.sm_enabled_event.clear()
|
||||
sm.session_started_event.clear()
|
||||
await sm.connect_fresh(timeout=30)
|
||||
|
||||
for _ in range(40):
|
||||
if queued_body in received:
|
||||
break
|
||||
await asyncio.sleep(0.25)
|
||||
assert queued_body in received, (
|
||||
f"queued message {queued_body!r} not delivered from offline storage; "
|
||||
f"got {received}"
|
||||
)
|
||||
finally:
|
||||
if sm.session_started_event.is_set() or sm.resumed_event.is_set():
|
||||
await sm.async_disconnect()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Low-risk smoke tests (test-improvement.md Phase 7).
|
||||
|
||||
One positive smoke per module that otherwise has no dedicated coverage:
|
||||
* filter_chatstates — drops chatstate-only messages to a CSI-inactive client
|
||||
* idlecompat — injects <idle/> into presence carrying jabber:iq:last
|
||||
* http_altconnect — /.well-known/host-meta.json advertises alt-connections
|
||||
* webpresence — /status/<jid> returns an image
|
||||
* admin_telnet — reachable on a non-loopback address (proves "*" bind)
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _http_base(xmpp_config):
|
||||
"""Derive an http://<host>:<port> base for Prosody's HTTP port."""
|
||||
for key in ("bosh_url", "ws_url"):
|
||||
url = xmpp_config.get(key)
|
||||
if url:
|
||||
p = urlparse(url)
|
||||
if p.hostname and p.port:
|
||||
return f"{p.scheme or 'http'}://{p.hostname}:{p.port}", p.hostname
|
||||
return None, None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestFilterChatstates:
|
||||
async def test_chatstate_dropped_when_inactive(self, xmpp_client, second_client, xmpp_config):
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.xfail("Need --xmpp-jid2 with a distinct account")
|
||||
|
||||
from slixmpp.xmlstream.handler import Callback
|
||||
from slixmpp.xmlstream.matcher import MatchXPath
|
||||
|
||||
seen = []
|
||||
|
||||
def on_message(stanza):
|
||||
if stanza.xml.find("{http://jabber.org/protocol/chatstates}composing") is not None:
|
||||
seen.append("composing")
|
||||
|
||||
# slixmpp's "message" event only fires for stanzas with a <body>, but a
|
||||
# chatstate-only message has none — register a low-level stream handler.
|
||||
handler_name = "cs_composing_smoke"
|
||||
second_client.register_handler(Callback(
|
||||
handler_name,
|
||||
MatchXPath(f"{{{second_client.default_ns}}}message"),
|
||||
on_message,
|
||||
))
|
||||
try:
|
||||
# CSI inactive → filter_chatstates strips chatstates; a chatstate-only
|
||||
# message has no other tags and is dropped entirely.
|
||||
second_client.send_raw('<inactive xmlns="urn:xmpp:csi:0"/>')
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
m1 = xmpp_client.make_message(mto=second_client.boundjid.bare, mtype="chat")
|
||||
ET.SubElement(m1.xml, "{http://jabber.org/protocol/chatstates}composing")
|
||||
m1.send()
|
||||
await asyncio.sleep(2)
|
||||
assert not seen, "chatstate delivered to CSI-inactive client — filter_chatstates did not drop it"
|
||||
|
||||
# CSI active → chatstates pass through again.
|
||||
second_client.send_raw('<active xmlns="urn:xmpp:csi:0"/>')
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
m2 = xmpp_client.make_message(mto=second_client.boundjid.bare, mtype="chat")
|
||||
ET.SubElement(m2.xml, "{http://jabber.org/protocol/chatstates}composing")
|
||||
m2.send()
|
||||
for _ in range(20):
|
||||
if seen:
|
||||
break
|
||||
await asyncio.sleep(0.25)
|
||||
assert seen, "chatstate not delivered to CSI-active client — filter_chatstates kept filtering"
|
||||
finally:
|
||||
second_client.remove_handler(handler_name)
|
||||
second_client.send_raw('<active xmlns="urn:xmpp:csi:0"/>')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestIdlecompat:
|
||||
async def test_idle_injected_into_last_activity_presence(self, xmpp_client, second_client):
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.xfail("Need --xmpp-jid2 with a distinct account")
|
||||
|
||||
got = asyncio.Event()
|
||||
|
||||
def on_presence(pres):
|
||||
if pres.xml.find("{urn:xmpp:idle:1}idle") is not None:
|
||||
got.set()
|
||||
|
||||
second_client.add_event_handler("presence", on_presence)
|
||||
try:
|
||||
# Directed presence carrying jabber:iq:last → mod_idlecompat must
|
||||
# add an <idle xmlns='urn:xmpp:idle:1'/> child.
|
||||
p = xmpp_client.make_presence(pto=second_client.boundjid.bare)
|
||||
ET.SubElement(p.xml, "{jabber:iq:last}query").set("seconds", "42")
|
||||
p.send()
|
||||
await asyncio.wait_for(got.wait(), timeout=8)
|
||||
finally:
|
||||
second_client.del_event_handler("presence", on_presence)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestHttpAltconnect:
|
||||
async def test_host_meta_json_advertises_alt_connections(self, xmpp_config):
|
||||
import aiohttp
|
||||
base, host = _http_base(xmpp_config)
|
||||
if not base:
|
||||
pytest.skip("No BOSH/WS URL configured")
|
||||
domain = xmpp_config.get("domain") or host
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{base}/.well-known/host-meta.json",
|
||||
headers={"Host": domain},
|
||||
timeout=aiohttp.ClientTimeout(total=8),
|
||||
) as resp:
|
||||
assert resp.status == 200, f"host-meta.json returned {resp.status}"
|
||||
data = await resp.json(content_type=None)
|
||||
links = [l.get("rel") for l in data.get("links", [])]
|
||||
# The WebSocket alt-connection is always advertised; BOSH is only
|
||||
# present when mod_bosh's http URL resolves on the vhost, which depends
|
||||
# on the deployment, so assert the reliable one and soft-check BOSH.
|
||||
assert "urn:xmpp:alt-connections:websocket" in links, (
|
||||
f"WebSocket alt-connection missing from host-meta.json: {links}"
|
||||
)
|
||||
assert any("alt-connections" in r for r in links), (
|
||||
f"no xmpp:alt-connections advertised in host-meta.json: {links}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestWebpresence:
|
||||
async def test_presence_endpoint_returns_image(self, xmpp_config):
|
||||
import aiohttp
|
||||
base, host = _http_base(xmpp_config)
|
||||
if not base:
|
||||
pytest.skip("No BOSH/WS URL configured")
|
||||
domain = xmpp_config.get("domain") or host
|
||||
jid = xmpp_config.get("jid") or f"user1@{domain}"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{base}/status/{jid}",
|
||||
headers={"Host": domain},
|
||||
timeout=aiohttp.ClientTimeout(total=8),
|
||||
) as resp:
|
||||
assert resp.status == 200, f"/status/{jid} returned {resp.status}"
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
assert ctype.startswith("image/"), (
|
||||
f"webpresence returned Content-Type {ctype!r}, expected image/*"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAdminTelnetNonLoopback:
|
||||
async def test_telnet_reachable_on_non_loopback(self, xmpp_config):
|
||||
host = xmpp_config["admin_telnet_host"]
|
||||
port = xmpp_config["admin_telnet_port"]
|
||||
if host in ("127.0.0.1", "localhost", "::1"):
|
||||
pytest.skip("ADMIN_TELNET_HOST is loopback — cannot prove wildcard bind")
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port), timeout=5
|
||||
)
|
||||
data = await asyncio.wait_for(reader.read(256), timeout=5)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except (OSError, asyncio.TimeoutError) as e:
|
||||
pytest.fail(f"admin telnet not reachable on {host}:{port}: {e}")
|
||||
assert b"Prosody" in data or b"\x00" in data, "no telnet banner from non-loopback address"
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Image/runtime verification (test-improvement.md Phase 6).
|
||||
|
||||
* mod_http_upload_external slot handshake + signed-URL PUT (live XMPP + mock)
|
||||
* healthcheck.sh exit 2 on a tampered cert (docker compose exec)
|
||||
* rendered config has no residual ${...} placeholders (docker compose exec)
|
||||
|
||||
The two docker-exec tests automate MANUAL §3.1 and the latent envsubst bug
|
||||
documented in test-improvement.md §1.2. They skip when the compose stack is
|
||||
not running.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _compose_available():
|
||||
if not shutil.which("docker"):
|
||||
return False
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["docker", "compose", "ps", "--services", "--filter", "status=running"],
|
||||
cwd=REPO_ROOT, capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
return res.returncode == 0 and "prosody" in res.stdout.split()
|
||||
|
||||
|
||||
def _compose_exec(args, timeout=30, user=None):
|
||||
cmd = ["docker", "compose", "exec"]
|
||||
if user:
|
||||
cmd += ["--user", user]
|
||||
cmd += ["-T", "prosody"] + args
|
||||
return subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestHttpUploadExternal:
|
||||
"""mod_http_upload_external slot handshake (XEP-0363) + file PUT."""
|
||||
|
||||
async def test_http_upload_slot_handshake(self, xmpp_client, xmpp_config, mock_client):
|
||||
domain = xmpp_config.get("domain")
|
||||
if not domain:
|
||||
pytest.skip("No XMPP domain configured")
|
||||
|
||||
# XEP-0363 slot request is an IQ-get to the vhost (http_upload_external
|
||||
# is loaded on the VirtualHost and hooks iq/host/<ns>:request).
|
||||
iq = xmpp_client.make_iq_get(ito=domain)
|
||||
req = ET.SubElement(iq.xml, "{urn:xmpp:http:upload:0}request")
|
||||
req.set("filename", "smoke.txt")
|
||||
req.set("size", "4")
|
||||
res = await iq.send(timeout=10)
|
||||
|
||||
slot = res.xml.find("{urn:xmpp:http:upload:0}slot")
|
||||
assert slot is not None, "no <slot> in http_upload response — module not loaded"
|
||||
put_el = slot.find("{urn:xmpp:http:upload:0}put")
|
||||
get_el = slot.find("{urn:xmpp:http:upload:0}get")
|
||||
assert put_el is not None and get_el is not None, "slot missing put/get urls"
|
||||
put_url = put_el.text or put_el.get("url")
|
||||
get_url = get_el.text or get_el.get("url")
|
||||
assert put_url and "/share/" in put_url, f"put_url not on mock share: {put_url}"
|
||||
|
||||
# The returned URLs reference the internal `mocks` host. Re-PUT through
|
||||
# the mock_client session (same container network) by path so the test
|
||||
# works whether run from the tester container or the host.
|
||||
put_path = urlparse(put_url).path
|
||||
await mock_client.reset()
|
||||
async with mock_client.session.put(
|
||||
f"{mock_client.base_url}{put_path}", data=b"DATA"
|
||||
) as r:
|
||||
assert r.status == 201, f"PUT to slot target failed: {r.status}"
|
||||
|
||||
captured = await mock_client.wait_for(put_path, count=1, timeout=5)
|
||||
assert captured, "slot PUT was not recorded by the mock"
|
||||
assert captured[0]["method"] == "PUT"
|
||||
|
||||
|
||||
class TestHealthcheckExecution:
|
||||
"""Run healthcheck.sh inside the container with a tampered cert."""
|
||||
|
||||
def test_healthcheck_exit2_on_cert_mismatch(self):
|
||||
if not _compose_available():
|
||||
pytest.skip("compose stack not running")
|
||||
# Backup current tls.crt (if any) so we can restore the original state.
|
||||
script = (
|
||||
"cp /etc/prosody/certs/prosody-ssl.pem /tmp/healthcheck_orig.pem 2>/dev/null; "
|
||||
"mkdir -p /etc/tls-update; "
|
||||
"echo tampered-cert-$(date +%s) > /etc/tls-update/tls.crt; "
|
||||
"/vnc/config/healthcheck.sh >/dev/null 2>&1; echo $?; "
|
||||
"cp /tmp/healthcheck_orig.pem /etc/tls-update/tls.crt 2>/dev/null"
|
||||
)
|
||||
res = _compose_exec(["sh", "-c", script], user="0:0", timeout=30)
|
||||
out = res.stdout.strip().splitlines()
|
||||
code = out[-1] if out else ""
|
||||
assert code == "2", (
|
||||
f"healthcheck exited {code!r} on tampered cert, expected 2. stdout={res.stdout!r}"
|
||||
)
|
||||
|
||||
def test_healthcheck_not_exit2_after_restore(self):
|
||||
if not _compose_available():
|
||||
pytest.skip("compose stack not running")
|
||||
# Ensure tls.crt matches the loaded cert (copy ssl.pem into tls.crt),
|
||||
# then healthcheck must NOT exit 2 (it may exit 0/1 depending on telnet).
|
||||
script = (
|
||||
"mkdir -p /etc/tls-update; "
|
||||
"cp /etc/prosody/certs/prosody-ssl.pem /etc/tls-update/tls.crt; "
|
||||
"/vnc/config/healthcheck.sh >/dev/null 2>&1; echo $?"
|
||||
)
|
||||
res = _compose_exec(["sh", "-c", script], user="0:0", timeout=30)
|
||||
out = res.stdout.strip().splitlines()
|
||||
code = out[-1] if out else ""
|
||||
assert code != "2", (
|
||||
f"healthcheck exited 2 with matching certs, expected 0/1. stdout={res.stdout!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestConfigRendering:
|
||||
"""No residual ${...} placeholders in the rendered config."""
|
||||
|
||||
def test_config_rendering_no_residual_placeholders(self):
|
||||
if not _compose_available():
|
||||
pytest.skip("compose stack not running")
|
||||
res = _compose_exec(["sh", "-c", "grep -c '\\${' /etc/prosody/prosody.cfg.lua || true"], timeout=20)
|
||||
out = res.stdout.strip()
|
||||
count = int(out) if out.isdigit() else 0
|
||||
assert count == 0, (
|
||||
f"rendered config has {count} residual ${{...}} placeholders — "
|
||||
"an env var is missing or misnamed (see test-improvement.md §1.2)"
|
||||
)
|
||||
Reference in New Issue
Block a user