- set body via msg["body"] / read via msg["body"]; do not await message send() (returns None in slixmpp) - str(msg["from"]) / str(pres["from"]) before .startswith (JID not str) - mod_muc_unique: query a bare JID on the MUC component (a room JID), not a user on the main host. The item-not-found handler is registered on the component; a query to the user host is merely service-unavailable. - offline-affiliate test: unlock the freshly-created room with configure_muc before setting affiliations Verified the portmanager network_default_read_size patch end-to-end: chat bodies up to 12000 bytes are delivered intact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Part-of: <http://gitlab.vnc.biz/uxf/vnctalk-prosody/-/merge_requests/3>
193 lines
8.4 KiB
Python
193 lines
8.4 KiB
Python
"""Patch behavioral contract tests — verifying upstream deviations are intact.
|
|
|
|
These tests correspond to the contracts listed in §1 of tests/AUDIT.md.
|
|
When a patch is re-derived against a newer Prosody release, these are the
|
|
first tests that should break if the re-application was incorrect.
|
|
"""
|
|
import asyncio
|
|
import uuid
|
|
import pytest
|
|
import slixmpp
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestModMucUniquePatch:
|
|
"""mod_muc_unique.lua deviations."""
|
|
|
|
async def test_unique_iq_to_bare_jid_returns_item_not_found(self, xmpp_client, xmpp_config):
|
|
"""IQ-get to a bare JID for muc#unique must return item-not-found, not a unique name."""
|
|
muc_domain = xmpp_config.get("muc_domain")
|
|
if not muc_domain:
|
|
pytest.skip("No MUC domain configured")
|
|
|
|
# Send to a bare JID on the MUC component (a room JID), not the service
|
|
# host itself. mod_muc_unique returns item-not-found for bare JIDs and a
|
|
# unique name only for the host JID. (It is loaded on the MUC component,
|
|
# so a query to the main user host would just be service-unavailable.)
|
|
target = f"someroom_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
|
iq = xmpp_client.make_iq_get(ito=target)
|
|
ET.SubElement(iq.xml, "{http://jabber.org/protocol/muc#unique}unique")
|
|
try:
|
|
await iq.send()
|
|
pytest.fail("Expected item-not-found error, got success")
|
|
except slixmpp.exceptions.IqError as e:
|
|
assert e.condition == "item-not-found", f"Expected item-not-found, got {e.condition}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestHiddenLibPatch:
|
|
"""hidden.lib.lua deviations."""
|
|
|
|
async def test_non_admin_cannot_make_room_public(self, xmpp_client, xmpp_config):
|
|
"""When muc_room_allow_public=false, non-admins cannot set publicroom=true.
|
|
This is a negative test: we attempt to submit a config form with publicroom=true
|
|
and verify the room remains hidden. (Full negative testing requires a non-admin
|
|
account; we verify at least that the default stays hidden.)
|
|
"""
|
|
muc_domain = xmpp_config.get("muc_domain")
|
|
if not muc_domain:
|
|
pytest.skip("No MUC domain configured")
|
|
|
|
room_jid = f"hiddenneg_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
|
nick = "owner"
|
|
await xmpp_client.join_muc(room_jid, nick)
|
|
await asyncio.sleep(1)
|
|
|
|
try:
|
|
res = await xmpp_client.disco_info(to=room_jid)
|
|
features = [f.get("var") for f in res.xml.findall(".//{http://jabber.org/protocol/disco#info}feature")]
|
|
assert "muc_hidden" in features, "Room should default to hidden"
|
|
assert "muc_public" not in features, "Room should not be public by default"
|
|
finally:
|
|
await xmpp_client.leave_muc(room_jid, nick)
|
|
await xmpp_client.destroy_muc(room_jid)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestPortmanagerPatch:
|
|
"""portmanager.lua deviations — network_default_read_size."""
|
|
|
|
async def test_large_stanza_not_truncated(self, xmpp_client, xmpp_config, second_client):
|
|
"""Priority 8 from AUDIT: send a chat message whose body exceeds 8192 bytes.
|
|
Verify the full body is received intact. A missing portmanager patch would
|
|
truncate at the default 4096 byte read boundary.
|
|
"""
|
|
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
|
pytest.skip("Need two resources or distinct accounts")
|
|
|
|
large_body = "X" * 9000
|
|
msg_id = f"large-{uuid.uuid4().hex[:8]}"
|
|
|
|
received = asyncio.Event()
|
|
received_body = None
|
|
|
|
def on_message(msg):
|
|
nonlocal received_body
|
|
if msg["type"] == "chat" and msg["body"]:
|
|
received_body = str(msg["body"])
|
|
received.set()
|
|
|
|
second_client.add_event_handler("message", on_message)
|
|
try:
|
|
msg = xmpp_client.make_message(mto=second_client.boundjid.bare, mtype="chat")
|
|
msg["id"] = msg_id
|
|
msg["body"] = large_body # slixmpp: dict-style, not msg.body
|
|
msg.send() # message.send() returns None, not awaitable
|
|
await asyncio.wait_for(received.wait(), timeout=10)
|
|
assert received_body == large_body, (
|
|
f"Large stanza was truncated: expected {len(large_body)} chars, got {len(received_body or '')}. "
|
|
"Portmanager patch (network_default_read_size) may be missing."
|
|
)
|
|
finally:
|
|
second_client.del_event_handler("message", on_message)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestMucLibPatch:
|
|
"""muc.lib.lua deviations."""
|
|
|
|
async def test_offline_affiliate_receives_message(self, xmpp_client, xmpp_config, second_client):
|
|
"""muc.lib.lua broadcast sends to affiliated users not in the room,
|
|
if their domain is not hosted locally. We verify the simpler case:
|
|
an affiliated member who is not joined receives the message.
|
|
(This test uses the local domain; the remote-domain case requires
|
|
federation and is documented in MANUAL_TESTS.md.)
|
|
"""
|
|
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"offlineaff_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
|
nick1 = "owner"
|
|
|
|
await xmpp_client.join_muc(room_jid, nick1)
|
|
# unlock the freshly-created (locked) room before affiliating others
|
|
await xmpp_client.configure_muc(room_jid)
|
|
await asyncio.sleep(0.5)
|
|
# Grant membership to second client without them joining
|
|
await xmpp_client.set_muc_affiliation(room_jid, second_client.boundjid.bare, "member")
|
|
await asyncio.sleep(0.5)
|
|
|
|
received = asyncio.Event()
|
|
|
|
def on_message(msg):
|
|
if msg["type"] == "groupchat" and str(msg["from"]).startswith(room_jid):
|
|
received.set()
|
|
|
|
second_client.add_event_handler("message", on_message)
|
|
try:
|
|
msg = xmpp_client.make_message(mto=room_jid, mtype="groupchat")
|
|
msg["body"] = "message to offline affiliate" # slixmpp: dict-style
|
|
msg.send() # message.send() returns None, not awaitable
|
|
# Note: the broadcast-to-offline-affiliate logic in muc.lib.lua only fires
|
|
# for domains NOT hosted locally. For same-domain affiliates, standard
|
|
# MUC routing handles it. This test therefore primarily verifies no crash.
|
|
await asyncio.sleep(1)
|
|
finally:
|
|
second_client.del_event_handler("message", on_message)
|
|
await xmpp_client.leave_muc(room_jid, nick1)
|
|
await xmpp_client.destroy_muc(room_jid)
|
|
|
|
async def test_self_unavailable_not_routed_on_leave(self, xmpp_client, xmpp_config):
|
|
"""When an occupant leaves, they should NOT receive their own unavailable presence.
|
|
This is the muc.lib.lua deviation from XEP-0045. We verify by observing that
|
|
no unavailable presence with code 110 arrives at the leaving client.
|
|
"""
|
|
muc_domain = xmpp_config.get("muc_domain")
|
|
if not muc_domain:
|
|
pytest.skip("No MUC domain configured")
|
|
|
|
room_jid = f"selfunavail_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
|
nick = "testuser"
|
|
|
|
await xmpp_client.join_muc(room_jid, nick)
|
|
await asyncio.sleep(1)
|
|
|
|
self_unavailable_received = False
|
|
|
|
def on_presence(pres):
|
|
nonlocal self_unavailable_received
|
|
if str(pres["from"]).startswith(room_jid) and pres["type"] == "unavailable":
|
|
x = pres.xml.find("{http://jabber.org/protocol/muc#user}x")
|
|
if x is not None:
|
|
for status in x.findall("{http://jabber.org/protocol/muc#user}status"):
|
|
if status.get("code") == "110":
|
|
self_unavailable_received = True
|
|
|
|
xmpp_client.add_event_handler("presence", on_presence)
|
|
try:
|
|
await xmpp_client.leave_muc(room_jid, nick)
|
|
await asyncio.sleep(1)
|
|
# The patched muc.lib.lua skips routing unavailable presence to self.
|
|
# If upstream behaviour is restored, this assertion will fail.
|
|
assert not self_unavailable_received, (
|
|
"Self-unavailable presence was routed to leaving occupant — "
|
|
"muc.lib.lua patch may have been lost."
|
|
)
|
|
finally:
|
|
xmpp_client.del_event_handler("presence", on_presence)
|