import asyncio import asyncio.subprocess import binascii import copy import hashlib import hmac import os import socket import subprocess import sys import time from contextlib import closing import aiohttp import pytest from aiohttp import web from aiohttp.web_urldispatcher import UrlDispatcher import threema.gateway from threema.gateway import e2e from threema.gateway.key import Key # Turn off deprecation warnings for now # TODO: Port code to async/await os.environ['PYTHONWARNINGS'] = 'ignore' _res_path = os.path.normpath(os.path.join( os.path.abspath(__file__), os.pardir, 'res')) class RawMessage(e2e.Message): def __init__(self, connection, nonce=None, message=None, **kwargs): super().__init__(connection, e2e.Message.Type.text_message, **kwargs) self.nonce = nonce self.message = message @asyncio.coroutine def pack(self, writer): raise NotImplementedError @classmethod @asyncio.coroutine def unpack(cls, connection, parameters, key_pair, reader): raise NotImplementedError @asyncio.coroutine def send(self, get_data_only=False): """ Send the raw message Return the ID of the message. """ # Send message if get_data_only: return self.nonce, self.message else: return (yield from self._connection.send_e2e(**{ 'to': self.to_id, 'nonce': binascii.hexlify(self.nonce).decode(), 'box': binascii.hexlify(self.message).decode() })) class Server: def __init__(self): self.threema_jpg = os.path.join(_res_path, 'threema.jpg') self.threema_mp4 = os.path.join(_res_path, 'threema.mp4') key = b'4a6a1b34dcef15d43cb74de2fd36091be99fbbaf126d099d47d83d919712c72b' self.echoecho_key = key self.echoecho_encoded_key = 'public:' + key.decode('ascii') decoded_private_key = Key.decode(pytest.msgapi.private, Key.Type.private) self.mocking_key = Key.derive_public(decoded_private_key).hex_pk() self.blobs = {} self.latest_blob_ids = [] router = UrlDispatcher() router.add_route('GET', '/pubkeys/{key}', self.pubkeys) router.add_route('GET', '/lookup/phone/{phone}', self.lookup_phone) router.add_route('GET', '/lookup/phone_hash/{phone_hash}', self.lookup_phone_hash) router.add_route('GET', '/lookup/email/{email}', self.lookup_email) router.add_route('GET', '/lookup/email_hash/{email_hash}', self.lookup_email_hash) router.add_route('GET', '/capabilities/{id}', self.capabilities) router.add_route('GET', '/credits', self.credits) router.add_route('POST', '/send_simple', self.send_simple) router.add_route('POST', '/send_e2e', self.send_e2e) router.add_route('POST', '/upload_blob', self.upload_blob) router.add_route('GET', '/blobs/{blob_id}', self.download_blob) self.router = router @asyncio.coroutine def pubkeys(self, request): key = request.match_info['key'] from_, secret = request.query['from'], request.query['secret'] if (from_, secret) not in pytest.msgapi.api_identities: return web.Response(status=401) elif len(key) != 8: return web.Response(status=404) elif key == 'ECHOECHO': return web.Response(body=self.echoecho_key) elif key == '*MOCKING': return web.Response(body=self.mocking_key) return web.Response(status=404) @asyncio.coroutine def lookup_phone(self, request): phone = request.match_info['phone'] from_, secret = request.query['from'], request.query['secret'] if (from_, secret) not in pytest.msgapi.api_identities: return web.Response(status=401) elif not phone.isdigit(): return web.Response(status=404) elif phone == '44123456789': return web.Response(body=b'ECHOECHO') return web.Response(status=404) @asyncio.coroutine def lookup_phone_hash(self, request): phone_hash = request.match_info['phone_hash'] from_, secret = request.query['from'], request.query['secret'] hash_ = '98b05f6eda7a878f6f016bdcdc9db6eb61a6b190e814ff787142115af144214c' if (from_, secret) not in pytest.msgapi.api_identities: return web.Response(status=401) elif len(phone_hash) % 2 != 0: # Note: This status code might not be intended and may change in the future return web.Response(status=500) elif len(phone_hash) != 64: return web.Response(status=400) elif phone_hash == hash_: return web.Response(body=b'ECHOECHO') return web.Response(status=404) @asyncio.coroutine def lookup_email(self, request): email = request.match_info['email'] from_, secret = request.query['from'], request.query['secret'] if (from_, secret) not in pytest.msgapi.api_identities: return web.Response(status=401) elif email == 'echoecho@example.com': return web.Response(body=b'ECHOECHO') return web.Response(status=404) @asyncio.coroutine def lookup_email_hash(self, request): email_hash = request.match_info['email_hash'] from_, secret = request.query['from'], request.query['secret'] hash_ = '45a13d422b40f81936a9987245d3f6d9064c90607273af4f578246b4484669e2' if (from_, secret) not in pytest.msgapi.api_identities: return web.Response(status=401) elif len(email_hash) % 2 != 0: # Note: This status code might not be intended and may change in the future return web.Response(status=500) elif len(email_hash) != 64: return web.Response(status=400) elif email_hash == hash_: return web.Response(body=b'ECHOECHO') return web.Response(status=404) @asyncio.coroutine def capabilities(self, request): id_ = request.match_info['id'] from_, secret = request.query['from'], request.query['secret'] if (from_, secret) not in pytest.msgapi.api_identities: return web.Response(status=401) elif id_ == 'ECHOECHO': return web.Response(body=b'text,image,video,file') elif id_ == '*MOCKING': return web.Response(body=b'text,image,video,file') return web.Response(status=404) @asyncio.coroutine def credits(self, request): from_, secret = request.query['from'], request.query['secret'] if (from_, secret) not in pytest.msgapi.api_identities: return web.Response(status=401) return web.Response(body=b'100') @asyncio.coroutine def send_simple(self, request): post = (yield from request.post()) # Check API identity if (post['from'], post['secret']) not in pytest.msgapi.api_identities: return web.Response(status=401) # Get ID from to, email or phone if 'to' in post: id_ = post['to'] elif post.get('email', None) == 'echoecho@example.com': id_ = 'ECHOECHO' elif post.get('phone', None) == '44123456789': id_ = 'ECHOECHO' else: return web.Response(status=404) # Process text = post['text'] if post['from'] == pytest.msgapi.nocredit_id: return web.Response(status=402) elif id_ != 'ECHOECHO': return web.Response(status=400) elif len(text) > 3500: return web.Response(status=413) return web.Response(body=b'0' * 16) @asyncio.coroutine def send_e2e(self, request): post = (yield from request.post()) # Check API identity if (post['from'], post['secret']) not in pytest.msgapi.api_identities: return web.Response(status=401) # Get ID, nonce and box id_ = post['to'] nonce, box = binascii.unhexlify(post['nonce']), binascii.unhexlify(post['box']) # Process if post['from'] == pytest.msgapi.nocredit_id: return web.Response(status=402) elif id_ != 'ECHOECHO': return web.Response(status=400) elif len(nonce) != 24: # Note: This status code might not be intended and may change in the future return web.Response(status=400) elif len(box) > 4000: return web.Response(status=413) return web.Response(body=b'1' * 16) @asyncio.coroutine def upload_blob(self, request): try: data = (yield from request.post()) # Check API identity api_identity = (request.query['from'], request.query['secret']) if api_identity not in pytest.msgapi.api_identities: return web.Response(status=401) except KeyError: return web.Response(status=401) try: # Get blob blob = data['blob'].file.read() except KeyError: # Note: This status code might not be intended and may change in the future return web.Response(status=500) # Generate ID blob_id = hashlib.md5(blob).hexdigest() # Process if request.query['from'] == pytest.msgapi.nocredit_id: return web.Response(status=402) elif len(blob) == 0: return web.Response(status=400) elif len(blob) > 20 * (2**20): return web.Response(status=413) # Store blob and return self.blobs[blob_id] = blob self.latest_blob_ids.append(blob_id) return web.Response(body=blob_id.encode()) @asyncio.coroutine def download_blob(self, request): blob_id = request.match_info['blob_id'] # Check API identity from_, secret = request.query['from'], request.query['secret'] if (from_, secret) not in pytest.msgapi.api_identities: return web.Response(status=401) # Get blob try: blob = self.blobs[blob_id] except KeyError: return web.Response(status=404) else: return web.Response( body=blob, content_type='application/octet-stream' ) def pytest_addoption(parser): help_ = 'loop: Use a different event loop, supported: asyncio, uvloop' parser.addoption("--loop", action="store", help=help_) def pytest_report_header(config): return 'Using event loop: {}'.format(default_event_loop(config=config)) def pytest_namespace(): private = 'private:dd9413d597092b004fedc4895db978425efa328ba1f1ec6729e46e09231b8a7e' public = Key.encode(Key.derive_public(Key.decode(private, Key.Type.private))) values = {'msgapi': { 'cli_path': os.path.join( os.path.dirname(__file__), '../threema/gateway/bin/gateway_client.py', ), 'cert_path': os.path.join(_res_path, 'cert.pem'), 'base_url': 'https://msgapi.threema.ch', 'ip': '127.0.0.1', 'id': '*MOCKING', 'secret': 'mock', 'private': private, 'public': public, 'nocredit_id': 'NOCREDIT', 'noexist_id': '*NOEXIST', }} values['msgapi']['api_identities'] = { (values['msgapi']['id'], values['msgapi']['secret']), (values['msgapi']['nocredit_id'], values['msgapi']['secret']) } return values def default_event_loop(request=None, config=None): if request is not None: config = request.config loop = config.getoption("--loop") if loop == 'uvloop': import uvloop asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) else: loop = 'asyncio' return loop def unused_tcp_port(): """ Find an unused localhost TCP port from 1024-65535 and return it. """ with closing(socket.socket()) as sock: sock.bind((pytest.msgapi.ip, 0)) return sock.getsockname()[1] def identity(): return pytest.msgapi.id, pytest.msgapi.secret @pytest.fixture(scope='module') def server(): return Server() @pytest.fixture(scope='module') def raw_message(): return RawMessage @pytest.fixture(scope='module') def event_loop(request): """ Create an instance of the requested event loop. """ default_event_loop(request=request) # Close previous event loop policy = asyncio.get_event_loop_policy() policy.get_event_loop().close() # Create new event loop _event_loop = policy.new_event_loop() policy.set_event_loop(_event_loop) def fin(): _event_loop.close() # Add finaliser and return new event loop request.addfinalizer(fin) return _event_loop @pytest.fixture(scope='module') def api_server_port(): return unused_tcp_port() @pytest.fixture(scope='module') def api_server(request, event_loop, api_server_port, server): port = api_server_port app = web.Application( loop=event_loop, router=server.router, client_max_size=100 * (2**20)) handler = app.make_handler() # Set up server coroutine = event_loop.create_server(handler, host=pytest.msgapi.ip, port=port) server_ = event_loop.run_until_complete(coroutine) def fin(): event_loop.run_until_complete(handler.shutdown(1.0)) server_.close() event_loop.run_until_complete(server_.wait_closed()) event_loop.run_until_complete(app.cleanup()) request.addfinalizer(fin) @pytest.fixture(scope='module') def mock_url(api_server_port): """ Return the URL where the test server can be reached. """ return 'http://{}:{}'.format(pytest.msgapi.ip, api_server_port) @pytest.fixture(scope='module') def connection(request, api_server, mock_url): # Note: We're not doing anything with the server but obviously the # server needs to be started to be able to connect connection_ = threema.gateway.Connection( identity=pytest.msgapi.id, secret=pytest.msgapi.secret, key=pytest.msgapi.private ) # Patch URLs connection_.urls = {key: value.replace(pytest.msgapi.base_url, mock_url) for key, value in connection_.urls.items()} def fin(): connection_.close() request.addfinalizer(fin) return connection_ @pytest.fixture(scope='module') def connection_blocking(request, api_server, mock_url): # Note: We're not doing anything with the server but obviously the # server needs to be started to be able to connect connection_ = threema.gateway.Connection( identity=pytest.msgapi.id, secret=pytest.msgapi.secret, key=pytest.msgapi.private, blocking=True, ) # Patch URLs connection_.urls = {key: value.replace(pytest.msgapi.base_url, mock_url) for key, value in connection_.urls.items()} def fin(): connection_.close() request.addfinalizer(fin) return connection_ @pytest.fixture(scope='module') def invalid_connection(connection): invalid_connection_ = copy.copy(connection) invalid_connection_.id = pytest.msgapi.noexist_id return invalid_connection_ @pytest.fixture(scope='module') def nocredit_connection(connection): nocredit_connection_ = copy.copy(connection) nocredit_connection_.id = pytest.msgapi.nocredit_id return nocredit_connection_ @pytest.fixture(scope='module') def blob(): return b'\x01\x02\x03' @pytest.fixture(scope='module') def blob_id(event_loop, connection, blob): coroutine = connection.upload(blob) return event_loop.run_until_complete(coroutine) @pytest.fixture(scope='module') def cli(api_server, api_server_port, event_loop): @asyncio.coroutine def call_cli(*args, input=None, timeout=3.0): # Prepare environment env = os.environ.copy() env['THREEMA_TEST_API'] = str(api_server_port) test_api_mode = 'WARNING: Currently running in test mode!' # Call CLI in subprocess and get output parameters = [sys.executable, pytest.msgapi.cli_path] + list(args) if isinstance(input, str): input = input.encode('utf-8') # Create process create = asyncio.create_subprocess_exec( *parameters, env=env, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT) process = yield from create # Wait for process to terminate coroutine = process.communicate(input=input) output, _ = yield from asyncio.wait_for(coroutine, timeout, loop=event_loop) # Process output output = output.decode('utf-8') if test_api_mode not in output: print(output) raise ValueError('Not running in test mode') # Strip leading empty lines and pydev debugger output rubbish = [ 'pydev debugger: process', 'Traceback (most recent call last):', test_api_mode, ] lines = [] skip_following_empty_lines = True for line in output.splitlines(keepends=True): if any((line.startswith(s) for s in rubbish)): skip_following_empty_lines = True elif not skip_following_empty_lines or len(line.strip()) > 0: lines.append(line) skip_following_empty_lines = False # Strip trailing empty lines empty_lines_count = 0 for line in reversed(lines): if len(line.strip()) > 0: break empty_lines_count += 1 if empty_lines_count > 0: lines = lines[:-empty_lines_count] output = ''.join(lines) # Check return code if process.returncode != 0: raise subprocess.CalledProcessError(process.returncode, parameters, output=output) return output return call_cli @pytest.fixture(scope='module') def private_key_file(tmpdir_factory): file = tmpdir_factory.mktemp('keys').join('private_key') file.write(pytest.msgapi.private) return str(file) @pytest.fixture(scope='module') def public_key_file(tmpdir_factory): file = tmpdir_factory.mktemp('keys').join('public_key') file.write(pytest.msgapi.public) return str(file) class Callback(e2e.AbstractCallback): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.queue = asyncio.Queue(loop=self.loop) @asyncio.coroutine def receive_message(self, message): yield from self.queue.put(message) @pytest.fixture(scope='module') def callback(event_loop, connection): return Callback(connection, loop=event_loop) @pytest.fixture(scope='module') def callback_server_port(): return unused_tcp_port() @pytest.fixture(scope='module') def callback_server(request, event_loop, callback, callback_server_port): cert_path = pytest.msgapi.cert_path server_ = event_loop.run_until_complete(callback.create_server( certfile=cert_path, host=pytest.msgapi.ip, port=callback_server_port)) def fin(): server_.close() event_loop.run_until_complete(server_.wait_closed()) event_loop.run_until_complete(callback.close()) request.addfinalizer(fin) @pytest.fixture(scope='module') def callback_client(request, event_loop, callback_server): # Note: This is ONLY required because we are using a self-signed certificate # for test purposes. connector = aiohttp.TCPConnector(verify_ssl=False) session = aiohttp.ClientSession(connector=connector, loop=event_loop) def fin(): session.close() request.addfinalizer(fin) return session @pytest.fixture(scope='module') def callback_send(callback_client, callback_server_port, connection): @asyncio.coroutine def send(message): # Get data from message nonce, data = yield from message.send(get_data_only=True) # Create callback parameters params = { 'from': connection.id, 'to': message.to_id, 'messageId': hashlib.md5(message.to_id.encode('ascii')).hexdigest()[16:], 'date': str(time.time()), 'nonce': binascii.hexlify(nonce).decode('ascii'), 'box': binascii.hexlify(data).decode('ascii'), } # Calculate MAC message = ''.join((params['from'], params['to'], params['messageId'], params['date'], params['nonce'], params['box'])) message = message.encode('ascii') encoded_secret = connection.secret.encode('ascii') hmac_ = hmac.new(encoded_secret, msg=message, digestmod=hashlib.sha256) params['mac'] = hmac_.hexdigest() # Send message url = 'https://{}:{}/gateway_callback'.format( pytest.msgapi.ip, callback_server_port) return (yield from callback_client.post(url, data=params)) return send @pytest.fixture(scope='module') def callback_receive(event_loop, callback, callback_server): @asyncio.coroutine def receive(timeout=3.0): coroutine = asyncio.wait_for(callback.queue.get(), timeout, loop=event_loop) return (yield from coroutine) return receive