Files
rust-xmpp/tests/pep_order_network.py
2026-10-20 15:59:55 +07:00

127 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Legacy Android first-item PEP parsing over real loopback STARTTLS.
Usage: python3 tests/pep_order_network.py target/debug/rust-xmpp
Monocles IqGenerator.retrieveDeviceIds requests every item without max_items;
IqParser.getItem returns only the first item, then deviceIds reads its list.
An older empty record must not hide the latest populated device list.
"""
import json
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from push_discovery import Client
from pep_client_network import LEGACY, PUBSUB, fetch, publish, result
NODE = LEGACY + '.devicelist'
DEVICE_IDS = ('2073401123', '2073401456', '2073401789')
def check_android_fetch(client, expected):
response = result(fetch(client, NODE, identifier='own-device-list'))
# Match IqParser.getItem: do not search all descendants or merge item lists.
items = response.find(f'{{{PUBSUB}}}pubsub/{{{PUBSUB}}}items')
assert items is not None, ET.tostring(response)
first = items.find(f'{{{PUBSUB}}}item')
assert first is not None and first.get('id') == 'current', ET.tostring(response)
device_list = first.find(f'{{{LEGACY}}}list')
assert device_list is not None, ET.tostring(response)
device_ids = {device.get('id') for device in device_list
if device.tag == f'{{{LEGACY}}}device'}
assert device_ids == expected, ET.tostring(response)
# Retain the old record to exercise real multi-item ordering, not truncation.
assert [item.get('id') for item in items] == ['current', 'older'], ET.tostring(response)
def check_info_log(path, expected_reads):
deadline = time.monotonic() + 5
while True:
text = path.read_text() if path.exists() else ''
records = [json.loads(line) for line in text.splitlines()]
reads = []
for record in records:
if record.get('event') != 'pubsub-iq-result':
continue
fields = {field['key']: field['value'] for field in record['fields']}
if fields.get('action') == 'items':
reads.append((record, fields))
if len(reads) >= expected_reads:
break
assert time.monotonic() < deadline, 'missing PEP info log records'
time.sleep(.02)
assert len(reads) == expected_reads, 'unexpected PEP retrieval count'
for record, fields in reads:
assert record['level'] == 'info', record
for key, value in {'status': 'result', 'items_count': '2',
'first_content_kind': 'omemo-devices',
'first_device_count': '2',
'target_scope': 'self-account'}.items():
assert fields.get(key) == value, (key, fields)
assert all(device not in text for device in DEVICE_IDS), 'device ID leaked in info logs'
assert LEGACY not in text, 'raw PEP node leaked in info logs'
def run(binary):
with tempfile.TemporaryDirectory(prefix='xmpp-pep-order-') as tmp:
root = Path(tmp)
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
config = root/'config.json'
config.write_text(json.dumps({'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'pep'], 'log_format': 'json', 'log': {'info': str(root/'structured.jsonl')}}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}))
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL)
for restart in (False, True):
info_log = root/'structured.jsonl'
if info_log.exists():
info_log.unlink()
with (root/'server.log').open('w+') as log:
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
client = None
try:
for _ in range(100):
assert server.poll() is None, 'server stopped'
try:
with socket.create_connection(('127.0.0.1', port), timeout=.1):
break
except OSError:
time.sleep(.05)
else:
raise AssertionError('server did not start')
client = Client(port, root/'cert.pem')
features, identities = client.disco('own-pep', 'alice@localhost')
assert ('pubsub', 'pep') in identities and PUBSUB + '#persistent-items' in features
if not restart:
# Existing device-list nodes can retain multiple differently named
# records. The older item is intentionally a valid empty list.
result(publish(client, NODE, 'older', f"<list xmlns='{LEGACY}'/>", {'pubsub#max_items': '3'}))
result(publish(client, NODE, 'current', f"<list xmlns='{LEGACY}'><device id='2073401123'/><device id='2073401456'/></list>"))
check_android_fetch(client, set(DEVICE_IDS[:2]))
# Monocles publishes id=current, then reads its own node without
# an item selector. Replacement must remain the first record.
result(publish(client, NODE, 'current', f"<list xmlns='{LEGACY}'><device id='2073401123'/><device id='2073401789'/></list>"))
check_android_fetch(client, {DEVICE_IDS[0], DEVICE_IDS[2]})
check_info_log(info_log, 1 if restart else 2)
finally:
if client is not None:
client.sock.close()
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
if sys.exc_info()[0] is not None:
log.seek(0)
print(log.read(), file=sys.stderr)
print('PASS: Android first-item device-list parsing sees latest publish and replacement before and after restart')
if __name__ == '__main__':
run(str(Path(sys.argv[1]).resolve()))