diff --git a/backend/secfit/requirements.txt b/backend/secfit/requirements.txt index 6f0113d893cbc4ff3738e3653d4efbcf64730140..07380affa92e1f0060a118dfa97c50742b734960 100644 --- a/backend/secfit/requirements.txt +++ b/backend/secfit/requirements.txt @@ -25,6 +25,7 @@ pylint-plugin-utils==0.6 pytz==2020.1 requests==2.24.0 rope==0.17.0 +selenium==3.141.0 six==1.15.0 sqlparse==0.3.1 toml==0.10.1 diff --git a/backend/secfit/tests/common.py b/backend/secfit/tests/common.py new file mode 100644 index 0000000000000000000000000000000000000000..029153832bcf212fdf93ab5fcd3a86111aa2358a --- /dev/null +++ b/backend/secfit/tests/common.py @@ -0,0 +1,121 @@ +import enum +import os +from dataclasses import dataclass +from urllib.parse import urljoin + +from django.test import LiveServerTestCase +from selenium import webdriver +from selenium.webdriver.support.wait import WebDriverWait + +backend_port = os.environ.get("BACKEND_PORT", 8000) +frontend_port = os.environ.get("FRONTEND_PORT", 8001) +frontend_address = f"http://localhost:{frontend_port}" +webdriver_path = os.environ.get("WEBDRIVER", "chromedriver") + + +class Visibility(str, enum.Enum): + """Enum representing workout visibility.""" + + PUBLIC = "PU" + COACH = "CO" + PRIVATE = "PR" + + +@dataclass +class LoginData(dict): + username: str + password: str + + +login_data = { + "athlete": LoginData("arya", "dOnOtCaLlMeMiLaDy"), + "coach": LoginData("syrio", "wHaTdOwEsAyToThEgOdOfDeAtH"), +} + + +class BlackBoxTestServer(LiveServerTestCase): + """Base class for running black box tests with Selenium. + + The frontend needs be started before running the tests. The frontend port + can be specified using the environment variable FRONTEND_PORT. If this + environment variable is not present, the test case expect the frontend to + be running on port 8001. + + Likewise, the port for the backend test server can be specified using the + BACKEND_PORT environment variable, which defaults to 8000. + + Examples: + + The following examples assumes you stand in the root folder of repo. + + Run tests with default values: + + (cd frontend && cordova run browser --port=8001) + (cd backend/secfit && python manage.py test tests) + + Run tests with backend listening on port 12345: + + ( + cd frontend && + BACKEND_HOST=http://localhost:12345 \ + cordova run browser --port=8001 + ) + ( + cd backend/secfit && + BACKEND_PORT=12345 python manage.py test tests + ) + + """ + + port = backend_port + timeout = 5 + headless = False + + @classmethod + def setUpClass(cls): + super().setUpClass() + options = webdriver.ChromeOptions() + options.headless = cls.headless + cls.driver = webdriver.Chrome(webdriver_path, options=options) + cls.wait = WebDriverWait(cls.driver, cls.timeout) + + @classmethod + def tearDownClass(cls): + cls.driver.quit() + super().tearDownClass() + + @property + def address(self): + return frontend_address + + def get(self, page: str, **params): + """Navigate to a given web page. + + Args: + page (str): The web page to navigate to. + **params: Arbitrary keyword arguments used to create and attach a + query string to the web page URL. + + """ + url = urljoin(self.address, page) + if params: + query_str = "&".join([f"{k}={v}" for k, v in params.items()]) + url = f"{url}?{query_str}" + self.driver.get(url) + + def login(self, user: LoginData): + """Log in a user and wait until redirected to the workouts page.""" + self.get("login.html") + self.assertEqual("Login", self.driver.title) + username_input = self.driver.find_element_by_name("username") + username_input.send_keys(user.username) + password_input = self.driver.find_element_by_name("password") + password_input.send_keys(user.password) + self.driver.find_element_by_id("btn-login").click() + self.wait.until(lambda driver: driver.title == "Workouts") + + def logout(self): + """Log out a user and wait until redirected to the home page.""" + self.get("logout.html") + self.assertEqual("Logout", self.driver.title) + self.wait.until(lambda driver: driver.title == "Home") diff --git a/backend/secfit/tests/data/exercises.json b/backend/secfit/tests/data/exercises.json new file mode 100644 index 0000000000000000000000000000000000000000..f563867789cc6e137e441ffd7b06d7d9a4b25d22 --- /dev/null +++ b/backend/secfit/tests/data/exercises.json @@ -0,0 +1,20 @@ +[ + { + "model": "workouts.exercise", + "pk": 1, + "fields": { + "name": "Fencing", + "description": "Stick them with the pointy end.", + "unit": "reps" + } + }, + { + "model": "workouts.exercise", + "pk": 2, + "fields": { + "name": "Stick fighting", + "description": "Hit opponent and pair strokes from opponent. Repeat.", + "unit": "reps" + } + } +] diff --git a/backend/secfit/tests/data/users.json b/backend/secfit/tests/data/users.json new file mode 100644 index 0000000000000000000000000000000000000000..bf3d8317e8279bad0fa4b406f8cb80b14a654004 --- /dev/null +++ b/backend/secfit/tests/data/users.json @@ -0,0 +1,21 @@ +[ + { + "model": "users.user", + "pk": 1, + "fields": { + "username": "syrio", + "email": "syrio@forel.net", + "password": "pbkdf2_sha256$216000$4rwboPYCivJX$/ixnhVdqQoX3G+t4dlNwcgb0lpPg00pZuOBqpv/ML9E=" + } + }, + { + "model": "users.user", + "pk": 2, + "fields": { + "username": "arya", + "email": "arya@stark.io", + "password": "pbkdf2_sha256$216000$glNd7zmKsCPk$Et1Wg9R1857TSb3eiaV6p4OZKi46AEwupcix2pAIS+I=", + "coach": 1 + } + } +] diff --git a/backend/secfit/tests/data/workouts.json b/backend/secfit/tests/data/workouts.json new file mode 100644 index 0000000000000000000000000000000000000000..3dbcbdfa6a6be90137b36e7aa07890b49ad2b6c4 --- /dev/null +++ b/backend/secfit/tests/data/workouts.json @@ -0,0 +1,45 @@ +[ + { + "model": "workouts.workout", + "pk": 1, + "fields": { + "name": "Sword practice with Mycah", + "date": "2011-04-24T20:00:00.00000Z", + "notes": "Got interrupted by Sansa and Joffrey. Nymeria bit Joffrey in the arm, and I threw his sword into the river!", + "visibility": "PU", + "owner": 2 + } + }, + { + "model": "workouts.workout", + "pk": 2, + "fields": { + "name": "Sword practice with Syrio", + "date": "2021-03-09T20:15:00.00000Z", + "visibility": "CO", + "owner": 2 + } + }, + { + "model": "workouts.workout", + "pk": 3, + "fields": { + "name": "My last duel", + "date": "2011-06-11T20:00:00.00000Z", + "notes": "Got interrupted by the Kingsquard during practice with Arya, and had to battle Ser Meryn Trant...", + "visibility": "PU", + "owner": 1 + } + }, + { + "model": "workouts.workout", + "pk": 4, + "fields": { + "name": "Blind training in Braavos", + "date": "2016-04-24T20:00:00.00000Z", + "notes": "Got really beaten up by the Waif today. Hard to fight back when I'm blind.", + "visibility": "PR", + "owner": 2 + } + } +] diff --git a/backend/secfit/tests/test_view_workout.py b/backend/secfit/tests/test_view_workout.py new file mode 100644 index 0000000000000000000000000000000000000000..08fff0d5a0b1b911a945a9cbfdffe235ab88df6f --- /dev/null +++ b/backend/secfit/tests/test_view_workout.py @@ -0,0 +1,130 @@ +import os +import pathlib + +from selenium.webdriver.common.by import By + +from tests.common import BlackBoxTestServer +from tests.common import login_data +from tests.common import Visibility + +here = pathlib.Path(__file__).parent.absolute() +data_dir = here / "data" +backend_port = os.environ.get("BACKEND_PORT", 8000) +frontend_port = os.environ.get("FRONTEND_PORT", 8001) +frontend_address = f"http://localhost:{frontend_port}" + + +workout_input_ids = ( + "inputName", + "inputDateTime", + "inputOwner", + "inputVisibility", + "inputNotes", +) + + +class ViewWorkoutTest(BlackBoxTestServer): + """Test case for FR5 View Workout. + + Requirement description: + + The user should be able to view all of the details, files, and comments + on workouts of sufficient visibility. For athletes, this means that the + workout needs to be either their own or public. For coaches, this means + that the workout is at least one of their athletes non-private workouts + OR the workout is public. For visitors, this means that the workout + needs to be public. + """ + + headless = True + fixtures = [ + data_dir / "users.json", + data_dir / "exercises.json", + data_dir / "workouts.json", + ] + + def wait_until_value_not_empty(self, by: By, locator: str): + self.wait.until(lambda _: self.get_input_value(by, locator) != "") + + def get_input_value(self, by: By, locator: str) -> str: + element = self.driver.find_element(by, locator) + self.assertIsNotNone(element) + return element.get_attribute("value") + + def test_user_can_view_own_public_workout(self): + """Test if a user is able to view their own public workout.""" + user = login_data["athlete"] + self.login(user) + self.get("workout.html", id=1) + self.assertEqual("Workout", self.driver.title) + self.wait_until_value_not_empty(By.ID, "inputOwner") + for input_id in workout_input_ids: + element = self.driver.find_element_by_id(input_id) + value = element.get_attribute("value") + self.assertIsNotNone(value) + if input_id == "inputOwner": + self.assertEqual(value, user.username) + if input_id == "inputVisibility": + self.assertEqual(value, Visibility.PUBLIC) + + def test_coach_can_view_athletes_workout(self): + """Test if a coach is able to view their athlete's workout.""" + user = login_data["coach"] + self.login(user) + self.get("workout.html", id=2) + self.assertEqual("Workout", self.driver.title) + self.wait_until_value_not_empty(By.ID, "inputOwner") + for input_id in workout_input_ids: + value = self.get_input_value(By.ID, input_id) + self.assertIsNotNone(value) + if input_id == "inputOwner": + self.assertNotEqual(value, user.username) + if input_id == "inputVisibility": + self.assertEqual(value, Visibility.COACH) + + def test_user_can_view_others_public_workout(self): + """Test if a user is able to view another users' public workout.""" + user = login_data["athlete"] + self.login(user) + self.get("workout.html", id=3) + self.assertEqual("Workout", self.driver.title) + self.wait_until_value_not_empty(By.ID, "inputOwner") + for input_id in workout_input_ids: + value = self.get_input_value(By.ID, input_id) + self.assertIsNotNone(value) + if input_id == "inputOwner": + self.assertNotEqual(value, user.username) + if input_id == "inputVisibility": + self.assertEqual(value, Visibility.PUBLIC) + + def test_user_can_view_own_private_workout(self): + """Test if a user is able to view their own private workout.""" + user = login_data["athlete"] + self.login(user) + self.get("workout.html", id=4) + self.assertEqual("Workout", self.driver.title) + self.wait_until_value_not_empty(By.ID, "inputOwner") + for input_id in workout_input_ids: + value = self.get_input_value(By.ID, input_id) + self.assertIsNotNone(value) + if input_id == "inputOwner": + self.assertEqual(value, user.username) + if input_id == "inputVisibility": + self.assertEqual(value, Visibility.PRIVATE) + + def test_user_cannot_view_other_users_private_workout(self): + """Test if a user is unable to view another users' private workout.""" + user = login_data["coach"] + self.login(user) + self.get("workout.html", id=4) + self.assertEqual("Workout", self.driver.title) + alert = self.driver.find_elements_by_xpath("//div[@role='alert']") + self.assertIsNotNone(alert) + for input_id in workout_input_ids: + # For workouts that don't exist or have insufficient visibility, + # the form input elements will be populated with empty values. + # Except for the visibility field, which will have "PU" as default + # value regardess. It therefor makes no sense to check its value. + if input_id != "inputVisibility": + value = self.get_input_value(By.ID, input_id) + self.assertEqual(value, "") diff --git a/backend/secfit/workouts/permissions.py b/backend/secfit/workouts/permissions.py index 605264e90ccb8555b8a574c3558d20cb7969bf01..7a79ef658a286615b5cc8a5b89afe5f01f444aad 100644 --- a/backend/secfit/workouts/permissions.py +++ b/backend/secfit/workouts/permissions.py @@ -35,7 +35,7 @@ class IsCoachAndVisibleToCoach(permissions.BasePermission): """ def has_object_permission(self, request, view, obj): - return obj.owner.coach == request.user and (obj.visibility == 'PU' or obj.visibility == 'Coach') + return obj.owner.coach == request.user and (obj.visibility == 'PU' or obj.visibility == 'CO') class IsCoachOfWorkoutAndVisibleToCoach(permissions.BasePermission): @@ -44,7 +44,7 @@ class IsCoachOfWorkoutAndVisibleToCoach(permissions.BasePermission): """ def has_object_permission(self, request, view, obj): - return obj.workout.owner.coach == request.user and (obj.workout.visibility == 'PU' or obj.workout.visibility == 'Coach') + return obj.workout.owner.coach == request.user and (obj.workout.visibility == 'PU' or obj.workout.visibility == 'CO') class IsPublic(permissions.BasePermission): diff --git a/backend/secfit/workouts/tests.py b/backend/secfit/workouts/tests.py index 4ca19ba59a547437c0dae394562136777dd1556b..9aed493f508b7d462659dd2db42112cefe0dafe9 100644 --- a/backend/secfit/workouts/tests.py +++ b/backend/secfit/workouts/tests.py @@ -29,7 +29,7 @@ class PermissionTests(TestCase): date='2021-02-03T12:00:00.000000Z', notes='Go down to until your legs has 90 degrees.', owner=user, visibility='PU') - + Workout.objects.create(name='squat', date='2021-02-03T12:00:00.000000Z', notes='Go down to until your legs has 90 degrees.', @@ -38,13 +38,12 @@ class PermissionTests(TestCase): Workout.objects.create(name='squat', date='2021-02-03T12:00:00.000000Z', notes='Go down to until your legs has 90 degrees.', - owner=coach, visibility='Coach') - + owner=coach, visibility='CO') + Workout.objects.create(name='squat', date='2021-02-03T12:00:00.000000Z', notes='Go down to until your legs has 90 degrees.', - owner=coach, visibility='Private') - + owner=coach, visibility='PR') def setUp(self): # Setup run before every test method. @@ -65,7 +64,7 @@ class PermissionTests(TestCase): permission = IsOwner.has_object_permission(self, request=request, view=None, obj=workout) self.assertTrue(permission) - + def test_isNotOwner(self): factory = RequestFactory() request = factory.get('/') @@ -85,7 +84,7 @@ class PermissionTests(TestCase): permission = IsOwnerOfWorkout.has_permission(self, request=request, view=None) self.assertTrue(permission) - + def test_IsNotOwnerOfWorkout(self): factory = RequestFactory() request = factory.post('/workout/1') @@ -107,7 +106,7 @@ class PermissionTests(TestCase): permission = IsOwnerOfWorkout.has_permission(self, request=request, view=None) self.assertFalse(permission) - + def test_IsOwnerOfWorkout_MissingPOSTMethod(self): factory = RequestFactory() request = factory.get('/workout/1') @@ -128,7 +127,7 @@ class PermissionTests(TestCase): permission = IsOwnerOfWorkout.has_object_permission(self, request=request, view=None, obj=request) self.assertTrue(permission) - + def test_IsOwnerOfWorkout_hasNotObjectPermission(self): factory = RequestFactory() request = factory.get('/') @@ -147,7 +146,7 @@ class PermissionTests(TestCase): permission = IsCoachAndVisibleToCoach.has_object_permission(self, request=request, view=None, obj=workout) self.assertTrue(permission) - + def test_IsCoachAndVisibleToCoach_visibilityCoach(self): factory = RequestFactory() request = factory.get('/') @@ -156,7 +155,7 @@ class PermissionTests(TestCase): permission = IsCoachAndVisibleToCoach.has_object_permission(self, request=request, view=None, obj=workout) self.assertTrue(permission) - + def test_IsCoachAndVisibleToCoach_visibilityPrivate(self): factory = RequestFactory() request = factory.get('/') @@ -201,7 +200,7 @@ class PermissionTests(TestCase): permission = IsCoachOfWorkoutAndVisibleToCoach.has_object_permission(self, request=request, view=None, obj=request) self.assertFalse(permission) - + def test_IsPublic(self): workout = self.workout @@ -213,7 +212,7 @@ class PermissionTests(TestCase): permission = IsPublic.has_object_permission(self, request=None, view=None, obj=workout) self.assertFalse(permission) - + def test_IsWorkoutPublic(self): workout = self.workout2 factory = RequestFactory() @@ -231,17 +230,17 @@ class PermissionTests(TestCase): permission = IsWorkoutPublic.has_object_permission(self, request=None, view=None, obj=obj) self.assertFalse(permission) - + def test_IsReadOnly(self): factory = RequestFactory() request = factory.get('/') permission = IsReadOnly.has_object_permission(self, request=request, view=None, obj=None) self.assertTrue(permission) - + def test_IsNotReadOnly(self): factory = RequestFactory() request = factory.post('/') permission = IsReadOnly.has_object_permission(self, request=request, view=None, obj=None) - self.assertFalse(permission) \ No newline at end of file + self.assertFalse(permission)