Compare commits

..

14 Commits

Author SHA1 Message Date
missytake
ca70537453 added full path to tox 2023-10-24 21:53:23 +02:00
holger krekel
1e7d0d10f5 follow link2xt advise and don't check subject/body at all -- turns out there were no tests anyway. 2023-10-22 14:59:37 +02:00
holger krekel
3dd94cbe69 passes the test 2023-10-22 14:59:37 +02:00
holger krekel
ed1b2f9da1 add a failing test for read receipts between two instances 2023-10-22 14:59:37 +02:00
link2xt
7ee84b44df Use tox -c option 2023-10-22 14:48:30 +02:00
link2xt
02205246dd Setup deltachat dependency in init.sh 2023-10-22 14:48:30 +02:00
holger krekel
fcd3194eb1 run tests via scripts 2023-10-22 14:48:30 +02:00
holger krekel
bdef189ce1 try to run all offline tests in CI 2023-10-22 14:48:30 +02:00
holger krekel
3058ddc542 fix init.sh and test.sh
use tox for chatmaild non-online tests
2023-10-22 14:48:30 +02:00
holger krekel
bada933fef add missing file 2023-10-22 14:48:30 +02:00
holger krekel
1d74b94162 rename fixture to maildata and rename doveauth 2023-10-22 14:48:30 +02:00
holger krekel
eee6d0c871 more maildata shifting 2023-10-22 14:48:30 +02:00
holger krekel
ed5e37f1fa move all inlined mails to a data directory 2023-10-22 14:48:30 +02:00
holger krekel
364300274e move all tests into a root "tests" folder so they can share setup and config 2023-10-22 14:48:30 +02:00
14 changed files with 211 additions and 113 deletions

View File

@@ -5,14 +5,27 @@ on:
push: push:
jobs: jobs:
lint: tox:
name: Lint name: chatmail tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Lint chatmaild - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild
run: pipx run tox run: pipx run tox
- name: Lint deploy-chatmail - name: run deploy-chatmail offline tests
working-directory: deploy-chatmail working-directory: deploy-chatmail
run: pipx run tox run: pipx run tox
- name: run deploy-chatmail offline tests
working-directory: deploy-chatmail
run: pipx run tox
scripts:
name: chatmail script invocations
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: run init.sh
run: ./scripts/init.sh
- name: run test.sh
run: ./scripts/test.sh

View File

@@ -20,7 +20,7 @@ addopts = "-v -ra --strict-markers"
legacy_tox_ini = """ legacy_tox_ini = """
[tox] [tox]
isolated_build = true isolated_build = true
envlist = lint envlist = lint,py
[testenv:lint] [testenv:lint]
skipdist = True skipdist = True
@@ -31,4 +31,10 @@ deps =
commands = commands =
black --quiet --check --diff src/ black --quiet --check --diff src/
ruff src/ ruff src/
[testenv]
passenv = CHATMAIL_DOMAIN
deps = pytest
pdbpp
commands = pytest -v -rsXx {posargs: ../tests/chatmaild}
""" """

View File

