cleanup: remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings (#872)

* cleanup: remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings

major simplification: all chatmail fixtures used in the test are now created inside the cmdeploy plugin,
and do not inherit anything from other fixture machineries, let alone the legacy deltachat CFFI ones.
also fix that pytest report headers show correct chatmail domains under test
This commit is contained in:
holger krekel
2026-02-24 08:27:56 +01:00
committed by GitHub
parent 2c99cc84aa
commit 8ca0909fa5
7 changed files with 124 additions and 108 deletions

2
.gitignore vendored
View File

@@ -4,7 +4,7 @@ __pycache__/
*$py.class *$py.class
*.swp *.swp
*qr-*.png *qr-*.png
chatmail.ini chatmail*.ini
# C extensions # C extensions

View File

@@ -20,6 +20,7 @@ dependencies = [
"pytest-xdist", "pytest-xdist",
"execnet", "execnet",
"imap_tools", "imap_tools",
"deltachat-rpc-client",
] ]
[project.scripts] [project.scripts]

View File

@@ -5,7 +5,6 @@ along with command line option and subcommand parsing.
import argparse import argparse
import importlib.resources import importlib.resources
import importlib.util
import os import os
import pathlib import pathlib
import shutil import shutil
@@ -214,14 +213,8 @@ def test_cmd_options(parser):
def test_cmd(args, out): 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() env = os.environ.copy()
if args.ssh_host: if args.ssh_host:
env["CHATMAIL_SSH"] = args.ssh_host env["CHATMAIL_SSH"] = args.ssh_host

View File

@@ -42,9 +42,9 @@ class TestDC:
def dc_ping_pong(): def dc_ping_pong():
chat.send_text("ping") chat.send_text("ping")
msg = ac2._evtracker.wait_next_incoming_message() msg = ac2.wait_for_incoming_msg()
msg.chat.send_text("pong") msg.get_snapshot().chat.send_text("pong")
ac1._evtracker.wait_next_incoming_message() ac1.wait_for_incoming_msg()
benchmark(dc_ping_pong, 5) benchmark(dc_ping_pong, 5)
@@ -56,6 +56,6 @@ class TestDC:
for i in range(10): for i in range(10):
chat.send_text(f"hello {i}") chat.send_text(f"hello {i}")
for i in range(10): 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") benchmark(dc_send_10_receive_10, 5, cooldown="auto")

View File

