diff --git a/chatmaild/src/chatmaild/tests/test_doveauth.py b/chatmaild/src/chatmaild/tests/test_doveauth.py index 078bec42..3b18d97d 100644 --- a/chatmaild/src/chatmaild/tests/test_doveauth.py +++ b/chatmaild/src/chatmaild/tests/test_doveauth.py @@ -120,6 +120,60 @@ def test_handle_dovecot_protocol_iterate(gencreds, example_config): assert not lines[2] +def test_invalid_localpart_characters(make_config): + """Test that is_allowed_to_create rejects localparts with invalid characters.""" + config = make_config("chat.example.org", {"username_min_length": "3"}) + password = "zequ0Aimuchoodaechik" + domain = config.mail_domain + + # valid localparts + assert is_allowed_to_create(config, f"abc123@{domain}", password) + assert is_allowed_to_create(config, f"a.b-c_d@{domain}", password) + + # uppercase rejected + assert not is_allowed_to_create(config, f"Abc123@{domain}", password) + assert not is_allowed_to_create(config, f"ABCDEFG@{domain}", password) + + # spaces and special chars rejected + assert not is_allowed_to_create(config, f"a b cde@{domain}", password) + assert not is_allowed_to_create(config, f"abc+def@{domain}", password) + assert not is_allowed_to_create(config, f"abc!def@{domain}", password) + assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password) + assert not is_allowed_to_create(config, f"abc/def@{domain}", password) + assert not is_allowed_to_create(config, f"abc\\def@{domain}", password) + + +def test_concurrent_creation_same_account(dictproxy): + """Test that concurrent creation of the same account doesn't corrupt password.""" + addr = "racetest1@chat.example.org" + password = "zequ0Aimuchoodaechik" + num_threads = 10 + results = queue.Queue() + + def create(): + try: + res = dictproxy.lookup_passdb(addr, password) + results.put(("ok", res)) + except Exception: + results.put(("err", traceback.format_exc())) + + threads = [threading.Thread(target=create, daemon=True) for _ in range(num_threads)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + + passwords_seen = set() + for _ in range(num_threads): + status, res = results.get() + if status == "err": + pytest.fail(f"concurrent creation failed\n{res}") + passwords_seen.add(res["password"]) + + # all threads must see the same password hash + assert len(passwords_seen) == 1 + + def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy): num_threads = 50 req_per_thread = 5 diff --git a/chatmaild/src/chatmaild/tests/test_expire.py b/chatmaild/src/chatmaild/tests/test_expire.py index fcdb4506..70355ffb 100644 --- a/chatmaild/src/chatmaild/tests/test_expire.py +++ b/chatmaild/src/chatmaild/tests/test_expire.py @@ -112,6 +112,43 @@ def test_report(mbox1, example_config): report_main(args) +def test_report_mdir_filters_by_path(mbox1, example_config): + """Test that Report with mdir='cur' only counts messages in cur/ subdirectory.""" + from chatmaild.fsreport import Report + + now = datetime.utcnow().timestamp() + + # Set password mtime to old enough so min_login_age check passes + password = Path(mbox1.basedir).joinpath("password") + old_time = now - 86400 * 10 # 10 days ago + os.utime(password, (old_time, old_time)) + + # Reload mailbox with updated mtime + from chatmaild.expire import MailboxStat + + mbox = MailboxStat(mbox1.basedir) + + # Report without mdir — should count all messages + rep_all = Report(now=now, min_login_age=1, mdir=None) + rep_all.process_mailbox_stat(mbox) + total_all = rep_all.message_buckets[0] + + # Report with mdir='cur' — should only count cur/ messages + rep_cur = Report(now=now, min_login_age=1, mdir="cur") + rep_cur.process_mailbox_stat(mbox) + total_cur = rep_cur.message_buckets[0] + + # Report with mdir='new' — should only count new/ messages + rep_new = Report(now=now, min_login_age=1, mdir="new") + rep_new.process_mailbox_stat(mbox) + total_new = rep_new.message_buckets[0] + + # cur has 500-byte msg, new has 600-byte msg (from fill_mbox) + assert total_cur == 500 + assert total_new == 600 + assert total_all == 500 + 600 + + def test_expiry_cli_basic(example_config, mbox1): args = (str(example_config._inipath),) expiry_main(args) diff --git a/chatmaild/src/chatmaild/tests/test_metadata.py b/chatmaild/src/chatmaild/tests/test_metadata.py index 356d2067..fd49b5b8 100644 --- a/chatmaild/src/chatmaild/tests/test_metadata.py +++ b/chatmaild/src/chatmaild/tests/test_metadata.py @@ -314,6 +314,51 @@ def test_persistent_queue_items(tmp_path, testaddr, token): assert not queue_item < item2 and not item2 < queue_item +def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch): + """Test that turn_credentials() failure returns N\\n instead of crashing.""" + import chatmaild.metadata + + dictproxy = MetadataDictProxy( + notifier=notifier, + metadata=metadata, + turn_hostname="turn.example.org", + ) + + def mock_turn_credentials(): + raise ConnectionRefusedError("socket not available") + + monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials) + + transactions = {} + res = dictproxy.handle_dovecot_request( + "Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn" + "\tuser@example.org", + transactions, + ) + assert res == "N\n" + + +def test_turn_credentials_success(notifier, metadata, monkeypatch): + """Test that valid turn_credentials() returns TURN URI.""" + import chatmaild.metadata + + dictproxy = MetadataDictProxy( + notifier=notifier, + metadata=metadata, + turn_hostname="turn.example.org", + ) + + monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass") + + transactions = {} + res = dictproxy.handle_dovecot_request( + "Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn" + "\tuser@example.org", + transactions, + ) + assert res == "Oturn.example.org:3478:user:pass\n" + + def test_iroh_relay(dictproxy): rfile = io.BytesIO( b"\n".join( diff --git a/chatmaild/src/chatmaild/tests/test_turnserver.py b/chatmaild/src/chatmaild/tests/test_turnserver.py new file mode 100644 index 00000000..7764bdb3 --- /dev/null +++ b/chatmaild/src/chatmaild/tests/test_turnserver.py @@ -0,0 +1,73 @@ +import socket +import threading +import time +from unittest.mock import patch + +import pytest + +from chatmaild.turnserver import turn_credentials + +SOCKET_PATH = "/run/chatmail-turn/turn.socket" + + +@pytest.fixture +def turn_socket(tmp_path): + """Create a real Unix socket server at a temp path.""" + sock_path = str(tmp_path / "turn.socket") + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(sock_path) + server.listen(1) + yield sock_path, server + server.close() + + +def _call_turn_credentials(sock_path): + """Call turn_credentials but connect to sock_path instead of hardcoded path.""" + original_connect = socket.socket.connect + + def patched_connect(self, address): + if address == SOCKET_PATH: + address = sock_path + return original_connect(self, address) + + with patch.object(socket.socket, "connect", patched_connect): + return turn_credentials() + + +def test_turn_credentials_timeout(turn_socket): + """Server accepts but never responds — must raise socket.timeout.""" + sock_path, server = turn_socket + + def accept_and_hang(): + conn, _ = server.accept() + time.sleep(30) + conn.close() + + t = threading.Thread(target=accept_and_hang, daemon=True) + t.start() + + with pytest.raises(socket.timeout): + _call_turn_credentials(sock_path) + + +def test_turn_credentials_connection_refused(tmp_path): + """Socket file doesn't exist — must raise ConnectionRefusedError or FileNotFoundError.""" + missing = str(tmp_path / "nonexistent.socket") + with pytest.raises((ConnectionRefusedError, FileNotFoundError)): + _call_turn_credentials(missing) + + +def test_turn_credentials_success(turn_socket): + """Server responds with credentials — must return stripped string.""" + sock_path, server = turn_socket + + def respond(): + conn, _ = server.accept() + conn.sendall(b"testuser:testpass\n") + conn.close() + + t = threading.Thread(target=respond, daemon=True) + t.start() + + result = _call_turn_credentials(sock_path) + assert result == "testuser:testpass" diff --git a/cmdeploy/src/cmdeploy/tests/test_dns.py b/cmdeploy/src/cmdeploy/tests/test_dns.py index 774820b5..a7869244 100644 --- a/cmdeploy/src/cmdeploy/tests/test_dns.py +++ b/cmdeploy/src/cmdeploy/tests/test_dns.py @@ -60,6 +60,29 @@ def mockdns(request, mockdns_base, mockdns_expected): return mockdns_base +class TestGetDkimEntry: + def test_dkim_entry_returns_tuple_on_success(self, mockdns): + entry, web_entry = remote.rdns.get_dkim_entry( + "some.domain", "", dkim_selector="opendkim" + ) + # May return None,None if openssl not available, but should never crash + if entry is not None: + assert "opendkim._domainkey.some.domain" in entry + assert "opendkim._domainkey.some.domain" in web_entry + + def test_dkim_entry_returns_none_tuple_on_error(self, monkeypatch): + """CalledProcessError must return (None, None), not bare None.""" + from subprocess import CalledProcessError + + def failing_shell(command, fail_ok=False, print=print): + raise CalledProcessError(1, command) + + monkeypatch.setattr(remote.rdns, "shell", failing_shell) + result = remote.rdns.get_dkim_entry("some.domain", "", dkim_selector="opendkim") + assert result == (None, None) + assert result[0] is None and result[1] is None + + class TestPerformInitialChecks: def test_perform_initial_checks_ok1(self, mockdns, mockdns_expected): remote_data = remote.rdns.perform_initial_checks("some.domain") diff --git a/cmdeploy/src/cmdeploy/tests/test_rshell.py b/cmdeploy/src/cmdeploy/tests/test_rshell.py new file mode 100644 index 00000000..43ceb9bb --- /dev/null +++ b/cmdeploy/src/cmdeploy/tests/test_rshell.py @@ -0,0 +1,68 @@ +from unittest.mock import patch + +from cmdeploy.remote.rshell import dovecot_recalc_quota + + +def test_dovecot_recalc_quota_normal_output(): + """Normal doveadm output returns parsed dict.""" + normal_output = ( + "Quota name Type Value Limit %\n" + "User quota STORAGE 5 102400 0\n" + "User quota MESSAGE 2 - 0\n" + ) + + with patch("cmdeploy.remote.rshell.shell", return_value=normal_output): + result = dovecot_recalc_quota("user@example.org") + + # shell is called twice (recalc + get), patch returns same for both + assert result == {"value": 5, "limit": 102400, "percent": 0} + + +def test_dovecot_recalc_quota_empty_output(): + """Empty doveadm output (trailing newline) must not IndexError.""" + call_count = [0] + + def mock_shell(cmd): + call_count[0] += 1 + if "recalc" in cmd: + return "" + # quota get returns only empty lines + return "\n\n" + + with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell): + result = dovecot_recalc_quota("user@example.org") + + assert result is None + + +def test_dovecot_recalc_quota_malformed_output(): + """Malformed output with too few columns must not crash.""" + call_count = [0] + + def mock_shell(cmd): + call_count[0] += 1 + if "recalc" in cmd: + return "" + # partial line, fewer than 6 parts + return "Quota name\nUser quota STORAGE\n" + + with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell): + result = dovecot_recalc_quota("user@example.org") + + assert result is None + + +def test_dovecot_recalc_quota_header_only(): + """Only header line, no data rows.""" + call_count = [0] + + def mock_shell(cmd): + call_count[0] += 1 + if "recalc" in cmd: + return "" + return "Quota name Type Value Limit %\n" + + with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell): + result = dovecot_recalc_quota("user@example.org") + + assert result is None