Skip to content

Commit 4cc96bd

Browse files
committed
feat(madmail): add test-madmail command and E2E tests, with some selected madmail tests run by default
1 parent 07a099f commit 4cc96bd

5 files changed

Lines changed: 177 additions & 10 deletions

File tree

src/cmlxc/cli.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""cmlxc -- Manage local chatmail relay containers via Incus.
22
33
Standard workflow:
4-
init -> deploy-cmdeploy/deploy-madmail -> test-cmdeploy/test-mini.
4+
init -> deploy-cmdeploy/deploy-madmail -> test-cmdeploy/test-madmail/test-mini.
55
"""
66

77
import argparse
@@ -289,6 +289,56 @@ def test_cmdeploy_cmd(args, out):
289289
return driver.run_tests(second_domain=second_domain)
290290

291291

292+
# -------------------------------------------------------------------
293+
# test-madmail
294+
# -------------------------------------------------------------------
295+
296+
297+
def test_madmail_cmd_options(parser):
298+
_add_test_relay_args(parser)
299+
parser.add_argument(
300+
"--cool",
301+
action="store_true",
302+
help="Minimal colored output (pass --cool to madmail test suite).",
303+
)
304+
parser.add_argument(
305+
"--simple",
306+
action="store_true",
307+
default=True,
308+
help="Run only simpler tests (1-6). Enabled by default.",
309+
)
310+
parser.add_argument(
311+
"--all",
312+
action="store_false",
313+
dest="simple",
314+
help="Run all tests (disables --simple).",
315+
)
316+
317+
318+
def test_madmail_cmd(args, out):
319+
"""Run madmail E2E tests inside the builder container."""
320+
ix = Incus(out)
321+
ct = ix.get_running_relay(args.relay)
322+
driver = MadmailDriver(ct, out)
323+
if not driver.check_init():
324+
return 1
325+
326+
if not driver.get_builder():
327+
return 1
328+
329+
second_domain = None
330+
if args.relay2:
331+
ct2 = ix.get_running_relay(args.relay2)
332+
drv2 = MadmailDriver(ct2, out)
333+
second_domain = drv2.get_test_domain_or_ip()
334+
335+
return driver.run_tests(
336+
second_domain=second_domain,
337+
cool=args.cool,
338+
simple=args.simple,
339+
)
340+
341+
292342
# -------------------------------------------------------------------
293343
# minitest
294344
# -------------------------------------------------------------------
@@ -523,6 +573,7 @@ def _print_dns_forwarding_status(out, dns_ip, *, host=False):
523573
SUBCOMMANDS = [
524574
("init", init_cmd, init_cmd_options),
525575
("test-cmdeploy", test_cmdeploy_cmd, test_cmdeploy_cmd_options),
576+
("test-madmail", test_madmail_cmd, test_madmail_cmd_options),
526577
("test-mini", test_mini_cmd, test_mini_cmd_options),
527578
("status", status_cmd, status_cmd_options),
528579
("start", start_cmd, start_cmd_options),

src/cmlxc/container.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,11 @@ def install_deps(self):
448448
deltachat-rpc-client \
449449
deltachat-rpc-server \
450450
imap-tools \
451-
requests
451+
requests \
452+
python-dotenv \
453+
cryptography
454+
# python-dotenv and cryptography are needed by
455+
# the madmail E2E test suite (test-madmail).
452456
fi
453457
""")
454458

@@ -526,7 +530,15 @@ def init_ssh(self):
526530
)
527531
self.bash("chown root:root /root/.ssh/id_localchat")
528532
self.bash("chmod 600 /root/.ssh/id_localchat")
529-
self.bash("echo 'Include /root/.ssh/config.d/*' > /root/.ssh/config")
533+
self.bash(
534+
"printf 'Include /root/.ssh/config.d/*\\n\\n"
535+
"Host *\\n"
536+
" IdentityFile /root/.ssh/id_localchat\\n"
537+
" StrictHostKeyChecking accept-new\\n"
538+
" UserKnownHostsFile /dev/null\\n"
539+
" LogLevel ERROR\\n'"
540+
" > /root/.ssh/config"
541+
)
530542

