From 69aa578ad2c081209dd38e0589fbc57ab8c27df0 Mon Sep 17 00:00:00 2001 From: Tom Irgang Date: Thu, 27 Apr 2023 11:09:41 +0200 Subject: [PATCH] finish liveservertests --- .gitignore | 1 + rc_hip/repapp/backends.py | 23 ++++- rc_hip/repapp/fixtures/cafe-data.json | 47 ++++++++++ rc_hip/repapp/forms.py | 41 ++++++--- rc_hip/repapp/live_tests.py | 89 +++++++++++++++++++ .../repapp/templates/repapp/member_login.html | 2 +- rc_hip/repapp/tests.py | 88 ++++++++++++++++-- rc_hip/repapp/urls.py | 5 +- 8 files changed, 272 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 306b0ca..d3bea30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env +**/data.json rc_hip/static rc_hip/static/** diff --git a/rc_hip/repapp/backends.py b/rc_hip/repapp/backends.py index ac8e454..dcac6ab 100644 --- a/rc_hip/repapp/backends.py +++ b/rc_hip/repapp/backends.py @@ -12,6 +12,9 @@ logger = logging.getLogger(__name__) def generate_username(email): + """ + Generate a valid user name form the mail address. + """ # Using Python 3 and Django 1.11+, usernames can contain alphanumeric # (ascii and unicode), _, @, +, . and - characters. So we normalize # it and slice at 150 characters. @@ -19,19 +22,29 @@ def generate_username(email): def create_repapp_user(user): + """ + Create the RepApp user objects for the new user. + If a user logs in using OIDC it is a member of the Repair-Café, so it can be + either a Reparateur or an Organisator. Organisators have access to the private + data form the guests and must be nominated by an admin. + """ organisator = Organisator.objects.filter(mail=user.email).first() if not organisator: + # no organisator, create or update reparateur reparateur = Reparateur.objects.filter(mail=user.email).first() if not reparateur: + # create new reparateur reparateur = Reparateur( name=user.username, mail=user.email, ) reparateur.save() else: + # update name of existing reparateur reparateur.name = user.username reparateur.save() else: + # update name of organisator organisator.name = user.username organisator.save() @@ -74,15 +87,17 @@ class KeycloakOIDCAB(OIDCAuthenticationBackend): def create_user(self, claims): user = super(KeycloakOIDCAB, self).create_user(claims) - logger.debug(f'Create user {user.email}') + logger.info('Create user %s' % user.email) + fallback_name = generate_username(user.email) try: - user.username = claims.get( - 'preferred_username', generate_username(user.email)) + user.username = claims.get('preferred_username', fallback_name) user.save() except Exception as exception: logger.error(exception) - user.username = generate_username(user.email) + logger.warning( + 'Update username failed! Using fallback name %s.' % fallback_name) + user.username = fallback_name user.save() logger.debug(f'Updated username {user.username}') diff --git a/rc_hip/repapp/fixtures/cafe-data.json b/rc_hip/repapp/fixtures/cafe-data.json index 05a5e96..da8e763 100644 --- a/rc_hip/repapp/fixtures/cafe-data.json +++ b/rc_hip/repapp/fixtures/cafe-data.json @@ -7,5 +7,52 @@ "address": "Patersholzer Weg 19, Hilpoltstein", "event_date": "2023-05-13" } + }, + { + "model": "repapp.customuser", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$600000$O70jrsL2vmSssirJPvWBMw$lbvFy/OI7mUfz26Qvh+bxnsp19v+xsETJydvsgLxE4E=", + "last_login": "2023-04-25T18:47:07.685Z", + "is_superuser": false, + "username": "tom", + "first_name": "", + "last_name": "", + "is_staff": false, + "is_active": true, + "date_joined": "2023-04-25T07:10:40.972Z", + "email": "thomas@irgang.eu", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "repapp.guest", + "pk": 4, + "fields": { + "identifier": "272623d8b634cef82ba1c8981593cd827a373226c492b328671d31418a19bcc1", + "name": "Thomas", + "phone": "012345678", + "residence": "Allersberg", + "mail": "thomas@irgang.eu", + "user": 3 + } + }, + { + "model": "repapp.device", + "pk": 2, + "fields": { + "identifier": "5f3d413409ca9c90c99540fd5677cc1147156f100d7bd29d3a1c1940e5f2c6ac", + "date": "2023-04-25", + "device": "Test Gerät", + "manufacturer": "Test Hersteller", + "error": "Fehler", + "follow_up": false, + "device_picture": "", + "type_plate_picture": "", + "confirmed": true, + "guest": 4, + "cafe": 1 + } } ] \ No newline at end of file diff --git a/rc_hip/repapp/forms.py b/rc_hip/repapp/forms.py index c96e21f..0938f85 100644 --- a/rc_hip/repapp/forms.py +++ b/rc_hip/repapp/forms.py @@ -3,25 +3,42 @@ Django forms for RepApp. """ from django import forms from django.core.exceptions import ValidationError +from crispy_forms.layout import Field -class HoneypotField(forms.BooleanField): +EMPTY_VALUES = (None, '') + + +class HoneypotWidget(forms.TextInput): """ - Simple honeypot field. + This widget shows a by CSS hidden input field. If this form gets manipulated + by a bot the form is rejected. """ - default_widget = forms.CheckboxInput( - {'style': 'display:none !important;', 'tabindex': '-1', 'autocomplete': 'off'}) + is_hidden = True - def __init__(self, *args, **kwargs): - kwargs.setdefault('widget', HoneypotField.default_widget) - kwargs['required'] = False - super().__init__(*args, **kwargs) + def __init__(self, attrs=None, html_comment=False, *args, **kwargs): + self.html_comment = html_comment + super(HoneypotWidget, self).__init__(attrs, *args, **kwargs) + if not 'class' in self.attrs: + self.attrs['style'] = 'display:none' + + def render(self, *args, **kwargs): + value = super(HoneypotWidget, self).render(*args, **kwargs) + if self.html_comment: + value = '' % value + return value + + +class HoneypotField(forms.Field): + """ + This widget implements a simple honey pot field as spam protection. + """ + widget = HoneypotWidget def clean(self, value): - if cleaned_value := super().clean(value): - raise ValidationError('') - else: - return cleaned_value + if self.initial in EMPTY_VALUES and value in EMPTY_VALUES or value == self.initial: + return value + raise ValidationError('Anti-spam field changed in value.') class RegisterDevice(forms.Form): diff --git a/rc_hip/repapp/live_tests.py b/rc_hip/repapp/live_tests.py index 1a3e0a3..60d14b7 100644 --- a/rc_hip/repapp/live_tests.py +++ b/rc_hip/repapp/live_tests.py @@ -1,12 +1,21 @@ import time import os +import datetime +import random from pathlib import Path +from hashlib import sha256 from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from django.contrib.auth import get_user_model from selenium.webdriver.common.by import By +# from selenium.webdriver.chrome.webdriver import WebDriver from selenium.webdriver.firefox.webdriver import WebDriver +from .models import CustomUser, Guest, OneTimeLogin class WorkflowTests(StaticLiveServerTestCase): + """ + Tests for workflows. + """ fixtures = ["cafe-data.json"] @classmethod @@ -20,7 +29,35 @@ class WorkflowTests(StaticLiveServerTestCase): cls.selenium.quit() super().tearDownClass() + def setUp(self): + super().setUp() + + self.password = "ATestPassword" + user = CustomUser.objects.get(pk=3) + user.set_password(self.password) + user.save() + self.user = user + print(f'Password of user {user.email} updated to {self.password}.') + + url = "/guest/profile/" + secret = sha256( + f'{user.email}{url}{datetime.datetime.now()}{random.randint(0,9999999)}'.encode( + 'utf-8') + ).hexdigest() + self.secret = secret + secret_hash = sha256(secret.encode('utf-8')).hexdigest() + one_time_login = OneTimeLogin( + secret=secret_hash, + user=user, + url=url, + ) + one_time_login.save() + self.one_time_login = one_time_login + def test_register_device(self): + """ + Test device registration flows. + """ # Open landing page self.selenium.get(f"{self.live_server_url}/") # Click on a register link @@ -110,3 +147,55 @@ class WorkflowTests(StaticLiveServerTestCase): # Guest is known, no guest registration needed. self.assertTrue("confirm" in self.selenium.current_url) self.assertTrue("Anmeldung erfolgreich!" in self.selenium.page_source) + + def test_login_and_view_profile(self): + """ + Test login and profile page. + """ + # Open login form + self.selenium.get(f"{self.live_server_url}/accounts/login/") + # Enter login data + # Enter guest mail address + username = self.selenium.find_element(By.NAME, "username") + username.send_keys(self.user.email) + # Enter password + password = self.selenium.find_element(By.NAME, "password") + password.send_keys(self.password) + time.sleep(1) + # Submit form + self.selenium.find_element( + By.XPATH, '//button[@type="submit"]').click() + time.sleep(1) + self.selenium.get(f"{self.live_server_url}/guest/profile/") + time.sleep(1) + self.assertTrue(self.user.email in self.selenium.page_source) + self.assertTrue("/guest/profile/" in self.selenium.current_url) + time.sleep(1) + + def test_one_time_login_and_view_device(self): + """ + Test one time login and device page. + """ + # Open login form + self.selenium.get( + f"{self.live_server_url}/onetimelogin/{self.secret}/") + time.sleep(1) + self.assertTrue(self.user.email in self.selenium.page_source) + self.assertTrue("Login erfolgreich" in self.selenium.page_source) + self.assertTrue("/guest/profile/" in self.selenium.current_url) + time.sleep(1) + self.selenium.get( + f"{self.live_server_url}/device/5f3d413409ca9c90c99540fd5677cc1147156f100d7bd29d3a1c1940e5f2c6ac/") + time.sleep(1) + self.assertTrue("Test Gerät" in self.selenium.page_source) + self.assertTrue("Test Hersteller" in self.selenium.page_source) + self.assertTrue("/device/" in self.selenium.current_url) + time.sleep(1) + + def test_member_login(self): + self.selenium.get( + f"{self.live_server_url}/member/login/") + time.sleep(1) + self.selenium.find_element(By.CLASS_NAME, 'login_link').click() + time.sleep(1) + self.assertTrue('sso.makes-hacks-hip.de' in self.selenium.current_url) diff --git a/rc_hip/repapp/templates/repapp/member_login.html b/rc_hip/repapp/templates/repapp/member_login.html index c93b392..e885ba4 100644 --- a/rc_hip/repapp/templates/repapp/member_login.html +++ b/rc_hip/repapp/templates/repapp/member_login.html @@ -20,7 +20,7 @@ {% else %} - Anmelden mit Makes-Hacks-Hip + Anmelden mit Makes-Hacks-Hip {% endif %} {% endblock content %} diff --git a/rc_hip/repapp/tests.py b/rc_hip/repapp/tests.py index 167e173..d93aace 100644 --- a/rc_hip/repapp/tests.py +++ b/rc_hip/repapp/tests.py @@ -10,8 +10,8 @@ from django.test import TestCase from django.test import Client from django.urls import reverse -from .backends import OneTimeLoginBackend, EmailBackend -from .models import OneTimeLogin, CustomUser, Cafe +from .backends import OneTimeLoginBackend, EmailBackend, create_repapp_user +from .models import (OneTimeLogin, CustomUser, Cafe, Organisator, Reparateur) class OneTimeLoginTest(TestCase): @@ -100,11 +100,89 @@ class EmailLoginTest(TestCase): self.assertEqual(result, None) -class UtilsTest(TestCase): +class BackendsTest(TestCase): """ - Test for utility functions. + Test for backend utility functions. """ - pass + + def setUp(self): + organisator = Organisator( + name="Organisator Name", + mail="orga@example.com", + ) + organisator.save() + self.organisator = organisator + + reparateur = Reparateur( + name="Reparateur Name", + mail="repa@example.com", + ) + reparateur.save() + self.reparateur = reparateur + + def test_create_reparateur(self): + """ + Ensure that a new reparateur is created. + """ + user = CustomUser( + username="Other Reparateur", + email="repa2@example.com", + ) + user.save() + create_repapp_user(user) + + reparateur = Reparateur.objects.filter(mail=user.email).first() + self.assertIsNotNone(reparateur) + self.assertEqual(reparateur.name, user.username) + + def test_update_reparateur(self): + """ + Ensure that the reparateur name gets updated. + """ + user = CustomUser( + username="First Reparateur", + email="repa@example.com", + ) + user.save() + create_repapp_user(user) + + reparateur = Reparateur.objects.filter(mail=user.email).first() + self.assertIsNotNone(reparateur) + self.assertEqual(reparateur.name, user.username) + + def test_update_organisator(self): + """ + Ensure that the organisator name gets updated. + """ + user = CustomUser( + username="The Organisator", + email="orga@example.com", + ) + user.save() + create_repapp_user(user) + + organisator = Organisator.objects.filter(mail=user.email).first() + self.assertIsNotNone(organisator) + self.assertEqual(organisator.name, user.username) + + +class FormsTest(TestCase): + """ + Test for RepApp form features. + """ + + fixtures = ["cafe-data.json"] + + def test_from_protection(self): + client = Client(enforce_csrf_checks=True) + response = client.get(reverse('register_device', kwargs={ + 'cafe': 1, + })) + # ensure honeypot field is available + self.assertContains( + response, 'type="text" name="accept_agb" style="display:none"') + # ensure csrf is available + self.assertContains(response, 'csrfmiddlewaretoken') class ViewsTest(TestCase): diff --git a/rc_hip/repapp/urls.py b/rc_hip/repapp/urls.py index d4aac47..5d51289 100644 --- a/rc_hip/repapp/urls.py +++ b/rc_hip/repapp/urls.py @@ -22,10 +22,11 @@ urlpatterns = [ views.device_view, name="view_device"), path("guest/profile/", views.profile, name="guest_profile"), + path("onetimelogin//", + views.one_time_login, name="one_time_login"), path("member/login/", views.member_login, name="member_login"), path("cron", views.cron, name="cron"), path("process_mails", views.process_mails, name="process_mails"), - path("onetimelogin//", - views.one_time_login, name="one_time_login"), + ]