From 7f0a1b8cdd492917f84f90cade28cefa0a37c3e0 Mon Sep 17 00:00:00 2001 From: Lucas Hoffmann Date: Fri, 21 Dec 2018 00:33:19 +0100 Subject: Rename test files The two main reasons are - to run `python3 -m unittest discover` without specifying a custom `--pattern *_test.py` - to include the test files automatically when generating the MANIFEST file. --- tests/account_test.py | 187 --------- tests/addressbook/abook_test.py | 38 -- tests/addressbook/external_test.py | 68 ---- tests/addressbook/init_test.py | 74 ---- tests/addressbook/test_abook.py | 38 ++ tests/addressbook/test_external.py | 68 ++++ tests/addressbook/test_init.py | 74 ++++ tests/commands/envelope_test.py | 386 ------------------ tests/commands/global_test.py | 246 ------------ tests/commands/init_test.py | 50 --- tests/commands/test_envelope.py | 386 ++++++++++++++++++ tests/commands/test_global.py | 246 ++++++++++++ tests/commands/test_init.py | 50 +++ tests/commands/test_thread.py | 230 +++++++++++ tests/commands/thread_test.py | 230 ----------- tests/completion_test.py | 98 ----- tests/crypto_test.py | 392 ------------------- tests/db/envelope_test.py | 103 ----- tests/db/manager_test.py | 53 --- tests/db/message_test.py | 115 ------ tests/db/test_envelope.py | 103 +++++ tests/db/test_manager.py | 53 +++ tests/db/test_message.py | 115 ++++++ tests/db/test_thread.py | 76 ++++ tests/db/test_utils.py | 774 +++++++++++++++++++++++++++++++++++++ tests/db/thread_test.py | 76 ---- tests/db/utils_test.py | 774 ------------------------------------- tests/helper_test.py | 473 ----------------------- tests/settings/manager_test.py | 311 --------------- tests/settings/test_manager.py | 311 +++++++++++++++ tests/settings/test_theme.py | 88 +++++ tests/settings/test_utils.py | 74 ++++ tests/settings/theme_test.py | 88 ----- tests/settings/utils_test.py | 74 ---- tests/test_account.py | 187 +++++++++ tests/test_completion.py | 98 +++++ tests/test_crypto.py | 392 +++++++++++++++++++ tests/test_helper.py | 473 +++++++++++++++++++++++ tests/utils/argparse_test.py | 178 --------- tests/utils/configobj_test.py | 19 - tests/utils/test_argparse.py | 178 +++++++++ tests/utils/test_configobj.py | 19 + tests/widgets/globals_test.py | 51 --- tests/widgets/test_globals.py | 51 +++ 44 files changed, 4084 insertions(+), 4084 deletions(-) delete mode 100644 tests/account_test.py delete mode 100644 tests/addressbook/abook_test.py delete mode 100644 tests/addressbook/external_test.py delete mode 100644 tests/addressbook/init_test.py create mode 100644 tests/addressbook/test_abook.py create mode 100644 tests/addressbook/test_external.py create mode 100644 tests/addressbook/test_init.py delete mode 100644 tests/commands/envelope_test.py delete mode 100644 tests/commands/global_test.py delete mode 100644 tests/commands/init_test.py create mode 100644 tests/commands/test_envelope.py create mode 100644 tests/commands/test_global.py create mode 100644 tests/commands/test_init.py create mode 100644 tests/commands/test_thread.py delete mode 100644 tests/commands/thread_test.py delete mode 100644 tests/completion_test.py delete mode 100644 tests/crypto_test.py delete mode 100644 tests/db/envelope_test.py delete mode 100644 tests/db/manager_test.py delete mode 100644 tests/db/message_test.py create mode 100644 tests/db/test_envelope.py create mode 100644 tests/db/test_manager.py create mode 100644 tests/db/test_message.py create mode 100644 tests/db/test_thread.py create mode 100644 tests/db/test_utils.py delete mode 100644 tests/db/thread_test.py delete mode 100644 tests/db/utils_test.py delete mode 100644 tests/helper_test.py delete mode 100644 tests/settings/manager_test.py create mode 100644 tests/settings/test_manager.py create mode 100644 tests/settings/test_theme.py create mode 100644 tests/settings/test_utils.py delete mode 100644 tests/settings/theme_test.py delete mode 100644 tests/settings/utils_test.py create mode 100644 tests/test_account.py create mode 100644 tests/test_completion.py create mode 100644 tests/test_crypto.py create mode 100644 tests/test_helper.py delete mode 100644 tests/utils/argparse_test.py delete mode 100644 tests/utils/configobj_test.py create mode 100644 tests/utils/test_argparse.py create mode 100644 tests/utils/test_configobj.py delete mode 100644 tests/widgets/globals_test.py create mode 100644 tests/widgets/test_globals.py (limited to 'tests') diff --git a/tests/account_test.py b/tests/account_test.py deleted file mode 100644 index 9d0ac125..00000000 --- a/tests/account_test.py +++ /dev/null @@ -1,187 +0,0 @@ -# encoding=utf-8 -# Copyright © 2017 Dylan Baker -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - - -import logging -import unittest - -from alot import account - -from . import utilities - -class _AccountTestClass(account.Account): - """Implements stubs for ABC methods.""" - - def send_mail(self, mail): - pass - - -class TestAccount(unittest.TestCase): - """Tests for the Account class.""" - - def test_matches_address(self): - """Tests address without aliases.""" - acct = _AccountTestClass(address="foo@example.com") - self.assertTrue(acct.matches_address(u"foo@example.com")) - self.assertFalse(acct.matches_address(u"bar@example.com")) - - def test_matches_address_with_aliases(self): - """Tests address with aliases.""" - acct = _AccountTestClass(address="foo@example.com", - aliases=['bar@example.com']) - self.assertTrue(acct.matches_address(u"foo@example.com")) - self.assertTrue(acct.matches_address(u"bar@example.com")) - self.assertFalse(acct.matches_address(u"baz@example.com")) - - def test_matches_address_with_regex_aliases(self): - """Tests address with regex aliases.""" - acct = _AccountTestClass(address=u"foo@example.com", - alias_regexp=r'to\+.*@example.com') - self.assertTrue(acct.matches_address(u"to+foo@example.com")) - self.assertFalse(acct.matches_address(u"to@example.com")) - - - def test_deprecated_encrypt_by_default(self): - """Tests that deprecated values are still accepted.""" - for each in ['true', 'yes', '1']: - acct = _AccountTestClass(address='foo@example.com', - encrypt_by_default=each) - self.assertEqual(acct.encrypt_by_default, 'all') - for each in ['false', 'no', '0']: - acct = _AccountTestClass(address='foo@example.com', - encrypt_by_default=each) - self.assertEqual(acct.encrypt_by_default, 'none') - - -class TestAddress(unittest.TestCase): - - """Tests for the Address class.""" - - def test_from_string(self): - addr = account.Address.from_string('user@example.com') - self.assertEqual(addr.username, 'user') - self.assertEqual(addr.domainname, 'example.com') - - def test_str(self): - addr = account.Address('ušer', 'example.com') - self.assertEqual(str(addr), 'ušer@example.com') - - def test_eq_unicode(self): - addr = account.Address('ušer', 'example.com') - self.assertEqual(addr, 'ušer@example.com') - - def test_eq_address(self): - addr = account.Address('ušer', 'example.com') - addr2 = account.Address('ušer', 'example.com') - self.assertEqual(addr, addr2) - - def test_ne_unicode(self): - addr = account.Address('ušer', 'example.com') - self.assertNotEqual(addr, 'user@example.com') - - def test_ne_address(self): - addr = account.Address('ušer', 'example.com') - addr2 = account.Address('user', 'example.com') - self.assertNotEqual(addr, addr2) - - def test_eq_unicode_case(self): - addr = account.Address('UŠer', 'example.com') - self.assertEqual(addr, 'ušer@example.com') - - def test_ne_unicode_case(self): - addr = account.Address('ušer', 'example.com') - self.assertEqual(addr, 'uŠer@example.com') - - def test_ne_address_case(self): - addr = account.Address('ušer', 'example.com') - addr2 = account.Address('uŠer', 'example.com') - self.assertEqual(addr, addr2) - - def test_eq_address_case(self): - addr = account.Address('UŠer', 'example.com') - addr2 = account.Address('ušer', 'example.com') - self.assertEqual(addr, addr2) - - def test_eq_unicode_case_sensitive(self): - addr = account.Address('UŠer', 'example.com', case_sensitive=True) - self.assertNotEqual(addr, 'ušer@example.com') - - def test_eq_address_case_sensitive(self): - addr = account.Address('UŠer', 'example.com', case_sensitive=True) - addr2 = account.Address('ušer', 'example.com') - self.assertNotEqual(addr, addr2) - - def test_eq_str(self): - addr = account.Address('user', 'example.com', case_sensitive=True) - with self.assertRaises(TypeError): - addr == 1 # pylint: disable=pointless-statement - - def test_ne_str(self): - addr = account.Address('user', 'example.com', case_sensitive=True) - with self.assertRaises(TypeError): - addr != 1 # pylint: disable=pointless-statement - - def test_repr(self): - addr = account.Address('user', 'example.com', case_sensitive=True) - self.assertEqual( - repr(addr), - "Address('user', 'example.com', case_sensitive=True)") - - def test_domain_name_ne(self): - addr = account.Address('user', 'example.com') - self.assertNotEqual(addr, 'user@example.org') - - def test_domain_name_eq_case(self): - addr = account.Address('user', 'example.com') - self.assertEqual(addr, 'user@Example.com') - - def test_domain_name_ne_unicode(self): - addr = account.Address('user', 'éxample.com') - self.assertNotEqual(addr, 'user@example.com') - - def test_domain_name_eq_unicode(self): - addr = account.Address('user', 'éxample.com') - self.assertEqual(addr, 'user@Éxample.com') - - def test_domain_name_eq_case_sensitive(self): - addr = account.Address('user', 'example.com', case_sensitive=True) - self.assertEqual(addr, 'user@Example.com') - - def test_domain_name_eq_unicode_sensitive(self): - addr = account.Address('user', 'éxample.com', case_sensitive=True) - self.assertEqual(addr, 'user@Éxample.com') - - def test_cmp_empty(self): - addr = account.Address('user', 'éxample.com') - self.assertNotEqual(addr, '') - - -class TestSend(unittest.TestCase): - - @utilities.async_test - async def test_logs_on_success(self): - a = account.SendmailAccount(address="test@alot.dev", cmd="true") - with self.assertLogs() as cm: - await a.send_mail("some text") - #self.assertIn(cm.output, "sent mail successfullya") - self.assertIn("INFO:root:sent mail successfully", cm.output) - - @utilities.async_test - async def test_failing_sendmail_command_is_noticed(self): - a = account.SendmailAccount(address="test@alot.dev", cmd="false") - with self.assertRaises(account.SendingMailFailed): - with self.assertLogs(level=logging.ERROR): - await a.send_mail("some text") diff --git a/tests/addressbook/abook_test.py b/tests/addressbook/abook_test.py deleted file mode 100644 index 13452fb3..00000000 --- a/tests/addressbook/abook_test.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (C) 2017 Lucas Hoffmann -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file -import os -import tempfile -import unittest - -from alot.addressbook import abook -from alot.settings.errors import ConfigError - - -class TestAbookAddressBook(unittest.TestCase): - - def test_abook_file_can_not_be_empty(self): - with self.assertRaises(ConfigError): - abook.AbookAddressBook("/dev/null") - - def test_get_contacts_lists_all_emails(self): - data = """ - [format] - version = unknown - program = alot-test-suite - [1] - name = me - email = me@example.com - [2] - name = you - email = you@other.domain, you@example.com - """ - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as tmp: - tmp.write(data) - path = tmp.name - self.addCleanup(os.unlink, path) - addressbook = abook.AbookAddressBook(path) - actual = addressbook.get_contacts() - expected = [('me', 'me@example.com'), ('you', 'you@other.domain'), - ('you', 'you@example.com')] - self.assertListEqual(actual, expected) diff --git a/tests/addressbook/external_test.py b/tests/addressbook/external_test.py deleted file mode 100644 index ca76cc50..00000000 --- a/tests/addressbook/external_test.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (C) 2017 Lucas Hoffmann -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file -import unittest - -import mock - -from alot.addressbook import external - - -class TestExternalAddressbookGetContacts(unittest.TestCase): - - """Some test cases for - alot.addressbook.external.ExternalAddressbook.get_contacts""" - - regex = '(?P.*)\t(?P.*)' - - @staticmethod - def _patch_call_cmd(return_value): - return mock.patch('alot.addressbook.external.call_cmd', - mock.Mock(return_value=return_value)) - - def test_raises_if_external_command_exits_with_non_zero_status(self): - abook = external.ExternalAddressbook('foobar', '') - with self._patch_call_cmd(('', '', 42)): - with self.assertRaises(external.AddressbookError) as contextmgr: - abook.get_contacts() - expected = u'abook command "foobar" returned with return code 42' - self.assertEqual(contextmgr.exception.args[0], expected) - - def test_stderr_of_failing_command_is_part_of_exception_message(self): - stderr = 'some text printed on stderr of external command' - abook = external.ExternalAddressbook('foobar', '') - with self._patch_call_cmd(('', stderr, 42)): - with self.assertRaises(external.AddressbookError) as contextmgr: - abook.get_contacts() - self.assertIn(stderr, contextmgr.exception.args[0]) - - def test_returns_empty_list_when_command_returns_no_output(self): - abook = external.ExternalAddressbook('foobar', self.regex) - with self._patch_call_cmd(('', '', 0)) as call_cmd: - actual = abook.get_contacts() - self.assertListEqual(actual, []) - call_cmd.assert_called_once_with(['foobar']) - - def test_splits_results_from_provider_by_regex(self): - abook = external.ExternalAddressbook('foobar', self.regex) - with self._patch_call_cmd( - ('me\t\nyou\t', '', 0)): - actual = abook.get_contacts() - expected = [('me', ''), ('you', '')] - self.assertListEqual(actual, expected) - - def test_returns_empty_list_if_regex_has_no_name_submatches(self): - abook = external.ExternalAddressbook( - 'foobar', self.regex.replace('name', 'xname')) - with self._patch_call_cmd( - ('me\t\nyou\t', '', 0)): - actual = abook.get_contacts() - self.assertListEqual(actual, []) - - def test_returns_empty_list_if_regex_has_no_email_submatches(self): - abook = external.ExternalAddressbook( - 'foobar', self.regex.replace('email', 'xemail')) - with self._patch_call_cmd( - ('me\t\nyou\t', '', 0)): - actual = abook.get_contacts() - self.assertListEqual(actual, []) diff --git a/tests/addressbook/init_test.py b/tests/addressbook/init_test.py deleted file mode 100644 index d8f96af2..00000000 --- a/tests/addressbook/init_test.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright (C) 2017 Lucas Hoffmann -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file -import unittest - -from alot import addressbook - - -class _AddressBook(addressbook.AddressBook): - - """Implements stubs for ABC methods. The return value for get_contacts can - be set on instance creation.""" - - def __init__(self, contacts, **kwargs): - self._contacts = contacts - super(_AddressBook, self).__init__(**kwargs) - - def get_contacts(self): - return self._contacts - - -class TestAddressBook(unittest.TestCase): - - def test_lookup_will_match_names(self): - contacts = [('foo', 'x@example.com'), ('bar', 'y@example.com'), - ('baz', 'z@example.com')] - abook = _AddressBook(contacts) - actual = abook.lookup('bar') - expected = [contacts[1]] - self.assertListEqual(actual, expected) - - def test_lookup_will_match_emails(self): - contacts = [('foo', 'x@example.com'), ('bar', 'y@example.com'), - ('baz', 'z@example.com')] - abook = _AddressBook(contacts) - actual = abook.lookup('y@example.com') - expected = [contacts[1]] - self.assertListEqual(actual, expected) - - def test_lookup_ignores_case_by_default(self): - contacts = [('name', 'email@example.com'), - ('Name', 'other@example.com'), - ('someone', 'someone@example.com')] - abook = _AddressBook(contacts) - actual = abook.lookup('name') - expected = [contacts[0], contacts[1]] - self.assertListEqual(actual, expected) - - def test_lookup_can_match_case(self): - contacts = [('name', 'email@example.com'), - ('Name', 'other@example.com'), - ('someone', 'someone@example.com')] - abook = _AddressBook(contacts, ignorecase=False) - actual = abook.lookup('name') - expected = [contacts[0]] - self.assertListEqual(actual, expected) - - def test_lookup_will_match_partial_in_the_middle(self): - contacts = [('name', 'email@example.com'), - ('My Own Name', 'other@example.com'), - ('someone', 'someone@example.com')] - abook = _AddressBook(contacts) - actual = abook.lookup('Own') - expected = [contacts[1]] - self.assertListEqual(actual, expected) - - def test_lookup_can_handle_special_regex_chars(self): - contacts = [('name [work]', 'email@example.com'), - ('My Own Name', 'other@example.com'), - ('someone', 'someone@example.com')] - abook = _AddressBook(contacts) - actual = abook.lookup('[wor') - expected = [contacts[0]] - self.assertListEqual(actual, expected) diff --git a/tests/addressbook/test_abook.py b/tests/addressbook/test_abook.py new file mode 100644 index 00000000..13452fb3 --- /dev/null +++ b/tests/addressbook/test_abook.py @@ -0,0 +1,38 @@ +# Copyright (C) 2017 Lucas Hoffmann +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +import os +import tempfile +import unittest + +from alot.addressbook import abook +from alot.settings.errors import ConfigError + + +class TestAbookAddressBook(unittest.TestCase): + + def test_abook_file_can_not_be_empty(self): + with self.assertRaises(ConfigError): + abook.AbookAddressBook("/dev/null") + + def test_get_contacts_lists_all_emails(self): + data = """ + [format] + version = unknown + program = alot-test-suite + [1] + name = me + email = me@example.com + [2] + name = you + email = you@other.domain, you@example.com + """ + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as tmp: + tmp.write(data) + path = tmp.name + self.addCleanup(os.unlink, path) + addressbook = abook.AbookAddressBook(path) + actual = addressbook.get_contacts() + expected = [('me', 'me@example.com'), ('you', 'you@other.domain'), + ('you', 'you@example.com')] + self.assertListEqual(actual, expected) diff --git a/tests/addressbook/test_external.py b/tests/addressbook/test_external.py new file mode 100644 index 00000000..ca76cc50 --- /dev/null +++ b/tests/addressbook/test_external.py @@ -0,0 +1,68 @@ +# Copyright (C) 2017 Lucas Hoffmann +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +import unittest + +import mock + +from alot.addressbook import external + + +class TestExternalAddressbookGetContacts(unittest.TestCase): + + """Some test cases for + alot.addressbook.external.ExternalAddressbook.get_contacts""" + + regex = '(?P.*)\t(?P.*)' + + @staticmethod + def _patch_call_cmd(return_value): + return mock.patch('alot.addressbook.external.call_cmd', + mock.Mock(return_value=return_value)) + + def test_raises_if_external_command_exits_with_non_zero_status(self): + abook = external.ExternalAddressbook('foobar', '') + with self._patch_call_cmd(('', '', 42)): + with self.assertRaises(external.AddressbookError) as contextmgr: + abook.get_contacts() + expected = u'abook command "foobar" returned with return code 42' + self.assertEqual(contextmgr.exception.args[0], expected) + + def test_stderr_of_failing_command_is_part_of_exception_message(self): + stderr = 'some text printed on stderr of external command' + abook = external.ExternalAddressbook('foobar', '') + with self._patch_call_cmd(('', stderr, 42)): + with self.assertRaises(external.AddressbookError) as contextmgr: + abook.get_contacts() + self.assertIn(stderr, contextmgr.exception.args[0]) + + def test_returns_empty_list_when_command_returns_no_output(self): + abook = external.ExternalAddressbook('foobar', self.regex) + with self._patch_call_cmd(('', '', 0)) as call_cmd: + actual = abook.get_contacts() + self.assertListEqual(actual, []) + call_cmd.assert_called_once_with(['foobar']) + + def test_splits_results_from_provider_by_regex(self): + abook = external.ExternalAddressbook('foobar', self.regex) + with self._patch_call_cmd( + ('me\t\nyou\t', '', 0)): + actual = abook.get_contacts() + expected = [('me', ''), ('you', '')] + self.assertListEqual(actual, expected) + + def test_returns_empty_list_if_regex_has_no_name_submatches(self): + abook = external.ExternalAddressbook( + 'foobar', self.regex.replace('name', 'xname')) + with self._patch_call_cmd( + ('me\t\nyou\t', '', 0)): + actual = abook.get_contacts() + self.assertListEqual(actual, []) + + def test_returns_empty_list_if_regex_has_no_email_submatches(self): + abook = external.ExternalAddressbook( + 'foobar', self.regex.replace('email', 'xemail')) + with self._patch_call_cmd( + ('me\t\nyou\t', '', 0)): + actual = abook.get_contacts() + self.assertListEqual(actual, []) diff --git a/tests/addressbook/test_init.py b/tests/addressbook/test_init.py new file mode 100644 index 00000000..d8f96af2 --- /dev/null +++ b/tests/addressbook/test_init.py @@ -0,0 +1,74 @@ +# Copyright (C) 2017 Lucas Hoffmann +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +import unittest + +from alot import addressbook + + +class _AddressBook(addressbook.AddressBook): + + """Implements stubs for ABC methods. The return value for get_contacts can + be set on instance creation.""" + + def __init__(self, contacts, **kwargs): + self._contacts = contacts + super(_AddressBook, self).__init__(**kwargs) + + def get_contacts(self): + return self._contacts + + +class TestAddressBook(unittest.TestCase): + + def test_lookup_will_match_names(self): + contacts = [('foo', 'x@example.com'), ('bar', 'y@example.com'), + ('baz', 'z@example.com')] + abook = _AddressBook(contacts) + actual = abook.lookup('bar') + expected = [contacts[1]] + self.assertListEqual(actual, expected) + + def test_lookup_will_match_emails(self): + contacts = [('foo', 'x@example.com'), ('bar', 'y@example.com'), + ('baz', 'z@example.com')] + abook = _AddressBook(contacts) + actual = abook.lookup('y@example.com') + expected = [contacts[1]] + self.assertListEqual(actual, expected) + + def test_lookup_ignores_case_by_default(self): + contacts = [('name', 'email@example.com'), + ('Name', 'other@example.com'), + ('someone', 'someone@example.com')] + abook = _AddressBook(contacts) + actual = abook.lookup('name') + expected = [contacts[0], contacts[1]] + self.assertListEqual(actual, expected) + + def test_lookup_can_match_case(self): + contacts = [('name', 'email@example.com'), + ('Name', 'other@example.com'), + ('someone', 'someone@example.com')] + abook = _AddressBook(contacts, ignorecase=False) + actual = abook.lookup('name') + expected = [contacts[0]] + self.assertListEqual(actual, expected) + + def test_lookup_will_match_partial_in_the_middle(self): + contacts = [('name', 'email@example.com'), + ('My Own Name', 'other@example.com'), + ('someone', 'someone@example.com')] + abook = _AddressBook(contacts) + actual = abook.lookup('Own') + expected = [contacts[1]] + self.assertListEqual(actual, expected) + + def test_lookup_can_handle_special_regex_chars(self): + contacts = [('name [work]', 'email@example.com'), + ('My Own Name', 'other@example.com'), + ('someone', 'someone@example.com')] + abook = _AddressBook(contacts) + actual = abook.lookup('[wor') + expected = [contacts[0]] + self.assertListEqual(actual, expected) diff --git a/tests/commands/envelope_test.py b/tests/commands/envelope_test.py deleted file mode 100644 index 76aa7e88..00000000 --- a/tests/commands/envelope_test.py +++ /dev/null @@ -1,386 +0,0 @@ -# encoding=utf-8 -# Copyright © 2017-2018 Dylan Baker - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Tests for the alot.commands.envelope module.""" - -import email -import os -import tempfile -import textwrap -import unittest - -import mock - -from alot.commands import envelope -from alot.db.envelope import Envelope -from alot.errors import GPGProblem -from alot.settings.errors import NoMatchingAccount -from alot.settings.manager import SettingsManager -from alot.account import Account - -from .. import utilities - -# When using an assert from a mock a TestCase method might not use self. That's -# okay. -# pylint: disable=no-self-use - - -class TestAttachCommand(unittest.TestCase): - """Tests for the AttachCommaned class.""" - - def test_single_path(self): - """A test for an existing single path.""" - ui = utilities.make_ui() - - with tempfile.TemporaryDirectory() as d: - testfile = os.path.join(d, 'foo') - with open(testfile, 'w') as f: - f.write('foo') - - cmd = envelope.AttachCommand(path=testfile) - cmd.apply(ui) - ui.current_buffer.envelope.attach.assert_called_with(testfile) - - def test_user(self): - """A test for an existing single path prefaced with ~/.""" - ui = utilities.make_ui() - - with tempfile.TemporaryDirectory() as d: - # This mock replaces expanduser to replace "~/" with a path to the - # temporary directory. This is easier and more reliable than - # relying on changing an environment variable (like HOME), since it - # doesn't rely on CPython implementation details. - with mock.patch('alot.commands.os.path.expanduser', - lambda x: os.path.join(d, x[2:])): - testfile = os.path.join(d, 'foo') - with open(testfile, 'w') as f: - f.write('foo') - - cmd = envelope.AttachCommand(path='~/foo') - cmd.apply(ui) - ui.current_buffer.envelope.attach.assert_called_with(testfile) - - def test_glob(self): - """A test using a glob.""" - ui = utilities.make_ui() - - with tempfile.TemporaryDirectory() as d: - testfile1 = os.path.join(d, 'foo') - testfile2 = os.path.join(d, 'far') - for t in [testfile1, testfile2]: - with open(t, 'w') as f: - f.write('foo') - - cmd = envelope.AttachCommand(path=os.path.join(d, '*')) - cmd.apply(ui) - ui.current_buffer.envelope.attach.assert_has_calls( - [mock.call(testfile1), mock.call(testfile2)], any_order=True) - - def test_no_match(self): - """A test for a file that doesn't exist.""" - ui = utilities.make_ui() - - with tempfile.TemporaryDirectory() as d: - cmd = envelope.AttachCommand(path=os.path.join(d, 'doesnt-exist')) - cmd.apply(ui) - ui.notify.assert_called() - - -class TestTagCommands(unittest.TestCase): - - def _test(self, tagstring, action, expected): - """Common steps for envelope.TagCommand tests - - :param tagstring: the string to pass to the TagCommand - :type tagstring: str - :param action: the action to pass to the TagCommand - :type action: str - :param expected: the expected output to assert in the test - :type expected: list(str) - """ - env = Envelope(tags=['one', 'two', 'three']) - ui = utilities.make_ui() - ui.current_buffer = mock.Mock() - ui.current_buffer.envelope = env - cmd = envelope.TagCommand(tags=tagstring, action=action) - cmd.apply(ui) - actual = env.tags - self.assertListEqual(sorted(actual), sorted(expected)) - - def test_add_new_tags(self): - self._test(u'four', 'add', ['one', 'two', 'three', 'four']) - - def test_adding_existing_tags_has_no_effect(self): - self._test(u'one', 'add', ['one', 'two', 'three']) - - def test_remove_existing_tags(self): - self._test(u'one', 'remove', ['two', 'three']) - - def test_remove_non_existing_tags_has_no_effect(self): - self._test(u'four', 'remove', ['one', 'two', 'three']) - - def test_set_tags(self): - self._test(u'a,b,c', 'set', ['a', 'b', 'c']) - - def test_toggle_will_remove_existing_tags(self): - self._test(u'one', 'toggle', ['two', 'three']) - - def test_toggle_will_add_new_tags(self): - self._test(u'four', 'toggle', ['one', 'two', 'three', 'four']) - - def test_toggle_can_remove_and_add_in_one_run(self): - self._test(u'one,four', 'toggle', ['two', 'three', 'four']) - - -class TestSignCommand(unittest.TestCase): - - """Tests for the SignCommand class.""" - - @staticmethod - def _make_ui_mock(): - """Create a mock for the ui and envelope and return them.""" - envelope = Envelope() - envelope['From'] = 'foo ' - envelope.sign = mock.sentinel.default - envelope.sign_key = mock.sentinel.default - ui = utilities.make_ui(current_buffer=mock.Mock(envelope=envelope)) - return envelope, ui - - @mock.patch('alot.commands.envelope.crypto.get_key', - mock.Mock(return_value=mock.sentinel.keyid)) - def test_apply_keyid_success(self): - """If there is a valid keyid then key and to sign should be set. - """ - env, ui = self._make_ui_mock() - # The actual keyid doesn't matter, since it'll be mocked anyway - cmd = envelope.SignCommand(action='sign', keyid=['a']) - cmd.apply(ui) - - self.assertTrue(env.sign) - self.assertEqual(env.sign_key, mock.sentinel.keyid) - - @mock.patch('alot.commands.envelope.crypto.get_key', - mock.Mock(side_effect=GPGProblem('sentinel', 0))) - def test_apply_keyid_gpgproblem(self): - """If there is an invalid keyid then the signing key and to sign should - be set to false and default. - """ - env, ui = self._make_ui_mock() - # The actual keyid doesn't matter, since it'll be mocked anyway - cmd = envelope.SignCommand(action='sign', keyid=['a']) - cmd.apply(ui) - self.assertFalse(env.sign) - self.assertEqual(env.sign_key, mock.sentinel.default) - ui.notify.assert_called_once() - - @mock.patch('alot.commands.envelope.settings.account_matching_address', - mock.Mock(side_effect=NoMatchingAccount)) - def test_apply_no_keyid_nomatchingaccount(self): - """If there is a nokeyid and no account can be found to match the From, - then the envelope should not be marked to sign. - """ - env, ui = self._make_ui_mock() - # The actual keyid doesn't matter, since it'll be mocked anyway - cmd = envelope.SignCommand(action='sign', keyid=None) - cmd.apply(ui) - - self.assertFalse(env.sign) - self.assertEqual(env.sign_key, mock.sentinel.default) - ui.notify.assert_called_once() - - def test_apply_no_keyid_no_gpg_key(self): - """If there is a nokeyid and the account has no gpg key then the - signing key and to sign should be set to false and default. - """ - env, ui = self._make_ui_mock() - env.account = mock.Mock(gpg_key=None) - - cmd = envelope.SignCommand(action='sign', keyid=None) - cmd.apply(ui) - - self.assertFalse(env.sign) - self.assertEqual(env.sign_key, mock.sentinel.default) - ui.notify.assert_called_once() - - def test_apply_no_keyid_default(self): - """If there is no keyid and the account has a gpg key, then that should - be used. - """ - env, ui = self._make_ui_mock() - env.account = mock.Mock(gpg_key='sentinel') - - cmd = envelope.SignCommand(action='sign', keyid=None) - cmd.apply(ui) - - self.assertTrue(env.sign) - self.assertEqual(env.sign_key, 'sentinel') - - @mock.patch('alot.commands.envelope.crypto.get_key', - mock.Mock(return_value=mock.sentinel.keyid)) - def test_apply_no_sign(self): - """If signing with a valid keyid and valid key then set sign and - sign_key. - """ - env, ui = self._make_ui_mock() - # The actual keyid doesn't matter, since it'll be mocked anyway - cmd = envelope.SignCommand(action='sign', keyid=['a']) - cmd.apply(ui) - - self.assertTrue(env.sign) - self.assertEqual(env.sign_key, mock.sentinel.keyid) - - @mock.patch('alot.commands.envelope.crypto.get_key', - mock.Mock(return_value=mock.sentinel.keyid)) - def test_apply_unsign(self): - """Test that settingun sign sets the sign to False if all other - conditions allow for it. - """ - env, ui = self._make_ui_mock() - env.sign = True - env.sign_key = mock.sentinel - # The actual keyid doesn't matter, since it'll be mocked anyway - cmd = envelope.SignCommand(action='unsign', keyid=['a']) - cmd.apply(ui) - - self.assertFalse(env.sign) - self.assertIs(env.sign_key, None) - - @mock.patch('alot.commands.envelope.crypto.get_key', - mock.Mock(return_value=mock.sentinel.keyid)) - def test_apply_togglesign(self): - """Test that toggling changes the sign and sign_key as approriate if - other condtiions allow for it - """ - env, ui = self._make_ui_mock() - env.sign = True - env.sign_key = mock.sentinel.keyid - - # The actual keyid doesn't matter, since it'll be mocked anyway - # Test that togling from true to false works - cmd = envelope.SignCommand(action='toggle', keyid=['a']) - cmd.apply(ui) - self.assertFalse(env.sign) - self.assertIs(env.sign_key, None) - - # Test that toggling back to True works - cmd.apply(ui) - self.assertTrue(env.sign) - self.assertIs(env.sign_key, mock.sentinel.keyid) - - def _make_local_settings(self): - config = textwrap.dedent("""\ - [accounts] - [[default]] - realname = foo - address = foo@example.com - sendmail_command = /bin/true - """) - - # Allow settings.reload to work by not deleting the file until the end - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(config) - self.addCleanup(os.unlink, f.name) - - # Set the gpg_key separately to avoid validation failures - manager = SettingsManager() - manager.read_config(f.name) - manager.get_accounts()[0].gpg_key = mock.sentinel.gpg_key - return manager - - def test_apply_from_email_only(self): - """Test that a key can be derived using a 'From' header that contains - only an email. - - If the from header is in the form "foo@example.com" and a key exists it - should be used. - """ - manager = self._make_local_settings() - env, ui = self._make_ui_mock() - env.headers = {'From': ['foo@example.com']} - - cmd = envelope.SignCommand(action='sign') - with mock.patch('alot.commands.envelope.settings', manager): - cmd.apply(ui) - - self.assertTrue(env.sign) - self.assertIs(env.sign_key, mock.sentinel.gpg_key) - - def test_apply_from_user_and_email(self): - """This tests that a gpg key can be derived using a 'From' header that - contains a realname-email combo. - - If the header is in the form "Foo ", a key should be - derived. - - See issue #1113 - """ - manager = self._make_local_settings() - env, ui = self._make_ui_mock() - - cmd = envelope.SignCommand(action='sign') - with mock.patch('alot.commands.envelope.settings', manager): - cmd.apply(ui) - - self.assertTrue(env.sign) - self.assertIs(env.sign_key, mock.sentinel.gpg_key) - - -class TestSendCommand(unittest.TestCase): - - """Tests for the SendCommand class.""" - - mail = textwrap.dedent("""\ - From: foo@example.com - To: bar@example.com - Subject: FooBar - - Foo Bar Baz - """) - - class MockedAccount(Account): - - def __init__(self): - super().__init__('foo@example.com') - - async def send_mail(self, mail): - pass - - @utilities.async_test - async def test_account_matching_address_with_str(self): - cmd = envelope.SendCommand(mail=self.mail) - account = mock.Mock(wraps=self.MockedAccount()) - with mock.patch( - 'alot.commands.envelope.settings.account_matching_address', - mock.Mock(return_value=account)) as account_matching_address: - await cmd.apply(mock.Mock()) - account_matching_address.assert_called_once_with('foo@example.com', - return_default=True) - # check that the apply did run through till the end. - account.send_mail.assert_called_once_with(self.mail) - - @utilities.async_test - async def test_account_matching_address_with_email_message(self): - mail = email.message_from_string(self.mail) - cmd = envelope.SendCommand(mail=mail) - account = mock.Mock(wraps=self.MockedAccount()) - with mock.patch( - 'alot.commands.envelope.settings.account_matching_address', - mock.Mock(return_value=account)) as account_matching_address: - await cmd.apply(mock.Mock()) - account_matching_address.assert_called_once_with('foo@example.com', - return_default=True) - # check that the apply did run through till the end. - account.send_mail.assert_called_once_with(mail) diff --git a/tests/commands/global_test.py b/tests/commands/global_test.py deleted file mode 100644 index 9b026e6a..00000000 --- a/tests/commands/global_test.py +++ /dev/null @@ -1,246 +0,0 @@ -# encoding=utf-8 -# Copyright © 2017-2018 Dylan Baker -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Tests for global commands.""" - -import logging -import os -import tempfile -import unittest - -import mock - -from alot.commands import globals as g_commands - -from .. import utilities - - -class Stop(Exception): - """exception for stopping testing of giant unmanagable functions.""" - pass - - -class TestComposeCommand(unittest.TestCase): - - """Tests for the compose command.""" - - @staticmethod - def _make_envelope_mock(): - envelope = mock.Mock() - envelope.headers = {'From': 'foo '} - envelope.get = envelope.headers.get - envelope.sign_key = None - envelope.sign = False - return envelope - - @staticmethod - def _make_account_mock( - sign_by_default=True, gpg_key=mock.sentinel.gpg_key): - account = mock.Mock() - account.sign_by_default = sign_by_default - account.gpg_key = gpg_key - account.signature = None - return account - - @utilities.async_test - async def test_apply_sign_by_default_okay(self): - envelope = self._make_envelope_mock() - envelope.account = self._make_account_mock() - cmd = g_commands.ComposeCommand(envelope=envelope) - - with mock.patch('alot.commands.globals.settings.get_addressbooks', - mock.Mock(side_effect=Stop)): - try: - await cmd.apply(mock.Mock()) - except Stop: - pass - - self.assertTrue(envelope.sign) - self.assertIs(envelope.sign_key, mock.sentinel.gpg_key) - - @utilities.async_test - async def test_apply_sign_by_default_false_doesnt_set_key(self): - envelope = self._make_envelope_mock() - envelope.account = self._make_account_mock(sign_by_default=False) - cmd = g_commands.ComposeCommand(envelope=envelope) - - with mock.patch('alot.commands.globals.settings.get_addressbooks', - mock.Mock(side_effect=Stop)): - try: - await cmd.apply(mock.Mock()) - except Stop: - pass - - self.assertFalse(envelope.sign) - self.assertIs(envelope.sign_key, None) - - @utilities.async_test - async def test_apply_sign_by_default_but_no_key(self): - envelope = self._make_envelope_mock() - envelope.account = self._make_account_mock(gpg_key=None) - cmd = g_commands.ComposeCommand(envelope=envelope) - - with mock.patch('alot.commands.globals.settings.get_addressbooks', - mock.Mock(side_effect=Stop)): - try: - await cmd.apply(mock.Mock()) - except Stop: - pass - - self.assertFalse(envelope.sign) - self.assertIs(envelope.sign_key, None) - - @utilities.async_test - async def test_decode_template_on_loading(self): - subject = u'This is a täßϑ subject.' - to = u'recipient@mail.com' - _from = u'foo.bar@mail.fr' - body = u'Body\n地初店会継思識棋御招告外児山望掲領環。\n€mail body €nd.' - with tempfile.NamedTemporaryFile('wb', delete=False) as f: - txt = u'Subject: {}\nTo: {}\nFrom: {}\n{}'.format(subject, to, - _from, body) - f.write(txt.encode('utf-8')) - self.addCleanup(os.unlink, f.name) - - cmd = g_commands.ComposeCommand(template=f.name) - - # Crutch to exit the giant `apply` method early. - with mock.patch( - 'alot.commands.globals.settings.get_accounts', - mock.Mock(side_effect=Stop)): - try: - await cmd.apply(mock.Mock()) - except Stop: - pass - - self.assertEqual({'To': [to], - 'From': [_from], - 'Subject': [subject]}, cmd.envelope.headers) - self.assertEqual(body, cmd.envelope.body) - - @utilities.async_test - async def test_single_account_no_from(self): - # issue #1277 - envelope = self._make_envelope_mock() - del envelope.headers['From'] - envelope.account = self._make_account_mock() - envelope.account.realname = "foo" - envelope.account.address = 1 # maybe this should be a real Address? - cmd = g_commands.ComposeCommand(envelope=envelope) - - with mock.patch('alot.commands.globals.settings.get_addressbooks', - mock.Mock(side_effect=Stop)): - try: - await cmd.apply(mock.Mock()) - except Stop: - pass - - -class TestExternalCommand(unittest.TestCase): - - @utilities.async_test - async def test_no_spawn_no_stdin_success(self): - ui = utilities.make_ui() - cmd = g_commands.ExternalCommand(u'true', refocus=False) - await cmd.apply(ui) - ui.notify.assert_not_called() - - @utilities.async_test - async def test_no_spawn_stdin_success(self): - ui = utilities.make_ui() - cmd = g_commands.ExternalCommand(u"awk '{ exit $0 }'", stdin=u'0', - refocus=False) - await cmd.apply(ui) - ui.notify.assert_not_called() - - @utilities.async_test - async def test_no_spawn_no_stdin_attached(self): - ui = utilities.make_ui() - cmd = g_commands.ExternalCommand(u'test -t 0', refocus=False) - await cmd.apply(ui) - ui.notify.assert_not_called() - - @utilities.async_test - async def test_no_spawn_stdin_attached(self): - ui = utilities.make_ui() - cmd = g_commands.ExternalCommand( - u"test -t 0", stdin=u'0', refocus=False) - await cmd.apply(ui) - ui.notify.assert_called_once_with('', priority='error') - - @utilities.async_test - async def test_no_spawn_failure(self): - ui = utilities.make_ui() - cmd = g_commands.ExternalCommand(u'false', refocus=False) - await cmd.apply(ui) - ui.notify.assert_called_once_with('', priority='error') - - @utilities.async_test - @mock.patch( - 'alot.commands.globals.settings.get', mock.Mock(return_value='')) - @mock.patch.dict(os.environ, {'DISPLAY': ':0'}) - async def test_spawn_no_stdin_success(self): - ui = utilities.make_ui() - cmd = g_commands.ExternalCommand(u'true', refocus=False, spawn=True) - await cmd.apply(ui) - ui.notify.assert_not_called() - - @utilities.async_test - @mock.patch( - 'alot.commands.globals.settings.get', mock.Mock(return_value='')) - @mock.patch.dict(os.environ, {'DISPLAY': ':0'}) - async def test_spawn_stdin_success(self): - ui = utilities.make_ui() - cmd = g_commands.ExternalCommand( - u"awk '{ exit $0 }'", - stdin=u'0', refocus=False, spawn=True) - await cmd.apply(ui) - ui.notify.assert_not_called() - - @utilities.async_test - @mock.patch( - 'alot.commands.globals.settings.get', mock.Mock(return_value='')) - @mock.patch.dict(os.environ, {'DISPLAY': ':0'}) - async def test_spawn_failure(self): - ui = utilities.make_ui() - cmd = g_commands.ExternalCommand(u'false', refocus=False, spawn=True) - await cmd.apply(ui) - ui.notify.assert_called_once_with('', priority='error') - - -class TestCallCommand(unittest.TestCase): - - @utilities.async_test - async def test_synchronous_call(self): - ui = mock.Mock() - cmd = g_commands.CallCommand('ui()') - await cmd.apply(ui) - ui.assert_called_once() - - @utilities.async_test - async def test_async_call(self): - async def func(obj): - obj() - - ui = mock.Mock() - hooks = mock.Mock() - hooks.ui = None - hooks.func = func - - with mock.patch('alot.commands.globals.settings.hooks', hooks): - cmd = g_commands.CallCommand('hooks.func(ui)') - await cmd.apply(ui) - ui.assert_called_once() diff --git a/tests/commands/init_test.py b/tests/commands/init_test.py deleted file mode 100644 index a2b358ff..00000000 --- a/tests/commands/init_test.py +++ /dev/null @@ -1,50 +0,0 @@ -# encoding=utf-8 - -"""Test suite for alot.commands.__init__ module.""" - -import argparse -import unittest - -import mock - -from alot import commands -from alot.commands import thread - -# Good descriptive test names often don't fit PEP8, which is meant to cover -# functions meant to be called by humans. -# pylint: disable=invalid-name - -# These are tests, don't worry about names like "foo" and "bar" -# pylint: disable=blacklisted-name - - -class TestLookupCommand(unittest.TestCase): - - def test_look_up_save_attachment_command_in_thread_mode(self): - cmd, parser, kwargs = commands.lookup_command('save', 'thread') - # TODO do some more tests with these return values - self.assertEqual(cmd, thread.SaveAttachmentCommand) - self.assertIsInstance(parser, argparse.ArgumentParser) - self.assertDictEqual(kwargs, {}) - - -class TestCommandFactory(unittest.TestCase): - - def test_create_save_attachment_command_with_arguments(self): - cmd = commands.commandfactory('save --all /foo', mode='thread') - self.assertIsInstance(cmd, thread.SaveAttachmentCommand) - self.assertTrue(cmd.all) - self.assertEqual(cmd.path, u'/foo') - - -class TestRegisterCommand(unittest.TestCase): - """Tests for the registerCommand class.""" - - def test_registered(self): - """using registerCommand adds to the COMMANDS dict.""" - with mock.patch('alot.commands.COMMANDS', {'foo': {}}): - @commands.registerCommand('foo', 'test') - def foo(): # pylint: disable=unused-variable - pass - - self.assertIn('test', commands.COMMANDS['foo']) diff --git a/tests/commands/test_envelope.py b/tests/commands/test_envelope.py new file mode 100644 index 00000000..76aa7e88 --- /dev/null +++ b/tests/commands/test_envelope.py @@ -0,0 +1,386 @@ +# encoding=utf-8 +# Copyright © 2017-2018 Dylan Baker + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for the alot.commands.envelope module.""" + +import email +import os +import tempfile +import textwrap +import unittest + +import mock + +from alot.commands import envelope +from alot.db.envelope import Envelope +from alot.errors import GPGProblem +from alot.settings.errors import NoMatchingAccount +from alot.settings.manager import SettingsManager +from alot.account import Account + +from .. import utilities + +# When using an assert from a mock a TestCase method might not use self. That's +# okay. +# pylint: disable=no-self-use + + +class TestAttachCommand(unittest.TestCase): + """Tests for the AttachCommaned class.""" + + def test_single_path(self): + """A test for an existing single path.""" + ui = utilities.make_ui() + + with tempfile.TemporaryDirectory() as d: + testfile = os.path.join(d, 'foo') + with open(testfile, 'w') as f: + f.write('foo') + + cmd = envelope.AttachCommand(path=testfile) + cmd.apply(ui) + ui.current_buffer.envelope.attach.assert_called_with(testfile) + + def test_user(self): + """A test for an existing single path prefaced with ~/.""" + ui = utilities.make_ui() + + with tempfile.TemporaryDirectory() as d: + # This mock replaces expanduser to replace "~/" with a path to the + # temporary directory. This is easier and more reliable than + # relying on changing an environment variable (like HOME), since it + # doesn't rely on CPython implementation details. + with mock.patch('alot.commands.os.path.expanduser', + lambda x: os.path.join(d, x[2:])): + testfile = os.path.join(d, 'foo') + with open(testfile, 'w') as f: + f.write('foo') + + cmd = envelope.AttachCommand(path='~/foo') + cmd.apply(ui) + ui.current_buffer.envelope.attach.assert_called_with(testfile) + + def test_glob(self): + """A test using a glob.""" + ui = utilities.make_ui() + + with tempfile.TemporaryDirectory() as d: + testfile1 = os.path.join(d, 'foo') + testfile2 = os.path.join(d, 'far') + for t in [testfile1, testfile2]: + with open(t, 'w') as f: + f.write('foo') + + cmd = envelope.AttachCommand(path=os.path.join(d, '*')) + cmd.apply(ui) + ui.current_buffer.envelope.attach.assert_has_calls( + [mock.call(testfile1), mock.call(testfile2)], any_order=True) + + def test_no_match(self): + """A test for a file that doesn't exist.""" + ui = utilities.make_ui() + + with tempfile.TemporaryDirectory() as d: + cmd = envelope.AttachCommand(path=os.path.join(d, 'doesnt-exist')) + cmd.apply(ui) + ui.notify.assert_called() + + +class TestTagCommands(unittest.TestCase): + + def _test(self, tagstring, action, expected): + """Common steps for envelope.TagCommand tests + + :param tagstring: the string to pass to the TagCommand + :type tagstring: str + :param action: the action to pass to the TagCommand + :type action: str + :param expected: the expected output to assert in the test + :type expected: list(str) + """ + env = Envelope(tags=['one', 'two', 'three']) + ui = utilities.make_ui() + ui.current_buffer = mock.Mock() + ui.current_buffer.envelope = env + cmd = envelope.TagCommand(tags=tagstring, action=action) + cmd.apply(ui) + actual = env.tags + self.assertListEqual(sorted(actual), sorted(expected)) + + def test_add_new_tags(self): + self._test(u'four', 'add', ['one', 'two', 'three', 'four']) + + def test_adding_existing_tags_has_no_effect(self): + self._test(u'one', 'add', ['one', 'two', 'three']) + + def test_remove_existing_tags(self): + self._test(u'one', 'remove', ['two', 'three']) + + def test_remove_non_existing_tags_has_no_effect(self): + self._test(u'four', 'remove', ['one', 'two', 'three']) + + def test_set_tags(self): + self._test(u'a,b,c', 'set', ['a', 'b', 'c']) + + def test_toggle_will_remove_existing_tags(self): + self._test(u'one', 'toggle', ['two', 'three']) + + def test_toggle_will_add_new_tags(self): + self._test(u'four', 'toggle', ['one', 'two', 'three', 'four']) + + def test_toggle_can_remove_and_add_in_one_run(self): + self._test(u'one,four', 'toggle', ['two', 'three', 'four']) + + +class TestSignCommand(unittest.TestCase): + + """Tests for the SignCommand class.""" + + @staticmethod + def _make_ui_mock(): + """Create a mock for the ui and envelope and return them.""" + envelope = Envelope() + envelope['From'] = 'foo ' + envelope.sign = mock.sentinel.default + envelope.sign_key = mock.sentinel.default + ui = utilities.make_ui(current_buffer=mock.Mock(envelope=envelope)) + return envelope, ui + + @mock.patch('alot.commands.envelope.crypto.get_key', + mock.Mock(return_value=mock.sentinel.keyid)) + def test_apply_keyid_success(self): + """If there is a valid keyid then key and to sign should be set. + """ + env, ui = self._make_ui_mock() + # The actual keyid doesn't matter, since it'll be mocked anyway + cmd = envelope.SignCommand(action='sign', keyid=['a']) + cmd.apply(ui) + + self.assertTrue(env.sign) + self.assertEqual(env.sign_key, mock.sentinel.keyid) + + @mock.patch('alot.commands.envelope.crypto.get_key', + mock.Mock(side_effect=GPGProblem('sentinel', 0))) + def test_apply_keyid_gpgproblem(self): + """If there is an invalid keyid then the signing key and to sign should + be set to false and default. + """ + env, ui = self._make_ui_mock() + # The actual keyid doesn't matter, since it'll be mocked anyway + cmd = envelope.SignCommand(action='sign', keyid=['a']) + cmd.apply(ui) + self.assertFalse(env.sign) + self.assertEqual(env.sign_key, mock.sentinel.default) + ui.notify.assert_called_once() + + @mock.patch('alot.commands.envelope.settings.account_matching_address', + mock.Mock(side_effect=NoMatchingAccount)) + def test_apply_no_keyid_nomatchingaccount(self): + """If there is a nokeyid and no account can be found to match the From, + then the envelope should not be marked to sign. + """ + env, ui = self._make_ui_mock() + # The actual keyid doesn't matter, since it'll be mocked anyway + cmd = envelope.SignCommand(action='sign', keyid=None) + cmd.apply(ui) + + self.assertFalse(env.sign) + self.assertEqual(env.sign_key, mock.sentinel.default) + ui.notify.assert_called_once() + + def test_apply_no_keyid_no_gpg_key(self): + """If there is a nokeyid and the account has no gpg key then the + signing key and to sign should be set to false and default. + """ + env, ui = self._make_ui_mock() + env.account = mock.Mock(gpg_key=None) + + cmd = envelope.SignCommand(action='sign', keyid=None) + cmd.apply(ui) + + self.assertFalse(env.sign) + self.assertEqual(env.sign_key, mock.sentinel.default) + ui.notify.assert_called_once() + + def test_apply_no_keyid_default(self): + """If there is no keyid and the account has a gpg key, then that should + be used. + """ + env, ui = self._make_ui_mock() + env.account = mock.Mock(gpg_key='sentinel') + + cmd = envelope.SignCommand(action='sign', keyid=None) + cmd.apply(ui) + + self.assertTrue(env.sign) + self.assertEqual(env.sign_key, 'sentinel') + + @mock.patch('alot.commands.envelope.crypto.get_key', + mock.Mock(return_value=mock.sentinel.keyid)) + def test_apply_no_sign(self): + """If signing with a valid keyid and valid key then set sign and + sign_key. + """ + env, ui = self._make_ui_mock() + # The actual keyid doesn't matter, since it'll be mocked anyway + cmd = envelope.SignCommand(action='sign', keyid=['a']) + cmd.apply(ui) + + self.assertTrue(env.sign) + self.assertEqual(env.sign_key, mock.sentinel.keyid) + + @mock.patch('alot.commands.envelope.crypto.get_key', + mock.Mock(return_value=mock.sentinel.keyid)) + def test_apply_unsign(self): + """Test that settingun sign sets the sign to False if all other + conditions allow for it. + """ + env, ui = self._make_ui_mock() + env.sign = True + env.sign_key = mock.sentinel + # The actual keyid doesn't matter, since it'll be mocked anyway + cmd = envelope.SignCommand(action='unsign', keyid=['a']) + cmd.apply(ui) + + self.assertFalse(env.sign) + self.assertIs(env.sign_key, None) + + @mock.patch('alot.commands.envelope.crypto.get_key', + mock.Mock(return_value=mock.sentinel.keyid)) + def test_apply_togglesign(self): + """Test that toggling changes the sign and sign_key as approriate if + other condtiions allow for it + """ + env, ui = self._make_ui_mock() + env.sign = True + env.sign_key = mock.sentinel.keyid + + # The actual keyid doesn't matter, since it'll be mocked anyway + # Test that togling from true to false works + cmd = envelope.SignCommand(action='toggle', keyid=['a']) + cmd.apply(ui) + self.assertFalse(env.sign) + self.assertIs(env.sign_key, None) + + # Test that toggling back to True works + cmd.apply(ui) + self.assertTrue(env.sign) + self.assertIs(env.sign_key, mock.sentinel.keyid) + + def _make_local_settings(self): + config = textwrap.dedent("""\ + [accounts] + [[default]] + realname = foo + address = foo@example.com + sendmail_command = /bin/true + """) + + # Allow settings.reload to work by not deleting the file until the end + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(config) + self.addCleanup(os.unlink, f.name) + + # Set the gpg_key separately to avoid validation failures + manager = SettingsManager() + manager.read_config(f.name) + manager.get_accounts()[0].gpg_key = mock.sentinel.gpg_key + return manager + + def test_apply_from_email_only(self): + """Test that a key can be derived using a 'From' header that contains + only an email. + + If the from header is in the form "foo@example.com" and a key exists it + should be used. + """ + manager = self._make_local_settings() + env, ui = self._make_ui_mock() + env.headers = {'From': ['foo@example.com']} + + cmd = envelope.SignCommand(action='sign') + with mock.patch('alot.commands.envelope.settings', manager): + cmd.apply(ui) + + self.assertTrue(env.sign) + self.assertIs(env.sign_key, mock.sentinel.gpg_key) + + def test_apply_from_user_and_email(self): + """This tests that a gpg key can be derived using a 'From' header that + contains a realname-email combo. + + If the header is in the form "Foo ", a key should be + derived. + + See issue #1113 + """ + manager = self._make_local_settings() + env, ui = self._make_ui_mock() + + cmd = envelope.SignCommand(action='sign') + with mock.patch('alot.commands.envelope.settings', manager): + cmd.apply(ui) + + self.assertTrue(env.sign) + self.assertIs(env.sign_key, mock.sentinel.gpg_key) + + +class TestSendCommand(unittest.TestCase): + + """Tests for the SendCommand class.""" + + mail = textwrap.dedent("""\ + From: foo@example.com + To: bar@example.com + Subject: FooBar + + Foo Bar Baz + """) + + class MockedAccount(Account): + + def __init__(self): + super().__init__('foo@example.com') + + async def send_mail(self, mail): + pass + + @utilities.async_test + async def test_account_matching_address_with_str(self): + cmd = envelope.SendCommand(mail=self.mail) + account = mock.Mock(wraps=self.MockedAccount()) + with mock.patch( + 'alot.commands.envelope.settings.account_matching_address', + mock.Mock(return_value=account)) as account_matching_address: + await cmd.apply(mock.Mock()) + account_matching_address.assert_called_once_with('foo@example.com', + return_default=True) + # check that the apply did run through till the end. + account.send_mail.assert_called_once_with(self.mail) + + @utilities.async_test + async def test_account_matching_address_with_email_message(self): + mail = email.message_from_string(self.mail) + cmd = envelope.SendCommand(mail=mail) + account = mock.Mock(wraps=self.MockedAccount()) + with mock.patch( + 'alot.commands.envelope.settings.account_matching_address', + mock.Mock(return_value=account)) as account_matching_address: + await cmd.apply(mock.Mock()) + account_matching_address.assert_called_once_with('foo@example.com', + return_default=True) + # check that the apply did run through till the end. + account.send_mail.assert_called_once_with(mail) diff --git a/tests/commands/test_global.py b/tests/commands/test_global.py new file mode 100644 index 00000000..9b026e6a --- /dev/null +++ b/tests/commands/test_global.py @@ -0,0 +1,246 @@ +# encoding=utf-8 +# Copyright © 2017-2018 Dylan Baker +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for global commands.""" + +import logging +import os +import tempfile +import unittest + +import mock + +from alot.commands import globals as g_commands + +from .. import utilities + + +class Stop(Exception): + """exception for stopping testing of giant unmanagable functions.""" + pass + + +class TestComposeCommand(unittest.TestCase): + + """Tests for the compose command.""" + + @staticmethod + def _make_envelope_mock(): + envelope = mock.Mock() + envelope.headers = {'From': 'foo '} + envelope.get = envelope.headers.get + envelope.sign_key = None + envelope.sign = False + return envelope + + @staticmethod + def _make_account_mock( + sign_by_default=True, gpg_key=mock.sentinel.gpg_key): + account = mock.Mock() + account.sign_by_default = sign_by_default + account.gpg_key = gpg_key + account.signature = None + return account + + @utilities.async_test + async def test_apply_sign_by_default_okay(self): + envelope = self._make_envelope_mock() + envelope.account = self._make_account_mock() + cmd = g_commands.ComposeCommand(envelope=envelope) + + with mock.patch('alot.commands.globals.settings.get_addressbooks', + mock.Mock(side_effect=Stop)): + try: + await cmd.apply(mock.Mock()) + except Stop: + pass + + self.assertTrue(envelope.sign) + self.assertIs(envelope.sign_key, mock.sentinel.gpg_key) + + @utilities.async_test + async def test_apply_sign_by_default_false_doesnt_set_key(self): + envelope = self._make_envelope_mock() + envelope.account = self._make_account_mock(sign_by_default=False) + cmd = g_commands.ComposeCommand(envelope=envelope) + + with mock.patch('alot.commands.globals.settings.get_addressbooks', + mock.Mock(side_effect=Stop)): + try: + await cmd.apply(mock.Mock()) + except Stop: + pass + + self.assertFalse(envelope.sign) + self.assertIs(envelope.sign_key, None) + + @utilities.async_test + async def test_apply_sign_by_default_but_no_key(self): + envelope = self._make_envelope_mock() + envelope.account = self._make_account_mock(gpg_key=None) + cmd = g_commands.ComposeCommand(envelope=envelope) + + with mock.patch('alot.commands.globals.settings.get_addressbooks', + mock.Mock(side_effect=Stop)): + try: + await cmd.apply(mock.Mock()) + except Stop: + pass + + self.assertFalse(envelope.sign) + self.assertIs(envelope.sign_key, None) + + @utilities.async_test + async def test_decode_template_on_loading(self): + subject = u'This is a täßϑ subject.' + to = u'recipient@mail.com' + _from = u'foo.bar@mail.fr' + body = u'Body\n地初店会継思識棋御招告外児山望掲領環。\n€mail body €nd.' + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + txt = u'Subject: {}\nTo: {}\nFrom: {}\n{}'.format(subject, to, + _from, body) + f.write(txt.encode('utf-8')) + self.addCleanup(os.unlink, f.name) + + cmd = g_commands.ComposeCommand(template=f.name) + + # Crutch to exit the giant `apply` method early. + with mock.patch( + 'alot.commands.globals.settings.get_accounts', + mock.Mock(side_effect=Stop)): + try: + await cmd.apply(mock.Mock()) + except Stop: + pass + + self.assertEqual({'To': [to], + 'From': [_from], + 'Subject': [subject]}, cmd.envelope.headers) + self.assertEqual(body, cmd.envelope.body) + + @utilities.async_test + async def test_single_account_no_from(self): + # issue #1277 + envelope = self._make_envelope_mock() + del envelope.headers['From'] + envelope.account = self._make_account_mock() + envelope.account.realname = "foo" + envelope.account.address = 1 # maybe this should be a real Address? + cmd = g_commands.ComposeCommand(envelope=envelope) + + with mock.patch('alot.commands.globals.settings.get_addressbooks', + mock.Mock(side_effect=Stop)): + try: + await cmd.apply(mock.Mock()) + except Stop: + pass + + +class TestExternalCommand(unittest.TestCase): + + @utilities.async_test + async def test_no_spawn_no_stdin_success(self): + ui = utilities.make_ui() + cmd = g_commands.ExternalCommand(u'true', refocus=False) + await cmd.apply(ui) + ui.notify.assert_not_called() + + @utilities.async_test + async def test_no_spawn_stdin_success(self): + ui = utilities.make_ui() + cmd = g_commands.ExternalCommand(u"awk '{ exit $0 }'", stdin=u'0', + refocus=False) + await cmd.apply(ui) + ui.notify.assert_not_called() + + @utilities.async_test + async def test_no_spawn_no_stdin_attached(self): + ui = utilities.make_ui() + cmd = g_commands.ExternalCommand(u'test -t 0', refocus=False) + await cmd.apply(ui) + ui.notify.assert_not_called() + + @utilities.async_test + async def test_no_spawn_stdin_attached(self): + ui = utilities.make_ui() + cmd = g_commands.ExternalCommand( + u"test -t 0", stdin=u'0', refocus=False) + await cmd.apply(ui) + ui.notify.assert_called_once_with('', priority='error') + + @utilities.async_test + async def test_no_spawn_failure(self): + ui = utilities.make_ui() + cmd = g_commands.ExternalCommand(u'false', refocus=False) + await cmd.apply(ui) + ui.notify.assert_called_once_with('', priority='error') + + @utilities.async_test + @mock.patch( + 'alot.commands.globals.settings.get', mock.Mock(return_value='')) + @mock.patch.dict(os.environ, {'DISPLAY': ':0'}) + async def test_spawn_no_stdin_success(self): + ui = utilities.make_ui() + cmd = g_commands.ExternalCommand(u'true', refocus=False, spawn=True) + await cmd.apply(ui) + ui.notify.assert_not_called() + + @utilities.async_test + @mock.patch( + 'alot.commands.globals.settings.get', mock.Mock(return_value='')) + @mock.patch.dict(os.environ, {'DISPLAY': ':0'}) + async def test_spawn_stdin_success(self): + ui = utilities.make_ui() + cmd = g_commands.ExternalCommand( + u"awk '{ exit $0 }'", + stdin=u'0', refocus=False, spawn=True) + await cmd.apply(ui) + ui.notify.assert_not_called() + + @utilities.async_test + @mock.patch( + 'alot.commands.globals.settings.get', mock.Mock(return_value='')) + @mock.patch.dict(os.environ, {'DISPLAY': ':0'}) + async def test_spawn_failure(self): + ui = utilities.make_ui() + cmd = g_commands.ExternalCommand(u'false', refocus=False, spawn=True) + await cmd.apply(ui) + ui.notify.assert_called_once_with('', priority='error') + + +class TestCallCommand(unittest.TestCase): + + @utilities.async_test + async def test_synchronous_call(self): + ui = mock.Mock() + cmd = g_commands.CallCommand('ui()') + await cmd.apply(ui) + ui.assert_called_once() + + @utilities.async_test + async def test_async_call(self): + async def func(obj): + obj() + + ui = mock.Mock() + hooks = mock.Mock() + hooks.ui = None + hooks.func = func + + with mock.patch('alot.commands.globals.settings.hooks', hooks): + cmd = g_commands.CallCommand('hooks.func(ui)') + await cmd.apply(ui) + ui.assert_called_once() diff --git a/tests/commands/test_init.py b/tests/commands/test_init.py new file mode 100644 index 00000000..a2b358ff --- /dev/null +++ b/tests/commands/test_init.py @@ -0,0 +1,50 @@ +# encoding=utf-8 + +"""Test suite for alot.commands.__init__ module.""" + +import argparse +import unittest + +import mock + +from alot import commands +from alot.commands import thread + +# Good descriptive test names often don't fit PEP8, which is meant to cover +# functions meant to be called by humans. +# pylint: disable=invalid-name + +# These are tests, don't worry about names like "foo" and "bar" +# pylint: disable=blacklisted-name + + +class TestLookupCommand(unittest.TestCase): + + def test_look_up_save_attachment_command_in_thread_mode(self): + cmd, parser, kwargs = commands.lookup_command('save', 'thread') + # TODO do some more tests with these return values + self.assertEqual(cmd, thread.SaveAttachmentCommand) + self.assertIsInstance(parser, argparse.ArgumentParser) + self.assertDictEqual(kwargs, {}) + + +class TestCommandFactory(unittest.TestCase): + + def test_create_save_attachment_command_with_arguments(self): + cmd = commands.commandfactory('save --all /foo', mode='thread') + self.assertIsInstance(cmd, thread.SaveAttachmentCommand) + self.assertTrue(cmd.all) + self.assertEqual(cmd.path, u'/foo') + + +class TestRegisterCommand(unittest.TestCase): + """Tests for the registerCommand class.""" + + def test_registered(self): + """using registerCommand adds to the COMMANDS dict.""" + with mock.patch('alot.commands.COMMANDS', {'foo': {}}): + @commands.registerCommand('foo', 'test') + def foo(): # pylint: disable=unused-variable + pass + + self.assertIn('test', commands.COMMANDS['foo']) diff --git a/tests/commands/test_thread.py b/tests/commands/test_thread.py new file mode 100644 index 00000000..634c35e8 --- /dev/null +++ b/tests/commands/test_thread.py @@ -0,0 +1,230 @@ +# encoding=utf-8 +# Copyright (C) 2017 Lucas Hoffmann +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +"""Test suite for alot.commands.thread module.""" +import email +import unittest + +import mock + +from alot.commands import thread +from alot.account import Account + +# Good descriptive test names often don't fit PEP8, which is meant to cover +# functions meant to be called by humans. +# pylint: disable=invalid-name + +# These are tests, don't worry about names like "foo" and "bar" +# pylint: disable=blacklisted-name + + +class Test_ensure_unique_address(unittest.TestCase): + + foo = 'foo ' + foo2 = 'foo the fanzy ' + bar = 'bar ' + baz = 'baz ' + + def test_unique_lists_are_unchanged(self): + expected = sorted([self.foo, self.bar]) + actual = thread.ReplyCommand.ensure_unique_address(expected) + self.assertListEqual(actual, expected) + + def test_equal_entries_are_detected(self): + actual = thread.ReplyCommand.ensure_unique_address( + [self.foo, self.bar, self.foo]) + expected = sorted([self.foo, self.bar]) + self.assertListEqual(actual, expected) + + def test_same_address_with_different_name_is_detected(self): + actual = thread.ReplyCommand.ensure_unique_address( + [self.foo, self.foo2]) + expected = [self.foo2] + self.assertListEqual(actual, expected) + + +class _AccountTestClass(Account): + """Implements stubs for ABC methods.""" + + def send_mail(self, mail): + pass + + +class TestClearMyAddress(unittest.TestCase): + + me1 = u'me@example.com' + me2 = u'ME@example.com' + me3 = u'me+label@example.com' + me4 = u'ME+label@example.com' + me_regex = r'me\+.*@example.com' + me_named = u'alot team ' + you = u'you@example.com' + named = u'somebody you know ' + imposter = u'alot team ' + mine = _AccountTestClass( + address=me1, aliases=[], alias_regexp=me_regex, case_sensitive_username=True) + + + def test_empty_input_returns_empty_list(self): + self.assertListEqual( + thread.ReplyCommand.clear_my_address(self.mine, []), []) + + def test_only_my_emails_result_in_empty_list(self): + expected = [] + actual = thread.ReplyCommand.clear_my_address( + self.mine, [self.me1, self.me3, self.me_named]) + self.assertListEqual(actual, expected) + + def test_other_emails_are_untouched(self): + input_ = [self.you, self.me1, self.me_named, self.named] + expected = [self.you, self.named] + actual = thread.ReplyCommand.clear_my_address(self.mine, input_) + self.assertListEqual(actual, expected) + + def test_case_matters(self): + input_ = [self.me1, self.me2, self.me3, self.me4] + expected = [self.me2, self.me4] + actual = thread.ReplyCommand.clear_my_address(self.mine, input_) + self.assertListEqual(actual, expected) + + def test_same_address_with_different_real_name_is_removed(self): + input_ = [self.me_named, self.you] + expected = [self.you] + actual = thread.ReplyCommand.clear_my_address(self.mine, input_) + self.assertListEqual(actual, expected) + + +class _AccountTestClass(Account): + """Implements stubs for ABC methods.""" + + def send_mail(self, mail): + pass + + +class TestDetermineSender(unittest.TestCase): + + header_priority = ["From", "To", "Cc", "Envelope-To", "X-Envelope-To", + "Delivered-To"] + mailstring = '\n'.join([ + "From: from@example.com", + "To: to@example.com", + "Cc: cc@example.com", + "Envelope-To: envelope-to@example.com", + "X-Envelope-To: x-envelope-to@example.com", + "Delivered-To: delivered-to@example.com", + "Subject: Alot test", + "\n", + "Some content", + ]) + mail = email.message_from_string(mailstring) + + def _test(self, accounts=(), expected=(), mail=None, header_priority=None, + force_realname=False, force_address=False): + """This method collects most of the steps that need to be done for most + tests. Especially a closure to mock settings.get and a mock for + settings.get_accounts are set up.""" + mail = self.mail if mail is None else mail + header_priority = self.header_priority if header_priority is None \ + else header_priority + + def settings_get(arg): + """Mock function for setting.get()""" + if arg == "reply_account_header_priority": + return header_priority + elif arg.endswith('_force_realname'): + return force_realname + elif arg.endswith('_force_address'): + return force_address + + with mock.patch('alot.commands.thread.settings.get_accounts', + mock.Mock(return_value=accounts)): + with mock.patch('alot.commands.thread.settings.get', settings_get): + actual = thread.determine_sender(mail) + self.assertTupleEqual(actual, expected) + + def test_assert_that_some_accounts_are_defined(self): + with mock.patch('alot.commands.thread.settings.get_accounts', + mock.Mock(return_value=[])) as cm1: + with self.assertRaises(AssertionError) as cm2: + thread.determine_sender(None) + expected = ('no accounts set!',) + cm1.assert_called_once_with() + self.assertTupleEqual(cm2.exception.args, expected) + + def test_default_account_is_used_if_no_match_is_found(self): + account1 = _AccountTestClass(address=u'foo@example.com') + account2 = _AccountTestClass(address=u'bar@example.com') + expected = (u'foo@example.com', account1) + self._test(accounts=[account1, account2], expected=expected) + + def test_matching_address_and_account_are_returned(self): + account1 = _AccountTestClass(address=u'foo@example.com') + account2 = _AccountTestClass(address=u'to@example.com') + account3 = _AccountTestClass(address=u'bar@example.com') + expected = (u'to@example.com', account2) + self._test(accounts=[account1, account2, account3], expected=expected) + + def test_force_realname_has_real_name_in_returned_address_if_defined(self): + account1 = _AccountTestClass(address=u'foo@example.com') + account2 = _AccountTestClass(address=u'to@example.com', realname='Bar') + account3 = _AccountTestClass(address=u'baz@example.com') + expected = (u'Bar ', account2) + self._test(accounts=[account1, account2, account3], expected=expected, + force_realname=True) + + def test_doesnt_fail_with_force_realname_if_real_name_not_defined(self): + account1 = _AccountTestClass(address=u'foo@example.com') + account2 = _AccountTestClass(address=u'to@example.com') + account3 = _AccountTestClass(address=u'bar@example.com') + expected = (u'to@example.com', account2) + self._test(accounts=[account1, account2, account3], expected=expected, + force_realname=True) + + def test_with_force_address_main_address_is_always_used(self): + # In python 3.4 this and the next test could be written as subtests. + account1 = _AccountTestClass(address=u'foo@example.com') + account2 = _AccountTestClass(address=u'bar@example.com', + aliases=[u'to@example.com']) + account3 = _AccountTestClass(address=u'bar@example.com') + expected = (u'bar@example.com', account2) + self._test(accounts=[account1, account2, account3], expected=expected, + force_address=True) + + def test_without_force_address_matching_address_is_used(self): + # In python 3.4 this and the previous test could be written as + # subtests. + account1 = _AccountTestClass(address=u'foo@example.com') + account2 = _AccountTestClass(address=u'bar@example.com', + aliases=[u'to@example.com']) + account3 = _AccountTestClass(address=u'baz@example.com') + expected = (u'to@example.com', account2) + self._test(accounts=[account1, account2, account3], expected=expected, + force_address=False) + + def test_uses_to_header_if_present(self): + account1 = _AccountTestClass(address=u'foo@example.com') + account2 = _AccountTestClass(address=u'to@example.com') + account3 = _AccountTestClass(address=u'bar@example.com') + expected = (u'to@example.com', account2) + self._test(accounts=[account1, account2, account3], expected=expected) + + def test_header_order_is_more_important_than_accounts_order(self): + account1 = _AccountTestClass(address=u'cc@example.com') + account2 = _AccountTestClass(address=u'to@example.com') + account3 = _AccountTestClass(address=u'bcc@example.com') + expected = (u'to@example.com', account2) + self._test(accounts=[account1, account2, account3], expected=expected) + + def test_accounts_can_be_found_by_alias_regex_setting(self): + account1 = _AccountTestClass(address=u'foo@example.com') + account2 = _AccountTestClass(address=u'to@example.com', + alias_regexp=r'to\+.*@example.com') + account3 = _AccountTestClass(address=u'bar@example.com') + mailstring = self.mailstring.replace(u'to@example.com', + u'to+some_tag@example.com') + mail = email.message_from_string(mailstring) + expected = (u'to+some_tag@example.com', account2) + self._test(accounts=[account1, account2, account3], expected=expected, + mail=mail) diff --git a/tests/commands/thread_test.py b/tests/commands/thread_test.py deleted file mode 100644 index 634c35e8..00000000 --- a/tests/commands/thread_test.py +++ /dev/null @@ -1,230 +0,0 @@ -# encoding=utf-8 -# Copyright (C) 2017 Lucas Hoffmann -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file - -"""Test suite for alot.commands.thread module.""" -import email -import unittest - -import mock - -from alot.commands import thread -from alot.account import Account - -# Good descriptive test names often don't fit PEP8, which is meant to cover -# functions meant to be called by humans. -# pylint: disable=invalid-name - -# These are tests, don't worry about names like "foo" and "bar" -# pylint: disable=blacklisted-name - - -class Test_ensure_unique_address(unittest.TestCase): - - foo = 'foo ' - foo2 = 'foo the fanzy ' - bar = 'bar ' - baz = 'baz ' - - def test_unique_lists_are_unchanged(self): - expected = sorted([self.foo, self.bar]) - actual = thread.ReplyCommand.ensure_unique_address(expected) - self.assertListEqual(actual, expected) - - def test_equal_entries_are_detected(self): - actual = thread.ReplyCommand.ensure_unique_address( - [self.foo, self.bar, self.foo]) - expected = sorted([self.foo, self.bar]) - self.assertListEqual(actual, expected) - - def test_same_address_with_different_name_is_detected(self): - actual = thread.ReplyCommand.ensure_unique_address( - [self.foo, self.foo2]) - expected = [self.foo2] - self.assertListEqual(actual, expected) - - -class _AccountTestClass(Account): - """Implements stubs for ABC methods.""" - - def send_mail(self, mail): - pass - - -class TestClearMyAddress(unittest.TestCase): - - me1 = u'me@example.com' - me2 = u'ME@example.com' - me3 = u'me+label@example.com' - me4 = u'ME+label@example.com' - me_regex = r'me\+.*@example.com' - me_named = u'alot team ' - you = u'you@example.com' - named = u'somebody you know ' - imposter = u'alot team ' - mine = _AccountTestClass( - address=me1, aliases=[], alias_regexp=me_regex, case_sensitive_username=True) - - - def test_empty_input_returns_empty_list(self): - self.assertListEqual( - thread.ReplyCommand.clear_my_address(self.mine, []), []) - - def test_only_my_emails_result_in_empty_list(self): - expected = [] - actual = thread.ReplyCommand.clear_my_address( - self.mine, [self.me1, self.me3, self.me_named]) - self.assertListEqual(actual, expected) - - def test_other_emails_are_untouched(self): - input_ = [self.you, self.me1, self.me_named, self.named] - expected = [self.you, self.named] - actual = thread.ReplyCommand.clear_my_address(self.mine, input_) - self.assertListEqual(actual, expected) - - def test_case_matters(self): - input_ = [self.me1, self.me2, self.me3, self.me4] - expected = [self.me2, self.me4] - actual = thread.ReplyCommand.clear_my_address(self.mine, input_) - self.assertListEqual(actual, expected) - - def test_same_address_with_different_real_name_is_removed(self): - input_ = [self.me_named, self.you] - expected = [self.you] - actual = thread.ReplyCommand.clear_my_address(self.mine, input_) - self.assertListEqual(actual, expected) - - -class _AccountTestClass(Account): - """Implements stubs for ABC methods.""" - - def send_mail(self, mail): - pass - - -class TestDetermineSender(unittest.TestCase): - - header_priority = ["From", "To", "Cc", "Envelope-To", "X-Envelope-To", - "Delivered-To"] - mailstring = '\n'.join([ - "From: from@example.com", - "To: to@example.com", - "Cc: cc@example.com", - "Envelope-To: envelope-to@example.com", - "X-Envelope-To: x-envelope-to@example.com", - "Delivered-To: delivered-to@example.com", - "Subject: Alot test", - "\n", - "Some content", - ]) - mail = email.message_from_string(mailstring) - - def _test(self, accounts=(), expected=(), mail=None, header_priority=None, - force_realname=False, force_address=False): - """This method collects most of the steps that need to be done for most - tests. Especially a closure to mock settings.get and a mock for - settings.get_accounts are set up.""" - mail = self.mail if mail is None else mail - header_priority = self.header_priority if header_priority is None \ - else header_priority - - def settings_get(arg): - """Mock function for setting.get()""" - if arg == "reply_account_header_priority": - return header_priority - elif arg.endswith('_force_realname'): - return force_realname - elif arg.endswith('_force_address'): - return force_address - - with mock.patch('alot.commands.thread.settings.get_accounts', - mock.Mock(return_value=accounts)): - with mock.patch('alot.commands.thread.settings.get', settings_get): - actual = thread.determine_sender(mail) - self.assertTupleEqual(actual, expected) - - def test_assert_that_some_accounts_are_defined(self): - with mock.patch('alot.commands.thread.settings.get_accounts', - mock.Mock(return_value=[])) as cm1: - with self.assertRaises(AssertionError) as cm2: - thread.determine_sender(None) - expected = ('no accounts set!',) - cm1.assert_called_once_with() - self.assertTupleEqual(cm2.exception.args, expected) - - def test_default_account_is_used_if_no_match_is_found(self): - account1 = _AccountTestClass(address=u'foo@example.com') - account2 = _AccountTestClass(address=u'bar@example.com') - expected = (u'foo@example.com', account1) - self._test(accounts=[account1, account2], expected=expected) - - def test_matching_address_and_account_are_returned(self): - account1 = _AccountTestClass(address=u'foo@example.com') - account2 = _AccountTestClass(address=u'to@example.com') - account3 = _AccountTestClass(address=u'bar@example.com') - expected = (u'to@example.com', account2) - self._test(accounts=[account1, account2, account3], expected=expected) - - def test_force_realname_has_real_name_in_returned_address_if_defined(self): - account1 = _AccountTestClass(address=u'foo@example.com') - account2 = _AccountTestClass(address=u'to@example.com', realname='Bar') - account3 = _AccountTestClass(address=u'baz@example.com') - expected = (u'Bar ', account2) - self._test(accounts=[account1, account2, account3], expected=expected, - force_realname=True) - - def test_doesnt_fail_with_force_realname_if_real_name_not_defined(self): - account1 = _AccountTestClass(address=u'foo@example.com') - account2 = _AccountTestClass(address=u'to@example.com') - account3 = _AccountTestClass(address=u'bar@example.com') - expected = (u'to@example.com', account2) - self._test(accounts=[account1, account2, account3], expected=expected, - force_realname=True) - - def test_with_force_address_main_address_is_always_used(self): - # In python 3.4 this and the next test could be written as subtests. - account1 = _AccountTestClass(address=u'foo@example.com') - account2 = _AccountTestClass(address=u'bar@example.com', - aliases=[u'to@example.com']) - account3 = _AccountTestClass(address=u'bar@example.com') - expected = (u'bar@example.com', account2) - self._test(accounts=[account1, account2, account3], expected=expected, - force_address=True) - - def test_without_force_address_matching_address_is_used(self): - # In python 3.4 this and the previous test could be written as - # subtests. - account1 = _AccountTestClass(address=u'foo@example.com') - account2 = _AccountTestClass(address=u'bar@example.com', - aliases=[u'to@example.com']) - account3 = _AccountTestClass(address=u'baz@example.com') - expected = (u'to@example.com', account2) - self._test(accounts=[account1, account2, account3], expected=expected, - force_address=False) - - def test_uses_to_header_if_present(self): - account1 = _AccountTestClass(address=u'foo@example.com') - account2 = _AccountTestClass(address=u'to@example.com') - account3 = _AccountTestClass(address=u'bar@example.com') - expected = (u'to@example.com', account2) - self._test(accounts=[account1, account2, account3], expected=expected) - - def test_header_order_is_more_important_than_accounts_order(self): - account1 = _AccountTestClass(address=u'cc@example.com') - account2 = _AccountTestClass(address=u'to@example.com') - account3 = _AccountTestClass(address=u'bcc@example.com') - expected = (u'to@example.com', account2) - self._test(accounts=[account1, account2, account3], expected=expected) - - def test_accounts_can_be_found_by_alias_regex_setting(self): - account1 = _AccountTestClass(address=u'foo@example.com') - account2 = _AccountTestClass(address=u'to@example.com', - alias_regexp=r'to\+.*@example.com') - account3 = _AccountTestClass(address=u'bar@example.com') - mailstring = self.mailstring.replace(u'to@example.com', - u'to+some_tag@example.com') - mail = email.message_from_string(mailstring) - expected = (u'to+some_tag@example.com', account2) - self._test(accounts=[account1, account2, account3], expected=expected, - mail=mail) diff --git a/tests/completion_test.py b/tests/completion_test.py deleted file mode 100644 index 79f07e1b..00000000 --- a/tests/completion_test.py +++ /dev/null @@ -1,98 +0,0 @@ -# encoding=utf-8 -# Copyright (C) 2017 Lucas Hoffmann -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file - -"""Tests for the alot.completion module.""" -import unittest - -import mock - -from alot import completion - -# Good descriptive test names often don't fit PEP8, which is meant to cover -# functions meant to be called by humans. -# pylint: disable=invalid-name - - -def _mock_lookup(query): - """Look up the query from fixed list of names and email addresses.""" - abook = [ - ("", "no-real-name@example.com"), - ("foo", "foo@example.com"), - ("comma, person", "comma@example.com"), - ("single 'quote' person", "squote@example.com"), - ('double "quote" person', "dquote@example.com"), - ("""all 'fanzy' "stuff" at, once""", "all@example.com") - ] - results = [] - for name, email in abook: - if query in name or query in email: - results.append((name, email)) - return results - - -class AbooksCompleterTest(unittest.TestCase): - """Tests for the address book completion class.""" - - @classmethod - def setUpClass(cls): - abook = mock.Mock() - abook.lookup = _mock_lookup - cls.empty_abook_completer = completion.AbooksCompleter([]) - cls.example_abook_completer = completion.AbooksCompleter([abook]) - - def test_empty_address_book_returns_empty_list(self): - actual = self.__class__.empty_abook_completer.complete('real-name', 9) - expected = [] - self.assertListEqual(actual, expected) - - def _assert_only_one_list_entry(self, actual, expected): - """Check that the given lists are both of length 1 and the tuple at the - first positions are equal.""" - self.assertEqual(len(actual), 1) - self.assertEqual(len(expected), 1) - self.assertTupleEqual(actual[0], expected[0]) - - def test_empty_real_name_returns_plain_email_address(self): - actual = self.__class__.example_abook_completer.complete( - "real-name", 9) - expected = [("no-real-name@example.com", 24)] - self._assert_only_one_list_entry(actual, expected) - - def test_simple_address_with_real_name(self): - actual = self.__class__.example_abook_completer.complete("foo", 3) - expected = [("foo ", 21)] - self.assertListEqual(actual, expected) - - def test_real_name_with_comma(self): - actual = self.__class__.example_abook_completer.complete("comma", 5) - expected = [('"comma, person" ', 35)] - self.assertListEqual(actual, expected) - - def test_real_name_with_single_quotes(self): - actual = self.__class__.example_abook_completer.complete("squote", 6) - expected = [("single 'quote' person ", 42)] - self._assert_only_one_list_entry(actual, expected) - - def test_real_name_double_quotes(self): - actual = self.__class__.example_abook_completer.complete("dquote", 6) - expected = [("", 0)] - expected = [ - (r""""double \"quote\" person" """, 46)] - self._assert_only_one_list_entry(actual, expected) - - def test_real_name_with_quotes_and_comma(self): - actual = self.__class__.example_abook_completer.complete("all", 3) - expected = [(r""""all 'fanzy' \"stuff\" at, once" """, - 50)] - self._assert_only_one_list_entry(actual, expected) - - -class StringlistCompleterTest(unittest.TestCase): - def test_dont_choke_on_special_regex_characters(self): - tags = ['[match]', 'nomatch'] - completer = completion.StringlistCompleter(tags) - actual = completer.complete('[', 1) - expected = [(tags[0], len(tags[0]))] - self.assertListEqual(actual, expected) diff --git a/tests/crypto_test.py b/tests/crypto_test.py deleted file mode 100644 index 334fcd56..00000000 --- a/tests/crypto_test.py +++ /dev/null @@ -1,392 +0,0 @@ -# Copyright (C) 2017 Lucas Hoffmann -# Copyright © 2017-2018 Dylan Baker -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file -import os -import shutil -import signal -import subprocess -import tempfile -import unittest - -import gpg -import mock -import urwid - -from alot import crypto -from alot.errors import GPGProblem, GPGCode - -from . import utilities - - -MOD_CLEAN = utilities.ModuleCleanup() - -# A useful single fingerprint for tests that only care about one key. This -# key will not be ambiguous -FPR = "F74091D4133F87D56B5D343C1974EC55FBC2D660" - -# Some additional keys, these keys may be ambigiuos -EXTRA_FPRS = [ - "DD19862809A7573A74058FF255937AFBB156245D", - "2071E9C8DB4EF5466F4D233CF730DF92C4566CE7", -] - -DEVNULL = open('/dev/null', 'w') -MOD_CLEAN.add_cleanup(DEVNULL.close) - - -@MOD_CLEAN.wrap_setup -def setUpModule(): - home = tempfile.mkdtemp() - MOD_CLEAN.add_cleanup(shutil.rmtree, home) - mock_home = mock.patch.dict(os.environ, {'GNUPGHOME': home}) - mock_home.start() - MOD_CLEAN.add_cleanup(mock_home.stop) - - with gpg.core.Context(armor=True) as ctx: - # Add the public and private keys. They have no password - search_dir = os.path.join(os.path.dirname(__file__), 'static/gpg-keys') - for each in os.listdir(search_dir): - if os.path.splitext(each)[1] == '.gpg': - with open(os.path.join(search_dir, each)) as f: - ctx.op_import(f) - - -@MOD_CLEAN.wrap_teardown -def tearDownModule(): - # Kill any gpg-agent's that have been opened - lookfor = 'gpg-agent --homedir {}'.format(os.environ['GNUPGHOME']) - - out = subprocess.check_output( - ['ps', 'xo', 'pid,cmd'], - stderr=DEVNULL).decode(urwid.util.detected_encoding) - for each in out.strip().split('\n'): - pid, cmd = each.strip().split(' ', 1) - if cmd.startswith(lookfor): - os.kill(int(pid), signal.SIGKILL) - - -def make_key(revoked=False, expired=False, invalid=False, can_encrypt=True, - can_sign=True): - # This is ugly - mock_key = mock.create_autospec(gpg._gpgme._gpgme_key) - mock_key.uids = [mock.Mock(uid=u'mocked')] - mock_key.revoked = revoked - mock_key.expired = expired - mock_key.invalid = invalid - mock_key.can_encrypt = can_encrypt - mock_key.can_sign = can_sign - - return mock_key - - -def make_uid(email, revoked=False, invalid=False, - validity=gpg.constants.validity.FULL): - uid = mock.Mock() - uid.email = email - uid.revoked = revoked - uid.invalid = invalid - uid.validity = validity - - return uid - - -class TestHashAlgorithmHelper(unittest.TestCase): - - """Test cases for the helper function RFC3156_canonicalize.""" - - def test_returned_string_starts_with_pgp(self): - result = crypto.RFC3156_micalg_from_algo(gpg.constants.md.MD5) - self.assertTrue(result.startswith('pgp-')) - - def test_returned_string_is_lower_case(self): - result = crypto.RFC3156_micalg_from_algo(gpg.constants.md.MD5) - self.assertTrue(result.islower()) - - def test_raises_for_unknown_hash_name(self): - with self.assertRaises(GPGProblem): - crypto.RFC3156_micalg_from_algo(gpg.constants.md.NONE) - - -class TestDetachedSignatureFor(unittest.TestCase): - - def test_valid_signature_generated(self): - to_sign = b"this is some text.\nit is more than nothing.\n" - with gpg.core.Context() as ctx: - _, detached = crypto.detached_signature_for( - to_sign, [ctx.get_key(FPR)]) - - with tempfile.NamedTemporaryFile(delete=False) as f: - f.write(detached) - sig = f.name - self.addCleanup(os.unlink, f.name) - - with tempfile.NamedTemporaryFile(delete=False) as f: - f.write(to_sign) - text = f.name - self.addCleanup(os.unlink, f.name) - - res = subprocess.check_call(['gpg2', '--verify', sig, text], - stdout=DEVNULL, stderr=DEVNULL) - self.assertEqual(res, 0) - - -class TestVerifyDetached(unittest.TestCase): - - def test_verify_signature_good(self): - to_sign = b"this is some text.\nIt's something\n." - with gpg.core.Context() as ctx: - _, detached = crypto.detached_signature_for( - to_sign, [ctx.get_key(FPR)]) - - try: - crypto.verify_detached(to_sign, detached) - except GPGProblem: - raise AssertionError - - def test_verify_signature_bad(self): - to_sign = b"this is some text.\nIt's something\n." - similar = b"this is some text.\r\n.It's something\r\n." - with gpg.core.Context() as ctx: - _, detached = crypto.detached_signature_for( - to_sign, [ctx.get_key(FPR)]) - - with self.assertRaises(GPGProblem): - crypto.verify_detached(similar, detached) - - -class TestValidateKey(unittest.TestCase): - - def test_valid(self): - try: - crypto.validate_key(utilities.make_key()) - except GPGProblem as e: - raise AssertionError(e) - - def test_revoked(self): - with self.assertRaises(GPGProblem) as caught: - crypto.validate_key(utilities.make_key(revoked=True)) - - self.assertEqual(caught.exception.code, GPGCode.KEY_REVOKED) - - def test_expired(self): - with self.assertRaises(GPGProblem) as caught: - crypto.validate_key(utilities.make_key(expired=True)) - - self.assertEqual(caught.exception.code, GPGCode.KEY_EXPIRED) - - def test_invalid(self): - with self.assertRaises(GPGProblem) as caught: - crypto.validate_key(utilities.make_key(invalid=True)) - - self.assertEqual(caught.exception.code, GPGCode.KEY_INVALID) - - def test_encrypt(self): - with self.assertRaises(GPGProblem) as caught: - crypto.validate_key( - utilities.make_key(can_encrypt=False), encrypt=True) - - self.assertEqual(caught.exception.code, GPGCode.KEY_CANNOT_ENCRYPT) - - def test_encrypt_no_check(self): - try: - crypto.validate_key(utilities.make_key(can_encrypt=False)) - except GPGProblem as e: - raise AssertionError(e) - - def test_sign(self): - with self.assertRaises(GPGProblem) as caught: - crypto.validate_key(utilities.make_key(can_sign=False), sign=True) - - self.assertEqual(caught.exception.code, GPGCode.KEY_CANNOT_SIGN) - - def test_sign_no_check(self): - try: - crypto.validate_key(utilities.make_key(can_sign=False)) - except GPGProblem as e: - raise AssertionError(e) - - -class TestCheckUIDValidity(unittest.TestCase): - - def test_valid_single(self): - key = utilities.make_key() - key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL) - ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) - self.assertTrue(ret) - - def test_valid_multiple(self): - key = utilities.make_key() - key.uids = [ - utilities.make_uid(mock.sentinel.EMAIL), - utilities.make_uid(mock.sentinel.EMAIL1), - ] - - ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL1) - self.assertTrue(ret) - - def test_invalid_email(self): - key = utilities.make_key() - key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL) - ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL1) - self.assertFalse(ret) - - def test_invalid_revoked(self): - key = utilities.make_key() - key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL, revoked=True) - ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) - self.assertFalse(ret) - - def test_invalid_invalid(self): - key = utilities.make_key() - key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL, invalid=True) - ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) - self.assertFalse(ret) - - def test_invalid_not_enough_trust(self): - key = utilities.make_key() - key.uids[0] = utilities.make_uid( - mock.sentinel.EMAIL, - validity=gpg.constants.validity.UNDEFINED) - ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) - self.assertFalse(ret) - - -class TestListKeys(unittest.TestCase): - - def test_list_no_hints(self): - # This only tests that you get 3 keys back (the number in our test - # keyring), it might be worth adding tests to check more about the keys - # returned - values = crypto.list_keys() - self.assertEqual(len(list(values)), 3) - - def test_list_hint(self): - values = crypto.list_keys(hint="ambig") - self.assertEqual(len(list(values)), 2) - - def test_list_keys_pub(self): - values = list(crypto.list_keys(hint="ambigu"))[0] - self.assertEqual(values.uids[0].email, u'amigbu@example.com') - self.assertFalse(values.secret) - - def test_list_keys_private(self): - values = list(crypto.list_keys(hint="ambigu", private=True))[0] - self.assertEqual(values.uids[0].email, u'amigbu@example.com') - self.assertTrue(values.secret) - - -class TestGetKey(unittest.TestCase): - - def test_plain(self): - # Test the uid of the only identity attached to the key we generated. - with gpg.core.Context() as ctx: - expected = ctx.get_key(FPR).uids[0].uid - actual = crypto.get_key(FPR).uids[0].uid - self.assertEqual(expected, actual) - - def test_validate(self): - # Since we already test validation we're only going to test validate - # once. - with gpg.core.Context() as ctx: - expected = ctx.get_key(FPR).uids[0].uid - actual = crypto.get_key( - FPR, validate=True, encrypt=True, sign=True).uids[0].uid - self.assertEqual(expected, actual) - - def test_missing_key(self): - with self.assertRaises(GPGProblem) as caught: - crypto.get_key('foo@example.com') - self.assertEqual(caught.exception.code, GPGCode.NOT_FOUND) - - def test_invalid_key(self): - with self.assertRaises(GPGProblem) as caught: - crypto.get_key('z') - self.assertEqual(caught.exception.code, GPGCode.NOT_FOUND) - - @mock.patch('alot.crypto.check_uid_validity', mock.Mock(return_value=True)) - def test_signed_only_true(self): - try: - crypto.get_key(FPR, signed_only=True) - except GPGProblem as e: - raise AssertionError(e) - - @mock.patch( - 'alot.crypto.check_uid_validity', mock.Mock(return_value=False)) - def test_signed_only_false(self): - with self.assertRaises(GPGProblem) as e: - crypto.get_key(FPR, signed_only=True) - self.assertEqual(e.exception.code, GPGCode.NOT_FOUND) - - @staticmethod - def _context_mock(): - class CustomError(gpg.errors.GPGMEError): - """A custom GPGMEError class that always has an errors code of - AMBIGUOUS_NAME. - """ - def getcode(self): - return gpg.errors.AMBIGUOUS_NAME - - context_mock = mock.Mock() - context_mock.get_key = mock.Mock(side_effect=CustomError) - - return context_mock - - def test_ambiguous_one_valid(self): - invalid_key = utilities.make_key(invalid=True) - valid_key = utilities.make_key() - - with mock.patch('alot.crypto.gpg.core.Context', - mock.Mock(return_value=self._context_mock())), \ - mock.patch('alot.crypto.list_keys', - mock.Mock(return_value=[valid_key, invalid_key])): - key = crypto.get_key('placeholder') - self.assertIs(key, valid_key) - - def test_ambiguous_two_valid(self): - with mock.patch('alot.crypto.gpg.core.Context', - mock.Mock(return_value=self._context_mock())), \ - mock.patch('alot.crypto.list_keys', - mock.Mock(return_value=[utilities.make_key(), - utilities.make_key()])): - with self.assertRaises(crypto.GPGProblem) as cm: - crypto.get_key('placeholder') - self.assertEqual(cm.exception.code, GPGCode.AMBIGUOUS_NAME) - - def test_ambiguous_no_valid(self): - with mock.patch('alot.crypto.gpg.core.Context', - mock.Mock(return_value=self._context_mock())), \ - mock.patch('alot.crypto.list_keys', - mock.Mock(return_value=[ - utilities.make_key(invalid=True), - utilities.make_key(invalid=True)])): - with self.assertRaises(crypto.GPGProblem) as cm: - crypto.get_key('placeholder') - self.assertEqual(cm.exception.code, GPGCode.NOT_FOUND) - - -class TestEncrypt(unittest.TestCase): - - def test_encrypt(self): - to_encrypt = b"this is a string\nof data." - encrypted = crypto.encrypt(to_encrypt, keys=[crypto.get_key(FPR)]) - - with tempfile.NamedTemporaryFile(delete=False) as f: - f.write(encrypted) - enc_file = f.name - self.addCleanup(os.unlink, enc_file) - - dec = subprocess.check_output( - ['gpg2', '--decrypt', enc_file], stderr=DEVNULL) - self.assertEqual(to_encrypt, dec) - - -class TestDecrypt(unittest.TestCase): - - def test_decrypt(self): - to_encrypt = b"this is a string\nof data." - encrypted = crypto.encrypt(to_encrypt, keys=[crypto.get_key(FPR)]) - _, dec = crypto.decrypt_verify(encrypted) - self.assertEqual(to_encrypt, dec) - - # TODO: test for "combined" method diff --git a/tests/db/envelope_test.py b/tests/db/envelope_test.py deleted file mode 100644 index f14b8594..00000000 --- a/tests/db/envelope_test.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright © 2017 Lucas Hoffmann -# Copyright © 2018 Dylan Baker -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import email.parser -import email.policy -import os -import tempfile -import unittest - -import mock - -from alot.db import envelope - -SETTINGS = { - 'user_agent': 'agent', -} - - -def email_to_dict(mail): - """Consumes an email, and returns a dict of headers and 'Body'.""" - split = mail.splitlines() - final = {} - for line in split: - if line.strip(): - try: - k, v = line.split(':') - final[k.strip()] = v.strip() - except ValueError: - final['Body'] = line.strip() - return final - - -class TestEnvelope(unittest.TestCase): - - def assertEmailEqual(self, first, second): - with self.subTest('body'): - self.assertEqual(first.is_multipart(), second.is_multipart()) - if not first.is_multipart(): - self.assertEqual(first.get_payload(), second.get_payload()) - else: - for f, s in zip(first.walk(), second.walk()): - if f.is_multipart() or s.is_multipart(): - self.assertEqual(first.is_multipart(), - second.is_multipart()) - else: - self.assertEqual(f.get_payload(), s.get_payload()) - with self.subTest('headers'): - self.assertListEqual(first.values(), second.values()) - - def test_setitem_stores_text_unchanged(self): - "Just ensure that the value is set and unchanged" - e = envelope.Envelope() - e['Subject'] = u'sm\xf8rebr\xf8d' - self.assertEqual(e['Subject'], u'sm\xf8rebr\xf8d') - - def _test_mail(self, envelope): - mail = envelope.construct_mail() - raw = mail.as_string(policy=email.policy.SMTP) - actual = email.parser.Parser().parsestr(raw) - self.assertEmailEqual(mail, actual) - - @mock.patch('alot.db.envelope.settings', SETTINGS) - def test_construct_mail_simple(self): - """Very simple envelope with a To, From, Subject, and body.""" - headers = { - 'From': 'foo@example.com', - 'To': 'bar@example.com', - 'Subject': 'Test email', - } - e = envelope.Envelope(headers={k: [v] for k, v in headers.items()}, - bodytext='Test') - self._test_mail(e) - - @mock.patch('alot.db.envelope.settings', SETTINGS) - def test_construct_mail_with_attachment(self): - """Very simple envelope with a To, From, Subject, body and attachment. - """ - headers = { - 'From': 'foo@example.com', - 'To': 'bar@example.com', - 'Subject': 'Test email', - } - e = envelope.Envelope(headers={k: [v] for k, v in headers.items()}, - bodytext='Test') - with tempfile.NamedTemporaryFile(mode='wt', delete=False) as f: - f.write('blah') - self.addCleanup(os.unlink, f.name) - e.attach(f.name) - - self._test_mail(e) diff --git a/tests/db/manager_test.py b/tests/db/manager_test.py deleted file mode 100644 index e675aed4..00000000 --- a/tests/db/manager_test.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (C) 2018 Patrick Totzke -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file - -"""Test suite for alot.db.manager module.""" - -import tempfile -import textwrap -import os -import shutil - -from alot.db.manager import DBManager -from alot.settings.const import settings -from notmuch import Database - -from .. import utilities - - -class TestDBManager(utilities.TestCaseClassCleanup): - - @classmethod - def setUpClass(cls): - - # create temporary notmuch config - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [maildir] - synchronize_flags = true - """)) - cls.notmuch_config_path = f.name - cls.addClassCleanup(os.unlink, f.name) - - # define an empty notmuch database in a temporary directory - cls.dbpath = tempfile.mkdtemp() - cls.db = Database(path=cls.dbpath, create=True) - cls.db.close() - cls.manager = DBManager(cls.dbpath) - - # clean up temporary database - cls.addClassCleanup(cls.manager.kill_search_processes) - cls.addClassCleanup(shutil.rmtree, cls.dbpath) - - # let global settings manager read our temporary notmuch config - settings.read_notmuch_config(cls.notmuch_config_path) - - def test_save_named_query(self): - alias = 'key' - querystring = 'query string' - self.manager.save_named_query(alias, querystring) - self.manager.flush() - - named_queries_dict = self.manager.get_named_queries() - self.assertDictEqual(named_queries_dict, {alias: querystring}) diff --git a/tests/db/message_test.py b/tests/db/message_test.py deleted file mode 100644 index 29ed5ee6..00000000 --- a/tests/db/message_test.py +++ /dev/null @@ -1,115 +0,0 @@ -# encoding=utf-8 -# Copyright © 2017 Dylan Baker -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import unittest - -import mock - -from notmuch import NullPointerError - -from alot import account -from alot.db import message - - -class MockNotmuchMessage(object): - """An object that looks very much like a notmuch message. - - All public instance variables that are not part of the notmuch Message - class are prefaced with mock. - """ - - def __init__(self, headers=None, tags=None): - self.mock_headers = headers or {} - self.mock_message_id = 'message id' - self.mock_thread_id = 'thread id' - self.mock_date = 0 - self.mock_filename = 'filename' - self.mock_tags = tags or [] - - def get_header(self, field): - return self.mock_headers.get(field, '') - - def get_message_id(self): - return self.mock_message_id - - def get_thread_id(self): - return self.mock_thread_id - - def get_date(self): - return self.mock_date - - def get_filename(self): - return self.mock_filename - - def get_tags(self): - return self.mock_tags - - def get_properties(self, prop, exact=False): - return [] - - -class TestMessage(unittest.TestCase): - - def test_get_author_email_only(self): - """Message._from is populated using the 'From' header when only an - email address is provided. - """ - msg = message.Message(mock.Mock(), - MockNotmuchMessage({'From': 'user@example.com'})) - self.assertEqual(msg.get_author(), ('', 'user@example.com')) - - def test_get_author_name_and_email(self): - """Message._from is populated using the 'From' header when an email and - name are provided. - """ - msg = message.Message( - mock.Mock(), - MockNotmuchMessage({'From': '"User Name" '})) - self.assertEqual(msg.get_author(), ('User Name', 'user@example.com')) - - def test_get_author_sender(self): - """Message._from is populated using the 'Sender' header when no 'From' - header is present. - """ - msg = message.Message( - mock.Mock(), - MockNotmuchMessage({'Sender': '"User Name" '})) - self.assertEqual(msg.get_author(), ('User Name', 'user@example.com')) - - def test_get_author_no_name_draft(self): - """Message._from is populated from the default account if the draft tag - is present. - """ - acc = mock.Mock() - acc.address = account.Address(u'user', u'example.com') - acc.realname = u'User Name' - with mock.patch('alot.db.message.settings.get_accounts', - mock.Mock(return_value=[acc])): - msg = message.Message( - mock.Mock(), MockNotmuchMessage(tags=['draft'])) - self.assertEqual(msg.get_author(), ('User Name', 'user@example.com')) - - def test_get_author_no_name(self): - """Message._from is set to 'Unkown' if there is no relavent header and - the message is not a draft. - """ - acc = mock.Mock() - acc.address = account.Address(u'user', u'example.com') - acc.realname = u'User Name' - with mock.patch('alot.db.message.settings.get_accounts', - mock.Mock(return_value=[acc])): - msg = message.Message(mock.Mock(), MockNotmuchMessage()) - self.assertEqual(msg.get_author(), ('Unknown', '')) diff --git a/tests/db/test_envelope.py b/tests/db/test_envelope.py new file mode 100644 index 00000000..f14b8594 --- /dev/null +++ b/tests/db/test_envelope.py @@ -0,0 +1,103 @@ +# Copyright © 2017 Lucas Hoffmann +# Copyright © 2018 Dylan Baker +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import email.parser +import email.policy +import os +import tempfile +import unittest + +import mock + +from alot.db import envelope + +SETTINGS = { + 'user_agent': 'agent', +} + + +def email_to_dict(mail): + """Consumes an email, and returns a dict of headers and 'Body'.""" + split = mail.splitlines() + final = {} + for line in split: + if line.strip(): + try: + k, v = line.split(':') + final[k.strip()] = v.strip() + except ValueError: + final['Body'] = line.strip() + return final + + +class TestEnvelope(unittest.TestCase): + + def assertEmailEqual(self, first, second): + with self.subTest('body'): + self.assertEqual(first.is_multipart(), second.is_multipart()) + if not first.is_multipart(): + self.assertEqual(first.get_payload(), second.get_payload()) + else: + for f, s in zip(first.walk(), second.walk()): + if f.is_multipart() or s.is_multipart(): + self.assertEqual(first.is_multipart(), + second.is_multipart()) + else: + self.assertEqual(f.get_payload(), s.get_payload()) + with self.subTest('headers'): + self.assertListEqual(first.values(), second.values()) + + def test_setitem_stores_text_unchanged(self): + "Just ensure that the value is set and unchanged" + e = envelope.Envelope() + e['Subject'] = u'sm\xf8rebr\xf8d' + self.assertEqual(e['Subject'], u'sm\xf8rebr\xf8d') + + def _test_mail(self, envelope): + mail = envelope.construct_mail() + raw = mail.as_string(policy=email.policy.SMTP) + actual = email.parser.Parser().parsestr(raw) + self.assertEmailEqual(mail, actual) + + @mock.patch('alot.db.envelope.settings', SETTINGS) + def test_construct_mail_simple(self): + """Very simple envelope with a To, From, Subject, and body.""" + headers = { + 'From': 'foo@example.com', + 'To': 'bar@example.com', + 'Subject': 'Test email', + } + e = envelope.Envelope(headers={k: [v] for k, v in headers.items()}, + bodytext='Test') + self._test_mail(e) + + @mock.patch('alot.db.envelope.settings', SETTINGS) + def test_construct_mail_with_attachment(self): + """Very simple envelope with a To, From, Subject, body and attachment. + """ + headers = { + 'From': 'foo@example.com', + 'To': 'bar@example.com', + 'Subject': 'Test email', + } + e = envelope.Envelope(headers={k: [v] for k, v in headers.items()}, + bodytext='Test') + with tempfile.NamedTemporaryFile(mode='wt', delete=False) as f: + f.write('blah') + self.addCleanup(os.unlink, f.name) + e.attach(f.name) + + self._test_mail(e) diff --git a/tests/db/test_manager.py b/tests/db/test_manager.py new file mode 100644 index 00000000..e675aed4 --- /dev/null +++ b/tests/db/test_manager.py @@ -0,0 +1,53 @@ +# Copyright (C) 2018 Patrick Totzke +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +"""Test suite for alot.db.manager module.""" + +import tempfile +import textwrap +import os +import shutil + +from alot.db.manager import DBManager +from alot.settings.const import settings +from notmuch import Database + +from .. import utilities + + +class TestDBManager(utilities.TestCaseClassCleanup): + + @classmethod + def setUpClass(cls): + + # create temporary notmuch config + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [maildir] + synchronize_flags = true + """)) + cls.notmuch_config_path = f.name + cls.addClassCleanup(os.unlink, f.name) + + # define an empty notmuch database in a temporary directory + cls.dbpath = tempfile.mkdtemp() + cls.db = Database(path=cls.dbpath, create=True) + cls.db.close() + cls.manager = DBManager(cls.dbpath) + + # clean up temporary database + cls.addClassCleanup(cls.manager.kill_search_processes) + cls.addClassCleanup(shutil.rmtree, cls.dbpath) + + # let global settings manager read our temporary notmuch config + settings.read_notmuch_config(cls.notmuch_config_path) + + def test_save_named_query(self): + alias = 'key' + querystring = 'query string' + self.manager.save_named_query(alias, querystring) + self.manager.flush() + + named_queries_dict = self.manager.get_named_queries() + self.assertDictEqual(named_queries_dict, {alias: querystring}) diff --git a/tests/db/test_message.py b/tests/db/test_message.py new file mode 100644 index 00000000..29ed5ee6 --- /dev/null +++ b/tests/db/test_message.py @@ -0,0 +1,115 @@ +# encoding=utf-8 +# Copyright © 2017 Dylan Baker +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import unittest + +import mock + +from notmuch import NullPointerError + +from alot import account +from alot.db import message + + +class MockNotmuchMessage(object): + """An object that looks very much like a notmuch message. + + All public instance variables that are not part of the notmuch Message + class are prefaced with mock. + """ + + def __init__(self, headers=None, tags=None): + self.mock_headers = headers or {} + self.mock_message_id = 'message id' + self.mock_thread_id = 'thread id' + self.mock_date = 0 + self.mock_filename = 'filename' + self.mock_tags = tags or [] + + def get_header(self, field): + return self.mock_headers.get(field, '') + + def get_message_id(self): + return self.mock_message_id + + def get_thread_id(self): + return self.mock_thread_id + + def get_date(self): + return self.mock_date + + def get_filename(self): + return self.mock_filename + + def get_tags(self): + return self.mock_tags + + def get_properties(self, prop, exact=False): + return [] + + +class TestMessage(unittest.TestCase): + + def test_get_author_email_only(self): + """Message._from is populated using the 'From' header when only an + email address is provided. + """ + msg = message.Message(mock.Mock(), + MockNotmuchMessage({'From': 'user@example.com'})) + self.assertEqual(msg.get_author(), ('', 'user@example.com')) + + def test_get_author_name_and_email(self): + """Message._from is populated using the 'From' header when an email and + name are provided. + """ + msg = message.Message( + mock.Mock(), + MockNotmuchMessage({'From': '"User Name" '})) + self.assertEqual(msg.get_author(), ('User Name', 'user@example.com')) + + def test_get_author_sender(self): + """Message._from is populated using the 'Sender' header when no 'From' + header is present. + """ + msg = message.Message( + mock.Mock(), + MockNotmuchMessage({'Sender': '"User Name" '})) + self.assertEqual(msg.get_author(), ('User Name', 'user@example.com')) + + def test_get_author_no_name_draft(self): + """Message._from is populated from the default account if the draft tag + is present. + """ + acc = mock.Mock() + acc.address = account.Address(u'user', u'example.com') + acc.realname = u'User Name' + with mock.patch('alot.db.message.settings.get_accounts', + mock.Mock(return_value=[acc])): + msg = message.Message( + mock.Mock(), MockNotmuchMessage(tags=['draft'])) + self.assertEqual(msg.get_author(), ('User Name', 'user@example.com')) + + def test_get_author_no_name(self): + """Message._from is set to 'Unkown' if there is no relavent header and + the message is not a draft. + """ + acc = mock.Mock() + acc.address = account.Address(u'user', u'example.com') + acc.realname = u'User Name' + with mock.patch('alot.db.message.settings.get_accounts', + mock.Mock(return_value=[acc])): + msg = message.Message(mock.Mock(), MockNotmuchMessage()) + self.assertEqual(msg.get_author(), ('Unknown', '')) diff --git a/tests/db/test_thread.py b/tests/db/test_thread.py new file mode 100644 index 00000000..e3e398eb --- /dev/null +++ b/tests/db/test_thread.py @@ -0,0 +1,76 @@ +# encoding=utf-8 +# Copyright © 2016 Dylan Baker + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for the alot.db.thread module.""" +import datetime +import unittest + +import mock + +from alot.db import thread + + +class TestThreadGetAuthor(unittest.TestCase): + + __patchers = [] + + @classmethod + def setUpClass(cls): + get_messages = [] + for a, d in [('foo', datetime.datetime(datetime.MINYEAR, 1, day=21)), + ('bar', datetime.datetime(datetime.MINYEAR, 1, day=17)), + ('foo', datetime.datetime(datetime.MINYEAR, 1, day=14)), + ('arf', datetime.datetime(datetime.MINYEAR, 1, 1, hour=1, + minute=5)), + ('oof', datetime.datetime(datetime.MINYEAR, 1, 1, hour=1, + minute=10)), + ('ooh', None)]: + m = mock.Mock() + m.get_date = mock.Mock(return_value=d) + m.get_author = mock.Mock(return_value=a) + get_messages.append(m) + gm = mock.Mock() + gm.keys = mock.Mock(return_value=get_messages) + + cls.__patchers.extend([ + mock.patch('alot.db.thread.Thread.get_messages', + new=mock.Mock(return_value=gm)), + mock.patch('alot.db.thread.Thread.refresh', new=mock.Mock()), + ]) + + for p in cls.__patchers: + p.start() + + @classmethod + def tearDownClass(cls): + for p in reversed(cls.__patchers): + p.stop() + + def setUp(self): + # values are cached and each test needs it's own instance. + self.thread = thread.Thread(mock.Mock(), mock.Mock()) + + def test_default(self): + self.assertEqual( + self.thread.get_authors(), + ['arf', 'oof', 'foo', 'bar', 'ooh']) + + def test_latest_message(self): + with mock.patch('alot.db.thread.settings.get', + mock.Mock(return_value='latest_message')): + self.assertEqual( + self.thread.get_authors(), + ['arf', 'oof', 'bar', 'foo', 'ooh']) diff --git a/tests/db/test_utils.py b/tests/db/test_utils.py new file mode 100644 index 00000000..7d54741f --- /dev/null +++ b/tests/db/test_utils.py @@ -0,0 +1,774 @@ +# encoding: utf-8 +# Copyright (C) 2017 Lucas Hoffmann +# Copyright © 2017 Dylan Baker +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +import base64 +import codecs +import email +import email.header +import email.mime.application +import email.policy +import io +import os +import os.path +import shutil +import tempfile +import unittest + +import gpg +import mock + +from alot import crypto +from alot.db import utils +from alot.errors import GPGProblem +from ..utilities import make_key, make_uid, TestCaseClassCleanup + + +class TestGetParams(unittest.TestCase): + + mailstring = '\n'.join([ + 'From: me', + 'To: you', + 'Subject: header field capitalisation', + 'Content-type: text/plain; charset=utf-8', + 'X-Header: param=one; and=two; or=three', + "X-Quoted: param=utf-8''%C3%9Cmlaut; second=plain%C3%9C", + 'X-UPPERCASE: PARAM1=ONE; PARAM2=TWO' + '\n', + 'content' + ]) + mail = email.message_from_string(mailstring) + + def test_returns_content_type_parameters_by_default(self): + actual = utils.get_params(self.mail) + expected = {'text/plain': '', 'charset': 'utf-8'} + self.assertDictEqual(actual, expected) + + def test_can_return_params_of_any_header_field(self): + actual = utils.get_params(self.mail, header='x-header') + expected = {'param': 'one', 'and': 'two', 'or': 'three'} + self.assertDictEqual(actual, expected) + + @unittest.expectedFailure + def test_parameters_are_decoded(self): + actual = utils.get_params(self.mail, header='x-quoted') + expected = {'param': 'Ümlaut', 'second': 'plain%C3%9C'} + self.assertDictEqual(actual, expected) + + def test_parameters_names_are_converted_to_lowercase(self): + actual = utils.get_params(self.mail, header='x-uppercase') + expected = {'param1': 'ONE', 'param2': 'TWO'} + self.assertDictEqual(actual, expected) + + def test_returns_empty_dict_if_header_not_present(self): + actual = utils.get_params(self.mail, header='x-header-not-present') + self.assertDictEqual(actual, dict()) + + def test_returns_failobj_if_header_not_present(self): + failobj = [('my special failobj for the test', 'needs to be a pair!')] + actual = utils.get_params(self.mail, header='x-header-not-present', + failobj=failobj) + expected = dict(failobj) + self.assertEqual(actual, expected) + + +class TestIsSubdirOf(unittest.TestCase): + + def test_both_paths_absolute_matching(self): + superpath = '/a/b' + subpath = '/a/b/c/d.rst' + result = utils.is_subdir_of(subpath, superpath) + self.assertTrue(result) + + def test_both_paths_absolute_not_matching(self): + superpath = '/a/z' + subpath = '/a/b/c/d.rst' + result = utils.is_subdir_of(subpath, superpath) + self.assertFalse(result) + + def test_both_paths_relative_matching(self): + superpath = 'a/b' + subpath = 'a/b/c/d.rst' + result = utils.is_subdir_of(subpath, superpath) + self.assertTrue(result) + + def test_both_paths_relative_not_matching(self): + superpath = 'a/z' + subpath = 'a/b/c/d.rst' + result = utils.is_subdir_of(subpath, superpath) + self.assertFalse(result) + + def test_relative_path_and_absolute_path_matching(self): + superpath = 'a/b' + subpath = os.path.join(os.getcwd(), 'a/b/c/d.rst') + result = utils.is_subdir_of(subpath, superpath) + self.assertTrue(result) + + +class TestExtractHeader(unittest.TestCase): + + mailstring = '\n'.join([ + 'From: me', + 'To: you', + 'Subject: header field capitalisation', + 'Content-type: text/plain; charset=utf-8', + 'X-Header: param=one; and=two; or=three', + "X-Quoted: param=utf-8''%C3%9Cmlaut; second=plain%C3%9C", + 'X-UPPERCASE: PARAM1=ONE; PARAM2=TWO' + '\n', + 'content' + ]) + mail = email.message_from_string(mailstring) + + def test_default_arguments_yield_all_headers(self): + actual = utils.extract_headers(self.mail) + # collect all lines until the first empty line, hence all header lines + expected = [] + for line in self.mailstring.splitlines(): + if not line: + break + expected.append(line) + expected = u'\n'.join(expected) + u'\n' + self.assertEqual(actual, expected) + + def test_single_headers_can_be_retrieved(self): + actual = utils.extract_headers(self.mail, ['from']) + expected = u'from: me\n' + self.assertEqual(actual, expected) + + def test_multible_headers_can_be_retrieved_in_predevined_order(self): + headers = ['x-header', 'to', 'x-uppercase'] + actual = utils.extract_headers(self.mail, headers) + expected = u'x-header: param=one; and=two; or=three\nto: you\n' \ + u'x-uppercase: PARAM1=ONE; PARAM2=TWO\n' + self.assertEqual(actual, expected) + + def test_headers_can_be_retrieved_multible_times(self): + headers = ['from', 'from'] + actual = utils.extract_headers(self.mail, headers) + expected = u'from: me\nfrom: me\n' + self.assertEqual(actual, expected) + + def test_case_is_prserved_in_header_keys_but_irelevant(self): + headers = ['FROM', 'from'] + actual = utils.extract_headers(self.mail, headers) + expected = u'FROM: me\nfrom: me\n' + self.assertEqual(actual, expected) + + @unittest.expectedFailure + def test_header_values_are_not_decoded(self): + actual = utils.extract_headers(self.mail, ['x-quoted']) + expected = u"x-quoted: param=utf-8''%C3%9Cmlaut; second=plain%C3%9C\n", + self.assertEqual(actual, expected) + + +class TestDecodeHeader(unittest.TestCase): + + @staticmethod + def _quote(unicode_string, encoding): + """Turn a unicode string into a RFC2047 quoted ascii string + + :param unicode_string: the string to encode + :type unicode_string: unicode + :param encoding: the encoding to use, 'utf-8', 'iso-8859-1', ... + :type encoding: str + :returns: the encoded string + :rtype: str + """ + string = unicode_string.encode(encoding) + output = b'=?' + encoding.encode('ascii') + b'?Q?' + for byte in string: + output += b'=' + codecs.encode(bytes([byte]), 'hex').upper() + return (output + b'?=').decode('ascii') + + @staticmethod + def _base64(unicode_string, encoding): + """Turn a unicode string into a RFC2047 base64 encoded ascii string + + :param unicode_string: the string to encode + :type unicode_string: unicode + :param encoding: the encoding to use, 'utf-8', 'iso-8859-1', ... + :type encoding: str + :returns: the encoded string + :rtype: str + """ + string = unicode_string.encode(encoding) + b64 = base64.encodebytes(string).strip() + result_bytes = b'=?' + encoding.encode('utf-8') + b'?B?' + b64 + b'?=' + result = result_bytes.decode('ascii') + return result + + def _test(self, teststring, expected): + actual = utils.decode_header(teststring) + self.assertEqual(actual, expected) + + def test_non_ascii_strings_are_returned_as_unicode_directly(self): + text = u'Nön ÄSCII string¡' + self._test(text, text) + + def test_basic_utf_8_quoted(self): + expected = u'ÄÖÜäöü' + text = self._quote(expected, 'utf-8') + self._test(text, expected) + + def test_basic_iso_8859_1_quoted(self): + expected = u'ÄÖÜäöü' + text = self._quote(expected, 'iso-8859-1') + self._test(text, expected) + + def test_basic_windows_1252_quoted(self): + expected = u'ÄÖÜäöü' + text = self._quote(expected, 'windows-1252') + self._test(text, expected) + + def test_basic_utf_8_base64(self): + expected = u'ÄÖÜäöü' + text = self._base64(expected, 'utf-8') + self._test(text, expected) + + def test_basic_iso_8859_1_base64(self): + expected = u'ÄÖÜäöü' + text = self._base64(expected, 'iso-8859-1') + self._test(text, expected) + + def test_basic_iso_1252_base64(self): + expected = u'ÄÖÜäöü' + text = self._base64(expected, 'windows-1252') + self._test(text, expected) + + def test_quoted_words_can_be_interrupted(self): + part = u'ÄÖÜäöü' + text = self._base64(part, 'utf-8') + ' and ' + \ + self._quote(part, 'utf-8') + expected = u'ÄÖÜäöü and ÄÖÜäöü' + self._test(text, expected) + + def test_different_encodings_can_be_mixed(self): + part = u'ÄÖÜäöü' + text = 'utf-8: ' + self._base64(part, 'utf-8') + \ + ' again: ' + self._quote(part, 'utf-8') + \ + ' latin1: ' + self._base64(part, 'iso-8859-1') + \ + ' and ' + self._quote(part, 'iso-8859-1') + expected = ( + u'utf-8: ÄÖÜäöü ' + u'again: ÄÖÜäöü ' + u'latin1: ÄÖÜäöü and ÄÖÜäöü' + ) + self._test(text, expected) + + def test_tabs_are_expanded_to_align_with_eigth_spaces(self): + text = 'tab: \t' + expected = u'tab: ' + self._test(text, expected) + + def test_newlines_are_not_touched_by_default(self): + text = 'first\nsecond\n third\n fourth' + expected = u'first\nsecond\n third\n fourth' + self._test(text, expected) + + def test_continuation_newlines_can_be_normalized(self): + text = 'first\nsecond\n third\n\tfourth\n \t fifth' + expected = u'first\nsecond third fourth fifth' + actual = utils.decode_header(text, normalize=True) + self.assertEqual(actual, expected) + + +class TestAddSignatureHeaders(unittest.TestCase): + + class FakeMail(object): + def __init__(self): + self.headers = [] + + def add_header(self, header, value): + self.headers.append((header, value)) + + def check(self, key, valid, error_msg=u''): + mail = self.FakeMail() + + with mock.patch('alot.db.utils.crypto.get_key', + mock.Mock(return_value=key)), \ + mock.patch('alot.db.utils.crypto.check_uid_validity', + mock.Mock(return_value=valid)): + utils.add_signature_headers(mail, [mock.Mock(fpr='')], error_msg) + + return mail + + def test_length_0(self): + mail = self.FakeMail() + utils.add_signature_headers(mail, [], u'') + self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'False'), mail.headers) + self.assertIn( + (utils.X_SIGNATURE_MESSAGE_HEADER, u'Invalid: no signature found'), + mail.headers) + + def test_valid(self): + key = make_key() + mail = self.check(key, True) + + self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'True'), mail.headers) + self.assertIn( + (utils.X_SIGNATURE_MESSAGE_HEADER, u'Valid: mocked'), mail.headers) + + def test_untrusted(self): + key = make_key() + mail = self.check(key, False) + + self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'True'), mail.headers) + self.assertIn( + (utils.X_SIGNATURE_MESSAGE_HEADER, u'Untrusted: mocked'), + mail.headers) + + def test_unicode_as_bytes(self): + mail = self.FakeMail() + key = make_key() + key.uids = [make_uid('andreá@example.com', uid=u'Andreá')] + mail = self.check(key, True) + + self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'True'), mail.headers) + self.assertIn( + (utils.X_SIGNATURE_MESSAGE_HEADER, u'Valid: Andreá'), + mail.headers) + + def test_error_message_unicode(self): + mail = self.check(mock.Mock(), mock.Mock(), u'error message') + self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'False'), mail.headers) + self.assertIn( + (utils.X_SIGNATURE_MESSAGE_HEADER, u'Invalid: error message'), + mail.headers) + + def test_get_key_fails(self): + mail = self.FakeMail() + with mock.patch('alot.db.utils.crypto.get_key', + mock.Mock(side_effect=GPGProblem(u'', 0))): + utils.add_signature_headers(mail, [mock.Mock(fpr='')], u'') + self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'False'), mail.headers) + self.assertIn( + (utils.X_SIGNATURE_MESSAGE_HEADER, u'Untrusted: '), + mail.headers) + + +class TestMessageFromFile(TestCaseClassCleanup): + + @classmethod + def setUpClass(cls): + home = tempfile.mkdtemp() + cls.addClassCleanup(shutil.rmtree, home) + mock_home = mock.patch.dict(os.environ, {'GNUPGHOME': home}) + mock_home.start() + cls.addClassCleanup(mock_home.stop) + + with gpg.core.Context() as ctx: + search_dir = os.path.join(os.path.dirname(__file__), + '../static/gpg-keys') + for each in os.listdir(search_dir): + if os.path.splitext(each)[1] == '.gpg': + with open(os.path.join(search_dir, each)) as f: + ctx.op_import(f) + + cls.keys = [ + ctx.get_key("DD19862809A7573A74058FF255937AFBB156245D")] + + def test_erase_alot_header_signature_valid(self): + """Alot uses special headers for passing certain kinds of information, + it's important that information isn't passed in from the original + message as a way to trick the user. + """ + m = email.message.Message() + m.add_header(utils.X_SIGNATURE_VALID_HEADER, 'Bad') + message = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIs(message.get(utils.X_SIGNATURE_VALID_HEADER), None) + + def test_erase_alot_header_message(self): + m = email.message.Message() + m.add_header(utils.X_SIGNATURE_MESSAGE_HEADER, 'Bad') + message = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIs(message.get(utils.X_SIGNATURE_MESSAGE_HEADER), None) + + def test_plain_mail(self): + m = email.mime.text.MIMEText(u'This is some text', 'plain', 'utf-8') + m['Subject'] = 'test' + m['From'] = 'me' + m['To'] = 'Nobody' + message = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertEqual(message.get_payload(), 'This is some text') + + def _make_signed(self): + """Create a signed message that is multipart/signed.""" + text = b'This is some text' + t = email.mime.text.MIMEText(text, 'plain', 'utf-8') + _, sig = crypto.detached_signature_for( + t.as_bytes(policy=email.policy.SMTP), self.keys) + s = email.mime.application.MIMEApplication( + sig, 'pgp-signature', email.encoders.encode_7or8bit) + m = email.mime.multipart.MIMEMultipart('signed', None, [t, s]) + m.set_param('protocol', 'application/pgp-signature') + m.set_param('micalg', 'pgp-sha256') + return m + + def test_signed_headers_included(self): + """Headers are added to the message.""" + m = self._make_signed() + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn(utils.X_SIGNATURE_VALID_HEADER, m) + self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) + + def test_signed_valid(self): + """Test that the signature is valid.""" + m = self._make_signed() + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertEqual(m[utils.X_SIGNATURE_VALID_HEADER], 'True') + + def test_signed_correct_from(self): + """Test that the signature is valid.""" + m = self._make_signed() + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + # Don't test for valid/invalid since that might change + self.assertIn( + 'ambig ', m[utils.X_SIGNATURE_MESSAGE_HEADER]) + + def test_signed_wrong_mimetype_second_payload(self): + m = self._make_signed() + m.get_payload(1).set_type('text/plain') + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn('expected Content-Type: ', + m[utils.X_SIGNATURE_MESSAGE_HEADER]) + + def test_signed_wrong_micalg(self): + m = self._make_signed() + m.set_param('micalg', 'foo') + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn('expected micalg=pgp-...', + m[utils.X_SIGNATURE_MESSAGE_HEADER]) + + def test_signed_micalg_cap(self): + """The micalg parameter should be normalized to lower case. + + From RFC 3156 § 5 + + The "micalg" parameter for the "application/pgp-signature" protocol + MUST contain exactly one hash-symbol of the format "pgp-", where identifies the Message + Integrity Check (MIC) algorithm used to generate the signature. + Hash-symbols are constructed from the text names registered in [1] + or according to the mechanism defined in that document by + converting the text name to lower case and prefixing it with the + four characters "pgp-". + + The spec is pretty clear that this is supposed to be lower cased. + """ + m = self._make_signed() + m.set_param('micalg', 'PGP-SHA1') + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn('expected micalg=pgp-', + m[utils.X_SIGNATURE_MESSAGE_HEADER]) + + def test_signed_more_than_two_messages(self): + """Per the spec only 2 payloads may be encapsulated inside the + multipart/signed payload, while it might be nice to cover more than 2 + payloads (Postel's law), it would introduce serious complexity + since we would also need to cover those payloads being misordered. + Since getting the right number of payloads and getting them in the + right order should be fairly easy to implement correctly enforcing that + there are only two payloads seems reasonable. + """ + m = self._make_signed() + m.attach(email.mime.text.MIMEText('foo')) + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn('expected exactly two messages, got 3', + m[utils.X_SIGNATURE_MESSAGE_HEADER]) + + # TODO: The case of more than two payloads, or the payloads being out of + # order. Also for the encrypted case. + + def _make_encrypted(self, signed=False): + """Create an encrypted (and optionally signed) message.""" + if signed: + t = self._make_signed() + else: + text = b'This is some text' + t = email.mime.text.MIMEText(text, 'plain', 'utf-8') + enc = crypto.encrypt(t.as_bytes(policy=email.policy.SMTP), self.keys) + e = email.mime.application.MIMEApplication( + enc, 'octet-stream', email.encoders.encode_7or8bit) + + f = email.mime.application.MIMEApplication( + b'Version: 1', 'pgp-encrypted', email.encoders.encode_7or8bit) + + m = email.mime.multipart.MIMEMultipart('encrypted', None, [f, e]) + m.set_param('protocol', 'application/pgp-encrypted') + + return m + + def test_encrypted_length(self): + # It seems string that we just attach the unsigned message to the end + # of the mail, rather than replacing the whole encrypted payload with + # it's unencrypted equivalent + m = self._make_encrypted() + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertEqual(len(m.get_payload()), 3) + + def test_encrypted_unsigned_is_decrypted(self): + m = self._make_encrypted() + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + # Check using m.walk, since we're not checking for ordering, just + # existence. + self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) + + def test_encrypted_unsigned_doesnt_add_signed_headers(self): + """Since the message isn't signed, it shouldn't have headers saying + that there is a signature. + """ + m = self._make_encrypted() + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertNotIn(utils.X_SIGNATURE_VALID_HEADER, m) + self.assertNotIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) + + def test_encrypted_signed_is_decrypted(self): + m = self._make_encrypted(True) + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) + + def test_encrypted_signed_headers(self): + """Since the message is signed, it should have headers saying that + there is a signature. + """ + m = self._make_encrypted(True) + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) + self.assertIn( + 'ambig ', m[utils.X_SIGNATURE_MESSAGE_HEADER]) + + # TODO: tests for the RFC 2440 style combined signed/encrypted blob + + def test_encrypted_wrong_mimetype_first_payload(self): + m = self._make_encrypted() + m.get_payload(0).set_type('text/plain') + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn('Malformed OpenPGP message:', + m.get_payload(2).get_payload()) + + def test_encrypted_wrong_mimetype_second_payload(self): + m = self._make_encrypted() + m.get_payload(1).set_type('text/plain') + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn('Malformed OpenPGP message:', + m.get_payload(2).get_payload()) + + def test_signed_in_multipart_mixed(self): + """It is valid to encapsulate a multipart/signed payload inside a + multipart/mixed payload, verify that works. + """ + s = self._make_signed() + m = email.mime.multipart.MIMEMultipart('mixed', None, [s]) + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn(utils.X_SIGNATURE_VALID_HEADER, m) + self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) + + def test_encrypted_unsigned_in_multipart_mixed(self): + """It is valid to encapsulate a multipart/encrypted payload inside a + multipart/mixed payload, verify that works. + """ + s = self._make_encrypted() + m = email.mime.multipart.MIMEMultipart('mixed', None, [s]) + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) + self.assertNotIn(utils.X_SIGNATURE_VALID_HEADER, m) + self.assertNotIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) + + def test_encrypted_signed_in_multipart_mixed(self): + """It is valid to encapsulate a multipart/encrypted payload inside a + multipart/mixed payload, verify that works when the multipart/encrypted + contains a multipart/signed. + """ + s = self._make_encrypted(True) + m = email.mime.multipart.MIMEMultipart('mixed', None, [s]) + m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) + self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) + self.assertIn(utils.X_SIGNATURE_VALID_HEADER, m) + self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) + + +class TestExtractBody(unittest.TestCase): + + @staticmethod + def _set_basic_headers(mail): + mail['Subject'] = 'Test email' + mail['To'] = 'foo@example.com' + mail['From'] = 'bar@example.com' + + def test_single_text_plain(self): + mail = email.mime.text.MIMEText('This is an email') + self._set_basic_headers(mail) + actual = utils.extract_body(mail) + + expected = 'This is an email' + + self.assertEqual(actual, expected) + + def test_two_text_plain(self): + mail = email.mime.multipart.MIMEMultipart() + self._set_basic_headers(mail) + mail.attach(email.mime.text.MIMEText('This is an email')) + mail.attach(email.mime.text.MIMEText('This is a second part')) + + actual = utils.extract_body(mail) + expected = 'This is an email\n\nThis is a second part' + + self.assertEqual(actual, expected) + + def test_text_plain_and_other(self): + mail = email.mime.multipart.MIMEMultipart() + self._set_basic_headers(mail) + mail.attach(email.mime.text.MIMEText('This is an email')) + mail.attach(email.mime.application.MIMEApplication(b'1')) + + actual = utils.extract_body(mail) + expected = 'This is an email' + + self.assertEqual(actual, expected) + + def test_text_plain_with_attachment_text(self): + mail = email.mime.multipart.MIMEMultipart() + self._set_basic_headers(mail) + mail.attach(email.mime.text.MIMEText('This is an email')) + attachment = email.mime.text.MIMEText('this shouldnt be displayed') + attachment['Content-Disposition'] = 'attachment' + mail.attach(attachment) + + actual = utils.extract_body(mail) + expected = 'This is an email' + + self.assertEqual(actual, expected) + + def _make_mixed_plain_html(self): + mail = email.mime.multipart.MIMEMultipart() + self._set_basic_headers(mail) + mail.attach(email.mime.text.MIMEText('This is an email')) + mail.attach(email.mime.text.MIMEText( + 'This is an html email', + 'html')) + return mail + + @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=True)) + def test_prefer_plaintext(self): + expected = 'This is an email' + mail = self._make_mixed_plain_html() + actual = utils.extract_body(mail) + + self.assertEqual(actual, expected) + + # Mock the handler to cat, so that no transformations of the html are made + # making the result non-deterministic + @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False)) + @mock.patch('alot.db.utils.settings.mailcap_find_match', + mock.Mock(return_value=(None, {'view': 'cat'}))) + def test_prefer_html(self): + expected = ( + 'This is an html email') + mail = self._make_mixed_plain_html() + actual = utils.extract_body(mail) + + self.assertEqual(actual, expected) + + @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False)) + @mock.patch('alot.db.utils.settings.mailcap_find_match', + mock.Mock(return_value=(None, {'view': 'cat'}))) + def test_types_provided(self): + # This should not return html, even though html is set to preferred + # since a types variable is passed + expected = 'This is an email' + mail = self._make_mixed_plain_html() + actual = utils.extract_body(mail, types=['text/plain']) + + self.assertEqual(actual, expected) + + @mock.patch('alot.db.utils.settings.mailcap_find_match', + mock.Mock(return_value=(None, {'view': 'cat'}))) + def test_require_mailcap_stdin(self): + mail = email.mime.multipart.MIMEMultipart() + self._set_basic_headers(mail) + mail.attach(email.mime.text.MIMEText( + 'This is an html email', + 'html')) + actual = utils.extract_body(mail) + expected = ( + 'This is an html email') + + self.assertEqual(actual, expected) + + @mock.patch('alot.db.utils.settings.mailcap_find_match', + mock.Mock(return_value=(None, {'view': 'cat %s'}))) + def test_require_mailcap_file(self): + mail = email.mime.multipart.MIMEMultipart() + self._set_basic_headers(mail) + mail.attach(email.mime.text.MIMEText( + 'This is an html email', + 'html')) + actual = utils.extract_body(mail) + expected = ( + 'This is an html email') + + self.assertEqual(actual, expected) + + @unittest.expectedFailure + def test_simple_utf8_file(self): + mail = email.message_from_binary_file( + open('tests/static/mail/utf8.eml', 'rb')) + actual = utils.extract_body(mail) + expected = "Liebe Grüße!\n" + self.assertEqual(actual, expected) + +class TestMessageFromString(unittest.TestCase): + + """Tests for decrypted_message_from_string. + + Because the implementation is that this is a wrapper around + decrypted_message_from_file, it's not important to have a large swath of + tests, just enough to show that things are being passed correctly. + """ + + def test(self): + m = email.mime.text.MIMEText(u'This is some text', 'plain', 'utf-8') + m['Subject'] = 'test' + m['From'] = 'me' + m['To'] = 'Nobody' + message = utils.decrypted_message_from_string(m.as_string()) + self.assertEqual(message.get_payload(), 'This is some text') + + +class TestRemoveCte(unittest.TestCase): + + def test_char_vs_cte_mismatch(self): # #1291 + with open('tests/static/mail/broken-utf8.eml') as fp: + mail = email.message_from_file(fp) + # This should not raise an UnicodeDecodeError. + with self.assertLogs(level='DEBUG') as cm: # keep logs + utils.remove_cte(mail, as_string=True) + # We expect no Exceptions but a complaint in the log + logmsg = 'DEBUG:root:Decoding failure: \'utf-8\' codec can\'t decode '\ + 'byte 0xa1 in position 14: invalid start byte' + self.assertIn(logmsg, cm.output) + + def test_malformed_cte_value(self): + with open('tests/static/mail/malformed-header-CTE.eml') as fp: + mail = email.message_from_file(fp) + + with self.assertLogs(level='INFO') as cm: # keep logs + utils.remove_cte(mail, as_string=True) + + # We expect no Exceptions but a complaint in the log + logmsg = 'INFO:root:Unknown Content-Transfer-Encoding: "7bit;"' + self.assertEqual(cm.output, [logmsg]) + + def test_unknown_cte_value(self): + with open('tests/static/mail/malformed-header-CTE-2.eml') as fp: + mail = email.message_from_file(fp) + + with self.assertLogs(level='DEBUG') as cm: # keep logs + utils.remove_cte(mail, as_string=True) + + # We expect no Exceptions but a complaint in the log + logmsg = 'DEBUG:root:failed to interpret Content-Transfer-Encoding: '\ + '"normal"' + self.assertIn(logmsg, cm.output) diff --git a/tests/db/thread_test.py b/tests/db/thread_test.py deleted file mode 100644 index e3e398eb..00000000 --- a/tests/db/thread_test.py +++ /dev/null @@ -1,76 +0,0 @@ -# encoding=utf-8 -# Copyright © 2016 Dylan Baker - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Tests for the alot.db.thread module.""" -import datetime -import unittest - -import mock - -from alot.db import thread - - -class TestThreadGetAuthor(unittest.TestCase): - - __patchers = [] - - @classmethod - def setUpClass(cls): - get_messages = [] - for a, d in [('foo', datetime.datetime(datetime.MINYEAR, 1, day=21)), - ('bar', datetime.datetime(datetime.MINYEAR, 1, day=17)), - ('foo', datetime.datetime(datetime.MINYEAR, 1, day=14)), - ('arf', datetime.datetime(datetime.MINYEAR, 1, 1, hour=1, - minute=5)), - ('oof', datetime.datetime(datetime.MINYEAR, 1, 1, hour=1, - minute=10)), - ('ooh', None)]: - m = mock.Mock() - m.get_date = mock.Mock(return_value=d) - m.get_author = mock.Mock(return_value=a) - get_messages.append(m) - gm = mock.Mock() - gm.keys = mock.Mock(return_value=get_messages) - - cls.__patchers.extend([ - mock.patch('alot.db.thread.Thread.get_messages', - new=mock.Mock(return_value=gm)), - mock.patch('alot.db.thread.Thread.refresh', new=mock.Mock()), - ]) - - for p in cls.__patchers: - p.start() - - @classmethod - def tearDownClass(cls): - for p in reversed(cls.__patchers): - p.stop() - - def setUp(self): - # values are cached and each test needs it's own instance. - self.thread = thread.Thread(mock.Mock(), mock.Mock()) - - def test_default(self): - self.assertEqual( - self.thread.get_authors(), - ['arf', 'oof', 'foo', 'bar', 'ooh']) - - def test_latest_message(self): - with mock.patch('alot.db.thread.settings.get', - mock.Mock(return_value='latest_message')): - self.assertEqual( - self.thread.get_authors(), - ['arf', 'oof', 'bar', 'foo', 'ooh']) diff --git a/tests/db/utils_test.py b/tests/db/utils_test.py deleted file mode 100644 index 7d54741f..00000000 --- a/tests/db/utils_test.py +++ /dev/null @@ -1,774 +0,0 @@ -# encoding: utf-8 -# Copyright (C) 2017 Lucas Hoffmann -# Copyright © 2017 Dylan Baker -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file -import base64 -import codecs -import email -import email.header -import email.mime.application -import email.policy -import io -import os -import os.path -import shutil -import tempfile -import unittest - -import gpg -import mock - -from alot import crypto -from alot.db import utils -from alot.errors import GPGProblem -from ..utilities import make_key, make_uid, TestCaseClassCleanup - - -class TestGetParams(unittest.TestCase): - - mailstring = '\n'.join([ - 'From: me', - 'To: you', - 'Subject: header field capitalisation', - 'Content-type: text/plain; charset=utf-8', - 'X-Header: param=one; and=two; or=three', - "X-Quoted: param=utf-8''%C3%9Cmlaut; second=plain%C3%9C", - 'X-UPPERCASE: PARAM1=ONE; PARAM2=TWO' - '\n', - 'content' - ]) - mail = email.message_from_string(mailstring) - - def test_returns_content_type_parameters_by_default(self): - actual = utils.get_params(self.mail) - expected = {'text/plain': '', 'charset': 'utf-8'} - self.assertDictEqual(actual, expected) - - def test_can_return_params_of_any_header_field(self): - actual = utils.get_params(self.mail, header='x-header') - expected = {'param': 'one', 'and': 'two', 'or': 'three'} - self.assertDictEqual(actual, expected) - - @unittest.expectedFailure - def test_parameters_are_decoded(self): - actual = utils.get_params(self.mail, header='x-quoted') - expected = {'param': 'Ümlaut', 'second': 'plain%C3%9C'} - self.assertDictEqual(actual, expected) - - def test_parameters_names_are_converted_to_lowercase(self): - actual = utils.get_params(self.mail, header='x-uppercase') - expected = {'param1': 'ONE', 'param2': 'TWO'} - self.assertDictEqual(actual, expected) - - def test_returns_empty_dict_if_header_not_present(self): - actual = utils.get_params(self.mail, header='x-header-not-present') - self.assertDictEqual(actual, dict()) - - def test_returns_failobj_if_header_not_present(self): - failobj = [('my special failobj for the test', 'needs to be a pair!')] - actual = utils.get_params(self.mail, header='x-header-not-present', - failobj=failobj) - expected = dict(failobj) - self.assertEqual(actual, expected) - - -class TestIsSubdirOf(unittest.TestCase): - - def test_both_paths_absolute_matching(self): - superpath = '/a/b' - subpath = '/a/b/c/d.rst' - result = utils.is_subdir_of(subpath, superpath) - self.assertTrue(result) - - def test_both_paths_absolute_not_matching(self): - superpath = '/a/z' - subpath = '/a/b/c/d.rst' - result = utils.is_subdir_of(subpath, superpath) - self.assertFalse(result) - - def test_both_paths_relative_matching(self): - superpath = 'a/b' - subpath = 'a/b/c/d.rst' - result = utils.is_subdir_of(subpath, superpath) - self.assertTrue(result) - - def test_both_paths_relative_not_matching(self): - superpath = 'a/z' - subpath = 'a/b/c/d.rst' - result = utils.is_subdir_of(subpath, superpath) - self.assertFalse(result) - - def test_relative_path_and_absolute_path_matching(self): - superpath = 'a/b' - subpath = os.path.join(os.getcwd(), 'a/b/c/d.rst') - result = utils.is_subdir_of(subpath, superpath) - self.assertTrue(result) - - -class TestExtractHeader(unittest.TestCase): - - mailstring = '\n'.join([ - 'From: me', - 'To: you', - 'Subject: header field capitalisation', - 'Content-type: text/plain; charset=utf-8', - 'X-Header: param=one; and=two; or=three', - "X-Quoted: param=utf-8''%C3%9Cmlaut; second=plain%C3%9C", - 'X-UPPERCASE: PARAM1=ONE; PARAM2=TWO' - '\n', - 'content' - ]) - mail = email.message_from_string(mailstring) - - def test_default_arguments_yield_all_headers(self): - actual = utils.extract_headers(self.mail) - # collect all lines until the first empty line, hence all header lines - expected = [] - for line in self.mailstring.splitlines(): - if not line: - break - expected.append(line) - expected = u'\n'.join(expected) + u'\n' - self.assertEqual(actual, expected) - - def test_single_headers_can_be_retrieved(self): - actual = utils.extract_headers(self.mail, ['from']) - expected = u'from: me\n' - self.assertEqual(actual, expected) - - def test_multible_headers_can_be_retrieved_in_predevined_order(self): - headers = ['x-header', 'to', 'x-uppercase'] - actual = utils.extract_headers(self.mail, headers) - expected = u'x-header: param=one; and=two; or=three\nto: you\n' \ - u'x-uppercase: PARAM1=ONE; PARAM2=TWO\n' - self.assertEqual(actual, expected) - - def test_headers_can_be_retrieved_multible_times(self): - headers = ['from', 'from'] - actual = utils.extract_headers(self.mail, headers) - expected = u'from: me\nfrom: me\n' - self.assertEqual(actual, expected) - - def test_case_is_prserved_in_header_keys_but_irelevant(self): - headers = ['FROM', 'from'] - actual = utils.extract_headers(self.mail, headers) - expected = u'FROM: me\nfrom: me\n' - self.assertEqual(actual, expected) - - @unittest.expectedFailure - def test_header_values_are_not_decoded(self): - actual = utils.extract_headers(self.mail, ['x-quoted']) - expected = u"x-quoted: param=utf-8''%C3%9Cmlaut; second=plain%C3%9C\n", - self.assertEqual(actual, expected) - - -class TestDecodeHeader(unittest.TestCase): - - @staticmethod - def _quote(unicode_string, encoding): - """Turn a unicode string into a RFC2047 quoted ascii string - - :param unicode_string: the string to encode - :type unicode_string: unicode - :param encoding: the encoding to use, 'utf-8', 'iso-8859-1', ... - :type encoding: str - :returns: the encoded string - :rtype: str - """ - string = unicode_string.encode(encoding) - output = b'=?' + encoding.encode('ascii') + b'?Q?' - for byte in string: - output += b'=' + codecs.encode(bytes([byte]), 'hex').upper() - return (output + b'?=').decode('ascii') - - @staticmethod - def _base64(unicode_string, encoding): - """Turn a unicode string into a RFC2047 base64 encoded ascii string - - :param unicode_string: the string to encode - :type unicode_string: unicode - :param encoding: the encoding to use, 'utf-8', 'iso-8859-1', ... - :type encoding: str - :returns: the encoded string - :rtype: str - """ - string = unicode_string.encode(encoding) - b64 = base64.encodebytes(string).strip() - result_bytes = b'=?' + encoding.encode('utf-8') + b'?B?' + b64 + b'?=' - result = result_bytes.decode('ascii') - return result - - def _test(self, teststring, expected): - actual = utils.decode_header(teststring) - self.assertEqual(actual, expected) - - def test_non_ascii_strings_are_returned_as_unicode_directly(self): - text = u'Nön ÄSCII string¡' - self._test(text, text) - - def test_basic_utf_8_quoted(self): - expected = u'ÄÖÜäöü' - text = self._quote(expected, 'utf-8') - self._test(text, expected) - - def test_basic_iso_8859_1_quoted(self): - expected = u'ÄÖÜäöü' - text = self._quote(expected, 'iso-8859-1') - self._test(text, expected) - - def test_basic_windows_1252_quoted(self): - expected = u'ÄÖÜäöü' - text = self._quote(expected, 'windows-1252') - self._test(text, expected) - - def test_basic_utf_8_base64(self): - expected = u'ÄÖÜäöü' - text = self._base64(expected, 'utf-8') - self._test(text, expected) - - def test_basic_iso_8859_1_base64(self): - expected = u'ÄÖÜäöü' - text = self._base64(expected, 'iso-8859-1') - self._test(text, expected) - - def test_basic_iso_1252_base64(self): - expected = u'ÄÖÜäöü' - text = self._base64(expected, 'windows-1252') - self._test(text, expected) - - def test_quoted_words_can_be_interrupted(self): - part = u'ÄÖÜäöü' - text = self._base64(part, 'utf-8') + ' and ' + \ - self._quote(part, 'utf-8') - expected = u'ÄÖÜäöü and ÄÖÜäöü' - self._test(text, expected) - - def test_different_encodings_can_be_mixed(self): - part = u'ÄÖÜäöü' - text = 'utf-8: ' + self._base64(part, 'utf-8') + \ - ' again: ' + self._quote(part, 'utf-8') + \ - ' latin1: ' + self._base64(part, 'iso-8859-1') + \ - ' and ' + self._quote(part, 'iso-8859-1') - expected = ( - u'utf-8: ÄÖÜäöü ' - u'again: ÄÖÜäöü ' - u'latin1: ÄÖÜäöü and ÄÖÜäöü' - ) - self._test(text, expected) - - def test_tabs_are_expanded_to_align_with_eigth_spaces(self): - text = 'tab: \t' - expected = u'tab: ' - self._test(text, expected) - - def test_newlines_are_not_touched_by_default(self): - text = 'first\nsecond\n third\n fourth' - expected = u'first\nsecond\n third\n fourth' - self._test(text, expected) - - def test_continuation_newlines_can_be_normalized(self): - text = 'first\nsecond\n third\n\tfourth\n \t fifth' - expected = u'first\nsecond third fourth fifth' - actual = utils.decode_header(text, normalize=True) - self.assertEqual(actual, expected) - - -class TestAddSignatureHeaders(unittest.TestCase): - - class FakeMail(object): - def __init__(self): - self.headers = [] - - def add_header(self, header, value): - self.headers.append((header, value)) - - def check(self, key, valid, error_msg=u''): - mail = self.FakeMail() - - with mock.patch('alot.db.utils.crypto.get_key', - mock.Mock(return_value=key)), \ - mock.patch('alot.db.utils.crypto.check_uid_validity', - mock.Mock(return_value=valid)): - utils.add_signature_headers(mail, [mock.Mock(fpr='')], error_msg) - - return mail - - def test_length_0(self): - mail = self.FakeMail() - utils.add_signature_headers(mail, [], u'') - self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'False'), mail.headers) - self.assertIn( - (utils.X_SIGNATURE_MESSAGE_HEADER, u'Invalid: no signature found'), - mail.headers) - - def test_valid(self): - key = make_key() - mail = self.check(key, True) - - self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'True'), mail.headers) - self.assertIn( - (utils.X_SIGNATURE_MESSAGE_HEADER, u'Valid: mocked'), mail.headers) - - def test_untrusted(self): - key = make_key() - mail = self.check(key, False) - - self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'True'), mail.headers) - self.assertIn( - (utils.X_SIGNATURE_MESSAGE_HEADER, u'Untrusted: mocked'), - mail.headers) - - def test_unicode_as_bytes(self): - mail = self.FakeMail() - key = make_key() - key.uids = [make_uid('andreá@example.com', uid=u'Andreá')] - mail = self.check(key, True) - - self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'True'), mail.headers) - self.assertIn( - (utils.X_SIGNATURE_MESSAGE_HEADER, u'Valid: Andreá'), - mail.headers) - - def test_error_message_unicode(self): - mail = self.check(mock.Mock(), mock.Mock(), u'error message') - self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'False'), mail.headers) - self.assertIn( - (utils.X_SIGNATURE_MESSAGE_HEADER, u'Invalid: error message'), - mail.headers) - - def test_get_key_fails(self): - mail = self.FakeMail() - with mock.patch('alot.db.utils.crypto.get_key', - mock.Mock(side_effect=GPGProblem(u'', 0))): - utils.add_signature_headers(mail, [mock.Mock(fpr='')], u'') - self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'False'), mail.headers) - self.assertIn( - (utils.X_SIGNATURE_MESSAGE_HEADER, u'Untrusted: '), - mail.headers) - - -class TestMessageFromFile(TestCaseClassCleanup): - - @classmethod - def setUpClass(cls): - home = tempfile.mkdtemp() - cls.addClassCleanup(shutil.rmtree, home) - mock_home = mock.patch.dict(os.environ, {'GNUPGHOME': home}) - mock_home.start() - cls.addClassCleanup(mock_home.stop) - - with gpg.core.Context() as ctx: - search_dir = os.path.join(os.path.dirname(__file__), - '../static/gpg-keys') - for each in os.listdir(search_dir): - if os.path.splitext(each)[1] == '.gpg': - with open(os.path.join(search_dir, each)) as f: - ctx.op_import(f) - - cls.keys = [ - ctx.get_key("DD19862809A7573A74058FF255937AFBB156245D")] - - def test_erase_alot_header_signature_valid(self): - """Alot uses special headers for passing certain kinds of information, - it's important that information isn't passed in from the original - message as a way to trick the user. - """ - m = email.message.Message() - m.add_header(utils.X_SIGNATURE_VALID_HEADER, 'Bad') - message = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIs(message.get(utils.X_SIGNATURE_VALID_HEADER), None) - - def test_erase_alot_header_message(self): - m = email.message.Message() - m.add_header(utils.X_SIGNATURE_MESSAGE_HEADER, 'Bad') - message = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIs(message.get(utils.X_SIGNATURE_MESSAGE_HEADER), None) - - def test_plain_mail(self): - m = email.mime.text.MIMEText(u'This is some text', 'plain', 'utf-8') - m['Subject'] = 'test' - m['From'] = 'me' - m['To'] = 'Nobody' - message = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertEqual(message.get_payload(), 'This is some text') - - def _make_signed(self): - """Create a signed message that is multipart/signed.""" - text = b'This is some text' - t = email.mime.text.MIMEText(text, 'plain', 'utf-8') - _, sig = crypto.detached_signature_for( - t.as_bytes(policy=email.policy.SMTP), self.keys) - s = email.mime.application.MIMEApplication( - sig, 'pgp-signature', email.encoders.encode_7or8bit) - m = email.mime.multipart.MIMEMultipart('signed', None, [t, s]) - m.set_param('protocol', 'application/pgp-signature') - m.set_param('micalg', 'pgp-sha256') - return m - - def test_signed_headers_included(self): - """Headers are added to the message.""" - m = self._make_signed() - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn(utils.X_SIGNATURE_VALID_HEADER, m) - self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) - - def test_signed_valid(self): - """Test that the signature is valid.""" - m = self._make_signed() - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertEqual(m[utils.X_SIGNATURE_VALID_HEADER], 'True') - - def test_signed_correct_from(self): - """Test that the signature is valid.""" - m = self._make_signed() - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - # Don't test for valid/invalid since that might change - self.assertIn( - 'ambig ', m[utils.X_SIGNATURE_MESSAGE_HEADER]) - - def test_signed_wrong_mimetype_second_payload(self): - m = self._make_signed() - m.get_payload(1).set_type('text/plain') - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn('expected Content-Type: ', - m[utils.X_SIGNATURE_MESSAGE_HEADER]) - - def test_signed_wrong_micalg(self): - m = self._make_signed() - m.set_param('micalg', 'foo') - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn('expected micalg=pgp-...', - m[utils.X_SIGNATURE_MESSAGE_HEADER]) - - def test_signed_micalg_cap(self): - """The micalg parameter should be normalized to lower case. - - From RFC 3156 § 5 - - The "micalg" parameter for the "application/pgp-signature" protocol - MUST contain exactly one hash-symbol of the format "pgp-", where identifies the Message - Integrity Check (MIC) algorithm used to generate the signature. - Hash-symbols are constructed from the text names registered in [1] - or according to the mechanism defined in that document by - converting the text name to lower case and prefixing it with the - four characters "pgp-". - - The spec is pretty clear that this is supposed to be lower cased. - """ - m = self._make_signed() - m.set_param('micalg', 'PGP-SHA1') - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn('expected micalg=pgp-', - m[utils.X_SIGNATURE_MESSAGE_HEADER]) - - def test_signed_more_than_two_messages(self): - """Per the spec only 2 payloads may be encapsulated inside the - multipart/signed payload, while it might be nice to cover more than 2 - payloads (Postel's law), it would introduce serious complexity - since we would also need to cover those payloads being misordered. - Since getting the right number of payloads and getting them in the - right order should be fairly easy to implement correctly enforcing that - there are only two payloads seems reasonable. - """ - m = self._make_signed() - m.attach(email.mime.text.MIMEText('foo')) - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn('expected exactly two messages, got 3', - m[utils.X_SIGNATURE_MESSAGE_HEADER]) - - # TODO: The case of more than two payloads, or the payloads being out of - # order. Also for the encrypted case. - - def _make_encrypted(self, signed=False): - """Create an encrypted (and optionally signed) message.""" - if signed: - t = self._make_signed() - else: - text = b'This is some text' - t = email.mime.text.MIMEText(text, 'plain', 'utf-8') - enc = crypto.encrypt(t.as_bytes(policy=email.policy.SMTP), self.keys) - e = email.mime.application.MIMEApplication( - enc, 'octet-stream', email.encoders.encode_7or8bit) - - f = email.mime.application.MIMEApplication( - b'Version: 1', 'pgp-encrypted', email.encoders.encode_7or8bit) - - m = email.mime.multipart.MIMEMultipart('encrypted', None, [f, e]) - m.set_param('protocol', 'application/pgp-encrypted') - - return m - - def test_encrypted_length(self): - # It seems string that we just attach the unsigned message to the end - # of the mail, rather than replacing the whole encrypted payload with - # it's unencrypted equivalent - m = self._make_encrypted() - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertEqual(len(m.get_payload()), 3) - - def test_encrypted_unsigned_is_decrypted(self): - m = self._make_encrypted() - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - # Check using m.walk, since we're not checking for ordering, just - # existence. - self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) - - def test_encrypted_unsigned_doesnt_add_signed_headers(self): - """Since the message isn't signed, it shouldn't have headers saying - that there is a signature. - """ - m = self._make_encrypted() - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertNotIn(utils.X_SIGNATURE_VALID_HEADER, m) - self.assertNotIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) - - def test_encrypted_signed_is_decrypted(self): - m = self._make_encrypted(True) - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) - - def test_encrypted_signed_headers(self): - """Since the message is signed, it should have headers saying that - there is a signature. - """ - m = self._make_encrypted(True) - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) - self.assertIn( - 'ambig ', m[utils.X_SIGNATURE_MESSAGE_HEADER]) - - # TODO: tests for the RFC 2440 style combined signed/encrypted blob - - def test_encrypted_wrong_mimetype_first_payload(self): - m = self._make_encrypted() - m.get_payload(0).set_type('text/plain') - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn('Malformed OpenPGP message:', - m.get_payload(2).get_payload()) - - def test_encrypted_wrong_mimetype_second_payload(self): - m = self._make_encrypted() - m.get_payload(1).set_type('text/plain') - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn('Malformed OpenPGP message:', - m.get_payload(2).get_payload()) - - def test_signed_in_multipart_mixed(self): - """It is valid to encapsulate a multipart/signed payload inside a - multipart/mixed payload, verify that works. - """ - s = self._make_signed() - m = email.mime.multipart.MIMEMultipart('mixed', None, [s]) - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn(utils.X_SIGNATURE_VALID_HEADER, m) - self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) - - def test_encrypted_unsigned_in_multipart_mixed(self): - """It is valid to encapsulate a multipart/encrypted payload inside a - multipart/mixed payload, verify that works. - """ - s = self._make_encrypted() - m = email.mime.multipart.MIMEMultipart('mixed', None, [s]) - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) - self.assertNotIn(utils.X_SIGNATURE_VALID_HEADER, m) - self.assertNotIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) - - def test_encrypted_signed_in_multipart_mixed(self): - """It is valid to encapsulate a multipart/encrypted payload inside a - multipart/mixed payload, verify that works when the multipart/encrypted - contains a multipart/signed. - """ - s = self._make_encrypted(True) - m = email.mime.multipart.MIMEMultipart('mixed', None, [s]) - m = utils.decrypted_message_from_file(io.StringIO(m.as_string())) - self.assertIn('This is some text', [n.get_payload() for n in m.walk()]) - self.assertIn(utils.X_SIGNATURE_VALID_HEADER, m) - self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) - - -class TestExtractBody(unittest.TestCase): - - @staticmethod - def _set_basic_headers(mail): - mail['Subject'] = 'Test email' - mail['To'] = 'foo@example.com' - mail['From'] = 'bar@example.com' - - def test_single_text_plain(self): - mail = email.mime.text.MIMEText('This is an email') - self._set_basic_headers(mail) - actual = utils.extract_body(mail) - - expected = 'This is an email' - - self.assertEqual(actual, expected) - - def test_two_text_plain(self): - mail = email.mime.multipart.MIMEMultipart() - self._set_basic_headers(mail) - mail.attach(email.mime.text.MIMEText('This is an email')) - mail.attach(email.mime.text.MIMEText('This is a second part')) - - actual = utils.extract_body(mail) - expected = 'This is an email\n\nThis is a second part' - - self.assertEqual(actual, expected) - - def test_text_plain_and_other(self): - mail = email.mime.multipart.MIMEMultipart() - self._set_basic_headers(mail) - mail.attach(email.mime.text.MIMEText('This is an email')) - mail.attach(email.mime.application.MIMEApplication(b'1')) - - actual = utils.extract_body(mail) - expected = 'This is an email' - - self.assertEqual(actual, expected) - - def test_text_plain_with_attachment_text(self): - mail = email.mime.multipart.MIMEMultipart() - self._set_basic_headers(mail) - mail.attach(email.mime.text.MIMEText('This is an email')) - attachment = email.mime.text.MIMEText('this shouldnt be displayed') - attachment['Content-Disposition'] = 'attachment' - mail.attach(attachment) - - actual = utils.extract_body(mail) - expected = 'This is an email' - - self.assertEqual(actual, expected) - - def _make_mixed_plain_html(self): - mail = email.mime.multipart.MIMEMultipart() - self._set_basic_headers(mail) - mail.attach(email.mime.text.MIMEText('This is an email')) - mail.attach(email.mime.text.MIMEText( - 'This is an html email', - 'html')) - return mail - - @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=True)) - def test_prefer_plaintext(self): - expected = 'This is an email' - mail = self._make_mixed_plain_html() - actual = utils.extract_body(mail) - - self.assertEqual(actual, expected) - - # Mock the handler to cat, so that no transformations of the html are made - # making the result non-deterministic - @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False)) - @mock.patch('alot.db.utils.settings.mailcap_find_match', - mock.Mock(return_value=(None, {'view': 'cat'}))) - def test_prefer_html(self): - expected = ( - 'This is an html email') - mail = self._make_mixed_plain_html() - actual = utils.extract_body(mail) - - self.assertEqual(actual, expected) - - @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False)) - @mock.patch('alot.db.utils.settings.mailcap_find_match', - mock.Mock(return_value=(None, {'view': 'cat'}))) - def test_types_provided(self): - # This should not return html, even though html is set to preferred - # since a types variable is passed - expected = 'This is an email' - mail = self._make_mixed_plain_html() - actual = utils.extract_body(mail, types=['text/plain']) - - self.assertEqual(actual, expected) - - @mock.patch('alot.db.utils.settings.mailcap_find_match', - mock.Mock(return_value=(None, {'view': 'cat'}))) - def test_require_mailcap_stdin(self): - mail = email.mime.multipart.MIMEMultipart() - self._set_basic_headers(mail) - mail.attach(email.mime.text.MIMEText( - 'This is an html email', - 'html')) - actual = utils.extract_body(mail) - expected = ( - 'This is an html email') - - self.assertEqual(actual, expected) - - @mock.patch('alot.db.utils.settings.mailcap_find_match', - mock.Mock(return_value=(None, {'view': 'cat %s'}))) - def test_require_mailcap_file(self): - mail = email.mime.multipart.MIMEMultipart() - self._set_basic_headers(mail) - mail.attach(email.mime.text.MIMEText( - 'This is an html email', - 'html')) - actual = utils.extract_body(mail) - expected = ( - 'This is an html email') - - self.assertEqual(actual, expected) - - @unittest.expectedFailure - def test_simple_utf8_file(self): - mail = email.message_from_binary_file( - open('tests/static/mail/utf8.eml', 'rb')) - actual = utils.extract_body(mail) - expected = "Liebe Grüße!\n" - self.assertEqual(actual, expected) - -class TestMessageFromString(unittest.TestCase): - - """Tests for decrypted_message_from_string. - - Because the implementation is that this is a wrapper around - decrypted_message_from_file, it's not important to have a large swath of - tests, just enough to show that things are being passed correctly. - """ - - def test(self): - m = email.mime.text.MIMEText(u'This is some text', 'plain', 'utf-8') - m['Subject'] = 'test' - m['From'] = 'me' - m['To'] = 'Nobody' - message = utils.decrypted_message_from_string(m.as_string()) - self.assertEqual(message.get_payload(), 'This is some text') - - -class TestRemoveCte(unittest.TestCase): - - def test_char_vs_cte_mismatch(self): # #1291 - with open('tests/static/mail/broken-utf8.eml') as fp: - mail = email.message_from_file(fp) - # This should not raise an UnicodeDecodeError. - with self.assertLogs(level='DEBUG') as cm: # keep logs - utils.remove_cte(mail, as_string=True) - # We expect no Exceptions but a complaint in the log - logmsg = 'DEBUG:root:Decoding failure: \'utf-8\' codec can\'t decode '\ - 'byte 0xa1 in position 14: invalid start byte' - self.assertIn(logmsg, cm.output) - - def test_malformed_cte_value(self): - with open('tests/static/mail/malformed-header-CTE.eml') as fp: - mail = email.message_from_file(fp) - - with self.assertLogs(level='INFO') as cm: # keep logs - utils.remove_cte(mail, as_string=True) - - # We expect no Exceptions but a complaint in the log - logmsg = 'INFO:root:Unknown Content-Transfer-Encoding: "7bit;"' - self.assertEqual(cm.output, [logmsg]) - - def test_unknown_cte_value(self): - with open('tests/static/mail/malformed-header-CTE-2.eml') as fp: - mail = email.message_from_file(fp) - - with self.assertLogs(level='DEBUG') as cm: # keep logs - utils.remove_cte(mail, as_string=True) - - # We expect no Exceptions but a complaint in the log - logmsg = 'DEBUG:root:failed to interpret Content-Transfer-Encoding: '\ - '"normal"' - self.assertIn(logmsg, cm.output) diff --git a/tests/helper_test.py b/tests/helper_test.py deleted file mode 100644 index 2aff842d..00000000 --- a/tests/helper_test.py +++ /dev/null @@ -1,473 +0,0 @@ -# encoding=utf-8 -# Copyright © 2016-2018 Dylan Baker -# Copyright © 2017 Lucas Hoffman - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Test suite for alot.helper module.""" - -import datetime -import email -import errno -import os -import random -import unittest - -import mock - -from alot import helper - -from . import utilities - -# Descriptive names for tests often violate PEP8. That's not an issue, users -# aren't meant to call these functions. -# pylint: disable=invalid-name - -# They're tests, only add docstrings when it makes sense -# pylint: disable=missing-docstring - - -class TestHelperShortenAuthorString(unittest.TestCase): - - authors = u'King Kong, Mucho Muchacho, Jaime Huerta, Flash Gordon' - - def test_high_maxlength_keeps_string_intact(self): - short = helper.shorten_author_string(self.authors, 60) - self.assertEqual(short, self.authors) - - def test_shows_only_first_names_if_they_fit(self): - short = helper.shorten_author_string(self.authors, 40) - self.assertEqual(short, u"King, Mucho, Jaime, Flash") - - def test_adds_ellipses_to_long_first_names(self): - short = helper.shorten_author_string(self.authors, 20) - self.assertEqual(short, u"King, …, Jai…, Flash") - - def test_replace_all_but_first_name_with_ellipses(self): - short = helper.shorten_author_string(self.authors, 10) - self.assertEqual(short, u"King, …") - - def test_shorten_first_name_with_ellipses(self): - short = helper.shorten_author_string(self.authors, 2) - self.assertEqual(short, u"K…") - - def test_only_display_initial_letter_for_maxlength_1(self): - short = helper.shorten_author_string(self.authors, 1) - self.assertEqual(short, u"K") - - -class TestShellQuote(unittest.TestCase): - - def test_all_strings_are_sourrounded_by_single_quotes(self): - quoted = helper.shell_quote("hello") - self.assertEqual(quoted, "'hello'") - - def test_single_quotes_are_escaped_using_double_quotes(self): - quoted = helper.shell_quote("hello'there") - self.assertEqual(quoted, """'hello'"'"'there'""") - - -class TestHumanizeSize(unittest.TestCase): - - def test_small_numbers_are_converted_to_strings_directly(self): - readable = helper.humanize_size(1) - self.assertEqual(readable, "1") - readable = helper.humanize_size(123) - self.assertEqual(readable, "123") - - def test_numbers_above_1024_are_converted_to_kilobyte(self): - readable = helper.humanize_size(1023) - self.assertEqual(readable, "1023") - readable = helper.humanize_size(1024) - self.assertEqual(readable, "1KiB") - readable = helper.humanize_size(1234) - self.assertEqual(readable, "1KiB") - - def test_numbers_above_1048576_are_converted_to_megabyte(self): - readable = helper.humanize_size(1024*1024-1) - self.assertEqual(readable, "1023KiB") - readable = helper.humanize_size(1024*1024) - self.assertEqual(readable, "1.0MiB") - - def test_megabyte_numbers_are_converted_with_precision_1(self): - readable = helper.humanize_size(1234*1024) - self.assertEqual(readable, "1.2MiB") - - def test_numbers_are_not_converted_to_gigabyte(self): - readable = helper.humanize_size(1234*1024*1024) - self.assertEqual(readable, "1234.0MiB") - - -class TestSplitCommandline(unittest.TestCase): - - def _test(self, base, expected): - """Shared helper to reduce some boilerplate.""" - actual = helper.split_commandline(base) - self.assertListEqual(actual, expected) - - def test_simple(self): - base = 'echo "foo";sleep 1' - expected = ['echo "foo"', 'sleep 1'] - self._test(base, expected) - - def test_single(self): - base = 'echo "foo bar"' - expected = [base] - self._test(base, expected) - - def test_unicode(self): - base = u'echo "foo";sleep 1' - expected = ['echo "foo"', 'sleep 1'] - self._test(base, expected) - - -class TestSplitCommandstring(unittest.TestCase): - - def _test(self, base, expected): - """Shared helper to reduce some boilerplate.""" - actual = helper.split_commandstring(base) - self.assertListEqual(actual, expected) - - def test_bytes(self): - base = 'echo "foo bar"' - expected = ['echo', 'foo bar'] - self._test(base, expected) - - def test_unicode(self): - base = 'echo "foo €"' - expected = ['echo', 'foo €'] - self._test(base, expected) - - -class TestStringSanitize(unittest.TestCase): - - def test_tabs(self): - base = 'foo\tbar\noink\n' - expected = 'foo' + ' ' * 5 + 'bar\noink\n' - actual = helper.string_sanitize(base) - self.assertEqual(actual, expected) - - -class TestStringDecode(unittest.TestCase): - - def _test(self, base, expected, encoding='ascii'): - actual = helper.string_decode(base, encoding) - self.assertEqual(actual, expected) - - def test_ascii_bytes(self): - base = u'test'.encode('ascii') - expected = u'test' - self._test(base, expected) - - def test_utf8_bytes(self): - base = u'test'.encode('utf-8') - expected = u'test' - self._test(base, expected, 'utf-8') - - def test_unicode(self): - base = u'test' - expected = u'test' - self._test(base, expected) - - -class TestPrettyDatetime(unittest.TestCase): - - # TODO: Currently these tests use the ampm format based on whether or not - # the testing machine's locale sets them. To be really good mock should be - # used to change the locale between an am/pm locale and a 24 hour locale - # and test both scenarios. - - __patchers = [] - - @classmethod - def setUpClass(cls): - # Create a random number generator, but seed it so that it will produce - # deterministic output. This is used to select a subset of possible - # values for each of the tests in this class, since otherwise they - # would get really expensive (time wise). - cls.random = random.Random() - cls.random.seed(42) - - # Pick an exact date to ensure that the tests run the same no matter - # what time of day they're run. - cls.now = datetime.datetime(2000, 1, 5, 12, 0, 0, 0) - - # Mock datetime.now, which ensures that the time is always the same - # removing race conditions from the tests. - dt = mock.Mock() - dt.now = mock.Mock(return_value=cls.now) - cls.__patchers.append(mock.patch('alot.helper.datetime', dt)) - - for p in cls.__patchers: - p.start() - - @classmethod - def tearDownClass(cls): - for p in cls.__patchers: - p.stop() - - def test_just_now(self): - for i in (self.random.randint(0, 60) for _ in range(5)): - test = self.now - datetime.timedelta(seconds=i) - actual = helper.pretty_datetime(test) - self.assertEqual(actual, u'just now') - - def test_x_minutes_ago(self): - for i in (self.random.randint(60, 3600) for _ in range(10)): - test = self.now - datetime.timedelta(seconds=i) - actual = helper.pretty_datetime(test) - self.assertEqual( - actual, u'{}min ago'.format((self.now - test).seconds // 60)) - - def test_x_hours_ago(self): - for i in (self.random.randint(3600, 3600 * 6) for _ in range(10)): - test = self.now - datetime.timedelta(seconds=i) - actual = helper.pretty_datetime(test) - self.assertEqual( - actual, u'{}h ago'.format((self.now - test).seconds // 3600)) - - # TODO: yesterday - # TODO: yesterday > now > a year - # TODO: last year - # XXX: when can the last else be hit? - - @staticmethod - def _future_expected(test): - if test.strftime('%p'): - expected = test.strftime('%I:%M%p').lower() - else: - expected = test.strftime('%H:%M') - expected = expected - return expected - - def test_future_seconds(self): - test = self.now + datetime.timedelta(seconds=30) - actual = helper.pretty_datetime(test) - expected = self._future_expected(test) - self.assertEqual(actual, expected) - - # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure - def test_future_minutes(self): - test = self.now + datetime.timedelta(minutes=5) - actual = helper.pretty_datetime(test) - expected = test.strftime('%a ') + self._future_expected(test) - self.assertEqual(actual, expected) - - # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure - def test_future_hours(self): - test = self.now + datetime.timedelta(hours=1) - actual = helper.pretty_datetime(test) - expected = test.strftime('%a ') + self._future_expected(test) - self.assertEqual(actual, expected) - - # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure - def test_future_days(self): - def make_expected(): - # Uses the hourfmt instead of the hourminfmt from pretty_datetime - if test.strftime('%p'): - expected = test.strftime('%I%p') - else: - expected = test.strftime('%Hh') - expected = expected.decode('utf-8') - return expected - - test = self.now + datetime.timedelta(days=1) - actual = helper.pretty_datetime(test) - expected = test.strftime('%a ') + make_expected() - self.assertEqual(actual, expected) - - # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure - def test_future_week(self): - test = self.now + datetime.timedelta(days=7) - actual = helper.pretty_datetime(test) - expected = test.strftime('%b %d') - self.assertEqual(actual, expected) - - # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure - def test_future_month(self): - test = self.now + datetime.timedelta(days=31) - actual = helper.pretty_datetime(test) - expected = test.strftime('%b %d') - self.assertEqual(actual, expected) - - # Returns 'just now', instead of 'from future' or something similar - @unittest.expectedFailure - def test_future_year(self): - test = self.now + datetime.timedelta(days=365) - actual = helper.pretty_datetime(test) - expected = test.strftime('%b %Y') - self.assertEqual(actual, expected) - - -class TestCallCmd(unittest.TestCase): - """Tests for the call_cmd function.""" - - def test_no_stdin(self): - out, err, code = helper.call_cmd(['echo', '-n', 'foo']) - self.assertEqual(out, u'foo') - self.assertEqual(err, u'') - self.assertEqual(code, 0) - - def test_no_stdin_unicode(self): - out, err, code = helper.call_cmd(['echo', '-n', '�']) - self.assertEqual(out, u'�') - self.assertEqual(err, u'') - self.assertEqual(code, 0) - - def test_stdin(self): - out, err, code = helper.call_cmd(['cat'], stdin='�') - self.assertEqual(out, u'�') - self.assertEqual(err, u'') - self.assertEqual(code, 0) - - def test_no_such_command(self): - out, err, code = helper.call_cmd(['thiscommandabsolutelydoesntexist']) - self.assertEqual(out, u'') - - # We don't control the output of err, the shell does. Therefore simply - # assert that the shell said *something* - self.assertNotEqual(err, u'') - self.assertEqual(code, errno.ENOENT) - - def test_no_such_command_stdin(self): - out, err, code = helper.call_cmd(['thiscommandabsolutelydoesntexist'], - stdin='foo') - self.assertEqual(out, u'') - - # We don't control the output of err, the shell does. Therefore simply - # assert that the shell said *something* - self.assertNotEqual(err, u'') - self.assertEqual(code, errno.ENOENT) - - def test_bad_argument_stdin(self): - out, err, code = helper.call_cmd(['cat', '-Y'], stdin='�') - self.assertEqual(out, u'') - self.assertNotEqual(err, u'') - - # We don't control this, although 1 might be a fairly safe guess, we - # know for certain it should *not* return 0 - self.assertNotEqual(code, 0) - - def test_bad_argument(self): - out, err, code = helper.call_cmd(['cat', '-Y']) - self.assertEqual(out, u'') - self.assertNotEqual(err, u'') - - # We don't control this, although 1 might be a fairly safe guess, we - # know for certain it should *not* return 0 - self.assertNotEqual(code, 0) - - def test_os_errors_from_popen_are_caught(self): - with mock.patch('subprocess.Popen', - mock.Mock(side_effect=OSError(42, u'foobar'))): - out, err, code = helper.call_cmd( - ['does_not_matter_as_subprocess_popen_is_mocked']) - self.assertEqual(out, u'') - self.assertEqual(err, u'foobar') - self.assertEqual(code, 42) - - -class TestShorten(unittest.TestCase): - - def test_lt_maxlen(self): - expected = u'a string' - actual = helper.shorten(expected, 25) - self.assertEqual(expected, actual) - - def test_eq_maxlen(self): - expected = 'a string' - actual = helper.shorten(expected, len(expected)) - self.assertEqual(expected, actual) - - def test_gt_maxlen(self): - expected = u'a long string…' - actual = helper.shorten('a long string that is full of text', 14) - self.assertEqual(expected, actual) - - -class TestCallCmdAsync(unittest.TestCase): - - @utilities.async_test - async def test_no_stdin(self): - ret = await helper.call_cmd_async(['echo', '-n', 'foo']) - self.assertEqual(ret[0], 'foo') - - @utilities.async_test - async def test_stdin(self): - ret = await helper.call_cmd_async(['cat', '-'], stdin='foo') - self.assertEqual(ret[0], 'foo') - - @utilities.async_test - async def test_env_set(self): - with mock.patch.dict(os.environ, {}, clear=True): - ret = await helper.call_cmd_async( - ['python3', '-c', 'import os; ' - 'print(os.environ.get("foo", "fail"), end="")' - ], - env={'foo': 'bar'}) - self.assertEqual(ret[0], 'bar') - - @utilities.async_test - async def test_env_doesnt_pollute(self): - with mock.patch.dict(os.environ, {}, clear=True): - await helper.call_cmd_async(['echo', '-n', 'foo'], - env={'foo': 'bar'}) - self.assertEqual(os.environ, {}) - - @utilities.async_test - async def test_command_fails(self): - _, err, ret = await helper.call_cmd_async(['_____better_not_exist']) - self.assertEqual(ret, 1) - self.assertTrue(err) - - -class TestGetEnv(unittest.TestCase): - env_name = 'XDG_CONFIG_HOME' - default = '~/.config' - - def test_env_not_set(self): - with mock.patch.dict('os.environ'): - if self.env_name in os.environ: - del os.environ[self.env_name] - self.assertEqual(helper.get_xdg_env(self.env_name, self.default), - self.default) - - def test_env_empty(self): - with mock.patch.dict('os.environ', {self.env_name: ''}): - self.assertEqual(helper.get_xdg_env(self.env_name, self.default), - self.default) - - def test_env_not_empty(self): - custom_path = '/my/personal/config/home' - - with mock.patch.dict('os.environ', {self.env_name: custom_path}): - self.assertEqual(helper.get_xdg_env(self.env_name, self.default), - custom_path) - - -class TestParseMailto(unittest.TestCase): - - def test_parsing_working(self): - uri = 'mailto:test%40example.org?Subject=Re%3A%20Hello\ -&In-Reply-To=%3CC8CE9EFD-CB23-4BC0-B70D-9B7FEAD59F8C%40example.org%3E' - actual = helper.parse_mailto(uri) - expected = ({'To': ['test@example.org'], - 'Subject': ['Re: Hello'], - 'In-reply-to': ['']}, '') - self.assertEqual(actual, expected) diff --git a/tests/settings/manager_test.py b/tests/settings/manager_test.py deleted file mode 100644 index 9b1cff34..00000000 --- a/tests/settings/manager_test.py +++ /dev/null @@ -1,311 +0,0 @@ -# Copyright (C) 2017 Lucas Hoffmann -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file - -"""Test suite for alot.settings.manager module.""" - -import os -import re -import tempfile -import textwrap -import unittest - -import mock - -from alot.settings.manager import SettingsManager -from alot.settings.errors import ConfigError, NoMatchingAccount - -from .. import utilities - - -class TestSettingsManager(unittest.TestCase): - - def test_reading_synchronize_flags_from_notmuch_config(self): - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [maildir] - synchronize_flags = true - """)) - self.addCleanup(os.unlink, f.name) - - manager = SettingsManager() - manager.read_notmuch_config(f.name) - actual = manager.get_notmuch_setting('maildir', 'synchronize_flags') - self.assertTrue(actual) - - def test_parsing_notmuch_config_with_non_bool_synchronize_flag_fails(self): - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [maildir] - synchronize_flags = not bool - """)) - self.addCleanup(os.unlink, f.name) - - with self.assertRaises(ConfigError): - manager = SettingsManager() - manager.read_notmuch_config(f.name) - - def test_reload_notmuch_config(self): - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [maildir] - synchronize_flags = false - """)) - self.addCleanup(os.unlink, f.name) - manager = SettingsManager() - manager.read_notmuch_config(f.name) - - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [maildir] - synchronize_flags = true - """)) - self.addCleanup(os.unlink, f.name) - - manager.read_notmuch_config(f.name) - actual = manager.get_notmuch_setting('maildir', 'synchronize_flags') - self.assertTrue(actual) - - def test_read_config_doesnt_exist(self): - """If there is not an alot config things don't break. - - This specifically tests for issue #1094, which is caused by the - defaults not being loaded if there isn't an alot config files, and thus - calls like `get_theming_attribute` fail with strange exceptions. - """ - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [maildir] - synchronize_flags = true - """)) - self.addCleanup(os.unlink, f.name) - manager = SettingsManager() - manager.read_config(f.name) - - manager.get_theming_attribute('global', 'body') - - def test_unknown_settings_in_config_are_logged(self): - # todo: For py3, don't mock the logger, use assertLogs - unknown_settings = ['templates_dir', 'unknown_section', 'unknown_1', - 'unknown_2'] - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - {x[0]} = /templates/dir - [{x[1]}] - # Values in unknown sections are not reported. - barfoo = barfoo - [tags] - [[foobar]] - {x[2]} = baz - translated = translation - {x[3]} = bar - """.format(x=unknown_settings))) - self.addCleanup(os.unlink, f.name) - - with mock.patch('alot.settings.utils.logging') as mock_logger: - manager = SettingsManager() - manager.read_config(f.name) - - success = any(all([s in call_args[0][0] for s in unknown_settings]) - for call_args in mock_logger.info.call_args_list) - self.assertTrue(success, msg='Could not find all unknown settings in ' - 'logging.info.\nUnknown settings:\n{}\nCalls to mocked' - ' logging.info:\n{}'.format( - unknown_settings, mock_logger.info.call_args_list)) - - def test_read_notmuch_config_doesnt_exist(self): - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [accounts] - [[default]] - realname = That Guy - address = thatguy@example.com - """)) - self.addCleanup(os.unlink, f.name) - manager = SettingsManager() - manager.read_notmuch_config(f.name) - - setting = manager.get_notmuch_setting('foo', 'bar') - self.assertIsNone(setting) - - def test_choke_on_invalid_regex_in_tagstring(self): - tag = 'to**do' - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [tags] - [[{tag}]] - normal = '','', 'white','light red', 'white','#d66' - """.format(tag=tag))) - self.addCleanup(os.unlink, f.name) - manager = SettingsManager() - manager.read_config(f.name) - with self.assertRaises(re.error): - manager.get_tagstring_representation(tag) - - def test_translate_tagstring_prefix(self): - # Test for behavior mentioned in bcb2670f56fa251c0f1624822928d664f6455902, - # namely that 'foo' does not match 'foobar' - tag = 'foobar' - tagprefix = 'foo' - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [tags] - [[{tag}]] - translated = matched - """.format(tag=tagprefix))) - self.addCleanup(os.unlink, f.name) - manager = SettingsManager() - manager.read_config(f.name) - tagrep = manager.get_tagstring_representation(tag) - self.assertIs(tagrep['translated'], tag) - tagprefixrep = manager.get_tagstring_representation(tagprefix) - self.assertEqual(tagprefixrep['translated'], 'matched') - - def test_translate_tagstring_prefix_regex(self): - # Test for behavior mentioned in bcb2670f56fa251c0f1624822928d664f6455902, - # namely that 'foo.*' does match 'foobar' - tagprefixregexp = 'foo.*' - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [tags] - [[{tag}]] - translated = matched - """.format(tag=tagprefixregexp))) - self.addCleanup(os.unlink, f.name) - manager = SettingsManager() - manager.read_config(f.name) - def matched(t): - return manager.get_tagstring_representation(t)['translated'] == 'matched' - self.assertTrue(all(matched(t) for t in ['foo', 'foobar', tagprefixregexp])) - self.assertFalse(any(matched(t) for t in ['bar', 'barfoobar'])) - - def test_translate_regexp(self): - # Test for behavior mentioned in 108df3df8571aea2164a5d3fc42655ac2bd06c17 - # namely that translations themselves can use regex - tag = "notmuch::foo" - section = "[[notmuch::.*]]" - translation = r"'notmuch::(.*)', 'nm:\1'" - translated_goal = "nm:foo" - - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - [tags] - {section} - translation = {translation} - """.format(section=section, translation=translation))) - self.addCleanup(os.unlink, f.name) - manager = SettingsManager() - manager.read_config(f.name) - self.assertEqual(manager.get_tagstring_representation(tag)['translated'], translated_goal) - -class TestSettingsManagerExpandEnvironment(unittest.TestCase): - """ Tests SettingsManager._expand_config_values """ - setting_name = 'template_dir' - xdg_name = 'XDG_CONFIG_HOME' - default = '$%s/alot/templates' % xdg_name - xdg_fallback = '~/.config' - xdg_custom = '/foo/bar/.config' - default_expanded = default.replace('$%s' % xdg_name, xdg_fallback) - - def test_user_setting_and_env_not_empty(self): - user_setting = '/path/to/template/dir' - - with mock.patch.dict('os.environ', {self.xdg_name: self.xdg_custom}): - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write('template_dir = {}'.format(user_setting)) - self.addCleanup(os.unlink, f.name) - - manager = SettingsManager() - manager.read_config(f.name) - self.assertEqual(manager._config.get(self.setting_name), - os.path.expanduser(user_setting)) - - def test_configobj_and_env_expansion(self): - """ Three expansion styles: - %(FOO)s - expanded by ConfigObj (string interpolation) - $FOO and ${FOO} - should be expanded with environment variable - """ - foo_env = 'foo_set_from_env' - with mock.patch.dict('os.environ', {self.xdg_name: self.xdg_custom, - 'foo': foo_env}): - foo_in_config = 'foo_set_in_config' - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(textwrap.dedent("""\ - foo = {} - template_dir = ${{XDG_CONFIG_HOME}}/$foo/%(foo)s/${{foo}} - """.format(foo_in_config))) - self.addCleanup(os.unlink, f.name) - - manager = SettingsManager() - manager.read_config(f.name) - self.assertEqual(manager._config.get(self.setting_name), - os.path.join(self.xdg_custom, foo_env, - foo_in_config, foo_env)) - - - -class TestSettingsManagerGetAccountByAddress(utilities.TestCaseClassCleanup): - """Test the account_matching_address helper.""" - - @classmethod - def setUpClass(cls): - config = textwrap.dedent("""\ - [accounts] - [[default]] - realname = That Guy - address = that_guy@example.com - sendmail_command = /bin/true - - [[other]] - realname = A Dude - address = a_dude@example.com - sendmail_command = /bin/true - """) - - # Allow settings.reload to work by not deleting the file until the end - with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: - f.write(config) - cls.addClassCleanup(os.unlink, f.name) - - # Replace the actual settings object with our own using mock, but - # ensure it's put back afterwards - cls.manager = SettingsManager() - cls.manager.read_config(f.name) - - def test_exists_addr(self): - acc = self.manager.account_matching_address(u'that_guy@example.com') - self.assertEqual(acc.realname, 'That Guy') - - def test_doesnt_exist_return_default(self): - acc = self.manager.account_matching_address(u'doesntexist@example.com', - return_default=True) - self.assertEqual(acc.realname, 'That Guy') - - def test_doesnt_exist_raise(self): - with self.assertRaises(NoMatchingAccount): - self.manager.account_matching_address(u'doesntexist@example.com') - - def test_doesnt_exist_no_default(self): - with tempfile.NamedTemporaryFile() as f: - f.write(b'') - settings = SettingsManager() - settings.read_config(f.name) - with self.assertRaises(NoMatchingAccount): - settings.account_matching_address('that_guy@example.com', - return_default=True) - - def test_real_name_will_be_stripped_before_matching(self): - acc = self.manager.account_matching_address( - 'That Guy ') - self.assertEqual(acc.realname, 'A Dude') - - def test_address_case(self): - """Some servers do not differentiate addresses by case. - - So, for example, "foo@example.com" and "Foo@example.com" would be - considered the same. Among servers that do this gmail, yahoo, fastmail, - anything running Exchange (i.e., most large corporations), and others. - """ - acc1 = self.manager.account_matching_address('That_guy@example.com') - acc2 = self.manager.account_matching_address('that_guy@example.com') - self.assertIs(acc1, acc2) diff --git a/tests/settings/test_manager.py b/tests/settings/test_manager.py new file mode 100644 index 00000000..9b1cff34 --- /dev/null +++ b/tests/settings/test_manager.py @@ -0,0 +1,311 @@ +# Copyright (C) 2017 Lucas Hoffmann +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +"""Test suite for alot.settings.manager module.""" + +import os +import re +import tempfile +import textwrap +import unittest + +import mock + +from alot.settings.manager import SettingsManager +from alot.settings.errors import ConfigError, NoMatchingAccount + +from .. import utilities + + +class TestSettingsManager(unittest.TestCase): + + def test_reading_synchronize_flags_from_notmuch_config(self): + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [maildir] + synchronize_flags = true + """)) + self.addCleanup(os.unlink, f.name) + + manager = SettingsManager() + manager.read_notmuch_config(f.name) + actual = manager.get_notmuch_setting('maildir', 'synchronize_flags') + self.assertTrue(actual) + + def test_parsing_notmuch_config_with_non_bool_synchronize_flag_fails(self): + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [maildir] + synchronize_flags = not bool + """)) + self.addCleanup(os.unlink, f.name) + + with self.assertRaises(ConfigError): + manager = SettingsManager() + manager.read_notmuch_config(f.name) + + def test_reload_notmuch_config(self): + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [maildir] + synchronize_flags = false + """)) + self.addCleanup(os.unlink, f.name) + manager = SettingsManager() + manager.read_notmuch_config(f.name) + + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [maildir] + synchronize_flags = true + """)) + self.addCleanup(os.unlink, f.name) + + manager.read_notmuch_config(f.name) + actual = manager.get_notmuch_setting('maildir', 'synchronize_flags') + self.assertTrue(actual) + + def test_read_config_doesnt_exist(self): + """If there is not an alot config things don't break. + + This specifically tests for issue #1094, which is caused by the + defaults not being loaded if there isn't an alot config files, and thus + calls like `get_theming_attribute` fail with strange exceptions. + """ + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [maildir] + synchronize_flags = true + """)) + self.addCleanup(os.unlink, f.name) + manager = SettingsManager() + manager.read_config(f.name) + + manager.get_theming_attribute('global', 'body') + + def test_unknown_settings_in_config_are_logged(self): + # todo: For py3, don't mock the logger, use assertLogs + unknown_settings = ['templates_dir', 'unknown_section', 'unknown_1', + 'unknown_2'] + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + {x[0]} = /templates/dir + [{x[1]}] + # Values in unknown sections are not reported. + barfoo = barfoo + [tags] + [[foobar]] + {x[2]} = baz + translated = translation + {x[3]} = bar + """.format(x=unknown_settings))) + self.addCleanup(os.unlink, f.name) + + with mock.patch('alot.settings.utils.logging') as mock_logger: + manager = SettingsManager() + manager.read_config(f.name) + + success = any(all([s in call_args[0][0] for s in unknown_settings]) + for call_args in mock_logger.info.call_args_list) + self.assertTrue(success, msg='Could not find all unknown settings in ' + 'logging.info.\nUnknown settings:\n{}\nCalls to mocked' + ' logging.info:\n{}'.format( + unknown_settings, mock_logger.info.call_args_list)) + + def test_read_notmuch_config_doesnt_exist(self): + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [accounts] + [[default]] + realname = That Guy + address = thatguy@example.com + """)) + self.addCleanup(os.unlink, f.name) + manager = SettingsManager() + manager.read_notmuch_config(f.name) + + setting = manager.get_notmuch_setting('foo', 'bar') + self.assertIsNone(setting) + + def test_choke_on_invalid_regex_in_tagstring(self): + tag = 'to**do' + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [tags] + [[{tag}]] + normal = '','', 'white','light red', 'white','#d66' + """.format(tag=tag))) + self.addCleanup(os.unlink, f.name) + manager = SettingsManager() + manager.read_config(f.name) + with self.assertRaises(re.error): + manager.get_tagstring_representation(tag) + + def test_translate_tagstring_prefix(self): + # Test for behavior mentioned in bcb2670f56fa251c0f1624822928d664f6455902, + # namely that 'foo' does not match 'foobar' + tag = 'foobar' + tagprefix = 'foo' + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [tags] + [[{tag}]] + translated = matched + """.format(tag=tagprefix))) + self.addCleanup(os.unlink, f.name) + manager = SettingsManager() + manager.read_config(f.name) + tagrep = manager.get_tagstring_representation(tag) + self.assertIs(tagrep['translated'], tag) + tagprefixrep = manager.get_tagstring_representation(tagprefix) + self.assertEqual(tagprefixrep['translated'], 'matched') + + def test_translate_tagstring_prefix_regex(self): + # Test for behavior mentioned in bcb2670f56fa251c0f1624822928d664f6455902, + # namely that 'foo.*' does match 'foobar' + tagprefixregexp = 'foo.*' + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [tags] + [[{tag}]] + translated = matched + """.format(tag=tagprefixregexp))) + self.addCleanup(os.unlink, f.name) + manager = SettingsManager() + manager.read_config(f.name) + def matched(t): + return manager.get_tagstring_representation(t)['translated'] == 'matched' + self.assertTrue(all(matched(t) for t in ['foo', 'foobar', tagprefixregexp])) + self.assertFalse(any(matched(t) for t in ['bar', 'barfoobar'])) + + def test_translate_regexp(self): + # Test for behavior mentioned in 108df3df8571aea2164a5d3fc42655ac2bd06c17 + # namely that translations themselves can use regex + tag = "notmuch::foo" + section = "[[notmuch::.*]]" + translation = r"'notmuch::(.*)', 'nm:\1'" + translated_goal = "nm:foo" + + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + [tags] + {section} + translation = {translation} + """.format(section=section, translation=translation))) + self.addCleanup(os.unlink, f.name) + manager = SettingsManager() + manager.read_config(f.name) + self.assertEqual(manager.get_tagstring_representation(tag)['translated'], translated_goal) + +class TestSettingsManagerExpandEnvironment(unittest.TestCase): + """ Tests SettingsManager._expand_config_values """ + setting_name = 'template_dir' + xdg_name = 'XDG_CONFIG_HOME' + default = '$%s/alot/templates' % xdg_name + xdg_fallback = '~/.config' + xdg_custom = '/foo/bar/.config' + default_expanded = default.replace('$%s' % xdg_name, xdg_fallback) + + def test_user_setting_and_env_not_empty(self): + user_setting = '/path/to/template/dir' + + with mock.patch.dict('os.environ', {self.xdg_name: self.xdg_custom}): + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write('template_dir = {}'.format(user_setting)) + self.addCleanup(os.unlink, f.name) + + manager = SettingsManager() + manager.read_config(f.name) + self.assertEqual(manager._config.get(self.setting_name), + os.path.expanduser(user_setting)) + + def test_configobj_and_env_expansion(self): + """ Three expansion styles: + %(FOO)s - expanded by ConfigObj (string interpolation) + $FOO and ${FOO} - should be expanded with environment variable + """ + foo_env = 'foo_set_from_env' + with mock.patch.dict('os.environ', {self.xdg_name: self.xdg_custom, + 'foo': foo_env}): + foo_in_config = 'foo_set_in_config' + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(textwrap.dedent("""\ + foo = {} + template_dir = ${{XDG_CONFIG_HOME}}/$foo/%(foo)s/${{foo}} + """.format(foo_in_config))) + self.addCleanup(os.unlink, f.name) + + manager = SettingsManager() + manager.read_config(f.name) + self.assertEqual(manager._config.get(self.setting_name), + os.path.join(self.xdg_custom, foo_env, + foo_in_config, foo_env)) + + + +class TestSettingsManagerGetAccountByAddress(utilities.TestCaseClassCleanup): + """Test the account_matching_address helper.""" + + @classmethod + def setUpClass(cls): + config = textwrap.dedent("""\ + [accounts] + [[default]] + realname = That Guy + address = that_guy@example.com + sendmail_command = /bin/true + + [[other]] + realname = A Dude + address = a_dude@example.com + sendmail_command = /bin/true + """) + + # Allow settings.reload to work by not deleting the file until the end + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write(config) + cls.addClassCleanup(os.unlink, f.name) + + # Replace the actual settings object with our own using mock, but + # ensure it's put back afterwards + cls.manager = SettingsManager() + cls.manager.read_config(f.name) + + def test_exists_addr(self): + acc = self.manager.account_matching_address(u'that_guy@example.com') + self.assertEqual(acc.realname, 'That Guy') + + def test_doesnt_exist_return_default(self): + acc = self.manager.account_matching_address(u'doesntexist@example.com', + return_default=True) + self.assertEqual(acc.realname, 'That Guy') + + def test_doesnt_exist_raise(self): + with self.assertRaises(NoMatchingAccount): + self.manager.account_matching_address(u'doesntexist@example.com') + + def test_doesnt_exist_no_default(self): + with tempfile.NamedTemporaryFile() as f: + f.write(b'') + settings = SettingsManager() + settings.read_config(f.name) + with self.assertRaises(NoMatchingAccount): + settings.account_matching_address('that_guy@example.com', + return_default=True) + + def test_real_name_will_be_stripped_before_matching(self): + acc = self.manager.account_matching_address( + 'That Guy ') + self.assertEqual(acc.realname, 'A Dude') + + def test_address_case(self): + """Some servers do not differentiate addresses by case. + + So, for example, "foo@example.com" and "Foo@example.com" would be + considered the same. Among servers that do this gmail, yahoo, fastmail, + anything running Exchange (i.e., most large corporations), and others. + """ + acc1 = self.manager.account_matching_address('That_guy@example.com') + acc2 = self.manager.account_matching_address('that_guy@example.com') + self.assertIs(acc1, acc2) diff --git a/tests/settings/test_theme.py b/tests/settings/test_theme.py new file mode 100644 index 00000000..c74de50e --- /dev/null +++ b/tests/settings/test_theme.py @@ -0,0 +1,88 @@ +# Copyright (C) 2017 Lucas Hoffmann +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +import unittest + +from alot.settings import theme + + +DUMMY_THEME = """\ +[bufferlist] + line = '', '', '', '', '', '' + line_even = '', '', '', '', '', '' + line_focus = '', '', '', '', '', '' + line_odd = '', '', '', '', '', '' +[envelope] + body = '', '', '', '', '', '' + header = '', '', '', '', '', '' + header_key = '', '', '', '', '', '' + header_value = '', '', '', '', '', '' +[global] + body = '', '', '', '', '', '' + footer = '', '', '', '', '', '' + notify_error = '', '', '', '', '', '' + notify_normal = '', '', '', '', '', '' + prompt = '', '', '', '', '', '' + tag = '', '', '', '', '', '' + tag_focus = '', '', '', '', '', '' +[help] + section = '', '', '', '', '', '' + text = '', '', '', '', '', '' + title = '', '', '', '', '', '' +[taglist] + line_even = '', '', '', '', '', '' + line_focus = '', '', '', '', '', '' + line_odd = '', '', '', '', '', '' +[namedqueries] + line_even = '', '', '', '', '', '' + line_focus = '', '', '', '', '', '' + line_odd = '', '', '', '', '', '' +[search] + focus = '', '', '', '', '', '' + normal = '', '', '', '', '', '' + [[threadline]] + focus = '', '', '', '', '', '' + normal = '', '', '', '', '', '' +[thread] + arrow_bars = '', '', '', '', '', '' + arrow_heads = '', '', '', '', '', '' + attachment = '', '', '', '', '', '' + attachment_focus = '', '', '', '', '', '' + body = '', '', '', '', '', '' + header = '', '', '', '', '', '' + header_key = '', '', '', '', '', '' + header_value = '', '', '', '', '', '' + [[summary]] + even = '', '', '', '', '', '' + focus = '', '', '', '', '', '' + odd = '', '', '', '', '', '' +""" + + +class TestThemeGetAttribute(unittest.TestCase): + + @classmethod + def setUpClass(cls): + # We use a list of strings instead of a file path to pass in the config + # file. This is possible because the argument is handed to + # configobj.ConfigObj directly and that accepts eigher: + # http://configobj.rtfd.io/en/latest/configobj.html#reading-a-config-file + cls.theme = theme.Theme(DUMMY_THEME.splitlines()) + + def test_invalid_mode_raises_key_error(self): + with self.assertRaises(KeyError) as cm: + self.theme.get_attribute(0, 'mode does not exist', + 'name does not exist') + self.assertTupleEqual(cm.exception.args, ('mode does not exist',)) + + def test_invalid_name_raises_key_error(self): + with self.assertRaises(KeyError) as cm: + self.theme.get_attribute(0, 'global', 'name does not exist') + self.assertTupleEqual(cm.exception.args, ('name does not exist',)) + + # TODO tests for invalid part arguments. + + def test_invalid_colorindex_raises_value_error(self): + with self.assertRaises(ValueError): + self.theme.get_attribute(0, 'global', 'body') diff --git a/tests/settings/test_utils.py b/tests/settings/test_utils.py new file mode 100644 index 00000000..869054ce --- /dev/null +++ b/tests/settings/test_utils.py @@ -0,0 +1,74 @@ +# Copyright (C) 2017 Lucas Hoffmann +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +"""Tests for the alot.setting.utils module.""" + +import unittest + +import mock + +from alot.settings import utils + + +class TestResolveAtt(unittest.TestCase): + + __patchers = [] + fallback = mock.Mock() + fallback.foreground = 'some fallback foreground value' + fallback.background = 'some fallback background value' + + @classmethod + def setUpClass(cls): + cls.__patchers.append(mock.patch( + 'alot.settings.utils.AttrSpec', + mock.Mock(side_effect=lambda *args: args))) + for p in cls.__patchers: + p.start() + + @classmethod + def tearDownClass(cls): + for p in cls.__patchers: + p.stop() + + @staticmethod + def _mock(foreground, background): + """Create a mock object that is needed very often.""" + m = mock.Mock() + m.foreground = foreground + m.background = background + return m + + def test_passing_none_returns_fallback(self): + actual = utils.resolve_att(None, self.fallback) + self.assertEqual(actual, self.fallback) + + def test_empty_string_in_background_picks_up_background_from_fallback(self): + attr = self._mock('valid foreground', '') + expected = (attr.foreground, self.fallback.background) + actual = utils.resolve_att(attr, self.fallback) + self.assertTupleEqual(actual, expected) + + def test_default_in_background_picks_up_background_from_fallback(self): + attr = self._mock('valid foreground', 'default') + expected = attr.foreground, self.fallback.background + actual = utils.resolve_att(attr, self.fallback) + self.assertTupleEqual(actual, expected) + + def test_empty_string_in_foreground_picks_up_foreground_from_fallback(self): + attr = self._mock('', 'valid background') + expected = self.fallback.foreground, attr.background + actual = utils.resolve_att(attr, self.fallback) + self.assertTupleEqual(actual, expected) + + def test_default_in_foreground_picks_up_foreground_from_fallback(self): + attr = self._mock('default', 'valid background') + expected = self.fallback.foreground, attr.background + actual = utils.resolve_att(attr, self.fallback) + self.assertTupleEqual(actual, expected) + + def test_other_values_are_used(self): + attr = self._mock('valid foreground', 'valid background') + expected = attr.foreground, attr.background + actual = utils.resolve_att(attr, self.fallback) + self.assertTupleEqual(actual, expected) diff --git a/tests/settings/theme_test.py b/tests/settings/theme_test.py deleted file mode 100644 index c74de50e..00000000 --- a/tests/settings/theme_test.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (C) 2017 Lucas Hoffmann -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file - -import unittest - -from alot.settings import theme - - -DUMMY_THEME = """\ -[bufferlist] - line = '', '', '', '', '', '' - line_even = '', '', '', '', '', '' - line_focus = '', '', '', '', '', '' - line_odd = '', '', '', '', '', '' -[envelope] - body = '', '', '', '', '', '' - header = '', '', '', '', '', '' - header_key = '', '', '', '', '', '' - header_value = '', '', '', '', '', '' -[global] - body = '', '', '', '', '', '' - footer = '', '', '', '', '', '' - notify_error = '', '', '', '', '', '' - notify_normal = '', '', '', '', '', '' - prompt = '', '', '', '', '', '' - tag = '', '', '', '', '', '' - tag_focus = '', '', '', '', '', '' -[help] - section = '', '', '', '', '', '' - text = '', '', '', '', '', '' - title = '', '', '', '', '', '' -[taglist] - line_even = '', '', '', '', '', '' - line_focus = '', '', '', '', '', '' - line_odd = '', '', '', '', '', '' -[namedqueries] - line_even = '', '', '', '', '', '' - line_focus = '', '', '', '', '', '' - line_odd = '', '', '', '', '', '' -[search] - focus = '', '', '', '', '', '' - normal = '', '', '', '', '', '' - [[threadline]] - focus = '', '', '', '', '', '' - normal = '', '', '', '', '', '' -[thread] - arrow_bars = '', '', '', '', '', '' - arrow_heads = '', '', '', '', '', '' - attachment = '', '', '', '', '', '' - attachment_focus = '', '', '', '', '', '' - body = '', '', '', '', '', '' - header = '', '', '', '', '', '' - header_key = '', '', '', '', '', '' - header_value = '', '', '', '', '', '' - [[summary]] - even = '', '', '', '', '', '' - focus = '', '', '', '', '', '' - odd = '', '', '', '', '', '' -""" - - -class TestThemeGetAttribute(unittest.TestCase): - - @classmethod - def setUpClass(cls): - # We use a list of strings instead of a file path to pass in the config - # file. This is possible because the argument is handed to - # configobj.ConfigObj directly and that accepts eigher: - # http://configobj.rtfd.io/en/latest/configobj.html#reading-a-config-file - cls.theme = theme.Theme(DUMMY_THEME.splitlines()) - - def test_invalid_mode_raises_key_error(self): - with self.assertRaises(KeyError) as cm: - self.theme.get_attribute(0, 'mode does not exist', - 'name does not exist') - self.assertTupleEqual(cm.exception.args, ('mode does not exist',)) - - def test_invalid_name_raises_key_error(self): - with self.assertRaises(KeyError) as cm: - self.theme.get_attribute(0, 'global', 'name does not exist') - self.assertTupleEqual(cm.exception.args, ('name does not exist',)) - - # TODO tests for invalid part arguments. - - def test_invalid_colorindex_raises_value_error(self): - with self.assertRaises(ValueError): - self.theme.get_attribute(0, 'global', 'body') diff --git a/tests/settings/utils_test.py b/tests/settings/utils_test.py deleted file mode 100644 index 869054ce..00000000 --- a/tests/settings/utils_test.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright (C) 2017 Lucas Hoffmann -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file - -"""Tests for the alot.setting.utils module.""" - -import unittest - -import mock - -from alot.settings import utils - - -class TestResolveAtt(unittest.TestCase): - - __patchers = [] - fallback = mock.Mock() - fallback.foreground = 'some fallback foreground value' - fallback.background = 'some fallback background value' - - @classmethod - def setUpClass(cls): - cls.__patchers.append(mock.patch( - 'alot.settings.utils.AttrSpec', - mock.Mock(side_effect=lambda *args: args))) - for p in cls.__patchers: - p.start() - - @classmethod - def tearDownClass(cls): - for p in cls.__patchers: - p.stop() - - @staticmethod - def _mock(foreground, background): - """Create a mock object that is needed very often.""" - m = mock.Mock() - m.foreground = foreground - m.background = background - return m - - def test_passing_none_returns_fallback(self): - actual = utils.resolve_att(None, self.fallback) - self.assertEqual(actual, self.fallback) - - def test_empty_string_in_background_picks_up_background_from_fallback(self): - attr = self._mock('valid foreground', '') - expected = (attr.foreground, self.fallback.background) - actual = utils.resolve_att(attr, self.fallback) - self.assertTupleEqual(actual, expected) - - def test_default_in_background_picks_up_background_from_fallback(self): - attr = self._mock('valid foreground', 'default') - expected = attr.foreground, self.fallback.background - actual = utils.resolve_att(attr, self.fallback) - self.assertTupleEqual(actual, expected) - - def test_empty_string_in_foreground_picks_up_foreground_from_fallback(self): - attr = self._mock('', 'valid background') - expected = self.fallback.foreground, attr.background - actual = utils.resolve_att(attr, self.fallback) - self.assertTupleEqual(actual, expected) - - def test_default_in_foreground_picks_up_foreground_from_fallback(self): - attr = self._mock('default', 'valid background') - expected = self.fallback.foreground, attr.background - actual = utils.resolve_att(attr, self.fallback) - self.assertTupleEqual(actual, expected) - - def test_other_values_are_used(self): - attr = self._mock('valid foreground', 'valid background') - expected = attr.foreground, attr.background - actual = utils.resolve_att(attr, self.fallback) - self.assertTupleEqual(actual, expected) diff --git a/tests/test_account.py b/tests/test_account.py new file mode 100644 index 00000000..9d0ac125 --- /dev/null +++ b/tests/test_account.py @@ -0,0 +1,187 @@ +# encoding=utf-8 +# Copyright © 2017 Dylan Baker +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import logging +import unittest + +from alot import account + +from . import utilities + +class _AccountTestClass(account.Account): + """Implements stubs for ABC methods.""" + + def send_mail(self, mail): + pass + + +class TestAccount(unittest.TestCase): + """Tests for the Account class.""" + + def test_matches_address(self): + """Tests address without aliases.""" + acct = _AccountTestClass(address="foo@example.com") + self.assertTrue(acct.matches_address(u"foo@example.com")) + self.assertFalse(acct.matches_address(u"bar@example.com")) + + def test_matches_address_with_aliases(self): + """Tests address with aliases.""" + acct = _AccountTestClass(address="foo@example.com", + aliases=['bar@example.com']) + self.assertTrue(acct.matches_address(u"foo@example.com")) + self.assertTrue(acct.matches_address(u"bar@example.com")) + self.assertFalse(acct.matches_address(u"baz@example.com")) + + def test_matches_address_with_regex_aliases(self): + """Tests address with regex aliases.""" + acct = _AccountTestClass(address=u"foo@example.com", + alias_regexp=r'to\+.*@example.com') + self.assertTrue(acct.matches_address(u"to+foo@example.com")) + self.assertFalse(acct.matches_address(u"to@example.com")) + + + def test_deprecated_encrypt_by_default(self): + """Tests that deprecated values are still accepted.""" + for each in ['true', 'yes', '1']: + acct = _AccountTestClass(address='foo@example.com', + encrypt_by_default=each) + self.assertEqual(acct.encrypt_by_default, 'all') + for each in ['false', 'no', '0']: + acct = _AccountTestClass(address='foo@example.com', + encrypt_by_default=each) + self.assertEqual(acct.encrypt_by_default, 'none') + + +class TestAddress(unittest.TestCase): + + """Tests for the Address class.""" + + def test_from_string(self): + addr = account.Address.from_string('user@example.com') + self.assertEqual(addr.username, 'user') + self.assertEqual(addr.domainname, 'example.com') + + def test_str(self): + addr = account.Address('ušer', 'example.com') + self.assertEqual(str(addr), 'ušer@example.com') + + def test_eq_unicode(self): + addr = account.Address('ušer', 'example.com') + self.assertEqual(addr, 'ušer@example.com') + + def test_eq_address(self): + addr = account.Address('ušer', 'example.com') + addr2 = account.Address('ušer', 'example.com') + self.assertEqual(addr, addr2) + + def test_ne_unicode(self): + addr = account.Address('ušer', 'example.com') + self.assertNotEqual(addr, 'user@example.com') + + def test_ne_address(self): + addr = account.Address('ušer', 'example.com') + addr2 = account.Address('user', 'example.com') + self.assertNotEqual(addr, addr2) + + def test_eq_unicode_case(self): + addr = account.Address('UŠer', 'example.com') + self.assertEqual(addr, 'ušer@example.com') + + def test_ne_unicode_case(self): + addr = account.Address('ušer', 'example.com') + self.assertEqual(addr, 'uŠer@example.com') + + def test_ne_address_case(self): + addr = account.Address('ušer', 'example.com') + addr2 = account.Address('uŠer', 'example.com') + self.assertEqual(addr, addr2) + + def test_eq_address_case(self): + addr = account.Address('UŠer', 'example.com') + addr2 = account.Address('ušer', 'example.com') + self.assertEqual(addr, addr2) + + def test_eq_unicode_case_sensitive(self): + addr = account.Address('UŠer', 'example.com', case_sensitive=True) + self.assertNotEqual(addr, 'ušer@example.com') + + def test_eq_address_case_sensitive(self): + addr = account.Address('UŠer', 'example.com', case_sensitive=True) + addr2 = account.Address('ušer', 'example.com') + self.assertNotEqual(addr, addr2) + + def test_eq_str(self): + addr = account.Address('user', 'example.com', case_sensitive=True) + with self.assertRaises(TypeError): + addr == 1 # pylint: disable=pointless-statement + + def test_ne_str(self): + addr = account.Address('user', 'example.com', case_sensitive=True) + with self.assertRaises(TypeError): + addr != 1 # pylint: disable=pointless-statement + + def test_repr(self): + addr = account.Address('user', 'example.com', case_sensitive=True) + self.assertEqual( + repr(addr), + "Address('user', 'example.com', case_sensitive=True)") + + def test_domain_name_ne(self): + addr = account.Address('user', 'example.com') + self.assertNotEqual(addr, 'user@example.org') + + def test_domain_name_eq_case(self): + addr = account.Address('user', 'example.com') + self.assertEqual(addr, 'user@Example.com') + + def test_domain_name_ne_unicode(self): + addr = account.Address('user', 'éxample.com') + self.assertNotEqual(addr, 'user@example.com') + + def test_domain_name_eq_unicode(self): + addr = account.Address('user', 'éxample.com') + self.assertEqual(addr, 'user@Éxample.com') + + def test_domain_name_eq_case_sensitive(self): + addr = account.Address('user', 'example.com', case_sensitive=True) + self.assertEqual(addr, 'user@Example.com') + + def test_domain_name_eq_unicode_sensitive(self): + addr = account.Address('user', 'éxample.com', case_sensitive=True) + self.assertEqual(addr, 'user@Éxample.com') + + def test_cmp_empty(self): + addr = account.Address('user', 'éxample.com') + self.assertNotEqual(addr, '') + + +class TestSend(unittest.TestCase): + + @utilities.async_test + async def test_logs_on_success(self): + a = account.SendmailAccount(address="test@alot.dev", cmd="true") + with self.assertLogs() as cm: + await a.send_mail("some text") + #self.assertIn(cm.output, "sent mail successfullya") + self.assertIn("INFO:root:sent mail successfully", cm.output) + + @utilities.async_test + async def test_failing_sendmail_command_is_noticed(self): + a = account.SendmailAccount(address="test@alot.dev", cmd="false") + with self.assertRaises(account.SendingMailFailed): + with self.assertLogs(level=logging.ERROR): + await a.send_mail("some text") diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 00000000..79f07e1b --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,98 @@ +# encoding=utf-8 +# Copyright (C) 2017 Lucas Hoffmann +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +"""Tests for the alot.completion module.""" +import unittest + +import mock + +from alot import completion + +# Good descriptive test names often don't fit PEP8, which is meant to cover +# functions meant to be called by humans. +# pylint: disable=invalid-name + + +def _mock_lookup(query): + """Look up the query from fixed list of names and email addresses.""" + abook = [ + ("", "no-real-name@example.com"), + ("foo", "foo@example.com"), + ("comma, person", "comma@example.com"), + ("single 'quote' person", "squote@example.com"), + ('double "quote" person', "dquote@example.com"), + ("""all 'fanzy' "stuff" at, once""", "all@example.com") + ] + results = [] + for name, email in abook: + if query in name or query in email: + results.append((name, email)) + return results + + +class AbooksCompleterTest(unittest.TestCase): + """Tests for the address book completion class.""" + + @classmethod + def setUpClass(cls): + abook = mock.Mock() + abook.lookup = _mock_lookup + cls.empty_abook_completer = completion.AbooksCompleter([]) + cls.example_abook_completer = completion.AbooksCompleter([abook]) + + def test_empty_address_book_returns_empty_list(self): + actual = self.__class__.empty_abook_completer.complete('real-name', 9) + expected = [] + self.assertListEqual(actual, expected) + + def _assert_only_one_list_entry(self, actual, expected): + """Check that the given lists are both of length 1 and the tuple at the + first positions are equal.""" + self.assertEqual(len(actual), 1) + self.assertEqual(len(expected), 1) + self.assertTupleEqual(actual[0], expected[0]) + + def test_empty_real_name_returns_plain_email_address(self): + actual = self.__class__.example_abook_completer.complete( + "real-name", 9) + expected = [("no-real-name@example.com", 24)] + self._assert_only_one_list_entry(actual, expected) + + def test_simple_address_with_real_name(self): + actual = self.__class__.example_abook_completer.complete("foo", 3) + expected = [("foo ", 21)] + self.assertListEqual(actual, expected) + + def test_real_name_with_comma(self): + actual = self.__class__.example_abook_completer.complete("comma", 5) + expected = [('"comma, person" ', 35)] + self.assertListEqual(actual, expected) + + def test_real_name_with_single_quotes(self): + actual = self.__class__.example_abook_completer.complete("squote", 6) + expected = [("single 'quote' person ", 42)] + self._assert_only_one_list_entry(actual, expected) + + def test_real_name_double_quotes(self): + actual = self.__class__.example_abook_completer.complete("dquote", 6) + expected = [("", 0)] + expected = [ + (r""""double \"quote\" person" """, 46)] + self._assert_only_one_list_entry(actual, expected) + + def test_real_name_with_quotes_and_comma(self): + actual = self.__class__.example_abook_completer.complete("all", 3) + expected = [(r""""all 'fanzy' \"stuff\" at, once" """, + 50)] + self._assert_only_one_list_entry(actual, expected) + + +class StringlistCompleterTest(unittest.TestCase): + def test_dont_choke_on_special_regex_characters(self): + tags = ['[match]', 'nomatch'] + completer = completion.StringlistCompleter(tags) + actual = completer.complete('[', 1) + expected = [(tags[0], len(tags[0]))] + self.assertListEqual(actual, expected) diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 00000000..334fcd56 --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,392 @@ +# Copyright (C) 2017 Lucas Hoffmann +# Copyright © 2017-2018 Dylan Baker +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +import os +import shutil +import signal +import subprocess +import tempfile +import unittest + +import gpg +import mock +import urwid + +from alot import crypto +from alot.errors import GPGProblem, GPGCode + +from . import utilities + + +MOD_CLEAN = utilities.ModuleCleanup() + +# A useful single fingerprint for tests that only care about one key. This +# key will not be ambiguous +FPR = "F74091D4133F87D56B5D343C1974EC55FBC2D660" + +# Some additional keys, these keys may be ambigiuos +EXTRA_FPRS = [ + "DD19862809A7573A74058FF255937AFBB156245D", + "2071E9C8DB4EF5466F4D233CF730DF92C4566CE7", +] + +DEVNULL = open('/dev/null', 'w') +MOD_CLEAN.add_cleanup(DEVNULL.close) + + +@MOD_CLEAN.wrap_setup +def setUpModule(): + home = tempfile.mkdtemp() + MOD_CLEAN.add_cleanup(shutil.rmtree, home) + mock_home = mock.patch.dict(os.environ, {'GNUPGHOME': home}) + mock_home.start() + MOD_CLEAN.add_cleanup(mock_home.stop) + + with gpg.core.Context(armor=True) as ctx: + # Add the public and private keys. They have no password + search_dir = os.path.join(os.path.dirname(__file__), 'static/gpg-keys') + for each in os.listdir(search_dir): + if os.path.splitext(each)[1] == '.gpg': + with open(os.path.join(search_dir, each)) as f: + ctx.op_import(f) + + +@MOD_CLEAN.wrap_teardown +def tearDownModule(): + # Kill any gpg-agent's that have been opened + lookfor = 'gpg-agent --homedir {}'.format(os.environ['GNUPGHOME']) + + out = subprocess.check_output( + ['ps', 'xo', 'pid,cmd'], + stderr=DEVNULL).decode(urwid.util.detected_encoding) + for each in out.strip().split('\n'): + pid, cmd = each.strip().split(' ', 1) + if cmd.startswith(lookfor): + os.kill(int(pid), signal.SIGKILL) + + +def make_key(revoked=False, expired=False, invalid=False, can_encrypt=True, + can_sign=True): + # This is ugly + mock_key = mock.create_autospec(gpg._gpgme._gpgme_key) + mock_key.uids = [mock.Mock(uid=u'mocked')] + mock_key.revoked = revoked + mock_key.expired = expired + mock_key.invalid = invalid + mock_key.can_encrypt = can_encrypt + mock_key.can_sign = can_sign + + return mock_key + + +def make_uid(email, revoked=False, invalid=False, + validity=gpg.constants.validity.FULL): + uid = mock.Mock() + uid.email = email + uid.revoked = revoked + uid.invalid = invalid + uid.validity = validity + + return uid + + +class TestHashAlgorithmHelper(unittest.TestCase): + + """Test cases for the helper function RFC3156_canonicalize.""" + + def test_returned_string_starts_with_pgp(self): + result = crypto.RFC3156_micalg_from_algo(gpg.constants.md.MD5) + self.assertTrue(result.startswith('pgp-')) + + def test_returned_string_is_lower_case(self): + result = crypto.RFC3156_micalg_from_algo(gpg.constants.md.MD5) + self.assertTrue(result.islower()) + + def test_raises_for_unknown_hash_name(self): + with self.assertRaises(GPGProblem): + crypto.RFC3156_micalg_from_algo(gpg.constants.md.NONE) + + +class TestDetachedSignatureFor(unittest.TestCase): + + def test_valid_signature_generated(self): + to_sign = b"this is some text.\nit is more than nothing.\n" + with gpg.core.Context() as ctx: + _, detached = crypto.detached_signature_for( + to_sign, [ctx.get_key(FPR)]) + + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(detached) + sig = f.name + self.addCleanup(os.unlink, f.name) + + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(to_sign) + text = f.name + self.addCleanup(os.unlink, f.name) + + res = subprocess.check_call(['gpg2', '--verify', sig, text], + stdout=DEVNULL, stderr=DEVNULL) + self.assertEqual(res, 0) + + +class TestVerifyDetached(unittest.TestCase): + + def test_verify_signature_good(self): + to_sign = b"this is some text.\nIt's something\n." + with gpg.core.Context() as ctx: + _, detached = crypto.detached_signature_for( + to_sign, [ctx.get_key(FPR)]) + + try: + crypto.verify_detached(to_sign, detached) + except GPGProblem: + raise AssertionError + + def test_verify_signature_bad(self): + to_sign = b"this is some text.\nIt's something\n." + similar = b"this is some text.\r\n.It's something\r\n." + with gpg.core.Context() as ctx: + _, detached = crypto.detached_signature_for( + to_sign, [ctx.get_key(FPR)]) + + with self.assertRaises(GPGProblem): + crypto.verify_detached(similar, detached) + + +class TestValidateKey(unittest.TestCase): + + def test_valid(self): + try: + crypto.validate_key(utilities.make_key()) + except GPGProblem as e: + raise AssertionError(e) + + def test_revoked(self): + with self.assertRaises(GPGProblem) as caught: + crypto.validate_key(utilities.make_key(revoked=True)) + + self.assertEqual(caught.exception.code, GPGCode.KEY_REVOKED) + + def test_expired(self): + with self.assertRaises(GPGProblem) as caught: + crypto.validate_key(utilities.make_key(expired=True)) + + self.assertEqual(caught.exception.code, GPGCode.KEY_EXPIRED) + + def test_invalid(self): + with self.assertRaises(GPGProblem) as caught: + crypto.validate_key(utilities.make_key(invalid=True)) + + self.assertEqual(caught.exception.code, GPGCode.KEY_INVALID) + + def test_encrypt(self): + with self.assertRaises(GPGProblem) as caught: + crypto.validate_key( + utilities.make_key(can_encrypt=False), encrypt=True) + + self.assertEqual(caught.exception.code, GPGCode.KEY_CANNOT_ENCRYPT) + + def test_encrypt_no_check(self): + try: + crypto.validate_key(utilities.make_key(can_encrypt=False)) + except GPGProblem as e: + raise AssertionError(e) + + def test_sign(self): + with self.assertRaises(GPGProblem) as caught: + crypto.validate_key(utilities.make_key(can_sign=False), sign=True) + + self.assertEqual(caught.exception.code, GPGCode.KEY_CANNOT_SIGN) + + def test_sign_no_check(self): + try: + crypto.validate_key(utilities.make_key(can_sign=False)) + except GPGProblem as e: + raise AssertionError(e) + + +class TestCheckUIDValidity(unittest.TestCase): + + def test_valid_single(self): + key = utilities.make_key() + key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL) + ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) + self.assertTrue(ret) + + def test_valid_multiple(self): + key = utilities.make_key() + key.uids = [ + utilities.make_uid(mock.sentinel.EMAIL), + utilities.make_uid(mock.sentinel.EMAIL1), + ] + + ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL1) + self.assertTrue(ret) + + def test_invalid_email(self): + key = utilities.make_key() + key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL) + ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL1) + self.assertFalse(ret) + + def test_invalid_revoked(self): + key = utilities.make_key() + key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL, revoked=True) + ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) + self.assertFalse(ret) + + def test_invalid_invalid(self): + key = utilities.make_key() + key.uids[0] = utilities.make_uid(mock.sentinel.EMAIL, invalid=True) + ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) + self.assertFalse(ret) + + def test_invalid_not_enough_trust(self): + key = utilities.make_key() + key.uids[0] = utilities.make_uid( + mock.sentinel.EMAIL, + validity=gpg.constants.validity.UNDEFINED) + ret = crypto.check_uid_validity(key, mock.sentinel.EMAIL) + self.assertFalse(ret) + + +class TestListKeys(unittest.TestCase): + + def test_list_no_hints(self): + # This only tests that you get 3 keys back (the number in our test + # keyring), it might be worth adding tests to check more about the keys + # returned + values = crypto.list_keys() + self.assertEqual(len(list(values)), 3) + + def test_list_hint(self): + values = crypto.list_keys(hint="ambig") + self.assertEqual(len(list(values)), 2) + + def test_list_keys_pub(self): + values = list(crypto.list_keys(hint="ambigu"))[0] + self.assertEqual(values.uids[0].email, u'amigbu@example.com') + self.assertFalse(values.secret) + + def test_list_keys_private(self): + values = list(crypto.list_keys(hint="ambigu", private=True))[0] + self.assertEqual(values.uids[0].email, u'amigbu@example.com') + self.assertTrue(values.secret) + + +class TestGetKey(unittest.TestCase): + + def test_plain(self): + # Test the uid of the only identity attached to the key we generated. + with gpg.core.Context() as ctx: + expected = ctx.get_key(FPR).uids[0].uid + actual = crypto.get_key(FPR).uids[0].uid + self.assertEqual(expected, actual) + + def test_validate(self): + # Since we already test validation we're only going to test validate + # once. + with gpg.core.Context() as ctx: + expected = ctx.get_key(FPR).uids[0].uid + actual = crypto.get_key( + FPR, validate=True, encrypt=True, sign=True).uids[0].uid + self.assertEqual(expected, actual) + + def test_missing_key(self): + with self.assertRaises(GPGProblem) as caught: + crypto.get_key('foo@example.com') + self.assertEqual(caught.exception.code, GPGCode.NOT_FOUND) + + def test_invalid_key(self): + with self.assertRaises(GPGProblem) as caught: + crypto.get_key('z') + self.assertEqual(caught.exception.code, GPGCode.NOT_FOUND) + + @mock.patch('alot.crypto.check_uid_validity', mock.Mock(return_value=True)) + def test_signed_only_true(self): + try: + crypto.get_key(FPR, signed_only=True) + except GPGProblem as e: + raise AssertionError(e) + + @mock.patch( + 'alot.crypto.check_uid_validity', mock.Mock(return_value=False)) + def test_signed_only_false(self): + with self.assertRaises(GPGProblem) as e: + crypto.get_key(FPR, signed_only=True) + self.assertEqual(e.exception.code, GPGCode.NOT_FOUND) + + @staticmethod + def _context_mock(): + class CustomError(gpg.errors.GPGMEError): + """A custom GPGMEError class that always has an errors code of + AMBIGUOUS_NAME. + """ + def getcode(self): + return gpg.errors.AMBIGUOUS_NAME + + context_mock = mock.Mock() + context_mock.get_key = mock.Mock(side_effect=CustomError) + + return context_mock + + def test_ambiguous_one_valid(self): + invalid_key = utilities.make_key(invalid=True) + valid_key = utilities.make_key() + + with mock.patch('alot.crypto.gpg.core.Context', + mock.Mock(return_value=self._context_mock())), \ + mock.patch('alot.crypto.list_keys', + mock.Mock(return_value=[valid_key, invalid_key])): + key = crypto.get_key('placeholder') + self.assertIs(key, valid_key) + + def test_ambiguous_two_valid(self): + with mock.patch('alot.crypto.gpg.core.Context', + mock.Mock(return_value=self._context_mock())), \ + mock.patch('alot.crypto.list_keys', + mock.Mock(return_value=[utilities.make_key(), + utilities.make_key()])): + with self.assertRaises(crypto.GPGProblem) as cm: + crypto.get_key('placeholder') + self.assertEqual(cm.exception.code, GPGCode.AMBIGUOUS_NAME) + + def test_ambiguous_no_valid(self): + with mock.patch('alot.crypto.gpg.core.Context', + mock.Mock(return_value=self._context_mock())), \ + mock.patch('alot.crypto.list_keys', + mock.Mock(return_value=[ + utilities.make_key(invalid=True), + utilities.make_key(invalid=True)])): + with self.assertRaises(crypto.GPGProblem) as cm: + crypto.get_key('placeholder') + self.assertEqual(cm.exception.code, GPGCode.NOT_FOUND) + + +class TestEncrypt(unittest.TestCase): + + def test_encrypt(self): + to_encrypt = b"this is a string\nof data." + encrypted = crypto.encrypt(to_encrypt, keys=[crypto.get_key(FPR)]) + + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(encrypted) + enc_file = f.name + self.addCleanup(os.unlink, enc_file) + + dec = subprocess.check_output( + ['gpg2', '--decrypt', enc_file], stderr=DEVNULL) + self.assertEqual(to_encrypt, dec) + + +class TestDecrypt(unittest.TestCase): + + def test_decrypt(self): + to_encrypt = b"this is a string\nof data." + encrypted = crypto.encrypt(to_encrypt, keys=[crypto.get_key(FPR)]) + _, dec = crypto.decrypt_verify(encrypted) + self.assertEqual(to_encrypt, dec) + + # TODO: test for "combined" method diff --git a/tests/test_helper.py b/tests/test_helper.py new file mode 100644 index 00000000..2aff842d --- /dev/null +++ b/tests/test_helper.py @@ -0,0 +1,473 @@ +# encoding=utf-8 +# Copyright © 2016-2018 Dylan Baker +# Copyright © 2017 Lucas Hoffman + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Test suite for alot.helper module.""" + +import datetime +import email +import errno +import os +import random +import unittest + +import mock + +from alot import helper + +from . import utilities + +# Descriptive names for tests often violate PEP8. That's not an issue, users +# aren't meant to call these functions. +# pylint: disable=invalid-name + +# They're tests, only add docstrings when it makes sense +# pylint: disable=missing-docstring + + +class TestHelperShortenAuthorString(unittest.TestCase): + + authors = u'King Kong, Mucho Muchacho, Jaime Huerta, Flash Gordon' + + def test_high_maxlength_keeps_string_intact(self): + short = helper.shorten_author_string(self.authors, 60) + self.assertEqual(short, self.authors) + + def test_shows_only_first_names_if_they_fit(self): + short = helper.shorten_author_string(self.authors, 40) + self.assertEqual(short, u"King, Mucho, Jaime, Flash") + + def test_adds_ellipses_to_long_first_names(self): + short = helper.shorten_author_string(self.authors, 20) + self.assertEqual(short, u"King, …, Jai…, Flash") + + def test_replace_all_but_first_name_with_ellipses(self): + short = helper.shorten_author_string(self.authors, 10) + self.assertEqual(short, u"King, …") + + def test_shorten_first_name_with_ellipses(self): + short = helper.shorten_author_string(self.authors, 2) + self.assertEqual(short, u"K…") + + def test_only_display_initial_letter_for_maxlength_1(self): + short = helper.shorten_author_string(self.authors, 1) + self.assertEqual(short, u"K") + + +class TestShellQuote(unittest.TestCase): + + def test_all_strings_are_sourrounded_by_single_quotes(self): + quoted = helper.shell_quote("hello") + self.assertEqual(quoted, "'hello'") + + def test_single_quotes_are_escaped_using_double_quotes(self): + quoted = helper.shell_quote("hello'there") + self.assertEqual(quoted, """'hello'"'"'there'""") + + +class TestHumanizeSize(unittest.TestCase): + + def test_small_numbers_are_converted_to_strings_directly(self): + readable = helper.humanize_size(1) + self.assertEqual(readable, "1") + readable = helper.humanize_size(123) + self.assertEqual(readable, "123") + + def test_numbers_above_1024_are_converted_to_kilobyte(self): + readable = helper.humanize_size(1023) + self.assertEqual(readable, "1023") + readable = helper.humanize_size(1024) + self.assertEqual(readable, "1KiB") + readable = helper.humanize_size(1234) + self.assertEqual(readable, "1KiB") + + def test_numbers_above_1048576_are_converted_to_megabyte(self): + readable = helper.humanize_size(1024*1024-1) + self.assertEqual(readable, "1023KiB") + readable = helper.humanize_size(1024*1024) + self.assertEqual(readable, "1.0MiB") + + def test_megabyte_numbers_are_converted_with_precision_1(self): + readable = helper.humanize_size(1234*1024) + self.assertEqual(readable, "1.2MiB") + + def test_numbers_are_not_converted_to_gigabyte(self): + readable = helper.humanize_size(1234*1024*1024) + self.assertEqual(readable, "1234.0MiB") + + +class TestSplitCommandline(unittest.TestCase): + + def _test(self, base, expected): + """Shared helper to reduce some boilerplate.""" + actual = helper.split_commandline(base) + self.assertListEqual(actual, expected) + + def test_simple(self): + base = 'echo "foo";sleep 1' + expected = ['echo "foo"', 'sleep 1'] + self._test(base, expected) + + def test_single(self): + base = 'echo "foo bar"' + expected = [base] + self._test(base, expected) + + def test_unicode(self): + base = u'echo "foo";sleep 1' + expected = ['echo "foo"', 'sleep 1'] + self._test(base, expected) + + +class TestSplitCommandstring(unittest.TestCase): + + def _test(self, base, expected): + """Shared helper to reduce some boilerplate.""" + actual = helper.split_commandstring(base) + self.assertListEqual(actual, expected) + + def test_bytes(self): + base = 'echo "foo bar"' + expected = ['echo', 'foo bar'] + self._test(base, expected) + + def test_unicode(self): + base = 'echo "foo €"' + expected = ['echo', 'foo €'] + self._test(base, expected) + + +class TestStringSanitize(unittest.TestCase): + + def test_tabs(self): + base = 'foo\tbar\noink\n' + expected = 'foo' + ' ' * 5 + 'bar\noink\n' + actual = helper.string_sanitize(base) + self.assertEqual(actual, expected) + + +class TestStringDecode(unittest.TestCase): + + def _test(self, base, expected, encoding='ascii'): + actual = helper.string_decode(base, encoding) + self.assertEqual(actual, expected) + + def test_ascii_bytes(self): + base = u'test'.encode('ascii') + expected = u'test' + self._test(base, expected) + + def test_utf8_bytes(self): + base = u'test'.encode('utf-8') + expected = u'test' + self._test(base, expected, 'utf-8') + + def test_unicode(self): + base = u'test' + expected = u'test' + self._test(base, expected) + + +class TestPrettyDatetime(unittest.TestCase): + + # TODO: Currently these tests use the ampm format based on whether or not + # the testing machine's locale sets them. To be really good mock should be + # used to change the locale between an am/pm locale and a 24 hour locale + # and test both scenarios. + + __patchers = [] + + @classmethod + def setUpClass(cls): + # Create a random number generator, but seed it so that it will produce + # deterministic output. This is used to select a subset of possible + # values for each of the tests in this class, since otherwise they + # would get really expensive (time wise). + cls.random = random.Random() + cls.random.seed(42) + + # Pick an exact date to ensure that the tests run the same no matter + # what time of day they're run. + cls.now = datetime.datetime(2000, 1, 5, 12, 0, 0, 0) + + # Mock datetime.now, which ensures that the time is always the same + # removing race conditions from the tests. + dt = mock.Mock() + dt.now = mock.Mock(return_value=cls.now) + cls.__patchers.append(mock.patch('alot.helper.datetime', dt)) + + for p in cls.__patchers: + p.start() + + @classmethod + def tearDownClass(cls): + for p in cls.__patchers: + p.stop() + + def test_just_now(self): + for i in (self.random.randint(0, 60) for _ in range(5)): + test = self.now - datetime.timedelta(seconds=i) + actual = helper.pretty_datetime(test) + self.assertEqual(actual, u'just now') + + def test_x_minutes_ago(self): + for i in (self.random.randint(60, 3600) for _ in range(10)): + test = self.now - datetime.timedelta(seconds=i) + actual = helper.pretty_datetime(test) + self.assertEqual( + actual, u'{}min ago'.format((self.now - test).seconds // 60)) + + def test_x_hours_ago(self): + for i in (self.random.randint(3600, 3600 * 6) for _ in range(10)): + test = self.now - datetime.timedelta(seconds=i) + actual = helper.pretty_datetime(test) + self.assertEqual( + actual, u'{}h ago'.format((self.now - test).seconds // 3600)) + + # TODO: yesterday + # TODO: yesterday > now > a year + # TODO: last year + # XXX: when can the last else be hit? + + @staticmethod + def _future_expected(test): + if test.strftime('%p'): + expected = test.strftime('%I:%M%p').lower() + else: + expected = test.strftime('%H:%M') + expected = expected + return expected + + def test_future_seconds(self): + test = self.now + datetime.timedelta(seconds=30) + actual = helper.pretty_datetime(test) + expected = self._future_expected(test) + self.assertEqual(actual, expected) + + # Returns 'just now', instead of 'from future' or something similar + @unittest.expectedFailure + def test_future_minutes(self): + test = self.now + datetime.timedelta(minutes=5) + actual = helper.pretty_datetime(test) + expected = test.strftime('%a ') + self._future_expected(test) + self.assertEqual(actual, expected) + + # Returns 'just now', instead of 'from future' or something similar + @unittest.expectedFailure + def test_future_hours(self): + test = self.now + datetime.timedelta(hours=1) + actual = helper.pretty_datetime(test) + expected = test.strftime('%a ') + self._future_expected(test) + self.assertEqual(actual, expected) + + # Returns 'just now', instead of 'from future' or something similar + @unittest.expectedFailure + def test_future_days(self): + def make_expected(): + # Uses the hourfmt instead of the hourminfmt from pretty_datetime + if test.strftime('%p'): + expected = test.strftime('%I%p') + else: + expected = test.strftime('%Hh') + expected = expected.decode('utf-8') + return expected + + test = self.now + datetime.timedelta(days=1) + actual = helper.pretty_datetime(test) + expected = test.strftime('%a ') + make_expected() + self.assertEqual(actual, expected) + + # Returns 'just now', instead of 'from future' or something similar + @unittest.expectedFailure + def test_future_week(self): + test = self.now + datetime.timedelta(days=7) + actual = helper.pretty_datetime(test) + expected = test.strftime('%b %d') + self.assertEqual(actual, expected) + + # Returns 'just now', instead of 'from future' or something similar + @unittest.expectedFailure + def test_future_month(self): + test = self.now + datetime.timedelta(days=31) + actual = helper.pretty_datetime(test) + expected = test.strftime('%b %d') + self.assertEqual(actual, expected) + + # Returns 'just now', instead of 'from future' or something similar + @unittest.expectedFailure + def test_future_year(self): + test = self.now + datetime.timedelta(days=365) + actual = helper.pretty_datetime(test) + expected = test.strftime('%b %Y') + self.assertEqual(actual, expected) + + +class TestCallCmd(unittest.TestCase): + """Tests for the call_cmd function.""" + + def test_no_stdin(self): + out, err, code = helper.call_cmd(['echo', '-n', 'foo']) + self.assertEqual(out, u'foo') + self.assertEqual(err, u'') + self.assertEqual(code, 0) + + def test_no_stdin_unicode(self): + out, err, code = helper.call_cmd(['echo', '-n', '�']) + self.assertEqual(out, u'�') + self.assertEqual(err, u'') + self.assertEqual(code, 0) + + def test_stdin(self): + out, err, code = helper.call_cmd(['cat'], stdin='�') + self.assertEqual(out, u'�') + self.assertEqual(err, u'') + self.assertEqual(code, 0) + + def test_no_such_command(self): + out, err, code = helper.call_cmd(['thiscommandabsolutelydoesntexist']) + self.assertEqual(out, u'') + + # We don't control the output of err, the shell does. Therefore simply + # assert that the shell said *something* + self.assertNotEqual(err, u'') + self.assertEqual(code, errno.ENOENT) + + def test_no_such_command_stdin(self): + out, err, code = helper.call_cmd(['thiscommandabsolutelydoesntexist'], + stdin='foo') + self.assertEqual(out, u'') + + # We don't control the output of err, the shell does. Therefore simply + # assert that the shell said *something* + self.assertNotEqual(err, u'') + self.assertEqual(code, errno.ENOENT) + + def test_bad_argument_stdin(self): + out, err, code = helper.call_cmd(['cat', '-Y'], stdin='�') + self.assertEqual(out, u'') + self.assertNotEqual(err, u'') + + # We don't control this, although 1 might be a fairly safe guess, we + # know for certain it should *not* return 0 + self.assertNotEqual(code, 0) + + def test_bad_argument(self): + out, err, code = helper.call_cmd(['cat', '-Y']) + self.assertEqual(out, u'') + self.assertNotEqual(err, u'') + + # We don't control this, although 1 might be a fairly safe guess, we + # know for certain it should *not* return 0 + self.assertNotEqual(code, 0) + + def test_os_errors_from_popen_are_caught(self): + with mock.patch('subprocess.Popen', + mock.Mock(side_effect=OSError(42, u'foobar'))): + out, err, code = helper.call_cmd( + ['does_not_matter_as_subprocess_popen_is_mocked']) + self.assertEqual(out, u'') + self.assertEqual(err, u'foobar') + self.assertEqual(code, 42) + + +class TestShorten(unittest.TestCase): + + def test_lt_maxlen(self): + expected = u'a string' + actual = helper.shorten(expected, 25) + self.assertEqual(expected, actual) + + def test_eq_maxlen(self): + expected = 'a string' + actual = helper.shorten(expected, len(expected)) + self.assertEqual(expected, actual) + + def test_gt_maxlen(self): + expected = u'a long string…' + actual = helper.shorten('a long string that is full of text', 14) + self.assertEqual(expected, actual) + + +class TestCallCmdAsync(unittest.TestCase): + + @utilities.async_test + async def test_no_stdin(self): + ret = await helper.call_cmd_async(['echo', '-n', 'foo']) + self.assertEqual(ret[0], 'foo') + + @utilities.async_test + async def test_stdin(self): + ret = await helper.call_cmd_async(['cat', '-'], stdin='foo') + self.assertEqual(ret[0], 'foo') + + @utilities.async_test + async def test_env_set(self): + with mock.patch.dict(os.environ, {}, clear=True): + ret = await helper.call_cmd_async( + ['python3', '-c', 'import os; ' + 'print(os.environ.get("foo", "fail"), end="")' + ], + env={'foo': 'bar'}) + self.assertEqual(ret[0], 'bar') + + @utilities.async_test + async def test_env_doesnt_pollute(self): + with mock.patch.dict(os.environ, {}, clear=True): + await helper.call_cmd_async(['echo', '-n', 'foo'], + env={'foo': 'bar'}) + self.assertEqual(os.environ, {}) + + @utilities.async_test + async def test_command_fails(self): + _, err, ret = await helper.call_cmd_async(['_____better_not_exist']) + self.assertEqual(ret, 1) + self.assertTrue(err) + + +class TestGetEnv(unittest.TestCase): + env_name = 'XDG_CONFIG_HOME' + default = '~/.config' + + def test_env_not_set(self): + with mock.patch.dict('os.environ'): + if self.env_name in os.environ: + del os.environ[self.env_name] + self.assertEqual(helper.get_xdg_env(self.env_name, self.default), + self.default) + + def test_env_empty(self): + with mock.patch.dict('os.environ', {self.env_name: ''}): + self.assertEqual(helper.get_xdg_env(self.env_name, self.default), + self.default) + + def test_env_not_empty(self): + custom_path = '/my/personal/config/home' + + with mock.patch.dict('os.environ', {self.env_name: custom_path}): + self.assertEqual(helper.get_xdg_env(self.env_name, self.default), + custom_path) + + +class TestParseMailto(unittest.TestCase): + + def test_parsing_working(self): + uri = 'mailto:test%40example.org?Subject=Re%3A%20Hello\ +&In-Reply-To=%3CC8CE9EFD-CB23-4BC0-B70D-9B7FEAD59F8C%40example.org%3E' + actual = helper.parse_mailto(uri) + expected = ({'To': ['test@example.org'], + 'Subject': ['Re: Hello'], + 'In-reply-to': ['']}, '') + self.assertEqual(actual, expected) diff --git a/tests/utils/argparse_test.py b/tests/utils/argparse_test.py deleted file mode 100644 index a793d5cf..00000000 --- a/tests/utils/argparse_test.py +++ /dev/null @@ -1,178 +0,0 @@ -# encoding=utf-8 -# Copyright © 2017 Dylan Baker - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Tests for alot.utils.argparse""" - -import argparse -import contextlib -import os -import shutil -import tempfile -import unittest - -import mock - -from alot.utils import argparse as cargparse - -# Good descriptive test names often don't fit PEP8, which is meant to cover -# functions meant to be called by humans. -# pylint: disable=invalid-name - -# When using mock asserts its possible that many methods will not use self, -# that's fine -# pylint: disable=no-self-use - - -class TestValidatedStore(unittest.TestCase): - """Tests for the ValidatedStore action class.""" - - def _argparse(self, args): - """Create an argparse instance with a validator.""" - - def validator(args): - if args == 'fail': - raise cargparse.ValidationFailed - - parser = argparse.ArgumentParser() - parser.add_argument( - 'foo', - action=cargparse.ValidatedStoreAction, - validator=validator) - with mock.patch('sys.stderr', mock.Mock()): - return parser.parse_args(args) - - def test_validates(self): - # Arparse will raise a SystemExit (calls sys.exit) rather than letting - # the exception cause the program to close. - with self.assertRaises(SystemExit): - self._argparse(['fail']) - - -@contextlib.contextmanager -def temporary_directory(suffix='', prefix='', dir=None): # pylint: disable=redefined-builtin - """Python3 interface implementation. - - Python3 provides a class that can be used as a context manager, which - creates a temporary directory and removes it when the context manager - exits. This function emulates enough of the interface of - TemporaryDirectory, for this module to use, and is designed as a drop in - replacement that can be replaced after the python3 port. - - The only user visible difference is that this does not implement the - cleanup method that TemporaryDirectory does. - """ - directory = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir) - yield directory - shutil.rmtree(directory) - - -class TestRequireFile(unittest.TestCase): - """Tests for the require_file validator.""" - - def test_doesnt_exist(self): - with temporary_directory() as d: - with self.assertRaises(cargparse.ValidationFailed): - cargparse.require_file(os.path.join(d, 'doesnt-exist')) - - def test_dir(self): - with temporary_directory() as d: - with self.assertRaises(cargparse.ValidationFailed): - cargparse.require_file(d) - - def test_file(self): - with tempfile.NamedTemporaryFile() as f: - cargparse.require_file(f.name) - - def test_char_special(self): - with self.assertRaises(cargparse.ValidationFailed): - cargparse.require_file('/dev/null') - - def test_fifo(self): - with temporary_directory() as d: - path = os.path.join(d, 'fifo') - os.mkfifo(path) - with self.assertRaises(cargparse.ValidationFailed): - cargparse.require_file(path) - - -class TestRequireDir(unittest.TestCase): - """Tests for the require_dir validator.""" - - def test_doesnt_exist(self): - with temporary_directory() as d: - with self.assertRaises(cargparse.ValidationFailed): - cargparse.require_dir(os.path.join(d, 'doesnt-exist')) - - def test_dir(self): - with temporary_directory() as d: - cargparse.require_dir(d) - - def test_file(self): - with tempfile.NamedTemporaryFile() as f: - with self.assertRaises(cargparse.ValidationFailed): - cargparse.require_dir(f.name) - - def test_char_special(self): - with self.assertRaises(cargparse.ValidationFailed): - cargparse.require_dir('/dev/null') - - def test_fifo(self): - with temporary_directory() as d: - path = os.path.join(d, 'fifo') - os.mkfifo(path) - with self.assertRaises(cargparse.ValidationFailed): - cargparse.require_dir(path) - - -class TestOptionalFileLike(unittest.TestCase): - """Tests for the optional_file_like validator.""" - - def test_doesnt_exist(self): - with temporary_directory() as d: - cargparse.optional_file_like(os.path.join(d, 'doesnt-exist')) - - def test_dir(self): - with temporary_directory() as d: - with self.assertRaises(cargparse.ValidationFailed): - cargparse.optional_file_like(d) - - def test_file(self): - with tempfile.NamedTemporaryFile() as f: - cargparse.optional_file_like(f.name) - - def test_char_special(self): - cargparse.optional_file_like('/dev/null') - - def test_fifo(self): - with temporary_directory() as d: - path = os.path.join(d, 'fifo') - os.mkfifo(path) - cargparse.optional_file_like(path) - - -class TestIntOrPlusOrMinus(unittest.TestCase): - """Tests for the is_int_or_pm validator.""" - - def test_int(self): - self.assertTrue(cargparse.is_int_or_pm('5')) - - def test_pm(self): - self.assertTrue(cargparse.is_int_or_pm('+')) - self.assertTrue(cargparse.is_int_or_pm('-')) - - def test_rubbish(self): - with self.assertRaises(cargparse.ValidationFailed): - cargparse.is_int_or_pm('XX') diff --git a/tests/utils/configobj_test.py b/tests/utils/configobj_test.py deleted file mode 100644 index bc1babab..00000000 --- a/tests/utils/configobj_test.py +++ /dev/null @@ -1,19 +0,0 @@ -# encoding=utf-8 -import unittest - -from alot.utils import configobj as checks - -# Good descriptive test names often don't fit PEP8, which is meant to cover -# functions meant to be called by humans. -# pylint: disable=invalid-name - - -class TestForceList(unittest.TestCase): - - def test_strings_are_converted_to_single_item_lists(self): - forced = checks.force_list('hello') - self.assertEqual(forced, ['hello']) - - def test_empty_strings_are_converted_to_empty_lists(self): - forced = checks.force_list('') - self.assertEqual(forced, []) diff --git a/tests/utils/test_argparse.py b/tests/utils/test_argparse.py new file mode 100644 index 00000000..a793d5cf --- /dev/null +++ b/tests/utils/test_argparse.py @@ -0,0 +1,178 @@ +# encoding=utf-8 +# Copyright © 2017 Dylan Baker + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for alot.utils.argparse""" + +import argparse +import contextlib +import os +import shutil +import tempfile +import unittest + +import mock + +from alot.utils import argparse as cargparse + +# Good descriptive test names often don't fit PEP8, which is meant to cover +# functions meant to be called by humans. +# pylint: disable=invalid-name + +# When using mock asserts its possible that many methods will not use self, +# that's fine +# pylint: disable=no-self-use + + +class TestValidatedStore(unittest.TestCase): + """Tests for the ValidatedStore action class.""" + + def _argparse(self, args): + """Create an argparse instance with a validator.""" + + def validator(args): + if args == 'fail': + raise cargparse.ValidationFailed + + parser = argparse.ArgumentParser() + parser.add_argument( + 'foo', + action=cargparse.ValidatedStoreAction, + validator=validator) + with mock.patch('sys.stderr', mock.Mock()): + return parser.parse_args(args) + + def test_validates(self): + # Arparse will raise a SystemExit (calls sys.exit) rather than letting + # the exception cause the program to close. + with self.assertRaises(SystemExit): + self._argparse(['fail']) + + +@contextlib.contextmanager +def temporary_directory(suffix='', prefix='', dir=None): # pylint: disable=redefined-builtin + """Python3 interface implementation. + + Python3 provides a class that can be used as a context manager, which + creates a temporary directory and removes it when the context manager + exits. This function emulates enough of the interface of + TemporaryDirectory, for this module to use, and is designed as a drop in + replacement that can be replaced after the python3 port. + + The only user visible difference is that this does not implement the + cleanup method that TemporaryDirectory does. + """ + directory = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir) + yield directory + shutil.rmtree(directory) + + +class TestRequireFile(unittest.TestCase): + """Tests for the require_file validator.""" + + def test_doesnt_exist(self): + with temporary_directory() as d: + with self.assertRaises(cargparse.ValidationFailed): + cargparse.require_file(os.path.join(d, 'doesnt-exist')) + + def test_dir(self): + with temporary_directory() as d: + with self.assertRaises(cargparse.ValidationFailed): + cargparse.require_file(d) + + def test_file(self): + with tempfile.NamedTemporaryFile() as f: + cargparse.require_file(f.name) + + def test_char_special(self): + with self.assertRaises(cargparse.ValidationFailed): + cargparse.require_file('/dev/null') + + def test_fifo(self): + with temporary_directory() as d: + path = os.path.join(d, 'fifo') + os.mkfifo(path) + with self.assertRaises(cargparse.ValidationFailed): + cargparse.require_file(path) + + +class TestRequireDir(unittest.TestCase): + """Tests for the require_dir validator.""" + + def test_doesnt_exist(self): + with temporary_directory() as d: + with self.assertRaises(cargparse.ValidationFailed): + cargparse.require_dir(os.path.join(d, 'doesnt-exist')) + + def test_dir(self): + with temporary_directory() as d: + cargparse.require_dir(d) + + def test_file(self): + with tempfile.NamedTemporaryFile() as f: + with self.assertRaises(cargparse.ValidationFailed): + cargparse.require_dir(f.name) + + def test_char_special(self): + with self.assertRaises(cargparse.ValidationFailed): + cargparse.require_dir('/dev/null') + + def test_fifo(self): + with temporary_directory() as d: + path = os.path.join(d, 'fifo') + os.mkfifo(path) + with self.assertRaises(cargparse.ValidationFailed): + cargparse.require_dir(path) + + +class TestOptionalFileLike(unittest.TestCase): + """Tests for the optional_file_like validator.""" + + def test_doesnt_exist(self): + with temporary_directory() as d: + cargparse.optional_file_like(os.path.join(d, 'doesnt-exist')) + + def test_dir(self): + with temporary_directory() as d: + with self.assertRaises(cargparse.ValidationFailed): + cargparse.optional_file_like(d) + + def test_file(self): + with tempfile.NamedTemporaryFile() as f: + cargparse.optional_file_like(f.name) + + def test_char_special(self): + cargparse.optional_file_like('/dev/null') + + def test_fifo(self): + with temporary_directory() as d: + path = os.path.join(d, 'fifo') + os.mkfifo(path) + cargparse.optional_file_like(path) + + +class TestIntOrPlusOrMinus(unittest.TestCase): + """Tests for the is_int_or_pm validator.""" + + def test_int(self): + self.assertTrue(cargparse.is_int_or_pm('5')) + + def test_pm(self): + self.assertTrue(cargparse.is_int_or_pm('+')) + self.assertTrue(cargparse.is_int_or_pm('-')) + + def test_rubbish(self): + with self.assertRaises(cargparse.ValidationFailed): + cargparse.is_int_or_pm('XX') diff --git a/tests/utils/test_configobj.py b/tests/utils/test_configobj.py new file mode 100644 index 00000000..bc1babab --- /dev/null +++ b/tests/utils/test_configobj.py @@ -0,0 +1,19 @@ +# encoding=utf-8 +import unittest + +from alot.utils import configobj as checks + +# Good descriptive test names often don't fit PEP8, which is meant to cover +# functions meant to be called by humans. +# pylint: disable=invalid-name + + +class TestForceList(unittest.TestCase): + + def test_strings_are_converted_to_single_item_lists(self): + forced = checks.force_list('hello') + self.assertEqual(forced, ['hello']) + + def test_empty_strings_are_converted_to_empty_lists(self): + forced = checks.force_list('') + self.assertEqual(forced, []) diff --git a/tests/widgets/globals_test.py b/tests/widgets/globals_test.py deleted file mode 100644 index a52a721c..00000000 --- a/tests/widgets/globals_test.py +++ /dev/null @@ -1,51 +0,0 @@ -# encoding=utf-8 -# Copyright © 2017 Dylan Baker - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Tests for the alot.widgets.globals module.""" - -import unittest - -import mock - -from alot.widgets import globals as globals_ - - -class TestTagWidget(unittest.TestCase): - - def test_sort(self): - """Test sorting.""" - # There's an upstream bug about this - # pylint: disable=bad-continuation - with mock.patch( - 'alot.widgets.globals.settings.get_tagstring_representation', - lambda t, _, __: {'translated': t, 'normal': None, - 'focussed': None}): - expected = ['a', 'z', 'aa', 'bar', 'foo'] - actual = [g.translated for g in - sorted(globals_.TagWidget(x) for x in expected)] - self.assertListEqual(actual, expected) - - def test_hash_for_unicode_representation(self): - with mock.patch( - 'alot.widgets.globals.settings.get_tagstring_representation', - lambda _, __, ___: {'translated': u'✉', 'normal': None, - 'focussed': None}): - # We don't have to assert anything, we just want the hash to be - # computed without an exception. The implementation currently - # (2017-08-20) caches the hash value when __init__ is called. This - # test should even test the correct thing if this is changed and - # the hash is only computed in __hash__. - hash(globals_.TagWidget('unread')) diff --git a/tests/widgets/test_globals.py b/tests/widgets/test_globals.py new file mode 100644 index 00000000..a52a721c --- /dev/null +++ b/tests/widgets/test_globals.py @@ -0,0 +1,51 @@ +# encoding=utf-8 +# Copyright © 2017 Dylan Baker + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for the alot.widgets.globals module.""" + +import unittest + +import mock + +from alot.widgets import globals as globals_ + + +class TestTagWidget(unittest.TestCase): + + def test_sort(self): + """Test sorting.""" + # There's an upstream bug about this + # pylint: disable=bad-continuation + with mock.patch( + 'alot.widgets.globals.settings.get_tagstring_representation', + lambda t, _, __: {'translated': t, 'normal': None, + 'focussed': None}): + expected = ['a', 'z', 'aa', 'bar', 'foo'] + actual = [g.translated for g in + sorted(globals_.TagWidget(x) for x in expected)] + self.assertListEqual(actual, expected) + + def test_hash_for_unicode_representation(self): + with mock.patch( + 'alot.widgets.globals.settings.get_tagstring_representation', + lambda _, __, ___: {'translated': u'✉', 'normal': None, + 'focussed': None}): + # We don't have to assert anything, we just want the hash to be + # computed without an exception. The implementation currently + # (2017-08-20) caches the hash value when __init__ is called. This + # test should even test the correct thing if this is changed and + # the hash is only computed in __hash__. + hash(globals_.TagWidget('unread')) -- cgit v1.2.3