@@ -21,21 +21,19 @@ def encrypt_password(password: str):
return "{SHA512-CRYPT}" + passhash return "{SHA512-CRYPT}" + passhash
class DictProxy: def create_user(db, user, password):
def __init__(self, db, mail_domain):
self.db = db
self.mail_domain = mail_domain
def create_user(self, user, password):
if os.path.exists(NOCREATE_FILE): if os.path.exists(NOCREATE_FILE):
logging.warning(f"Didn't create account: {NOCREATE_FILE} exists.") logging.warning(
f"Didn't create account: {NOCREATE_FILE} exists. Delete the file to enable account creation."
)
return return
with self.db.write_transaction() as conn: with db.write_transaction() as conn:
conn.create_user(user, password) conn.create_user(user, password)
return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password) return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password)
def get_user_data(self, user):
with self.db.read_connection() as conn: def get_user_data(db, user):
with db.read_connection() as conn:
result = conn.get_user(user) result = conn.get_user(user)
if result: if result:
result["uid"] = "vmail" result["uid"] = "vmail"
@@ -43,19 +41,19 @@ class DictProxy:
return result return result
def lookup_userdb(self, user): def lookup_userdb(db, user):
return self.get_user_data(user) return get_user_data(db, user)
def lookup_passdb(self, user, password): def lookup_passdb(db, user, password):
userdata = self.get_user_data(user) userdata = get_user_data(db, user)
if not userdata: if not userdata:
return self.create_user(user, encrypt_password(password)) return create_user(db, user, encrypt_password(password))
userdata["password"] = userdata["password"].strip() userdata["password"] = userdata["password"].strip()
return userdata return userdata
def handle_dovecot_request(self, msg): def handle_dovecot_request(msg, db, mail_domain):
print(f"received msg: {msg!r}", file=sys.stderr) print(f"received msg: {msg!r}", file=sys.stderr)
short_command = msg[0] short_command = msg[0]
if short_command == "L": # LOOKUP if short_command == "L": # LOOKUP
@@ -66,14 +64,14 @@ class DictProxy:
res = "" res = ""
if namespace == "shared": if namespace == "shared":
if type == "userdb": if type == "userdb":
if user.endswith(f"@{self.mail_domain}"): if user.endswith(f"@{mail_domain}"):
res = lookup_userdb(db, user) res = lookup_userdb(db, user)
if res: if res:
reply_command = "O" reply_command = "O"
else: else:
reply_command = "N" reply_command = "N"
elif type == "passdb": elif type == "passdb":
if user.endswith(f"@{self.mail_domain}"): if user.endswith(f"@{mail_domain}"):
res = lookup_passdb(db, user, password=args[0]) res = lookup_passdb(db, user, password=args[0])
if res: if res:
reply_command = "O" reply_command = "O"
@@ -92,18 +90,17 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
def main(): def main():
socket = sys.argv[1] socket = sys.argv[1]
passwd_entry = pwd.getpwnam(sys.argv[2]) passwd_entry = pwd.getpwnam(sys.argv[2])
db = Database(sys.argv[3])
with open("/etc/mailname", "r") as fp: with open("/etc/mailname", "r") as fp:
mail_domain = fp.read().strip() mail_domain = fp.read().strip()
db = Database(sys.argv[3])
dictproxy = DictProxy(db, mail_domain)
class Handler(StreamRequestHandler): class Handler(StreamRequestHandler):
def handle(self): def handle(self):
while True: while True:
msg = self.rfile.readline().strip().decode() msg = self.rfile.readline().strip().decode()
if not msg: if not msg:
break break
res = dictproxy.handle_dovecot_request(msg) res = handle_dovecot_request(msg, db, mail_domain)
if res: if res:
print(f"sending result: {res!r}", file=sys.stderr) print(f"sending result: {res!r}", file=sys.stderr)
self.wfile.write(res.encode("ascii")) self.wfile.write(res.encode("ascii"))

View File

@@ -34,6 +34,34 @@ def check_encrypted(message):
return True return True
def check_mdn(message, envelope):
if len(envelope.rcpt_tos) != 1:
return False
for name in ["auto-submitted", "chat-version"]:
if not message.get(name):
return False
if message.get_content_type() != "multipart/report":
return False
body = message.get_body()
if body.get_content_type() != "text/plain":
return False
if list(body.iter_attachments()) or list(body.iter_parts()):
return False
# even with all mime-structural checks an attacker
# could try to abuse the subject or body to contain links or other
# annoyance -- we skip on checking subject/body for now as Delta Chat
# should evolve to create E2E-encrypted read receipts anyway.
# and then MDNs are just encrypted mail and can pass the border
# to other instances.
return True
class SMTPController(Controller): class SMTPController(Controller):
def factory(self): def factory(self):
return SMTP(self.handler, **self.SMTP_kwargs) return SMTP(self.handler, **self.SMTP_kwargs)
@@ -82,6 +110,9 @@ def check_DATA(envelope):
if envelope.mail_from.lower() != from_addr.lower(): if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>" return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
if not mail_encrypted and check_mdn(message, envelope):
return
envelope_from_domain = from_addr.split("@").pop() envelope_from_domain = from_addr.split("@").pop()
for recipient in envelope.rcpt_tos: for recipient in envelope.rcpt_tos:
if envelope.mail_from == recipient: if envelope.mail_from == recipient:

