diff --git a/.gitignore b/.gitignore index 6e1054d0..c0f40b9b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ __pycache__/ *$py.class *.swp *qr-*.png -chatmail.ini +chatmail*.ini # C extensions diff --git a/cmdeploy/pyproject.toml b/cmdeploy/pyproject.toml index 9f2257b5..e9e88038 100644 --- a/cmdeploy/pyproject.toml +++ b/cmdeploy/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "pytest-xdist", "execnet", "imap_tools", + "deltachat-rpc-client", ] [project.scripts] diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index b0dde2e2..a7ed5fee 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -5,7 +5,6 @@ along with command line option and subcommand parsing. import argparse import importlib.resources -import importlib.util import os import pathlib import shutil @@ -214,14 +213,8 @@ def test_cmd_options(parser): def test_cmd(args, out): - """Run local and online tests for chatmail deployment. + """Run local and online tests for chatmail deployment.""" - This will automatically pip-install 'deltachat' if it's not available. - """ - - x = importlib.util.find_spec("deltachat") - if x is None: - out.check_call(f"{sys.executable} -m pip install deltachat") env = os.environ.copy() if args.ssh_host: env["CHATMAIL_SSH"] = args.ssh_host diff --git a/cmdeploy/src/cmdeploy/tests/online/benchmark.py b/cmdeploy/src/cmdeploy/tests/online/benchmark.py index c65292b5..8363651f 100644 --- a/cmdeploy/src/cmdeploy/tests/online/benchmark.py +++ b/cmdeploy/src/cmdeploy/tests/online/benchmark.py @@ -42,9 +42,9 @@ class TestDC: def dc_ping_pong(): chat.send_text("ping") - msg = ac2._evtracker.wait_next_incoming_message() - msg.chat.send_text("pong") - ac1._evtracker.wait_next_incoming_message() + msg = ac2.wait_for_incoming_msg() + msg.get_snapshot().chat.send_text("pong") + ac1.wait_for_incoming_msg() benchmark(dc_ping_pong, 5) @@ -56,6 +56,6 @@ class TestDC: for i in range(10): chat.send_text(f"hello {i}") for i in range(10): - ac2._evtracker.wait_next_incoming_message() + ac2.wait_for_incoming_msg() benchmark(dc_send_10_receive_10, 5, cooldown="auto") diff --git a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py index 50ca14b6..26691f8f 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py @@ -86,10 +86,8 @@ def test_remote(remote, imap_or_smtp): def test_use_two_chatmailservers(cmfactory, maildomain2): - ac1 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.switch_maildomain(maildomain2) - ac2 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.bring_accounts_online() + ac1 = cmfactory.get_online_account() + ac2 = cmfactory.get_online_account(domain=maildomain2) cmfactory.get_accepted_chat(ac1, ac2) domain1 = ac1.get_config("addr").split("@")[1] domain2 = ac2.get_config("addr").split("@")[1] diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index b1644794..69e58777 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -62,8 +62,8 @@ class TestEndToEndDeltaChat: chat.send_text("message0") lp.sec("wait for ac2 to receive message") - msg2 = ac2._evtracker.wait_next_incoming_message() - assert msg2.text == "message0" + msg2 = ac2.wait_for_incoming_msg() + assert msg2.get_snapshot().text == "message0" def test_exceed_quota( self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain @@ -110,26 +110,22 @@ class TestEndToEndDeltaChat: return def test_securejoin(self, cmfactory, lp, maildomain2): - ac1 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.switch_maildomain(maildomain2) - ac2 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.bring_accounts_online() + ac1 = cmfactory.get_online_account() + ac2 = cmfactory.get_online_account(domain=maildomain2) lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin") - qr = ac1.get_setup_contact_qr() + qr = ac1.get_qr_code() lp.sec("ac2: start QR-code based setup contact protocol") - ch = ac2.qr_setup_contact(qr) + ch = ac2.secure_join(qr) assert ch.id >= 10 - ac1._evtracker.wait_securejoin_inviter_progress(1000) + ac1.wait_for_securejoin_inviter_success() def test_dkim_header_stripped(self, cmfactory, maildomain2, lp, imap_mailbox): """Test that if a DC address receives a message, it has no DKIM-Signature and Authentication-Results headers.""" - ac1 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.switch_maildomain(maildomain2) - ac2 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.bring_accounts_online() + ac1 = cmfactory.get_online_account() + ac2 = cmfactory.get_online_account(domain=maildomain2) chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac) chat.send_text("message0") chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac) @@ -146,29 +142,28 @@ class TestEndToEndDeltaChat: assert "dkim-signature" not in msg.headers def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2): - ac1 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.switch_maildomain(maildomain2) - ac2 = cmfactory.new_online_configuring_account(cache=False) - cmfactory.bring_accounts_online() + ac1 = cmfactory.get_online_account() + ac2 = cmfactory.get_online_account(domain=maildomain2) lp.sec("setup encrypted comms between ac1 and ac2 on different instances") - qr = ac1.get_setup_contact_qr() - ch = ac2.qr_setup_contact(qr) + qr = ac1.get_qr_code() + ch = ac2.secure_join(qr) assert ch.id >= 10 - ac1._evtracker.wait_securejoin_inviter_progress(1000) + ac1.wait_for_securejoin_inviter_success() lp.sec("ac1 sends a message and ac2 marks it as seen") chat = ac1.create_chat(ac2) msg = chat.send_text("hi") - m = ac2._evtracker.wait_next_incoming_message() + m = ac2.wait_for_incoming_msg() m.mark_seen() # we can only indirectly wait for mark-seen to cause an smtp-error lp.sec("try to wait for markseen to complete and check error states") deadline = time.time() + 3.1 while time.time() < deadline: - msgs = m.chat.get_messages() + m_snap = m.get_snapshot() + msgs = m_snap.chat.get_messages() for msg in msgs: - assert "error" not in m.get_message_info() + assert "error" not in m.get_info() time.sleep(1) @@ -180,7 +175,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context): chat = cmfactory.get_accepted_chat(user1, user2) chat.send_text("testing submission header cleanup") - user2._evtracker.wait_next_incoming_message() + user2.wait_for_incoming_msg() addr = user2.get_config("addr") host = addr.split("@")[1] pw = user2.get_config("mail_pw") diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index 3f6a4333..14cea369 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -1,5 +1,4 @@ import imaplib -import io import itertools import os import random @@ -35,17 +34,24 @@ def pytest_runtest_setup(item): pytest.skip("skipping slow test, use --slow to run") -@pytest.fixture(scope="session") -def chatmail_config(pytestconfig): - current = basedir = Path().resolve() +def _get_chatmail_config(): + current = Path().resolve() while 1: path = current.joinpath("chatmail.ini").resolve() if path.exists(): - return read_config(path) + return read_config(path), path if current == current.parent: break current = current.parent + return None, None + +@pytest.fixture(scope="session") +def chatmail_config(pytestconfig): + config, path = _get_chatmail_config() + if config: + return config + basedir = Path().resolve() pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs") @@ -73,10 +79,17 @@ def sshdomain2(maildomain2): def pytest_report_header(): - domain = os.environ.get("CHATMAIL_DOMAIN") - if domain: - text = f"chatmail test instance: {domain}" - return ["-" * len(text), text, "-" * len(text)] + config, path = _get_chatmail_config() + domain2 = os.environ.get("CHATMAIL_DOMAIN2", "NOT SET") + domain = config.mail_domain if config else "NOT SET" + path = path if path else "NOT SET" + + lines = [ + f"chatmail.ini {domain} location: {path}", + f"chatmail2: {domain2}", + ] + sep = "-" * max(map(len, lines)) + return [sep, *lines, sep] @pytest.fixture @@ -283,79 +296,95 @@ def gencreds(chatmail_config): # -# Delta Chat testplugin re-use +# Delta Chat RPC-based test support # use the cmfactory fixture to get chatmail instance accounts # +from deltachat_rpc_client import DeltaChat, Rpc -class ChatmailTestProcess: - """Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory""" - def __init__(self, pytestconfig, maildomain, gencreds, chatmail_config): - self.pytestconfig = pytestconfig - self.maildomain = maildomain - assert "." in self.maildomain, maildomain +class ChatmailACFactory: + """RPC-based account factory for chatmail testing.""" + + def __init__(self, rpc, maildomain, gencreds, chatmail_config): + self.dc = DeltaChat(rpc) + self.rpc = rpc + self._maildomain = maildomain self.gencreds = gencreds self.chatmail_config = chatmail_config - self._addr2files = {} - def get_liveconfig_producer(self): - while 1: - user, password = self.gencreds(self.maildomain) - config = { - "addr": user, - "mail_pw": password, - } - # speed up account configuration - config["mail_server"] = self.maildomain - config["send_server"] = self.maildomain - if self.chatmail_config.tls_cert_mode == "self": - # Accept self-signed TLS certificates - config["imap_certificate_checks"] = "3" - yield config + def _make_transport(self, domain): + """Build a transport config dict for the given domain.""" + addr, password = self.gencreds(domain) + transport = { + "addr": addr, + "password": password, + # Setting server explicitly skips requesting autoconfig XML, + # see https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/ + "imapServer": domain, + "smtpServer": domain, + } + if self.chatmail_config.tls_cert_mode == "self": + transport["certificateChecks"] = "acceptInvalidCertificates" + return transport - def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path): - pass + def get_online_account(self, domain=None): + """Create, configure and bring online a single account.""" + return self.get_online_accounts(1, domain)[0] - def cache_maybe_store_configured_db_files(self, acc): - pass + def get_online_accounts(self, num, domain=None): + """Create multiple online accounts in parallel.""" + domain = domain or self._maildomain + futures = [] + accounts = [] + for _ in range(num): + account = self.dc.add_account() + future = account.add_or_update_transport.future( + self._make_transport(domain) + ) + futures.append(future) + + # ensure messages stay in INBOX so that they can be + # concurrently fetched via extra IMAP connections during tests + account.set_config("delete_server_after", "10") + accounts.append(account) + + for future in futures: + future() + + for account in accounts: + account.bring_online() + return accounts + + def get_accepted_chat(self, ac1, ac2): + """Create a 1:1 chat between ac1 and ac2 accepted on both sides.""" + ac2.create_chat(ac1) + return ac1.create_chat(ac2) + + +@pytest.fixture(scope="session") +def rpc(tmp_path_factory): + """Start a deltachat-rpc-server process for the test session.""" + + # NB: accounts_dir must NOT already exist as directory -- + # core-rust only creates accounts.toml if the dir doesn't exist yet. + accounts_dir = str(tmp_path_factory.mktemp("dc") / "accounts") + rpc = Rpc(accounts_dir=accounts_dir) + rpc.start() + yield rpc + rpc.close() @pytest.fixture -def cmfactory(request, gencreds, tmpdir, maildomain, chatmail_config): - # cloned from deltachat.testplugin.amfactory - pytest.importorskip("deltachat") - from deltachat.testplugin import ACFactory - - testproc = ChatmailTestProcess( - request.config, maildomain, gencreds, chatmail_config +def cmfactory(rpc, gencreds, maildomain, chatmail_config): + """Return a ChatmailACFactory for creating online Delta Chat accounts.""" + return ChatmailACFactory( + rpc=rpc, + maildomain=maildomain, + gencreds=gencreds, + chatmail_config=chatmail_config, ) - class Data: - def read_path(self, path): - return - - am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data()) - - # Skip upstream's init_imap to prevent extra imap connections not - # needed for relay testing - am._acsetup.init_imap = lambda acc: None - - # nb. a bit hacky - # would probably be better if deltachat's test machinery grows native support - def switch_maildomain(maildomain2): - am.testprocess.maildomain = maildomain2 - - am.switch_maildomain = switch_maildomain - - yield am - if hasattr(request.node, "rep_call") and request.node.rep_call.failed: - if testproc.pytestconfig.getoption("--extra-info"): - logfile = io.StringIO() - am.dump_imap_summary(logfile=logfile) - print(logfile.getvalue()) - # request.node.add_report_section("call", "imap-server-state", s) - @pytest.fixture def remote(sshdomain):