import csv from unittest import mock from urllib.parse import parse_qs, urlparse import icalendar from django.contrib.auth import get_user_model from django.http import QueryDict from django.test import Client from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property User = get_user_model() class MockLDAPMixin: """ Mixin pour simuler un appel à un serveur LDAP (e.g., celui de l'ENS) dans des tests unitaires. La réponse est une instance d'une classe Entry, qui simule grossièrement l'interface de ldap3. Cette classe patche la méthode magique `__enter__`, le code correspondant doit donc appeler `with Connection(*args, **kwargs) as foo` pour que le test fonctionne. """ def mockLDAP(self, results): class Elt: def __init__(self, value): self.value = value class Entry: def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, Elt(v)) results_as_ldap = [Entry(uid=uid, cn=name) for uid, name in results] mock_connection = mock.MagicMock() mock_connection.entries = results_as_ldap # Connection is used as a context manager. mock_context_manager = mock.MagicMock() mock_context_manager.return_value.__enter__.return_value = mock_connection patcher = mock.patch( "shared.views.autocomplete.Connection", new=mock_context_manager ) patcher.start() self.addCleanup(patcher.stop) return mock_connection class CSVResponseMixin: """ Mixin pour manipuler des réponses données via CSV. Deux choix sont possibles: - si `as_dict=False`, convertit le CSV en une liste de listes (une liste par ligne) - si `as_dict=True`, convertit le CSV en une liste de dicts, avec les champs donnés par la première ligne du CSV. """ def load_from_csv_response(self, r, as_dict=False, **reader_kwargs): content = r.content.decode("utf-8") # la dernière ligne du fichier CSV est toujours vide content = content.split("\n")[:-1] if as_dict: reader_class = csv.DictReader else: reader_class = csv.reader return list(reader_class(content, **reader_kwargs)) class ICalMixin: """ Mixin pour manipuler des iCalendars. Permet de tester l'égalité entre in iCal d'une part, et une liste d'évènements (représentés par des dicts) d'autre part. """ def _test_event_equal(self, event, exp): """ Les éléments du dict peuvent être de deux types: - un tuple `(getter, expected_value)`, auquel cas on teste l'égalité `getter(event[key]) == value)`; - une variable `value` de n'importe quel autre type, auquel cas on teste `event[key] == value`. """ for key, value in exp.items(): if isinstance(value, tuple): getter = value[0] v = value[1] else: getter = lambda v: v v = value # dans un iCal, les fields sont en majuscules if getter(event[key.upper()]) != v: return False return True def _find_event(self, ev, l): for i, elt in enumerate(l): if self._test_event_equal(ev, elt): return elt, i return False, -1 def assertCalEqual(self, ical_content, expected): remaining = expected.copy() unexpected = [] cal = icalendar.Calendar.from_ical(ical_content) for ev in cal.walk("vevent"): found, i_found = self._find_event(ev, remaining) if found: remaining.pop(i_found) else: unexpected.append(ev) class TestCaseMixin: def assertForbidden(self, response): """ Test that the response (retrieved with a Client) is a denial of access. The response should verify one of the following: - its HTTP response code is 403, - it redirects to the login page with a GET parameter named 'next' whose value is the url of the requested page. """ request = response.wsgi_request try: try: # Is this an HTTP Forbidden response ? self.assertEqual(response.status_code, 403) except AssertionError: # A redirection to the login view is fine too. # Let's build the login url with the 'next' param on current # page. full_path = request.get_full_path() querystring = QueryDict(mutable=True) querystring["next"] = full_path login_url = "{}?{}".format( reverse("cof-login"), querystring.urlencode(safe="/") ) # We don't focus on what the login view does. # So don't fetch the redirect. self.assertRedirects(response, login_url, fetch_redirect_response=False) except AssertionError: raise AssertionError( "%(http_method)s request at %(path)s should be forbidden for " "%(username)s user.\n" "Response isn't 403, nor a redirect to login view. Instead, " "response code is %(code)d." % { "http_method": request.method, "path": request.get_full_path(), "username": ( "'{}'".format(request.user) if request.user.is_authenticated() else "anonymous" ), "code": response.status_code, } ) def assertUrlsEqual(self, actual, expected): """ Test that the url 'actual' is as 'expected'. Arguments: actual (str): Url to verify. expected: Two forms are accepted. * (str): Expected url. Strings equality is checked. * (dict): Its keys must be attributes of 'urlparse(actual)'. Equality is checked for each present key, except for 'query' which must be a dict of the expected query string parameters. """ if type(expected) == dict: parsed = urlparse(actual) for part, expected_part in expected.items(): if part == "query": self.assertDictEqual( parse_qs(parsed.query), expected.get("query", {}) ) else: self.assertEqual(getattr(parsed, part), expected_part) else: self.assertEqual(actual, expected) class ViewTestCaseMixin(TestCaseMixin): """ TestCase extension to ease tests of kfet views. Urls concerns ------------- # Basic usage Attributes: url_name (str): Name of view under test, as given to 'reverse' function. url_args (list, optional): Will be given to 'reverse' call. url_kwargs (dict, optional): Same. url_expcted (str): What 'reverse' should return given previous attributes. View url can then be accessed at the 'url' attribute. # Advanced usage If multiple combinations of url name, args, kwargs can be used for a view, it is possible to define 'urls_conf' attribute. It must be a list whose each item is a dict defining arguments for 'reverse' call ('name', 'args', 'kwargs' keys) and its expected result ('expected' key). The reversed urls can be accessed at the 't_urls' attribute. Users concerns -------------- During setup, the following users are created: - 'user': a basic user without any permission, - 'root': a superuser, account trigramme: 200. Their password is their username. One can create additionnal users with 'get_users_extra' method, or prevent these users to be created with 'get_users_base' method. See these two methods for further informations. By using 'register_user' method, these users can then be accessed at 'users' attribute by their label. A user label can be given to 'auth_user' attribute. The related user is then authenticated on self.client during test setup. Its value defaults to 'None', meaning no user is authenticated. Automated tests --------------- # Url reverse Based on url-related attributes/properties, the test 'test_urls' checks that expected url is returned by 'reverse' (once with basic url usage and each for advanced usage). # Forbidden responses The 'test_forbidden' test verifies that each user, from labels of 'auth_forbidden' attribute, can't access the url(s), i.e. response should be a 403, or a redirect to login view. Tested HTTP requests are given by 'http_methods' attribute. Additional data can be given by defining an attribute '_data'. """ url_name = None url_expected = None http_methods = ["GET"] auth_user = None auth_forbidden = [] def setUp(self): """ Warning: Do not forget to call super().setUp() in subclasses. """ # Signals handlers on login/logout send messages. # Due to the way the Django' test Client performs login, this raise an # error. As workaround, we mock the Django' messages module. patcher_messages = mock.patch("gestioncof.signals.messages") patcher_messages.start() self.addCleanup(patcher_messages.stop) # A test can mock 'django.utils.timezone.now' and give this as return # value. E.g. it is useful if the test checks values of 'auto_now' or # 'auto_now_add' fields. self.now = timezone.now() # Register of User instances. self.users = {} for label, user in dict(self.users_base, **self.users_extra).items(): self.register_user(label, user) if self.auth_user: # The wrapper is a sanity check. self.assertTrue( self.client.login(username=self.auth_user, password=self.auth_user) ) def tearDown(self): del self.users_base del self.users_extra def get_users_base(self): """ Dict of . Note: Don't access yourself this property. Use 'users_base' attribute which cache the returned value from here. It allows to give functions calls, which creates users instances, as values here. """ return { "user": User.objects.create_user("user", "", "user"), "root": User.objects.create_superuser("root", "", "root"), } @cached_property def users_base(self): return self.get_users_base() def get_users_extra(self): """ Dict of . Note: Don't access yourself this property. Use 'users_base' attribute which cache the returned value from here. It allows to give functions calls, which create users instances, as values here. """ return {} @cached_property def users_extra(self): return self.get_users_extra() def register_user(self, label, user): self.users[label] = user def get_user(self, label): if self.auth_user is not None: return self.auth_user return self.auth_user_mapping.get(label) @property def urls_conf(self): return [ { "name": self.url_name, "args": getattr(self, "url_args", []), "kwargs": getattr(self, "url_kwargs", {}), "expected": self.url_expected, } ] @property def t_urls(self): return [ reverse( url_conf["name"], args=url_conf.get("args", []), kwargs=url_conf.get("kwargs", {}), ) for url_conf in self.urls_conf if url_conf["name"] is not None ] @property def url(self): return self.t_urls[0] def test_urls(self): for url, conf in zip(self.t_urls, self.urls_conf): self.assertEqual(url, conf["expected"]) def test_forbidden(self): for method in self.http_methods: for user in self.auth_forbidden: for url in self.t_urls: self.check_forbidden(method, url, user) def check_forbidden(self, method, url, user=None): method = method.lower() client = Client() if user is not None: client.login(username=user, password=user) send_request = getattr(client, method) data = getattr(self, "{}_data".format(method), {}) r = send_request(url, data) self.assertForbidden(r)