Skip to content

Commit ed9b409

Browse files
Retengartmissytake
authored andcommitted
test: add error-path tests for all bug fixes
- test_doveauth: invalid localpart chars rejected, concurrent same-account creation - test_expire: --mdir filtering uses msg.path correctly - test_metadata: TURN exception returns N\n, success returns credentials - test_turnserver: socket timeout, connection refused, happy path - test_dns: get_dkim_entry returns (None, None) on CalledProcessError - test_rshell: dovecot_recalc_quota handles empty/malformed output
1 parent 1b8ad3c commit ed9b409

6 files changed

Lines changed: 300 additions & 0 deletions

File tree

chatmaild/src/chatmaild/tests/test_doveauth.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,60 @@ def test_handle_dovecot_protocol_iterate(gencreds, example_config):
120120
assert not lines[2]
121121

122122

123+
def test_invalid_localpart_characters(make_config):
124+
"""Test that is_allowed_to_create rejects localparts with invalid characters."""
125+
config = make_config("chat.example.org", {"username_min_length": "3"})
126+
password = "zequ0Aimuchoodaechik"
127+
domain = config.mail_domain
128+
129+
# valid localparts
130+
assert is_allowed_to_create(config, f"abc123@{domain}", password)
131+
assert is_allowed_to_create(config, f"a.b-c_d@{domain}", password)
132+
133+
# uppercase rejected
134+
assert not is_allowed_to_create(config, f"Abc123@{domain}", password)
135+
assert not is_allowed_to_create(config, f"ABCDEFG@{domain}", password)
136+
137+
# spaces and special chars rejected
138+
assert not is_allowed_to_create(config, f"a b cde@{domain}", password)
139+
assert not is_allowed_to_create(config, f"abc+def@{domain}", password)
140+
assert not is_allowed_to_create(config, f"abc!def@{domain}", password)
141+
assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password)
142+
assert not is_allowed_to_create(config, f"abc/def@{domain}", password)
143+
assert not is_allowed_to_create(config, f"abc\\def@{domain}", password)
144+
145+
146+
def test_concurrent_creation_same_account(dictproxy):
147+
"""Test that concurrent creation of the same account doesn't corrupt password."""
148+
addr = "racetest1@chat.example.org"
149+
password = "zequ0Aimuchoodaechik"
150+
num_threads = 10
151+
results = queue.Queue()
152+
153+
def create():
154+
try:
155+
res = dictproxy.lookup_passdb(addr, password)
156+
results.put(("ok", res))
157+
except Exception:
158+
results.put(("err", traceback.format_exc()))
159+
160+
threads = [threading.Thread(target=create, daemon=True) for _ in range(num_threads)]
161+
for t in threads:
162+
t.start()
163+
for t in threads:
164+
t.join(timeout=10)
165+
166+
passwords_seen = set()
167+
for _ in range(num_threads):
168+
status, res = results.get()
169+
if status == "err":
170+
pytest.fail(f"concurrent creation failed\n{res}")
171+
passwords_seen.add(res["password"])
172+
173+
# all threads must see the same password hash
174+
assert len(passwords_seen) == 1
175+
176+
123177
def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy):
124178
num_threads = 50
125179
req_per_thread = 5

chatmaild/src/chatmaild/tests/test_expire.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,43 @@ def test_report(mbox1, example_config):
112112
report_main(args)
113113

114114

115+
def test_report_mdir_filters_by_path(mbox1, example_config):
116+
"""Test that Report with mdir='cur' only counts messages in cur/ subdirectory."""
117+
from chatmaild.fsreport import Report
118+
119+
now = datetime.utcnow().timestamp()
120+
121+
# Set password mtime to old enough so min_login_age check passes
122+
password = Path(mbox1.basedir).joinpath("password")
123+
old_time = now - 86400 * 10 # 10 days ago
124+
os.utime(password, (old_time, old_time))
125+
126+
# Reload mailbox with updated mtime
127+
from chatmaild.expire import MailboxStat
128+
129+
mbox = MailboxStat(mbox1.basedir)
130+
131+
# Report without mdir — should count all messages
132+
rep_all = Report(now=now, min_login_age=1, mdir=None)
133+
rep_all.process_mailbox_stat(mbox)
134+
total_all = rep_all.message_buckets[0]
135+
136+
# Report with mdir='cur' — should only count cur/ messages
137+
rep_cur = Report(now=now, min_login_age=1, mdir="cur")
138+
rep_cur.process_mailbox_stat(mbox)
139+
total_cur = rep_cur.message_buckets[0]
140+
141+
# Report with mdir='new' — should only count new/ messages
142+
rep_new = Report(now=now, min_login_age=1, mdir="new")
143+
rep_new.process_mailbox_stat(mbox)
144+
total_new = rep_new.message_buckets[0]
145+
146+
# cur has 500-byte msg, new has 600-byte msg (from fill_mbox)
147+
assert total_cur == 500
148+
assert total_new == 600
149+
assert total_all == 500 + 600
150+
151+
115152
def test_expiry_cli_basic(example_config, mbox1):
116153
args = (str(example_config._inipath),)
117154
expiry_main(args)