View File

@@ -11,3 +11,4 @@ conn.login(f"imapcapa", "pass")
status, res = conn.capability() status, res = conn.capability()
for capa in sorted(res[0].decode().split()): for capa in sorted(res[0].decode().split()):
print(capa) print(capa)

View File

@@ -1,13 +1,8 @@
#!/bin/sh #!/bin/sh
set -e set -e
python3 -m venv deploy-chatmail/venv python3 -m venv venv
deploy-chatmail/venv/bin/pip install pyinfra pytest pip=venv/bin/pip
deploy-chatmail/venv/bin/pip install -e deploy-chatmail
deploy-chatmail/venv/bin/pip install -e chatmaild
python3 -m venv chatmaild/venv $pip install pyinfra pytest build 'setuptools>=68' tox deltachat
chatmaild/venv/bin/pip install --upgrade pytest build 'setuptools>=68' $pip install -e deploy-chatmail
chatmaild/venv/bin/pip install -e chatmaild $pip install -e chatmaild
python3 -m venv online-tests/venv
online-tests/venv/bin/pip install pytest pytest-timeout pdbpp deltachat

View File

@@ -1,3 +1,4 @@
#!/bin/bash #!/bin/bash
chatmaild/venv/bin/pytest chatmaild/ $@ venv/bin/tox -c chatmaild
online-tests/venv/bin/pytest online-tests/ -vrx --durations=5 $@ venv/bin/tox -c deploy-chatmail
venv/bin/pytest tests/online -vrx --durations=5 $@

View File

@@ -1,9 +0,0 @@
import pytest
from chatmaild.database import Database
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)

View File

