diff --git a/backend/Dockerfile_auth_test b/backend/Dockerfile_auth_test new file mode 100644 index 00000000..d7541b0d --- /dev/null +++ b/backend/Dockerfile_auth_test @@ -0,0 +1,9 @@ +FROM python:3.9 +RUN mkdir /auth-app +WORKDIR /auth-app +ADD ./test_auth_server /auth-app/ +COPY auth_requirements.txt /auth-app/requirements.txt +RUN pip3 install -r requirements.txt +COPY . /auth-app +ENTRYPOINT ["python"] +CMD ["__main__.py"] \ No newline at end of file diff --git a/backend/auth_requirements.txt b/backend/auth_requirements.txt new file mode 100644 index 00000000..2a0efdf3 --- /dev/null +++ b/backend/auth_requirements.txt @@ -0,0 +1,4 @@ +flask~=3.0.2 +flask-restful +python-dotenv~=1.0.1 +psycopg2-binary \ No newline at end of file diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index cb00cb51..bd8e1fa6 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from dotenv import load_dotenv -from flask import request +from flask import abort, request from flask_restful import Resource from project.models.course_relation import CourseAdmin @@ -21,6 +21,7 @@ json_message ) from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.utils.authentication import login_required, authorize_teacher_of_course, authorize_teacher_or_course_admin load_dotenv() API_URL = getenv("API_HOST") @@ -32,6 +33,7 @@ class CourseForAdmins(Resource): the /courses/course_id/admins url, only the teacher of a course can do this """ + @authorize_teacher_or_course_admin def get(self, course_id): """ This function will return all the admins of a course @@ -47,6 +49,7 @@ def get(self, course_id): filters={"course_id": course_id}, ) + @authorize_teacher_of_course def post(self, course_id): """ Api endpoint for adding new admins to a course, can only be done by the teacher @@ -72,6 +75,7 @@ def post(self, course_id): "uid" ) + @authorize_teacher_of_course def delete(self, course_id): """ Api endpoint for removing admins of a course, can only be done by the teacher diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index 41b4abd5..56751c3d 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -19,6 +19,7 @@ from project.db_in import db from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model +from project.utils.authentication import login_required, authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -27,6 +28,7 @@ class CourseByCourseId(Resource): """Api endpoint for the /courses/course_id link""" + @login_required def get(self, course_id): """ This get function will return all the related projects of the course @@ -86,6 +88,7 @@ def get(self, course_id): "error": "Something went wrong while querying the database.", "url": RESPONSE_URL}, 500 + @authorize_teacher_of_course def delete(self, course_id): """ This function will delete the course with course_id @@ -97,6 +100,7 @@ def delete(self, course_id): RESPONSE_URL ) + @authorize_teacher_of_course def patch(self, course_id): """ This function will update the course with course_id diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 63b9213d..31e9c28c 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -26,6 +26,7 @@ ) from project.utils.query_agent import query_selected_from_model +from project.utils.authentication import login_required, authorize_teacher_or_course_admin load_dotenv() API_URL = getenv("API_HOST") @@ -38,13 +39,14 @@ class CourseToAddStudents(Resource): and everyone should be able to list all students assigned to a course """ + @login_required def get(self, course_id): """ Get function at /courses/course_id/students to get all the users assigned to a course everyone can get this data so no need to have uid query in the link """ - abort_url = f"{API_URL}/courses/{str(course_id)}/students" + abort_url = f"{API_URL}/courses/{course_id}/students" get_course_abort_if_not_found(course_id) return query_selected_from_model( @@ -55,12 +57,13 @@ def get(self, course_id): filters={"course_id": course_id} ) + @authorize_teacher_or_course_admin def post(self, course_id): """ Allows admins of a course to assign new students by posting to: /courses/course_id/students with a list of uid in the request body under key "students" """ - abort_url = f"{API_URL}/courses/{str(course_id)}/students" + abort_url = f"{API_URL}/courses/{course_id}/students" uid = request.args.get("uid") data = request.get_json() student_uids = data.get("students") @@ -85,6 +88,7 @@ def post(self, course_id): response["data"] = data return response, 201 + @authorize_teacher_or_course_admin def delete(self, course_id): """ This function allows admins of a course to remove students by sending a delete request to diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index bafd881e..a56541e7 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -14,6 +14,7 @@ from project.models.course import Course from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.utils.authentication import login_required, authorize_teacher load_dotenv() API_URL = getenv("API_HOST") @@ -22,6 +23,7 @@ class CourseForUser(Resource): """Api endpoint for the /courses link""" + @login_required def get(self): """ " Get function for /courses this will be the main endpoint @@ -36,15 +38,17 @@ def get(self): filters=request.args ) - def post(self): + @authorize_teacher + def post(self, teacher_id=None): """ This function will create a new course if the body of the post contains a name and uid is an admin or teacher """ - + req = request.json + req["teacher"] = teacher_id return insert_into_model( Course, - request.json, + req, RESPONSE_URL, "course_id", required_fields=["name", "teacher"] diff --git a/backend/project/endpoints/courses/join_codes/course_join_code.py b/backend/project/endpoints/courses/join_codes/course_join_code.py index df952877..97f7284d 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_code.py +++ b/backend/project/endpoints/courses/join_codes/course_join_code.py @@ -10,6 +10,7 @@ from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model from project.models.course_share_code import CourseShareCode from project.endpoints.courses.join_codes.join_codes_utils import check_course_exists +from project.utils.authentication import authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -18,7 +19,7 @@ class CourseJoinCode(Resource): """ This class will handle post and delete queries to - the /courses/course_id/join_codes url, only an admin of a course can do this + the /courses/course_id/join_codes/ url, only an admin of a course can do this """ @check_course_exists @@ -35,9 +36,10 @@ def get(self, course_id, join_code): ) @check_course_exists + @authorize_teacher_of_course def delete(self, course_id, join_code): """ - Api endpoint for adding new join codes to a course, can only be done by the teacher + Api endpoint for deleting join codes from a course, can only be done by the teacher """ return delete_by_id_from_model( diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py index 7ab142b6..103de7db 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_codes.py +++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py @@ -11,6 +11,7 @@ from project.utils.query_agent import query_selected_from_model, insert_into_model from project.models.course_share_code import CourseShareCode from project.endpoints.courses.courses_utils import get_course_abort_if_not_found +from project.utils.authentication import login_required, authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -18,10 +19,11 @@ class CourseJoinCodes(Resource): """ - This class will handle post and delete queries to + This class will handle get and post queries to the /courses/course_id/join_codes url, only an admin of a course can do this """ + @login_required def get(self, course_id): """ This function will return all the join codes of a course @@ -36,6 +38,7 @@ def get(self, course_id): filters={"course_id": course_id} ) + @authorize_teacher_of_course def post(self, course_id): """ Api endpoint for adding new join codes to a course, can only be done by the teacher diff --git a/backend/project/endpoints/courses/join_codes/join_codes_utils.py b/backend/project/endpoints/courses/join_codes/join_codes_utils.py index 5078fce2..65defbb4 100644 --- a/backend/project/endpoints/courses/join_codes/join_codes_utils.py +++ b/backend/project/endpoints/courses/join_codes/join_codes_utils.py @@ -8,7 +8,7 @@ def check_course_exists(func): """ Middleware to check if the course exists before handling the request """ - def wrapper(self, course_id, join_code, *args, **kwargs): - get_course_abort_if_not_found(course_id) - return func(self, course_id, join_code, *args, **kwargs) + def wrapper(*args, **kwargs): + get_course_abort_if_not_found(kwargs["course_id"]) + return func(*args, **kwargs) return wrapper diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 61447c94..88e12ac7 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -12,6 +12,7 @@ from project.models.project import Project from project.utils.query_agent import query_by_id_from_model +from project.utils.authentication import authorize_project_visible API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -21,6 +22,8 @@ class ProjectAssignmentFiles(Resource): """ Class for getting the assignment files of a project """ + + @authorize_project_visible def get(self, project_id): """ Get the assignment files of a project diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index df4e99d7..691aacf0 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -12,7 +12,7 @@ from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model - +from project.utils.authentication import authorize_teacher_or_project_admin, authorize_teacher_of_project, authorize_project_visible API_URL = getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -24,6 +24,7 @@ class ProjectDetail(Resource): for implementing get, delete and put methods """ + @authorize_project_visible def get(self, project_id): """ Get method for listing a specific project @@ -37,6 +38,7 @@ def get(self, project_id): project_id, RESPONSE_URL) + @authorize_teacher_or_project_admin def patch(self, project_id): """ Update method for updating a specific project @@ -51,6 +53,7 @@ def patch(self, project_id): request.json ) + @authorize_teacher_of_project def delete(self, project_id): """ Delete a project and all of its submissions in cascade diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index eabd29f9..ccbdca70 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -9,9 +9,9 @@ from flask import request, jsonify from flask_restful import Resource - from project.models.project import Project from project.utils.query_agent import query_selected_from_model, create_model_instance +from project.utils.authentication import authorize_teacher from project.endpoints.projects.endpoint_parser import parse_project_params @@ -25,7 +25,8 @@ class ProjectsEndpoint(Resource): for implementing get method """ - def get(self): + @authorize_teacher + def get(self, teacher_id=None): """ Get method for listing all available projects that are currently in the API @@ -39,7 +40,8 @@ def get(self): filters=request.args ) - def post(self): + @authorize_teacher + def post(self, teacher_id=None): """ Post functionality for project using flask_restfull parse lib diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 62d289ae..34ae2282 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -14,6 +14,7 @@ from project.utils.files import filter_files, all_files_uploaded, zip_files from project.utils.user import is_valid_user from project.utils.project import is_valid_project +from project.utils.authentication import authorize_submission_request, authorize_submissions_request, authorize_grader, authorize_student_submission, authorize_submission_author load_dotenv() API_HOST = getenv("API_HOST") @@ -24,6 +25,7 @@ class SubmissionsEndpoint(Resource): """API endpoint for the submissions""" + @authorize_submissions_request def get(self) -> dict[str, any]: """Get all the submissions from a user for a project @@ -66,6 +68,7 @@ def get(self) -> dict[str, any]: data["message"] = "An error occurred while fetching the submissions" return data, 500 + @authorize_student_submission def post(self) -> dict[str, any]: """Post a new submission to a project @@ -142,6 +145,7 @@ def post(self) -> dict[str, any]: class SubmissionEndpoint(Resource): """API endpoint for the submission""" + @authorize_submission_request def get(self, submission_id: int) -> dict[str, any]: """Get the submission given an submission ID @@ -180,6 +184,7 @@ def get(self, submission_id: int) -> dict[str, any]: f"An error occurred while fetching the submission (submission_id={submission_id})" return data, 500 + @authorize_grader def patch(self, submission_id:int) -> dict[str, any]: """Update some fields of a submission given a submission ID @@ -232,6 +237,7 @@ def patch(self, submission_id:int) -> dict[str, any]: f"An error occurred while patching submission (submission_id={submission_id})" return data, 500 + @authorize_submission_author def delete(self, submission_id: int) -> dict[str, any]: """Delete a submission given a submission ID @@ -270,4 +276,4 @@ def delete(self, submission_id: int) -> dict[str, any]: submissions_bp.add_url_rule( "/submissions/", view_func=SubmissionEndpoint.as_view("submission") -) +) \ No newline at end of file diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index cfaf63db..1e46994e 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -6,8 +6,9 @@ from flask_restful import Resource, Api from sqlalchemy.exc import SQLAlchemyError -from project.db_in import db +from project import db from project.models.user import User as userModel +from project.utils.authentication import login_required, authorize_user, not_allowed users_bp = Blueprint("users", __name__) users_api = Api(users_bp) @@ -15,9 +16,11 @@ load_dotenv() API_URL = getenv("API_HOST") + class Users(Resource): """Api endpoint for the /users route""" + @login_required def get(self): """ This function will respond to get requests made to /users. @@ -43,7 +46,9 @@ def get(self): return {"message": "An error occurred while fetching the users", "url": f"{API_URL}/users"}, 500 + @not_allowed def post(self): + # TODO make it so this just creates a user for yourself """ This function will respond to post requests made to /users. It should create a new user and return a success message. @@ -80,10 +85,10 @@ def post(self): "url": f"{API_URL}/users"}, 500 - class User(Resource): """Api endpoint for the /users/{user_id} route""" + @login_required def get(self, user_id): """ This function will respond to GET requests made to /users/. @@ -100,6 +105,7 @@ def get(self, user_id): return {"message": "An error occurred while fetching the user", "url": f"{API_URL}/users"}, 500 + @not_allowed def patch(self, user_id): """ Update the user's information. @@ -131,6 +137,7 @@ def patch(self, user_id): "url": f"{API_URL}/users"}, 500 + @authorize_user def delete(self, user_id): """ This function will respond to DELETE requests made to /users/. diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py new file mode 100644 index 00000000..6501da9e --- /dev/null +++ b/backend/project/utils/authentication.py @@ -0,0 +1,395 @@ +""" +This module contains the functions to authenticate API calls. +""" +from os import getenv + +from dotenv import load_dotenv + +from functools import wraps +from flask import abort, request, make_response +import requests + +from project import db + +from project.models.user import User +from project.models.course import Course +from project.models.project import Project +from project.models.submission import Submission +from project.models.course_relation import CourseAdmin, CourseStudent +from sqlalchemy.exc import SQLAlchemyError + +load_dotenv() +API_URL = getenv("API_HOST") +AUTHENTICATION_URL = getenv("AUTHENTICATION_URL") + + +def abort_with_message(code: int, message: str): + """Helper function to abort with a given status code and message""" + abort(make_response({"message": message}, code)) + + +def not_allowed(f): + """Decorator function to immediately abort the current request and return 403: Forbidden""" + @wraps(f) + def wrap(*args, **kwargs): + abort_with_message(403, "Forbidden action") + return wrap + + +def return_authenticated_user_id(): + """This function will authenticate the request and check whether the authenticated user + is already in the database, if not, they will be added + """ + authentication = request.headers.get("Authorization") + if not authentication: + abort_with_message(401, "No authorization given, you need an access token to use this API") + + auth_header = {"Authorization": authentication} + response = requests.get(AUTHENTICATION_URL, headers=auth_header) + if not response: + abort_with_message(401, "An error occured while trying to authenticate your access token") + if response.status_code != 200: + abort_with_message(401, response.json()["error"]) + + user_info = response.json() + auth_user_id = user_info["id"] + try: + user = db.session.get(User, auth_user_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return abort_with_message(500, "An unexpected database error occured while fetching the user") + + if user: + return auth_user_id + is_teacher = False + if user_info["jobTitle"] != None: + is_teacher = True + + # add user if not yet in database + try: + new_user = User(uid=auth_user_id, is_teacher=is_teacher, is_admin=False) + db.session.add(new_user) + db.session.commit() + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return abort_with_message(500, "An unexpected database error occured while creating the user during authentication") + return auth_user_id + + +def is_teacher(auth_user_id): + """This function checks whether the user with auth_user_id is a teacher""" + try: + user = db.session.get(User, auth_user_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + if not user: # should realistically never happen + abort(500, "A database error occured") + if user.is_teacher: + return True + return False + + +def is_teacher_of_course(auth_user_id, course_id): + """This function checks whether the user with auth_user_id is the teacher of the course: course_id""" + try: + course = db.session.get(Course, course_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + + if not course: + abort_with_message(404, f"Could not find course with id: {course_id}") + + if auth_user_id == course.teacher: + return True + + +def is_admin_of_course(auth_user_id, course_id): + """This function checks whether the user with auth_user_id is an admin of the course: course_id""" + try: + course_admin = db.session.get(CourseAdmin, (course_id, auth_user_id)) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + + if course_admin: + return True + + return False + + +def is_student_of_course(auth_user_id, course_id): + """This function checks whether the user with auth_user_id is a student of the course: course_id""" + try: + course_student = db.session.get(CourseStudent, (course_id, auth_user_id)) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + if course_student: + return True + return False + + +def get_course_of_project(project_id): + """This function returns the course_id of the course associated with the project: project_id""" + try: + project = db.session.get(Project, project_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the project", + "url": f"{API_URL}/users"}, 500 + + if not project: + abort_with_message(404, f"Could not find project with id: {project_id}") + + return project.course_id + + +def project_visible(project_id): + try: + project = db.session.get(Project, project_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the project") + if not project: + abort_with_message(404, "Project with given id not found") + return project.visible_for_students + + +def get_course_of_submission(submission_id): + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the submission") + if not submission: + abort_with_message(404, f"Submission with id: {submission_id} not found") + return get_course_of_project(submission.project_id) + + +def login_required(f): + """ + This function will check if the person sending a request to the API is logged in + and additionally create their user entry in the database if necessary + """ + @wraps(f) + def wrap(*args, **kwargs): + return_authenticated_user_id() + return f(*args, **kwargs) + return wrap + + +def authorize_teacher(f): + """ + This function will check if the person sending a request to the API is logged in and a teacher. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + if is_teacher(auth_user_id): + kwargs["teacher_id"] = auth_user_id + return f(*args, **kwargs) + abort_with_message(403, "You are not authorized to perfom this action, only teachers are authorized") + return wrap + + +def authorize_teacher_of_course(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course in the request. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + if is_teacher_of_course(auth_user_id, kwargs["course_id"]): + return f(*args, **kwargs) + + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_teacher_or_course_admin(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course in the request or an admin of this course. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + course_id = kwargs["course_id"] + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + + abort_with_message(403, "You are not authorized to perfom this action, only teachers and course admins are authorized") + return wrap + + +def authorize_user(f): + """ + This function will check if the person sending a request to the API is logged in, + and the same user that the request is about. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + user_id = kwargs["user_id"] + if auth_user_id == user_id: + return f(*args, **kwargs) + + abort_with_message(403, "You are not authorized to perfom this action, you are not this user") + return wrap + + +def authorize_teacher_of_project(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course which the project in the request belongs to. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + + if is_teacher_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + + abort_with_message(403, "You are not authorized to perfom this action, you are not the teacher of this project") + return wrap + + +def authorize_teacher_or_project_admin(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher or an admin of the course which the project in the request belongs to. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + abort_with_message(403, """You are not authorized to perfom this action, + you are not the teacher or an admin of this project""") + return wrap + + +def authorize_project_visible(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course which the project in the request belongs to. + Or if the person is a student of this course, it will return the project if it is visible for students. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + if is_student_of_course(auth_user_id, course_id) and project_visible(project_id): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + +def authorize_submissions_request(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = request.args["project_id"] + course_id = get_course_of_project(project_id) + + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + + if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.args.get("uid"): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_student_submission(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = request.form["project_id"] + course_id = get_course_of_project(project_id) + if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.form.get("uid"): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_submission_author(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + submission_id = kwargs["submission_id"] + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the submission") + if not submission: + abort_with_message(404, f"Submission with id: {submission_id} not found") + if submission.uid == auth_user_id: + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_grader(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + course_id = get_course_of_submission(kwargs["submission_id"]) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_submission_request(f): + @wraps(f) + def wrap(*args, **kwargs): + # submission_author / grader mag hier aan + auth_user_id = return_authenticated_user_id() + submission_id = kwargs["submission_id"] + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the submission") + if not submission: + abort_with_message(404, f"Submission with id: {submission_id} not found") + if submission.uid == auth_user_id: + return f(*args, **kwargs) + course_id = get_course_of_project(submission.project_id) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 745006a1..d9f7d9cd 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -232,4 +232,4 @@ def patch_by_id_from_model(model: DeclarativeMeta, "url": urljoin(f"{base_url}/", str(column_id))}), 200 except SQLAlchemyError: return {"error": "Something went wrong while updating the database.", - "url": base_url}, 500 + "url": base_url}, 500 \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 0076b0f8..9e9dc90a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,5 @@ flask-sqlalchemy python-dotenv~=1.0.1 psycopg2-binary pytest~=8.0.1 -SQLAlchemy~=2.0.27 \ No newline at end of file +SQLAlchemy~=2.0.27 +requests~=2.25.1 \ No newline at end of file diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py new file mode 100644 index 00000000..2544968d --- /dev/null +++ b/backend/test_auth_server/__main__.py @@ -0,0 +1,69 @@ +"""Main entry point for the application.""" + +from dotenv import load_dotenv +from flask import Flask + +"""Index api point""" +from flask import Blueprint, request +from flask_restful import Resource, Api + +index_bp = Blueprint("index", __name__) +index_endpoint = Api(index_bp) + +token_dict = { + "teacher1":{ + "id":"Gunnar", + "jobTitle":"teacher" + }, + "teacher2":{ + "id":"Bart", + "jobTitle":"teacher" + }, + "student1":{ + "id":"w_student", + "jobTitle":None + }, + "student01":{ + "id":"student01", + "jobTitle":None + }, + "course_admin1":{ + "id":"Rien", + "jobTitle":None + }, + "del_user":{ + "id":"del", + "jobTitle":None + }, + "ad3_teacher":{ + "id":"brinkmann", + "jobTitle0":"teacher" + }, + "student02":{ + "id":"student02", + "jobTitle":None + }, +} + +class Index(Resource): + """Api endpoint for the / route""" + + def get(self): + auth = request.headers.get("Authorization") + if not auth: + return {"error":"Please give authorization"}, 401 + if auth in token_dict.keys(): + return token_dict[auth], 200 + return {"error":"Wrong address"}, 401 + + +index_bp.add_url_rule("/", view_func=Index.as_view("index")) + +if __name__ == "__main__": + load_dotenv() + + app = Flask(__name__) + app.register_blueprint(index_bp) + + app.run(debug=True, host='0.0.0.0') + diff --git a/backend/tests.yaml b/backend/tests.yaml index fd6d7a16..d1a41efb 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -15,6 +15,16 @@ services: start_period: 5s volumes: - ./db_construct.sql:/docker-entrypoint-initdb.d/init.sql + auth-server: + build: + context: . + dockerfile: ./Dockerfile_auth_test + environment: + API_HOST: http://auth-server + volumes: + - .:/auth-app + command: ["test_auth_server"] + test-runner: build: @@ -23,12 +33,15 @@ services: depends_on: postgres: condition: service_healthy + auth-server: + condition: service_started environment: POSTGRES_HOST: postgres # Use the service name defined in Docker Compose POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here + AUTHENTICATION_URL: http://auth-server:5000 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments volumes: - .:/app diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index aebe7ce9..7be87a8c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -153,4 +153,4 @@ def session(): # Truncate all tables for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) - session.commit() + session.commit() \ No newline at end of file diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index c9b64e15..3d5e199f 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -8,14 +8,14 @@ def test_post_courses(self, client, valid_course): Test posting a course to the /courses endpoint """ - response = client.post("/courses", json=valid_course) + response = client.post("/courses", json=valid_course, headers={"Authorization":"teacher2"}) assert response.status_code == 201 data = response.json assert data["data"]["name"] == "Sel" assert data["data"]["teacher"] == valid_course["teacher"] # Is reachable using the API - get_response = client.get(f"/courses/{data['data']['course_id']}") + get_response = client.get(f"/courses/{data['data']['course_id']}", headers={"Authorization":"teacher2"}) assert get_response.status_code == 200 @@ -32,7 +32,7 @@ def test_post_courses_course_id_students_and_admins( response = client.post( sel2_students_link + f"/students?uid={valid_course_entry.teacher}", - json={"students": valid_students}, + json={"students": valid_students}, headers={"Authorization":"teacher2"} ) assert response.status_code == 403 @@ -43,7 +43,7 @@ def test_get_courses(self, valid_course_entries, client): Test all the getters for the courses endpoint """ - response = client.get("/courses") + response = client.get("/courses", headers={"Authorization":"teacher1"}) assert response.status_code == 200 data = response.json for course in valid_course_entries: @@ -53,12 +53,12 @@ def test_course_delete(self, valid_course_entry, client): """Test all course endpoint related delete functionality""" response = client.delete( - "/courses/" + str(valid_course_entry.course_id), + "/courses/" + str(valid_course_entry.course_id), headers={"Authorization":"teacher2"} ) assert response.status_code == 200 # Is not reachable using the API - get_response = client.get(f"/courses/{valid_course_entry.course_id}") + get_response = client.get(f"/courses/{valid_course_entry.course_id}", headers={"Authorization":"teacher2"}) assert get_response.status_code == 404 def test_course_patch(self, valid_course_entry, client): @@ -67,7 +67,7 @@ def test_course_patch(self, valid_course_entry, client): """ response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ "name": "TestTest" - }) + }, headers={"Authorization":"teacher2"}) data = response.json assert response.status_code == 200 assert data["data"]["name"] == "TestTest" diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index 6ca89968..f199ab06 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -11,38 +11,33 @@ class TestCourseShareLinks: def test_get_share_links(self, valid_course_entry, client): """Test whether the share links are accessible""" - response = client.get(f"courses/{valid_course_entry.course_id}/join_codes") + response = client.get(f"courses/{valid_course_entry.course_id}/join_codes", headers={"Authorization":"teacher2"}) assert response.status_code == 200 def test_post_share_links(self, valid_course_entry, client): """Test whether the share links are accessible to post to""" response = client.post( f"courses/{valid_course_entry.course_id}/join_codes", - json={"for_admins": True}) + json={"for_admins": True}, headers={"Authorization":"teacher2"}) assert response.status_code == 201 def test_delete_share_links(self, share_code_admin, client): """Test whether the share links are accessible to delete""" response = client.delete( - f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}") + f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}", headers={"Authorization":"teacher2"}) assert response.status_code == 200 def test_get_share_links_404(self, client): """Test whether the share links are accessible""" - response = client.get("courses/0/join_codes") + response = client.get("courses/0/join_codes", headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_post_share_links_404(self, client): """Test whether the share links are accessible to post to""" - response = client.post("courses/0/join_codes", json={"for_admins": True}) - assert response.status_code == 404 - - def test_delete_share_links_404(self, client): - """Test whether the share links are accessible to delete""" - response = client.delete("courses/0/join_codes/0") + response = client.post("courses/0/join_codes", json={"for_admins": True}, headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_for_admins_required(self, valid_course_entry, client): """Test whether the for_admins field is required""" - response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}) + response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}, headers={"Authorization":"teacher2"}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 24fcc2d0..fb9be82c 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -11,11 +11,12 @@ def test_assignment_download(client, valid_project): response = client.post( "/projects", data=valid_project, - content_type='multipart/form-data' + content_type='multipart/form-data', + headers={"Authorization":"teacher2"} ) assert response.status_code == 201 project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}/assignments") + response = client.get(f"/projects/{project_id}/assignments", headers={"Authorization":"teacher2"}) # file downloaded succesfully assert response.status_code == 200 @@ -26,19 +27,19 @@ def test_not_found_download(client): """ response = client.get("/projects") # get an index that doesnt exist - response = client.get("/projects/-1/assignments") + response = client.get("/projects/-1/assignments", headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_projects_home(client): """Test home project endpoint.""" - response = client.get("/projects") + response = client.get("/projects", headers={"Authorization":"teacher1"}) assert response.status_code == 200 def test_getting_all_projects(client): """Test getting all projects""" - response = client.get("/projects") + response = client.get("/projects", headers={"Authorization":"teacher1"}) assert response.status_code == 200 assert isinstance(response.json['data'], list) @@ -52,14 +53,14 @@ def test_post_project(client, valid_project): response = client.post( "/projects", data=valid_project, - content_type='multipart/form-data' + content_type='multipart/form-data', headers={"Authorization":"teacher2"} ) assert response.status_code == 201 # check if the project with the id is present project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}") + response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) assert response.status_code == 200 @@ -67,16 +68,16 @@ def test_remove_project(client, valid_project_entry): """Test removing a project to the datab and fetching it, testing if it's not present anymore""" project_id = valid_project_entry.project_id - response = client.delete(f"/projects/{project_id}") + response = client.delete(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) assert response.status_code == 200 # check if the project isn't present anymore and the delete indeed went through - response = client.get(f"/projects/{project_id}") + response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_patch_project(client, valid_project_entry): """ - Test functionality of the PUT method for projects + Test functionality of the PATCH method for projects """ project_id = valid_project_entry.project_id @@ -86,6 +87,6 @@ def test_patch_project(client, valid_project_entry): response = client.patch(f"/projects/{project_id}", json={ "title": new_title, "archived": new_archived - }) + }, headers={"Authorization":"teacher2"}) assert response.status_code == 200 diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index be36592f..60fd971a 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -14,186 +14,36 @@ class TestSubmissionsEndpoint: ### GET SUBMISSIONS ### def test_get_submissions_wrong_user(self, client: FlaskClient): """Test getting submissions for a non-existing user""" - response = client.get("/submissions?uid=-20") + response = client.get("/submissions?uid=-20", headers={"Authorization":"teacher1"}) assert response.status_code == 400 def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" - response = client.get("/submissions?project_id=-1") - assert response.status_code == 400 + response = client.get("/submissions?project_id=-1", headers={"Authorization":"teacher1"}) + assert response.status_code == 404 # can't find course of project in authorization assert "message" in response.json def test_get_submissions_wrong_project_type(self, client: FlaskClient): """Test getting submissions for a non-existing project of the wrong type""" - response = client.get("/submissions?project_id=zero") + response = client.get("/submissions?project_id=zero", headers={"Authorization":"teacher1"}) assert response.status_code == 400 assert "message" in response.json - def test_get_submissions_all(self, client: FlaskClient): - """Test getting the submissions""" - response = client.get("/submissions") - data = response.json - assert response.status_code == 200 - assert "message" in data - assert isinstance(data["data"], list) - - def test_get_submissions_user(self, client: FlaskClient, valid_submission_entry): - """Test getting the submissions given a specific user""" - response = client.get(f"/submissions?uid={valid_submission_entry.uid}") - data = response.json - assert response.status_code == 200 - assert "message" in data - - def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific project""" - response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}") - data = response.json - assert response.status_code == 200 - assert "message" in data - - def test_get_submissions_user_project(self, client: FlaskClient, valid_submission_entry): - """Test getting the submissions given a specific user and project""" - response = client.get( - f"/submissions? \ - uid={valid_submission_entry.uid}&\ - project_id={valid_submission_entry.project_id}") + response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", headers={"Authorization":"teacher2"}) data = response.json assert response.status_code == 200 assert "message" in data - ### POST SUBMISSIONS ### - def test_post_submissions_no_user(self, client: FlaskClient, valid_project_entry, files): - """Test posting a submission without specifying a user""" - response = client.post("/submissions", data={ - "project_id": valid_project_entry.project_id, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "The uid is missing" - - def test_post_submissions_wrong_user(self, client: FlaskClient, valid_project_entry, files): - """Test posting a submission for a non-existing user""" - response = client.post("/submissions", data={ - "uid": "unknown", - "project_id": valid_project_entry.project_id, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid user (uid=unknown)" - - def test_post_submissions_no_project(self, client: FlaskClient, valid_user_entry, files): - """Test posting a submission without specifying a project""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "The project_id is missing" - - def test_post_submissions_wrong_project(self, client: FlaskClient, valid_user_entry, files): - """Test posting a submission for a non-existing project""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "project_id": 0, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=0)" - - def test_post_submissions_wrong_project_type( - self, client: FlaskClient, valid_user_entry, files - ): - """Test posting a submission for a non-existing project of the wrong type""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "project_id": "zero", - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid project_id typing (project_id=zero)" - - def test_post_submissions_no_files( - self, client: FlaskClient, valid_user_entry, valid_project_entry): - """Test posting a submission when no files are uploaded""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "project_id": valid_project_entry.project_id - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "No files were uploaded" - - - def test_post_submissions_empty_file(self, client: FlaskClient, session: Session, file_empty): - """Test posting a submission for an empty file""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id, - "files": file_empty - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "No files were uploaded" - - def test_post_submissions_file_with_no_name( - self, client: FlaskClient, session: Session, file_no_name - ): - """Test posting a submission for a file without a name""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id, - "files": file_no_name - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "No files were uploaded" - - def test_post_submissions_missing_required_files( - self, client: FlaskClient, session: Session, files - ): - """Test posting a submissions for a file with a wrong name""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Not all required files were uploaded" - - def test_post_submissions_correct( - self, client: FlaskClient, session: Session, files - ): - """Test posting a submission""" - project = session.query(Project).filter_by(title="Predicaten").first() - response = client.post("/submissions", data={ - "uid": "student02", - "project_id": project.project_id, - "files": files - }) - data = response.json - assert response.status_code == 201 - assert data["message"] == "Successfully fetched the submissions" - assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" - assert data["data"]["user"] == f"{API_HOST}/users/student02" - assert data["data"]["project"] == f"{API_HOST}/projects/{project.project_id}" ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - response = client.get("/submissions/0") + response = client.get("/submissions/0", headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=0) not found" + assert data["message"] == "Submission with id: 0 not found" def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" @@ -201,7 +51,7 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.get(f"/submissions/{submission.submission_id}") + response = client.get(f"/submissions/{submission.submission_id}", headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" @@ -218,10 +68,10 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" - response = client.patch("/submissions/0", data={"grading": 20}) + response = client.patch("/submissions/0", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=0) not found" + assert data["message"] == "Submission with id: 0 not found" def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" @@ -229,7 +79,7 @@ def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Sess submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}) + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" @@ -240,18 +90,18 @@ def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}) + response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" - def test_patch_submission_correct(self, client: FlaskClient, session: Session): + def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Session): """Test patching a submission""" project = session.query(Project).filter_by(title="B+ Trees").first() submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}) + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" @@ -265,14 +115,16 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): "path": "/submissions/2", "status": False } + + # TODO test course admin (allowed) and student (not allowed) patch ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): """Test deleting a submission for a non-existing submission id""" - response = client.delete("submissions/0") + response = client.delete("submissions/0", headers={"Authorization":"student01"}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=0) not found" + assert data["message"] == "Submission with id: 0 not found" def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" @@ -280,7 +132,7 @@ def test_delete_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.delete(f"submissions/{submission.submission_id}") + response = client.delete(f"submissions/{submission.submission_id}", headers={"Authorization":"student01"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) deleted" diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index c20b0a29..25b28d62 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -37,48 +37,82 @@ def user_db_session(): for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) session.commit() + class TestUserEndpoint: """Class to test user management endpoints.""" def test_delete_user(self, client, valid_user_entry): """Test deleting a user.""" # Delete the user - response = client.delete(f"/users/{valid_user_entry.uid}") + response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"student1"}) assert response.status_code == 200 - get_response = client.get(f"/users/{valid_user_entry.uid}") + # If student 1 sends this request, he would get added again + get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + assert get_response.status_code == 404 + + def test_delete_user_not_yourself(self, client, valid_user_entry): + """Test deleting a user that is not the user the authentication belongs to.""" + # Delete the user + response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + assert response.status_code == 403 + + # If student 1 sends this request, he would get added again + get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + + assert get_response.status_code == 200 def test_delete_not_present(self, client): """Test deleting a user that does not exist.""" - response = client.delete("/users/-20") - assert response.status_code == 404 + response = client.delete("/users/-20", headers={"Authorization":"student1"}) + assert response.status_code == 403 # User does not exist, so you are not the user - def test_wrong_form_post(self, client, user_invalid_field): - """Test posting with a wrong form.""" + def test_post_no_authentication(self, client, user_invalid_field): + """Test posting without authentication.""" response = client.post("/users", json=user_invalid_field) - assert response.status_code == 400 + assert response.status_code == 403 # POST to /users is not allowed - def test_wrong_datatype_post(self, client, valid_user): - """Test posting with a wrong content type.""" - response = client.post("/users", data=valid_user) - assert response.status_code == 415 + def test_post_authenticated(self, client, valid_user): + """Test posting with wrong authentication.""" + response = client.post("/users", data=valid_user, headers={"Authorization":"teacher1"}) + assert response.status_code == 403 # POST to /users is not allowed def test_get_all_users(self, client, valid_user_entries): """Test getting all users.""" - response = client.get("/users") + response = client.get("/users", headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response is a list (even if it's empty) assert isinstance(response.json["data"], list) for valid_user in valid_user_entries: assert valid_user.uid in \ [user["uid"] for user in response.json["data"]] + + def test_get_all_users_no_authentication(self, client): + """Test getting all users without authentication.""" + response = client.get("/users") + assert response.status_code == 401 + + def test_get_all_users_wrong_authentication(self, client): + """Test getting all users with wrong authentication.""" + response = client.get("/users", headers={"Authorization":"wrong"}) + assert response.status_code == 401 def test_get_one_user(self, client, valid_user_entry): """Test getting a single user.""" - response = client.get(f"users/{valid_user_entry.uid}") + response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) assert response.status_code == 200 assert "data" in response.json + + def test_get_one_user_no_authentication(self, client, valid_user_entry): + """Test getting a single user without authentication.""" + response = client.get(f"users/{valid_user_entry.uid}") + assert response.status_code == 401 + + def test_get_one_user_wrong_authentication(self, client, valid_user_entry): + """Test getting a single user with wrong authentication.""" + response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"wrong"}) + assert response.status_code == 401 def test_patch_user(self, client, valid_user_entry): """Test updating a user.""" @@ -89,12 +123,7 @@ def test_patch_user(self, client, valid_user_entry): 'is_teacher': new_is_teacher, 'is_admin': not valid_user_entry.is_admin }) - assert response.status_code == 200 - assert response.json["message"] == "User updated successfully!" - - get_response = client.get(f"/users/{valid_user_entry.uid}") - assert get_response.status_code == 200 - assert get_response.json["data"]["is_teacher"] == new_is_teacher + assert response.status_code == 403 # Patching a user is never necessary and thus not allowed def test_patch_non_existent(self, client): """Test updating a non-existent user.""" @@ -102,19 +131,19 @@ def test_patch_non_existent(self, client): 'is_teacher': False, 'is_admin': True }) - assert response.status_code == 404 + assert response.status_code == 403 # Patching is not allowed def test_patch_non_json(self, client, valid_user_entry): """Test sending a non-JSON patch request.""" valid_user_form = asdict(valid_user_entry) valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form) - assert response.status_code == 415 + assert response.status_code == 403 # Patching is not allowed def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing - response = client.get("/users?is_admin=true&is_teacher=false") + response = client.get("/users?is_admin=true&is_teacher=false", headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response contains only the user that matches the query