Files
vnctalk-prosody/tests/test_09_http_sideeffects.py
T
Stefan-Sanger 26e7512b24 feat: upgrade Prosody 0.12.6 → 13.0.6 (milestone 3)
Bump Dockerfile to prosody-13.0.6; all 8 patches re-ported against 13.0.6.

Re-ported patches (3 changed, 5 applied with offset):
- hidden.lib.patch: 13.0 uses module:may() instead of um_is_admin; changed
  to 'if restrict_public then' (same intent: hide option for everyone)
- mod_muc.patch: 13.0 added restrict_pm between register and
  presence_broadcast; updated hunk 1 context
- mod_muc_unique.patch: 13.0 uses 'require "prosody.util.stanza"'
  (namespaced); updated context
- muc.lib, mod_carbons, mod_mam, mod_muc_mam, register.lib: applied
  with line offsets, no re-port needed

Config changes:
- Remove mod_posix from modules_enabled (13.0 absorbed signal handling,
  pidfile, and run_as_root check into core util/startup.lua)
- Move pubsub from modules_enabled to Component (13.0 requires pubsub
  to be loaded as a component, not a module)

Test fix:
- test_muc_fcm_push_to_offline_member: wait for count=2 captures instead
  of 1 — mod_vnc_muc_fcm pushes to ALL affiliated members (including
  sender, because it can't see main-host sessions from the MUC
  component); the test was racing on which push arrived first

Verified: prosodyctl check config passes; compose-harness testsuite
green (80 passed, 6 skipped, 0 failed).

Part-of: <http://gitlab.vnc.biz/uxf/vnctalk-prosody/-/merge_requests/3>
2026-07-15 17:58:02 +02:00

225 lines
9.4 KiB
Python

"""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()
# mod_vnc_muc_fcm pushes to ALL affiliated members (including the
# sender, because it can't see main-host sessions from the MUC
# component). Wait for at least 2 captures so we don't race on
# which push the mock sees first.
captured = await mock_client.wait_for("/fcm/notify", count=2, timeout=10)
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}"
)