531543
def write_relay_ssh_config(self, ct):
532544
"""Write an ssh-config.d file for a single relay container."""

src/cmlxc/driver_madmail.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,48 @@ def on_init_relay(self, repo_path):
9797
if check is None:
9898
raise SetupError("admin-web build produced no index.html")
9999

100+
def run_tests(self, second_domain=None, cool=False, simple=False):
101+
"""Execute the madmail E2E test suite against relays."""
102+
test_src = f"{self.get_git_main_path()}/tests/deltachat-test"
103+
104+
with self.out.section("test-madmail"):
105+
# Symlink the built maddy binary into the test directory so
106+
# tests that spawn a local server can find it at build/maddy.
107+
self.bld_ct.bash(
108+
f"mkdir -p {test_src}/build"
109+
f" && ln -sf {self.repo_path}/build/maddy {test_src}/build/maddy"
110+
)
111+
112+
relay1 = self.get_test_domain_or_ip()
113+
rpc = "/root/minitest-venv/bin/deltachat-rpc-server"
114+
env_exports = (
115+
f"export REMOTE1={relay1} REMOTE2={relay1} RPC_SERVER_PATH={rpc}"
116+
)
117+
if second_domain:
118+
env_exports = (
119+
f"export REMOTE1={relay1} REMOTE2={second_domain}"
120+
f" RPC_SERVER_PATH={rpc}"
121+
)
122+
123+
cool_flag = " --cool" if cool else ""
124+
if simple:
125+
test_flags = "--test-1 --test-2 --test-3 --test-4 --test-5 --test-6"
126+
else:
127+
test_flags = "--all"
128+
129+
cmd = (
130+
f"incus exec {self.bld_ct.name} --"
131+
f" bash -c '"
132+
f"{env_exports} &&"
133+
f" source /root/minitest-venv/bin/activate &&"
134+
f" cd {test_src} &&"
135+
f" python main.py {test_flags}{cool_flag}'"
136+
)
137+
ret = self.out.shell(cmd)
138+
if ret:
139+
self.out.red(f"test-madmail failed (exit {ret})")
140+
return ret
141+
100142
def get_test_domain_or_ip(self):
101143
if not self.ct.ipv4:
102144
self.ct.wait_ready()
@@ -178,8 +220,7 @@ def print_admin_info(out, ct, ip):
178220
status = ct.bash("madmail admin-web status", check=False) or ""
179221
enabled, path = _parse_admin_web_status(status)
180222
if enabled and path:
181-
out.print(f"admin web: https://{ip}{path}/")
182-
out.print(f"admin API: https://{ip}/api/admin")
223+
out.print(f"admin-url: https://{ip}{path}/")
183224
else:
184225
out.print("admin-url: disabled")
185226
except Exception:

src/relay_minitest/test_relay.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import imaplib
12
import ipaddress
3+
import smtplib
4+
import ssl
5+
from email.mime.text import MIMEText
26

37
import imap_tools
8+
import pytest
49
import requests
510

611

@@ -27,6 +32,18 @@ def test_one_on_one(self, cmfactory, lp):
2732
msg2 = ac2.wait_for_incoming_msg()
2833
assert msg2.get_snapshot().text == "message0"
2934

35+
def test_send_dot(self, cmfactory, lp):
36+
"""Test that a single dot is properly escaped in SMTP protocol"""
37+
ac1, ac2 = cmfactory.get_online_accounts(2)
38+
chat = cmfactory.get_accepted_chat(ac1, ac2)
39+
40+
lp.sec("ac1: sending single dot message")
41+
chat.send_text(".")
42+
43+
lp.sec("ac2: wait for receive")
44+
msg2 = ac2.wait_for_incoming_msg()
45+
assert msg2.get_snapshot().text == "."
46+
3047

