#!/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"", {'pubsub#max_items': '3'})) result(publish(client, NODE, 'current', f"")) 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"")) 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()))