gestioCOF/shared/tests/testcases.py

402 lines
13 KiB
Python
Raw Normal View History

import csv
2018-01-19 17:48:00 +01:00
from unittest import mock
from urllib.parse import parse_qs, urlparse
import icalendar
2018-01-19 17:48:00 +01:00
from django.contrib.auth import get_user_model
from django.http import QueryDict
from django.test import Client
2019-04-17 18:21:15 +02:00
from django.urls import reverse
2018-01-19 17:48:00 +01:00
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)
2018-01-19 17:48:00 +01:00
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
2018-01-19 17:48:00 +01:00
login_url = "{}?{}".format(
reverse("cof-login"), querystring.urlencode(safe="/")
)
2018-01-19 17:48:00 +01:00
# 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)
2018-01-19 17:48:00 +01:00
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": (
2018-01-19 17:48:00 +01:00
"'{}'".format(request.user)
if request.user.is_authenticated()
else "anonymous"
2018-01-19 17:48:00 +01:00
),
"code": response.status_code,
2018-01-19 17:48:00 +01:00
}
)
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":
2018-01-19 17:48:00 +01:00
self.assertDictEqual(
parse_qs(parsed.query), expected.get("query", {})
2018-01-19 17:48:00 +01:00
)
else:
self.assertEqual(getattr(parsed, part), expected_part)
else:
self.assertEqual(actual, expected)
2018-01-19 17:48:00 +01:00
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 '<method(lowercase)>_data'.
"""
2018-01-19 17:48:00 +01:00
url_name = None
url_expected = None
http_methods = ["GET"]
2018-01-19 17:48:00 +01:00
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")
2018-01-19 17:48:00 +01:00
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)
2018-01-19 17:48:00 +01:00
)
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"),
2018-01-19 17:48:00 +01:00
}
@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,
}
]
2018-01-19 17:48:00 +01:00
@property
def t_urls(self):
return [
reverse(
url_conf["name"],
args=url_conf.get("args", []),
kwargs=url_conf.get("kwargs", {}),
2018-01-19 17:48:00 +01:00
)
for url_conf in self.urls_conf
if url_conf["name"] is not None
]
2018-01-19 17:48:00 +01:00
@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"])
2018-01-19 17:48:00 +01:00
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), {})
2018-01-19 17:48:00 +01:00
r = send_request(url, data)
self.assertForbidden(r)