3148
class TestMultiRelay:
3249
"""Tests that use two different chatmail relays."""
@@ -73,3 +90,44 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
7390
msgs = list(mailbox.fetch(mark_seen=False))
7491
assert msgs, "expected at least one message"
7592
assert public_ip not in msgs[0].obj.as_string()
93+
94+
95+
def test_unencrypted_rejection(cmsetup, lp):
96+
"""Test that unencrypted messages are rejected by the relay."""
97+
lp.sec("creating users")
98+
u1, u2 = cmsetup.gen_users(2)
99+
100+
lp.sec("sending unencrypted mail via SMTP")
101+
msg = MIMEText("unencrypted")
102+
msg["Subject"] = "test"
103+
msg["From"] = u1.addr
104+
msg["To"] = u2.addr
105+
106+
try:
107+
u1.smtp.sendmail(u1.addr, [u2.addr], msg.as_string())
108+
pytest.fail("Unencrypted message was accepted!")
109+
except smtplib.SMTPDataError as e:
110+
assert e.smtp_code == 523
111+
except smtplib.SMTPRecipientsRefused as e:
112+
for addr, (code, msg) in e.recipients.items():
113+
assert code == 523
114+
115+
116+
def test_login_domain_validation(maildomain, lp):
117+
"""Test that IMAP LOGIN validates the domain part for JIT creation."""
118+
lp.sec("attempting login with invalid domain")
119+
# We use raw imaplib because cmsetup.gen_users() would fail at credential generation
120+
# or handle the error differently.
121+
user = f"invalid@{maildomain}.invalid"
122+
123+
ctx = ssl.create_default_context()
124+
ctx.check_hostname = False
125+
ctx.verify_mode = ssl.CERT_NONE
126+
127+
with imaplib.IMAP4_SSL(maildomain, ssl_context=ctx) as conn:
128+
try:
129+
conn.login(user, "password")
130+
pytest.fail("Login with invalid domain was accepted!")
131+
except imaplib.IMAP4.error as e:
132+
# Most relays return [AUTHENTICATIONFAILED] or similar
133+
assert "FAILED" in str(e) or "Invalid" in str(e)

tests/fullrun.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Functional test — exercises the complete cmlxc workflow in the live system.
22
3-
Uses and destroys ``fulltest0`` and ``fulltest1`` containers.
3+
Uses and destroys ``fulltest0``, ``fulltest1`` and ``fulltest-mad0`` containers.
44
55
Run with::
66
@@ -19,6 +19,7 @@
1919

2020
CT0 = "fulltest0"
2121
CT1 = "fulltest1"
22+
CT_MAD = "fulltest-mad0"
2223

2324

2425
def cmlxc(*args):
@@ -41,7 +42,7 @@ def _module_setup():
4142

4243
yield
4344
subprocess.run(
44-
["cmlxc", "destroy", CT0, CT1],
45+
["cmlxc", "destroy", CT0, CT1, CT_MAD],
4546
stdout=subprocess.DEVNULL,
4647
stderr=subprocess.DEVNULL,
4748
check=False,
@@ -96,12 +97,16 @@ def test_destroy():
9697

9798

9899
def test_mad_deploy():
99-
cmlxc("deploy-madmail", "--source", "@main", "--ipv4-only", CT0)
100+
cmlxc("deploy-madmail", "--source", "@main", "--ipv4-only", CT_MAD)
100101

101102

102103
def test_mini_madmail():
103-
cmlxc("test-mini", CT0)
104+
cmlxc("test-mini", CT_MAD)
105+
106+
107+
def test_madmail():
108+
cmlxc("test-madmail", CT_MAD)
104109

105110

106111
def test_destroy_madmail():
107-
cmlxc("destroy", CT0)
112+
cmlxc("destroy", CT_MAD)

0 commit comments

Comments
 (0)