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:
2026-07-15 17:58:02 +02:00
parent 54ca4e1877
commit 2b798bf583
19 changed files with 1589 additions and 3 deletions
+30
View File
@@ -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
+1 -1
View File
@@ -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;
+7
View File
@@ -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
+103
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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()
+7
View File
@@ -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"]
+177
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
aiohttp>=3.9
+7
View File
@@ -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
+50
View File
@@ -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)
+46
View File
@@ -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)
+79
View File
@@ -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:
+99
View File
@@ -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."
)
+220
View File
@@ -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}"
)
+335
View File
@@ -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()
+171
View File
@@ -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"
+137
View File
@@ -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)"
)