Files
Stefan-Sanger 2b798bf583 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>
2026-07-15 17:58:02 +02:00

336 lines
13 KiB
Python

"""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()