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"),
+
]