@@ -86,10 +86,8 @@ def test_remote(remote, imap_or_smtp):
def test_use_two_chatmailservers(cmfactory, maildomain2): def test_use_two_chatmailservers(cmfactory, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False) ac1 = cmfactory.get_online_account()
cmfactory.switch_maildomain(maildomain2) ac2 = cmfactory.get_online_account(domain=maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
cmfactory.get_accepted_chat(ac1, ac2) cmfactory.get_accepted_chat(ac1, ac2)
domain1 = ac1.get_config("addr").split("@")[1] domain1 = ac1.get_config("addr").split("@")[1]
domain2 = ac2.get_config("addr").split("@")[1] domain2 = ac2.get_config("addr").split("@")[1]

View File

@@ -62,8 +62,8 @@ class TestEndToEndDeltaChat:
chat.send_text("message0") chat.send_text("message0")
lp.sec("wait for ac2 to receive message") lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message() msg2 = ac2.wait_for_incoming_msg()
assert msg2.text == "message0" assert msg2.get_snapshot().text == "message0"
def test_exceed_quota( def test_exceed_quota(
self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain
@@ -110,26 +110,22 @@ class TestEndToEndDeltaChat:
return return
def test_securejoin(self, cmfactory, lp, maildomain2): def test_securejoin(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False) ac1 = cmfactory.get_online_account()
cmfactory.switch_maildomain(maildomain2) ac2 = cmfactory.get_online_account(domain=maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin") 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") 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 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): def test_dkim_header_stripped(self, cmfactory, maildomain2, lp, imap_mailbox):
"""Test that if a DC address receives a message, it has no """Test that if a DC address receives a message, it has no
DKIM-Signature and Authentication-Results headers.""" DKIM-Signature and Authentication-Results headers."""
ac1 = cmfactory.new_online_configuring_account(cache=False) ac1 = cmfactory.get_online_account()
cmfactory.switch_maildomain(maildomain2) ac2 = cmfactory.get_online_account(domain=maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac) chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac)
chat.send_text("message0") chat.send_text("message0")
chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac) chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac)
@@ -146,29 +142,28 @@ class TestEndToEndDeltaChat:
assert "dkim-signature" not in msg.headers assert "dkim-signature" not in msg.headers
def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2): def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False) ac1 = cmfactory.get_online_account()
cmfactory.switch_maildomain(maildomain2) ac2 = cmfactory.get_online_account(domain=maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("setup encrypted comms between ac1 and ac2 on different instances") lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
qr = ac1.get_setup_contact_qr() qr = ac1.get_qr_code()
ch = ac2.qr_setup_contact(qr) ch = ac2.secure_join(qr)
assert ch.id >= 10 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") lp.sec("ac1 sends a message and ac2 marks it as seen")
chat = ac1.create_chat(ac2) chat = ac1.create_chat(ac2)
msg = chat.send_text("hi") msg = chat.send_text("hi")
m = ac2._evtracker.wait_next_incoming_message() m = ac2.wait_for_incoming_msg()
m.mark_seen() m.mark_seen()
# we can only indirectly wait for mark-seen to cause an smtp-error # 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") lp.sec("try to wait for markseen to complete and check error states")
deadline = time.time() + 3.1 deadline = time.time() + 3.1
while time.time() < deadline: while time.time() < deadline:
msgs = m.chat.get_messages() m_snap = m.get_snapshot()
msgs = m_snap.chat.get_messages()
for msg in msgs: for msg in msgs:
assert "error" not in m.get_message_info() assert "error" not in m.get_info()
time.sleep(1) time.sleep(1)
@@ -180,7 +175,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat = cmfactory.get_accepted_chat(user1, user2) chat = cmfactory.get_accepted_chat(user1, user2)
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message() user2.wait_for_incoming_msg()
addr = user2.get_config("addr") addr = user2.get_config("addr")
host = addr.split("@")[1] host = addr.split("@")[1]
pw = user2.get_config("mail_pw") pw = user2.get_config("mail_pw")

View File

@@ -1,5 +1,4 @@
import imaplib import imaplib
import io
import itertools import itertools
import os import os
import random import random
@@ -35,17 +34,24 @@ def pytest_runtest_setup(item):
pytest.skip("skipping slow test, use --slow to run") pytest.skip("skipping slow test, use --slow to run")
@pytest.fixture(scope="session") def _get_chatmail_config():
def chatmail_config(pytestconfig): current = Path().resolve()
current = basedir = Path().resolve()
while 1: while 1:
path = current.joinpath("chatmail.ini").resolve() path = current.joinpath("chatmail.ini").resolve()
if path.exists(): if path.exists():
return read_config(path) return read_config(path), path
if current == current.parent: if current == current.parent:
break break
current = current.parent 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") pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
@@ -73,10 +79,17 @@ def sshdomain2(maildomain2):
def pytest_report_header(): def pytest_report_header():
domain = os.environ.get("CHATMAIL_DOMAIN") config, path = _get_chatmail_config()
if domain: domain2 = os.environ.get("CHATMAIL_DOMAIN2", "NOT SET")
text = f"chatmail test instance: {domain}" domain = config.mail_domain if config else "NOT SET"
return ["-" * len(text), text, "-" * len(text)] 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 @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 # 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): class ChatmailACFactory:
self.pytestconfig = pytestconfig """RPC-based account factory for chatmail testing."""
self.maildomain = maildomain
assert "." in self.maildomain, maildomain def __init__(self, rpc, maildomain, gencreds, chatmail_config):
self.dc = DeltaChat(rpc)
self.rpc = rpc
self._maildomain = maildomain
self.gencreds = gencreds self.gencreds = gencreds
self.chatmail_config = chatmail_config self.chatmail_config = chatmail_config
self._addr2files = {}
def get_liveconfig_producer(self): def _make_transport(self, domain):
while 1: """Build a transport config dict for the given domain."""
user, password = self.gencreds(self.maildomain) addr, password = self.gencreds(domain)
config = { transport = {
"addr": user, "addr": addr,
"mail_pw": password, "password": password,
} # Setting server explicitly skips requesting autoconfig XML,
# speed up account configuration # see https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/
config["mail_server"] = self.maildomain "imapServer": domain,
config["send_server"] = self.maildomain "smtpServer": domain,
if self.chatmail_config.tls_cert_mode == "self": }
# Accept self-signed TLS certificates if self.chatmail_config.tls_cert_mode == "self":
config["imap_certificate_checks"] = "3" transport["certificateChecks"] = "acceptInvalidCertificates"
yield config return transport
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path): def get_online_account(self, domain=None):
pass """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): def get_online_accounts(self, num, domain=None):
pass """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 @pytest.fixture
def cmfactory(request, gencreds, tmpdir, maildomain, chatmail_config): def cmfactory(rpc, gencreds, maildomain, chatmail_config):
# cloned from deltachat.testplugin.amfactory """Return a ChatmailACFactory for creating online Delta Chat accounts."""
pytest.importorskip("deltachat") return ChatmailACFactory(
from deltachat.testplugin import ACFactory rpc=rpc,
maildomain=maildomain,
testproc = ChatmailTestProcess( gencreds=gencreds,
request.config, maildomain, gencreds, chatmail_config 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 @pytest.fixture
def remote(sshdomain): def remote(sshdomain):