diff --git a/.gitignore b/.gitignore index f3cdb1bc..8c4bd1dc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tools/__pycache__/ externals/ .env .vagrant +*.pyc diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..09df7967 --- /dev/null +++ b/test/README.md @@ -0,0 +1,24 @@ +This is the mailinabox test suite. It uses the excellent pytest module to check the functionality of the different services. + +# Usage + +start-up a vagrant box + + vagrant up + +install test requirements (using a virtualenv is highly recommended) + + pip install -r requirements.txt + +run the tests + + pytest + +to just run a subset of the tests (e.g. the ssh related ones): + + pytest test_ssh.py + + +# Contributing + +pytest auto-discovers all tests in this directory. The test functions need to be named "test_..." and there needs to be at least one assert statement. diff --git a/test/common.py b/test/common.py new file mode 100644 index 00000000..8c20a435 --- /dev/null +++ b/test/common.py @@ -0,0 +1,5 @@ +import uuid + + +def random_id(): + return uuid.uuid4().hex[:8] diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 00000000..6c63d019 --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1,3 @@ +pytest>=3.0.5 +pyCardDAV==0.7.0 +caldav==0.5 diff --git a/test/settings.py b/test/settings.py new file mode 100644 index 00000000..8e37f0d7 --- /dev/null +++ b/test/settings.py @@ -0,0 +1,11 @@ +import socket + +TEST_SERVER = '127.0.0.1' +TEST_DOMAIN = 'mailinabox.lan' +TEST_PORT = 2222 +TEST_PASSWORD = '1234' +TEST_USER = 'me' +TEST_ADDRESS = TEST_USER + '@' + TEST_DOMAIN +TEST_SENDER = "someone@example.com" + +socket.setdefaulttimeout(5) diff --git a/test/test_backup.py b/test/test_backup.py new file mode 100644 index 00000000..b8edc9da --- /dev/null +++ b/test/test_backup.py @@ -0,0 +1,44 @@ +import pytest +from time import sleep +from subprocess import check_call, check_output +import smtplib + +from settings import * +from test_mail import new_message, check_imap_received + + +def test_backup_mail(): + # send a mail, to ensure we have something to backup + msg, subject = new_message(TEST_ADDRESS, TEST_ADDRESS) + s = smtplib.SMTP(TEST_DOMAIN, 587) + s.starttls() + s.login(TEST_ADDRESS, TEST_PASSWORD) + s.sendmail(TEST_ADDRESS, [TEST_ADDRESS], msg) + s.quit() + + # trigger a backup + sleep(2) + cmd_ssh = "sshpass -p vagrant ssh vagrant@{} -p {} ".format(TEST_SERVER, TEST_PORT) + cmd_count = cmd_ssh + "ls -l /home/user-data/backup/encrypted | wc -l" + num_backup_files = int(check_output(cmd_count, shell=True)) + cmd = cmd_ssh + "sudo /vagrant/management/backup.py" + check_call(cmd, shell=True) + num_backup_files_new = int(check_output(cmd_count, shell=True)) + assert num_backup_files_new > num_backup_files + + # delete mail + assert check_imap_received(subject) + assert not check_imap_received(subject) + + # restore backup + path = "/home/user-data" + passphrase = "export PASSPHRASE=\$(sudo cat /home/user-data/backup/secret_key.txt) &&" + # extract to temp directory + restore = "sudo -E duplicity restore --force file://{0}/backup/encrypted {0}/restore &&".format(path) + # move restored backup using rsync, because it allows to overwrite files + move = "sudo rsync -av {0}/restore/* {0}/ &&".format(path) + rm = "sudo rm -rf {0}/restore/".format(path) + check_call(cmd_ssh + "\"" + passphrase + restore + move + rm + "\"", shell=True) + + # check the mail is there again + assert check_imap_received(subject) diff --git a/test/test_caldav.py b/test/test_caldav.py new file mode 100644 index 00000000..7b9964e1 --- /dev/null +++ b/test/test_caldav.py @@ -0,0 +1,68 @@ +import pytest +import caldav +from time import sleep + +from settings import * +from common import random_id + + +def connect(): + url = "https://" + TEST_DOMAIN + "/cloud/remote.php/dav/calendars/" + TEST_ADDRESS + "/personal/" + client = caldav.DAVClient(url, username=TEST_ADDRESS, password=TEST_PASSWORD, ssl_verify_cert=False) + principal = client.principal() + calendars = principal.calendars() + return client, calendars[0] + + +vcal = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:{} +DTSTAMP:20170510T182145Z +DTSTART:20170512T170000Z +DTEND:20170512T180000Z +SUMMARY: this is a sample event +END:VEVENT +END:VCALENDAR +""" + + +def create_event(): + uid = random_id() + event = vcal.format(uid) + return event, uid + + +def event_exists(uid): + c, cal = connect() + try: + event = cal.event(uid) + return True + except caldav.lib.error.NotFoundError: + return False + + +def test_addremove_event(): + c, cal = connect() + event, uid = create_event() + cal.add_event(event) + assert event_exists(uid) + + # now delete the event again + event = cal.event(uid) + event.delete() + sleep(3) + assert (not event_exists(uid)) + + +def test_addremove_calendar(): + c, _ = connect() + cal_id = random_id() + cal = c.principal().make_calendar(name="test", cal_id=cal_id) + matching = [calendar for calendar in c.principal().calendars() if cal_id in str(calendar.url)] + assert len(matching) == 1 + + c.delete(cal.url) + matching = [calendar for calendar in c.principal().calendars() if cal_id in str(calendar.url)] + assert len(matching) == 0 diff --git a/test/test_carddav.py b/test/test_carddav.py new file mode 100644 index 00000000..a23c96dc --- /dev/null +++ b/test/test_carddav.py @@ -0,0 +1,48 @@ +import pytest +from pycarddav import carddav + +from settings import * + + +test_vcf = """ +BEGIN:VCARD +VERSION:3.0 +EMAIL;TYPE=PREF:foo@example.com +N:John Doe;;;; +FN:John Doe +REV:2012-08-02T21:16:14+00:00 +PRODID:-//ownCloud//NONSGML Contacts 0.2//EN +UID:c292c7212b +END:VCARD +""" + +def connect(): + url = "https://" + TEST_DOMAIN + "/cloud/remote.php/carddav/addressbooks/" + TEST_ADDRESS + "/contacts/" + return carddav.PyCardDAV(url, user=TEST_ADDRESS, passwd=TEST_PASSWORD, verify=False, write_support=True) + + +def test_adddelete_contact(): + c = connect() + abook = c.get_abook() + prev_len = len(abook) + + url, etag = c.upload_new_card(test_vcf) + abook = c.get_abook() + assert len(abook) == prev_len + 1 + + c.delete_vcard(url, etag) + abook = c.get_abook() + assert len(abook) == prev_len + + +def test_update_contact(): + c = connect() + url, etag = c.upload_new_card(test_vcf) + + card = c.get_vcard(url) + new_card = card.replace("John Doe", "Jane Doe") + c.update_vcard(new_card, url, etag) + + card = c.get_vcard(url) + assert "John Doe" not in card + assert "Jane Doe" in card diff --git a/test/test_mail.py b/test/test_mail.py new file mode 100644 index 00000000..227fc0eb --- /dev/null +++ b/test/test_mail.py @@ -0,0 +1,170 @@ +from time import sleep +import requests +import os +import pytest +import imaplib +import poplib +import smtplib +from email.mime.text import MIMEText + +from settings import * +from common import random_id + + +def new_message(from_email, to_email): + """Creates an email (headers & body) with a random subject""" + msg = MIMEText('Testing') + msg['Subject'] = random_id() + msg['From'] = from_email + msg['To'] = to_email + return msg.as_string(), msg['subject'] + + + +def check_imap_received(subject): + """Connects with IMAP and asserts the existence of an email, then deletes it""" + + sleep(3) + + # Login to IMAP + m = imaplib.IMAP4_SSL(TEST_DOMAIN, 993) + m.login(TEST_ADDRESS, TEST_PASSWORD) + m.select() + + # check the message exists + typ, data = m.search(None, '(SUBJECT \"{}\")'.format(subject)) + res = len(data[0].split()) == 1 + + if res: + m.store(data[0].strip(), '+FLAGS', '\\Deleted') + m.expunge() + m.close() + m.logout() + return res + + +def assert_pop3_received(subject): + """Connects with POP3S and asserts the existence of an email, then deletes it""" + + sleep(3) + + # Login to POP3 + mail = poplib.POP3_SSL(TEST_DOMAIN, 995) + mail.user(TEST_ADDRESS) + mail.pass_(TEST_PASSWORD) + + # Assert the message exists + num = len(mail.list()[1]) + resp, text, octets = mail.retr(num) + assert "Subject: " + subject in text + + # Delete it and log out + mail.dele(num) + mail.quit() + + +def test_imap_requires_ssl(): + """IMAP without SSL is NOT available""" + with pytest.raises(socket.timeout): + imaplib.IMAP4(TEST_DOMAIN, 143) + + +def test_pop3_requires_ssl(): + """POP3 without SSL is NOT available""" + with pytest.raises(socket.timeout): + poplib.POP3(TEST_DOMAIN, 110) + + +def test_smtps(): + """Email sent from an MUA via SMTPS is delivered""" + msg, subject = new_message(TEST_ADDRESS, TEST_ADDRESS) + s = smtplib.SMTP(TEST_DOMAIN, 587) + s.starttls() + s.login(TEST_ADDRESS, TEST_PASSWORD) + s.sendmail(TEST_ADDRESS, [TEST_ADDRESS], msg) + s.quit() + assert check_imap_received(subject) + + +def test_smtps_tag(): + """Email sent to address with tag is delivered""" + mail_address = TEST_ADDRESS.replace("@", "+sometag@") + msg, subject = new_message(TEST_ADDRESS, mail_address) + s = smtplib.SMTP(TEST_DOMAIN, 587) + s.starttls() + s.login(TEST_ADDRESS, TEST_PASSWORD) + s.sendmail(TEST_ADDRESS, [mail_address], msg) + s.quit() + assert check_imap_received(subject) + + +def test_smtps_requires_auth(): + """SMTPS with no authentication is rejected""" + import smtplib + s = smtplib.SMTP(TEST_DOMAIN, 587) + s.starttls() + + #FIXME why does this work without login? + + with pytest.raises(smtplib.SMTPRecipientsRefused): + s.sendmail(TEST_ADDRESS, [TEST_ADDRESS], 'Test') + + s.quit() + + +def test_smtp(): + """Email sent from an MTA is delivered""" + import smtplib + msg, subject = new_message(TEST_SENDER, TEST_ADDRESS) + s = smtplib.SMTP(TEST_DOMAIN, 25) + s.sendmail(TEST_SENDER, [TEST_ADDRESS], msg) + s.quit() + assert check_imap_received(subject) + + +def test_smtp_tls(): + """Email sent from an MTA via SMTP+TLS is delivered""" + msg, subject = new_message(TEST_SENDER, TEST_ADDRESS) + s = smtplib.SMTP(TEST_DOMAIN, 25) + s.starttls() + s.sendmail(TEST_SENDER, [TEST_ADDRESS], msg) + s.quit() + assert check_imap_received(subject) + + +def test_smtp_headers(): + """Email sent from an MTA via SMTP+TLS has TLS headers""" + # Send a message to root + msg, subject = new_message(TEST_SENDER, TEST_ADDRESS) + s = smtplib.SMTP(TEST_DOMAIN, 25) + s.starttls() + s.sendmail(TEST_SENDER, [TEST_ADDRESS], msg) + s.quit() + + sleep(3) + + # Get the message + m = imaplib.IMAP4_SSL(TEST_DOMAIN, 993) + m.login(TEST_ADDRESS, TEST_PASSWORD) + m.select() + _, res = m.search(None, '(SUBJECT \"{}\")'.format(subject)) + _, data = m.fetch(res[0], '(RFC822)') + + assert 'ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)' in data[0][1] + + # Clean up + m.store(res[0].strip(), '+FLAGS', '\\Deleted') + m.expunge() + m.close() + m.logout() + + +def test_pop3s(): + """Connects with POP3S and asserts the existance of an email""" + msg, subject = new_message(TEST_ADDRESS, TEST_ADDRESS) + s = smtplib.SMTP(TEST_DOMAIN, 587) + s.starttls() + s.login(TEST_ADDRESS, TEST_PASSWORD) + s.sendmail(TEST_ADDRESS, [TEST_ADDRESS], msg) + s.quit() + assert_pop3_received(subject) diff --git a/test/test_ssh.py b/test/test_ssh.py new file mode 100644 index 00000000..8340981f --- /dev/null +++ b/test/test_ssh.py @@ -0,0 +1,13 @@ +import pytest + +from settings import * + + +def test_ssh_banner(): + """SSH is responding with its banner""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect((TEST_SERVER, TEST_PORT)) + data = s.recv(1024) + s.close() + + assert data.startswith("SSH-2.0-OpenSSH") diff --git a/test/test_web.py b/test/test_web.py new file mode 100644 index 00000000..d6dc4001 --- /dev/null +++ b/test/test_web.py @@ -0,0 +1,60 @@ +from time import sleep +import requests +import os +import pytest + +from settings import * + + +def test_web_hosting_http(): + """web hosting is redirecting to https""" + url = 'http://' + TEST_DOMAIN + r = requests.get(url, verify=False) + + # We should be redirected to https + assert r.history[0].status_code == 301 + assert r.url == url.replace("http", "https") + "/" + + assert r.status_code == 200 + assert "this is a mail-in-a-box" in r.content + + +def test_admin_http(): + """Admin page is redirecting to https""" + url = 'http://' + TEST_DOMAIN + "/admin" + r = requests.get(url, verify=False) + + # We should be redirected to https + assert r.history[0].status_code == 301 + assert r.url == url.replace("http", "https") + + assert r.status_code == 200 + assert "Log in here for your Mail-in-a-Box control panel" in r.content + + +def test_webmail_http(): + """Webmail is redirecting to https and displaying login page""" + url = 'http://' + TEST_DOMAIN + "/mail" + r = requests.get(url, verify=False) + + # We should be redirected to https + assert r.history[0].status_code == 301 + assert r.url == url.replace("http", "https") + "/" + + # 200 - We should be at the login page + assert r.status_code == 200 + assert 'Welcome to ' + TEST_DOMAIN + ' Webmail' in r.content + + +def test_owncloud_http(): + """ownCloud is redirecting to https and displaying login page""" + url = 'http://' + TEST_DOMAIN + '/cloud' + r = requests.get(url, verify=False) + + # We should be redirected to https + assert r.history[0].status_code == 301 + assert r.url == url.replace("http", "https") + "/index.php/login" + + # 200 - We should be at the login page + assert r.status_code == 200 + assert 'ownCloud' in r.content