@@ -3,38 +3,43 @@ import os
import pytest import pytest
import chatmaild.dictproxy import chatmaild.dictproxy
from chatmaild.dictproxy import DictProxy from chatmaild.dictproxy import get_user_data, lookup_passdb
from chatmaild.database import DBError from chatmaild.database import Database, DBError
@pytest.fixture @pytest.fixture()
def dictproxy(db, maildomain): def db(tmpdir):
return DictProxy(db, maildomain) db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
def test_basic(dictproxy, tmpdir, monkeypatch): def test_basic(db):
monkeypatch.setattr( chatmaild.dictproxy.NOCREATE_FILE = "/tmp/nocreate"
chatmaild.dictproxy, "NOCREATE_FILE", tmpdir.join("nocreate").strpath if os.path.exists(chatmaild.dictproxy.NOCREATE_FILE):
) os.remove(chatmaild.dictproxy.NOCREATE_FILE)
dictproxy.lookup_passdb("link2xt@c1.testrun.org", "asdf") lookup_passdb(db, "link2xt@c1.testrun.org", "asdf")
assert dictproxy.get_user_data("link2xt@c1.testrun.org") data = get_user_data(db, "link2xt@c1.testrun.org")
assert data
def test_dont_overwrite_password_on_wrong_login(dictproxy): def test_dont_overwrite_password_on_wrong_login(db):
"""Test that logging in with a different password doesn't create a new user""" """Test that logging in with a different password doesn't create a new user"""
res = dictproxy.lookup_passdb("newuser1@something.org", "kajdlkajsldk12l3kj1983") res = lookup_passdb(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983")
assert res["password"] assert res["password"]
res2 = dictproxy.lookup_passdb("newuser1@something.org", "kajdlqweqwe") res2 = lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe")
# this function always returns a password hash, which is actually compared by dovecot. # this function always returns a password hash, which is actually compared by dovecot.
assert res["password"] == res2["password"] assert res["password"] == res2["password"]
def test_nocreate_file(dictproxy, tmpdir, monkeypatch): def test_nocreate_file(db):
nocreate = tmpdir.join("nocreate") chatmaild.dictproxy.NOCREATE_FILE = "/tmp/nocreate"
monkeypatch.setattr(chatmaild.dictproxy, "NOCREATE_FILE", str(nocreate)) with open(chatmaild.dictproxy.NOCREATE_FILE, "w+") as f:
nocreate.write("") f.write("")
dictproxy.lookup_passdb("newuser1@something.org", "kajdlqweqwe") assert os.path.exists(chatmaild.dictproxy.NOCREATE_FILE)
assert not dictproxy.get_user_data("newuser1@something.org") lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe")
assert not get_user_data(db, "newuser1@something.org")
os.remove(chatmaild.dictproxy.NOCREATE_FILE)
def test_db_version(db): def test_db_version(db):

View File

@@ -1,7 +1,13 @@
from chatmaild.filtermail import check_encrypted, check_DATA, SendRateLimiter from chatmaild.filtermail import check_encrypted, check_DATA, SendRateLimiter, check_mdn
import pytest import pytest
@pytest.fixture
def maildomain():
# let's not depend on a real chatmail instance for the offline tests below
return "chatmail.example.org"
def test_reject_forged_from(maildata, gencreds): def test_reject_forged_from(maildata, gencreds):
class env: class env:
mail_from = gencreds()[0] mail_from = gencreds()[0]
@@ -35,8 +41,33 @@ def test_filtermail_encryption_detection(maildata):
assert not check_encrypted(msg) assert not check_encrypted(msg)
def test_filtermail_mdn_is_not_encrypted(maildata): def test_filtermail_is_mdn(maildata, gencreds):
assert not check_encrypted(maildata("mdn.eml")) from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
msg = maildata("mdn.eml", from_addr, to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert check_mdn(msg, env)
print(msg.as_string())
assert not check_DATA(env)
def test_filtermail_to_multiple_recipients_no_mdn(maildata, gencreds):
from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
thirdaddr = gencreds()[0]
msg = maildata("mdn.eml", from_addr, to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr, thirdaddr]
content = msg.as_bytes()
assert not check_mdn(msg, env)
def test_send_rate_limiter(): def test_send_rate_limiter():

View File

@@ -290,7 +290,7 @@ class Remote:
def maildata(request, gencreds): def maildata(request, gencreds):
datadir = conftestdir.joinpath("mail-data") datadir = conftestdir.joinpath("mail-data")
def maildata(name, parsed=True, from_addr=None, to_addr=None): def maildata(name, from_addr=None, to_addr=None):
if from_addr is None: if from_addr is None:
from_addr = gencreds()[0] from_addr = gencreds()[0]
if to_addr is None: if to_addr is None:

View File

@@ -1,6 +1,6 @@
Subject: Message opened Subject: Message opened
From: <barbaz@c2.testrun.org> From: <{from_addr}>
To: <foobar@c2.testrun.org> To: <{to_addr}>
Date: Sun, 15 Oct 2023 16:43:25 +0000 Date: Sun, 15 Oct 2023 16:43:25 +0000
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org> Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
Auto-Submitted: auto-replied Auto-Submitted: auto-replied

View File

@@ -1,3 +1,4 @@
import time
import random import random
import pytest import pytest
@@ -81,3 +82,29 @@ class TestEndToEndDeltaChat:
ch = ac2.qr_setup_contact(qr) ch = ac2.qr_setup_contact(qr)
assert ch.id >= 10 assert ch.id >= 10
ac1._evtracker.wait_securejoin_inviter_progress(1000) ac1._evtracker.wait_securejoin_inviter_progress(1000)
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()
lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
qr = ac1.get_setup_contact_qr()
ch = ac2.qr_setup_contact(qr)
msg = ac2.wait_next_incoming_message()
assert "verified" in msg.text
lp.sec("ac1 sends a message and ac2 marks it as seen")
chat = ac1.create_chat(ac2)
msg = chat.send_text("hi")
m = ac2.wait_next_incoming_message()
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()
for msg in msgs:
assert "error" not in m.get_message_info()
time.sleep(1)