407 lines
14 KiB
Python
407 lines
14 KiB
Python
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:
|
|
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 <label: User instance>.
|
|
|
|
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
|
|
<methode>_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 <label: user instance>.
|
|
|
|
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 <label: user instance>.
|
|
|
|
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)
|