chatmaild/src/chatmaild/tests/test_metadata.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,51 @@ def test_persistent_queue_items(tmp_path, testaddr, token):
314314
assert not queue_item < item2 and not item2 < queue_item
315315

316316

317+
def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch):
318+
"""Test that turn_credentials() failure returns N\\n instead of crashing."""
319+
import chatmaild.metadata
320+
321+
dictproxy = MetadataDictProxy(
322+
notifier=notifier,
323+
metadata=metadata,
324+
turn_hostname="turn.example.org",
325+
)
326+
327+
def mock_turn_credentials():
328+
raise ConnectionRefusedError("socket not available")
329+
330+
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials)
331+
332+
transactions = {}
333+
res = dictproxy.handle_dovecot_request(
334+
"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn"
335+
"\tuser@example.org",
336+
transactions,
337+
)
338+
assert res == "N\n"
339+
340+
341+
def test_turn_credentials_success(notifier, metadata, monkeypatch):
342+
"""Test that valid turn_credentials() returns TURN URI."""
343+
import chatmaild.metadata
344+
345+
dictproxy = MetadataDictProxy(
346+
notifier=notifier,
347+
metadata=metadata,
348+
turn_hostname="turn.example.org",
349+
)
350+
351+
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass")
352+
353+
transactions = {}
354+
res = dictproxy.handle_dovecot_request(
355+
"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn"
356+
"\tuser@example.org",
357+
transactions,
358+
)
359+
assert res == "Oturn.example.org:3478:user:pass\n"
360+
361+
317362
def test_iroh_relay(dictproxy):
318363
rfile = io.BytesIO(
319364
b"\n".join(
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import socket
2+
import threading
3+
import time
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from chatmaild.turnserver import turn_credentials
9+
10+
SOCKET_PATH = "/run/chatmail-turn/turn.socket"
11+
12+
13+
@pytest.fixture
14+
def turn_socket(tmp_path):
15+
"""Create a real Unix socket server at a temp path."""
16+
sock_path = str(tmp_path / "turn.socket")
17+
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
18+
server.bind(sock_path)
19+
server.listen(1)
20+
yield sock_path, server
21+
server.close()
22+
23+
24+
def _call_turn_credentials(sock_path):
25+
"""Call turn_credentials but connect to sock_path instead of hardcoded path."""
26+
original_connect = socket.socket.connect
27+
28+
def patched_connect(self, address):
29+
if address == SOCKET_PATH:
30+
address = sock_path
31+
return original_connect(self, address)
32+
33+
with patch.object(socket.socket, "connect", patched_connect):
34+
return turn_credentials()
35+
36+
37+
def test_turn_credentials_timeout(turn_socket):
38+
"""Server accepts but never responds — must raise socket.timeout."""
39+
sock_path, server = turn_socket
40+
41+
def accept_and_hang():
42+
conn, _ = server.accept()
43+
time.sleep(30)
44+
conn.close()
45+
46+
t = threading.Thread(target=accept_and_hang, daemon=True)
47+
t.start()
48+
49+
with pytest.raises(socket.timeout):
50+
_call_turn_credentials(sock_path)
51+
52+
53+
def test_turn_credentials_connection_refused(tmp_path):
54+
"""Socket file doesn't exist — must raise ConnectionRefusedError or FileNotFoundError."""
55+
missing = str(tmp_path / "nonexistent.socket")
56+
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
57+
_call_turn_credentials(missing)
58+
59+
60+
def test_turn_credentials_success(turn_socket):
61+
"""Server responds with credentials — must return stripped string."""
62+
sock_path, server = turn_socket
63+
64+
def respond():
65+
conn, _ = server.accept()
66+
conn.sendall(b"testuser:testpass\n")
67+
conn.close()
68+
69+
t = threading.Thread(target=respond, daemon=True)
70+
t.start()
71+
72+
result = _call_turn_credentials(sock_path)
73+
assert result == "testuser:testpass"

cmdeploy/src/cmdeploy/tests/test_dns.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,29 @@ def mockdns(request, mockdns_base, mockdns_expected):
6060
return mockdns_base
6161

6262

63+
class TestGetDkimEntry:
64+
def test_dkim_entry_returns_tuple_on_success(self, mockdns):
65+
entry, web_entry = remote.rdns.get_dkim_entry(
66+
"some.domain", "", dkim_selector="opendkim"
67+
)
68+
# May return None,None if openssl not available, but should never crash
69+
if entry is not None:
70+
assert "opendkim._domainkey.some.domain" in entry
71+
assert "opendkim._domainkey.some.domain" in web_entry
72+
73+
def test_dkim_entry_returns_none_tuple_on_error(self, monkeypatch):
74+
"""CalledProcessError must return (None, None), not bare None."""
75+
from subprocess import CalledProcessError
76+
77+
def failing_shell(command, fail_ok=False, print=print):
78+
raise CalledProcessError(1, command)
79+
80+
monkeypatch.setattr(remote.rdns, "shell", failing_shell)
81+
result = remote.rdns.get_dkim_entry("some.domain", "", dkim_selector="opendkim")
82+
assert result == (None, None)
83+
assert result[0] is None and result[1] is None
84+
85+
6386
class TestPerformInitialChecks:
6487
def test_perform_initial_checks_ok1(self, mockdns, mockdns_expected):
6588
remote_data = remote.rdns.perform_initial_checks("some.domain")
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from unittest.mock import patch
2+
3+
from cmdeploy.remote.rshell import dovecot_recalc_quota
4+
5+
6+
def test_dovecot_recalc_quota_normal_output():
7+
"""Normal doveadm output returns parsed dict."""
8+
normal_output = (
9+
"Quota name Type Value Limit %\n"
10+
"User quota STORAGE 5 102400 0\n"
11+
"User quota MESSAGE 2 - 0\n"
12+
)
13+
14+
with patch("cmdeploy.remote.rshell.shell", return_value=normal_output):
15+
result = dovecot_recalc_quota("user@example.org")
16+
17+
# shell is called twice (recalc + get), patch returns same for both
18+
assert result == {"value": 5, "limit": 102400, "percent": 0}
19+
20+
21+
def test_dovecot_recalc_quota_empty_output():
22+
"""Empty doveadm output (trailing newline) must not IndexError."""
23+
call_count = [0]
24+
25+
def mock_shell(cmd):
26+
call_count[0] += 1
27+
if "recalc" in cmd:
28+
return ""
29+
# quota get returns only empty lines
30+
return "\n\n"
31+
32+
with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell):
33+
result = dovecot_recalc_quota("user@example.org")
34+
35+
assert result is None
36+
37+
38+
def test_dovecot_recalc_quota_malformed_output():
39+
"""Malformed output with too few columns must not crash."""
40+
call_count = [0]
41+
42+
def mock_shell(cmd):
43+
call_count[0] += 1
44+
if "recalc" in cmd:
45+
return ""
46+
# partial line, fewer than 6 parts
47+
return "Quota name\nUser quota STORAGE\n"
48+
49+
with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell):
50+
result = dovecot_recalc_quota("user@example.org")
51+
52+
assert result is None
53+
54+
55+
def test_dovecot_recalc_quota_header_only():
56+
"""Only header line, no data rows."""
57+
call_count = [0]
58+
59+
def mock_shell(cmd):
60+
call_count[0] += 1
61+
if "recalc" in cmd:
62+
return ""
63+
return "Quota name Type Value Limit %\n"
64+
65+
with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell):
66+
result = dovecot_recalc_quota("user@example.org")
67+
68+
assert result is None

0 commit comments

Comments
 (0)