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. """ class MockLDAPModule: SCOPE_SUBTREE = None # whatever def __init__(self, ldap_obj): self.ldap_obj = ldap_obj def initialize(self, *args): """Always return the same ldap object.""" return self.ldap_obj def mockLDAP(self, results): entries = [ ( "whatever", { "cn": [name.encode("utf-8")], "uid": [uid.encode("utf-8")], "mail": [mail.encode("utf-8")], }, ) for uid, name, mail in results ] # Mock ldap object whose `search_s` method always returns the same results. mock_ldap_obj = mock.Mock() mock_ldap_obj.search_s = mock.Mock(return_value=entries) # Mock ldap module whose `initialize_method` always return the same ldap object. mock_ldap_module = self.MockLDAPModule(mock_ldap_obj) patcher = mock.patch("shared.autocomplete.ldap", new=mock_ldap_module) patcher.start() self.addCleanup(patcher.stop) return mock_ldap_module 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: content = csv.DictReader(content, **reader_kwargs) # en python3.7, content est une liste d'OrderedDicts return list(map(dict, content)) else: content = csv.reader(content, **reader_kwargs) return list(content) def assertCSVEqual(self, response, expected): if type(expected[0]) == list: as_dict = False elif type(expected[0]) == dict: as_dict = True else: raise AssertionError( "Unsupported type in `assertCSVEqual`: " "%(expected)s is not of type `list` nor `dict` !" % {"expected": str(expected[0])} ) content = self._load_from_csv_response(response, as_dict=as_dict) self.assertCountEqual(content, expected) 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 i return None def assertCalEqual(self, ical_content, expected): remaining = expected.copy() unexpected = [] cal = icalendar.Calendar.from_ical(ical_content) for ev in cal.walk("vevent"): i_found = self._find_event(ev, remaining) if i_found is not None: remaining.pop(i_found) else: unexpected.append(ev) self.assertListEqual(remaining, []) self.assertListEqual(unexpected, []) 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): """ Utilitaire pour automatiser certains tests sur les vues Django. Création d'utilisateurs ------------------------ # Données de base On crée dans tous les cas deux utilisateurs : un utilisateur normal "user", et un superutilisateur "root", avec un mot de passe identique au username. # Accès et utilisateurs supplémentaires Les utilisateurs créés sont accessibles dans le dict `self.users`, qui associe un label à une instance de User. Pour rajouter des utilisateurs supplémentaires (et s'assurer qu'ils sont disponibles dans `self.users`), on peut redéfinir la fonction `get_users_extra()`, qui doit renvoyer là aussi un dict . Misc QoL ------------------------ Pour éviter une erreur de login (puisque les messages de Django ne sont pas disponibles), les messages de bienvenue de GestioCOF sont patchés. Un attribut `self.now` est fixé au démarrage, pour être donné comme valeur de retour à un patch local de `django.utils.timezone.now`. Cela permet de tester des dates/heures de manière robuste. Test d'URLS ------------------------ # Usage basique Teste que l'URL générée par `reverse` correspond bien à l'URL théorique. Attributs liés : - `url_name` : nom de l'URL qui sera donné à `reverse`, - `url_expected` : URL attendue en retour. - (optionnels) `url_args` et `url_kwargs` : arguments de l'URL pour `reverse`. # Usage avancé On peut tester plusieurs URLs pour une même vue, en redéfinissant la fonction `urls_conf()`. Cette fonction doit retourner une liste de dicts, avec les clés suivantes : `name`, `args`, `kwargs`, `expected`. # Accès aux URLs générées Dans le cas d'usage basique, l'attribut `self.url` contient l'URL de la vue testée (telle que renvoyée par `reverse()`). Si plusieurs URLs sont définies dans `urls_conf()`, elles sont accessibles par la suite dans `self.reversed_urls`. Authentification ------------------------ Si l'attribut `auth_user` est dans `self.users`, l'utilisateur correspondant est authentifié avant chaque test (cela n'empêche bien sûr pas de login un autre utilisateur à la main). Test de restrictions d'accès ------------------------ L'utilitaire vérifie automatiquement que certains utilisateurs n'ont pas accès à la vue. Plus spécifiquement, sont testés toutes les méthodes dans `self.http_methods` et tous les utilisateurs dans `self.auth_forbidden`. Pour rappel, l'utilisateur `None` sert à tester la vue sans authentification. On peut donner des paramètres GET/POST/etc. aux tests en définissant un attribut _data. TODO (?): faire pareil pour vérifier les GET/POST classiques (code 200) """ 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. """ patcher_messages = mock.patch("gestioncof.signals.messages") patcher_messages.start() self.addCleanup(patcher_messages.stop) self.now = timezone.now() 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 reversed_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.reversed_urls[0] def test_urls(self): for url, conf in zip(self.reversed_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.reversed_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)