From 031156cb8d3b441a14149ed9ff9f7679a449847a Mon Sep 17 00:00:00 2001 From: cskaandorp Date: Mon, 12 Jun 2023 16:15:41 +0200 Subject: [PATCH] Refactor test suite for webapp (#1461) --- .github/workflows/ci-frontend.yml | 7 +- asreview/entry_points/auth_tool.py | 70 +- asreview/webapp/api/auth.py | 136 ++-- asreview/webapp/api/projects.py | 12 +- asreview/webapp/api/team.py | 48 +- .../webapp/authentication/login_required.py | 2 +- asreview/webapp/authentication/models.py | 76 +- .../DashboardComponents/TableRowButton.js | 4 +- asreview/webapp/tests/README.md | 54 +- .../{temp_env_var.py => config/__init__.py} | 8 - asreview/webapp/tests/config/asreview.ini | 17 + .../tests/config/auth_basic_config.json | 8 + .../webapp/tests/config/auth_no_creation.json | 8 + .../auth_verified_config.json} | 11 +- .../{configs => config}/no_auth_config.json | 0 .../webapp/tests/configs/auth_config.json | 19 - .../configs/auth_config_no_accounts.json | 19 - asreview/webapp/tests/conftest.py | 176 +++-- .../{test_webapp.py => data/__init__.py} | 24 - .../tests/integration_tests/__init__.py | 13 + asreview/webapp/tests/test_api/__init__.py | 13 + asreview/webapp/tests/test_api/conftest.py | 78 ++ asreview/webapp/tests/test_api/test_auth.py | 547 +++++++++++++ .../webapp/tests/test_api/test_projects.py | 606 ++++++++++++++ asreview/webapp/tests/test_api/test_teams.py | 277 +++++++ asreview/webapp/tests/test_api/test_webapp.py | 38 + .../webapp/tests/test_asreview_database.py | 558 ------------- asreview/webapp/tests/test_auth.py | 437 ---------- asreview/webapp/tests/test_auth_conversion.py | 317 -------- .../test_database_and_models/__init__.py | 13 + .../test_database_and_models/conftest.py | 48 ++ .../test_collaboration_models.py | 120 +++ .../test_database_creation.py | 36 + .../test_project_model.py | 156 ++++ .../test_user_model.py | 308 ++++++++ .../webapp/tests/test_extensions/__init__.py | 13 + .../tests/test_extensions/test_auth_tool.py | 542 +++++++++++++ asreview/webapp/tests/test_project.py | 171 ---- .../tests/test_project_api_authenticated.py | 747 ------------------ .../tests/test_project_api_unauthenticated.py | 403 ---------- asreview/webapp/tests/test_teams_api.py | 445 ----------- asreview/webapp/tests/utils.py | 43 - asreview/webapp/tests/utils/__init__.py | 13 + asreview/webapp/tests/utils/api_utils.py | 466 +++++++++++ asreview/webapp/tests/utils/config_parser.py | 55 ++ asreview/webapp/tests/utils/crud.py | 160 ++++ asreview/webapp/tests/utils/misc.py | 138 ++++ setup.py | 2 +- 48 files changed, 4038 insertions(+), 3424 deletions(-) rename asreview/webapp/tests/{temp_env_var.py => config/__init__.py} (78%) create mode 100644 asreview/webapp/tests/config/asreview.ini create mode 100644 asreview/webapp/tests/config/auth_basic_config.json create mode 100644 asreview/webapp/tests/config/auth_no_creation.json rename asreview/webapp/tests/{configs/auth_config_verification.json => config/auth_verified_config.json} (59%) rename asreview/webapp/tests/{configs => config}/no_auth_config.json (100%) delete mode 100644 asreview/webapp/tests/configs/auth_config.json delete mode 100644 asreview/webapp/tests/configs/auth_config_no_accounts.json rename asreview/webapp/tests/{test_webapp.py => data/__init__.py} (51%) create mode 100644 asreview/webapp/tests/integration_tests/__init__.py create mode 100644 asreview/webapp/tests/test_api/__init__.py create mode 100644 asreview/webapp/tests/test_api/conftest.py create mode 100644 asreview/webapp/tests/test_api/test_auth.py create mode 100644 asreview/webapp/tests/test_api/test_projects.py create mode 100644 asreview/webapp/tests/test_api/test_teams.py create mode 100644 asreview/webapp/tests/test_api/test_webapp.py delete mode 100644 asreview/webapp/tests/test_asreview_database.py delete mode 100644 asreview/webapp/tests/test_auth.py delete mode 100644 asreview/webapp/tests/test_auth_conversion.py create mode 100644 asreview/webapp/tests/test_database_and_models/__init__.py create mode 100644 asreview/webapp/tests/test_database_and_models/conftest.py create mode 100644 asreview/webapp/tests/test_database_and_models/test_collaboration_models.py create mode 100644 asreview/webapp/tests/test_database_and_models/test_database_creation.py create mode 100644 asreview/webapp/tests/test_database_and_models/test_project_model.py create mode 100644 asreview/webapp/tests/test_database_and_models/test_user_model.py create mode 100644 asreview/webapp/tests/test_extensions/__init__.py create mode 100644 asreview/webapp/tests/test_extensions/test_auth_tool.py delete mode 100644 asreview/webapp/tests/test_project.py delete mode 100644 asreview/webapp/tests/test_project_api_authenticated.py delete mode 100644 asreview/webapp/tests/test_project_api_unauthenticated.py delete mode 100644 asreview/webapp/tests/test_teams_api.py delete mode 100644 asreview/webapp/tests/utils.py create mode 100644 asreview/webapp/tests/utils/__init__.py create mode 100644 asreview/webapp/tests/utils/api_utils.py create mode 100644 asreview/webapp/tests/utils/config_parser.py create mode 100644 asreview/webapp/tests/utils/crud.py create mode 100644 asreview/webapp/tests/utils/misc.py diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index eaa966fad..8513f09db 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -13,13 +13,18 @@ jobs: node-version: '16' cache: 'npm' cache-dependency-path: 'asreview/webapp/package-lock.json' + - name : Get Tags + run: | + git fetch --prune --unshallow --tags + git tag - name: Compile assets run: | python setup.py compile_assets - name: Install pytest and package run: | pip install pytest + pip install pytest-random-order pip install --no-cache-dir . - name: Test flask app run: | - pytest asreview/webapp/tests + pytest --random-order asreview/webapp/tests diff --git a/asreview/entry_points/auth_tool.py b/asreview/entry_points/auth_tool.py index a40c4eb41..682ad424d 100644 --- a/asreview/entry_points/auth_tool.py +++ b/asreview/entry_points/auth_tool.py @@ -1,13 +1,15 @@ import argparse import json +import sys from argparse import RawTextHelpFormatter -from pathlib import Path +from uuid import UUID from sqlalchemy import create_engine from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker from asreview.entry_points.base import BaseEntryPoint +from asreview.project import ASReviewProject from asreview.utils import asreview_path from asreview.webapp.authentication.models import Project from asreview.webapp.authentication.models import User @@ -86,6 +88,14 @@ def auth_parser(): return parser +def verify_id(id): + try: + UUID(id) + return True + except ValueError: + return False + + def insert_user(session, entry): """Inserts a dictionary containing user data into the database.""" @@ -102,27 +112,11 @@ def insert_user(session, entry): session.add(user) session.commit() print(f"User with email {user.email} created.") + return True except IntegrityError: - print(f"User with identifier {user.email} already exists") - - -def rename_project_folder(project_id, new_project_id): - """Rename folder with an authenticated project id""" - folder = asreview_path() / project_id - folder.rename(asreview_path() / new_project_id) - try: - # take care of the id inside the project.json file - with open(asreview_path() / new_project_id / "project.json", mode="r") as f: - data = json.load(f) - # change id - data["id"] = new_project_id - # overwrite original project.json file with new project id - with open(asreview_path() / new_project_id / "project.json", mode="w") as f: - json.dump(data, f) - except Exception as exc: - # revert renaming the folder - folder.rename(asreview_path() / project_id) - raise exc + session.rollback() + sys.stderr.write(f"User with identifier {user.email} already exists") + return False def insert_project(session, project): @@ -139,12 +133,12 @@ def insert_project(session, project): # create new record session.add(Project(owner_id=owner_id, project_id=project_id)) else: - # update record + # update record (project_id must be the same) db_project.owner_id = owner_id - db_project.project_id = project_id # commit session.commit() print("Project data is stored.") + return True def get_users(session): @@ -158,6 +152,7 @@ def execute(self, argv): self.args = args self.argv = argv + print(self.args) # create a conn object for the database if hasattr(self.args, "db_path") and self.args.db_path is not None: @@ -193,7 +188,7 @@ def _ensure_valid_value_for(self, name, validation_function, hint=""): if validation_function(value): return value else: - print(hint) + sys.stderr.write(hint) def enter_users(self): while True: @@ -249,17 +244,23 @@ def _get_projects(self): projects = [f for f in asreview_path().glob("*") if f.is_dir()] result = [] for folder in projects: - with open(Path(folder) / "project.json", "r") as out: - project = json.load(out) + project = ASReviewProject(folder) + + # Raise a RuntimeError if the project version is too low. + if project.config.get("version").startswith("0."): + id = project.config.get("id") + message = f"""Version of project with id {id} is too old, + please upgrade first before using this tool.""" + raise RuntimeError(message) result.append( { "folder": folder.name, - "version": project["version"], - "project_id": project["id"], - "name": project["name"], - "authors": project["authors"], - "created": project["datetimeCreated"], + "version": project.config.get("version"), + "project_id": project.config.get("id"), + "name": project.config.get("name"), + "authors": project.config.get("authors"), + "created": project.config.get("datetimeCreated"), "owner_id": 0, } ) @@ -275,6 +276,8 @@ def list_users(self): def list_projects(self): projects = self._get_projects() if self.args.json: + # dump the data twice to create a string + # that can be loaded again by the tool. print(json.dumps(json.dumps(projects))) else: [self._print_project(p) for p in projects] @@ -299,7 +302,8 @@ def _generate_project_links(self): while True: id = input("Enter the ID number of the owner: ") try: - id = id.replace(".", "") + if isinstance(id, str): + id = id.replace(".", "") id = int(id) if id not in user_ids: print("Entered ID does not exists, try again.") @@ -310,7 +314,7 @@ def _generate_project_links(self): ) break except ValueError: - print("Entered ID is not a number, please try again.") + sys.stderr.write("Entered ID is not a number, please try again.") return result def link_projects(self): diff --git a/asreview/webapp/api/auth.py b/asreview/webapp/api/auth.py index b3b2326b4..78dba8f8a 100644 --- a/asreview/webapp/api/auth.py +++ b/asreview/webapp/api/auth.py @@ -42,9 +42,13 @@ # of making the end-user decide the exact location. bp = Blueprint("auth", __name__, url_prefix="/auth") +# NOTE: not too sure about this, what if we are dealing with a +# domain name +ROOT_URL = "http://127.0.0.1:3000/" + CORS( bp, - resources={r"*": {"origins": ["http://localhost:3000", "http://127.0.0.1:3000"]}}, + resources={r"*": {"origins": ["http://localhost:3000", ROOT_URL]}}, supports_credentials=True, ) @@ -62,11 +66,8 @@ def send_forgot_password_email(user, cur_app): name = user.name or "ASReview user" # email config config = cur_app.config.get("EMAIL_CONFIG") - # TODO: this is horrible => what if there is a domain name, - # where is it coming from? Where can I get it? - root_url = "http://127.0.0.1:3000/" # redirect url - url = f"{root_url}reset_password?user_id={user.id}&token={user.token}" + url = f"{ROOT_URL}reset_password?user_id={user.id}&token={user.token}" # create a mailer mailer = Mail(cur_app) # open templates as string and render @@ -94,11 +95,8 @@ def send_confirm_account_email(user, cur_app): name = user.name or "ASReview user" # email config config = cur_app.config.get("EMAIL_CONFIG") - # TODO: this is horrible => what if there is a domain name, - # where is it coming from? Where can I get it? - root_url = "http://127.0.0.1:3000/" # redirect url - url = f"{root_url}confirm_account?user_id={user.id}&token={user.token}" + url = f"{ROOT_URL}confirm_account?user_id={user.id}&token={user.token}" # create a mailer mailer = Mail(cur_app) # open templates as string and render @@ -145,18 +143,23 @@ def signin(): logged_in = perform_login_user(user) result = ( 200, - {"logged_in": logged_in, "name": user.get_name(), "id": user.id}, + { + "logged_in": logged_in, + "name": user.get_name(), + "id": user.id, + "message": f"User {user.identifier} is logged in." + }, ) else: # password is wrong if user.origin == "asreview": # if this is an asreview user - result = (404, {"message": f"Incorrect password for user {email}"}) + result = (404, {"message": f"Incorrect password for user {email}."}) else: # this must be an OAuth user trying to get in with # a password service = user.origin.capitalize() - result = (404, {"message": f"Please login with the {service} service"}) + result = (404, {"message": f"Please login with the {service} service."}) status, message = result response = jsonify(message) @@ -165,9 +168,6 @@ def signin(): @bp.route("/signup", methods=["POST"]) def signup(): - # this is for the response - user_id = False - # Can we create accounts? if current_app.config.get("ALLOW_ACCOUNT_CREATION", False): email = request.form.get("email", "").strip() @@ -223,13 +223,13 @@ def signup(): send_confirm_account_email(user, current_app) # result result = ( - 200, + 201, f"An email has been sent to {user.email} to verify " + "your account. Please follow instructions.", ) else: # result is a 201 with message - result = (201, f'User "#{identifier}" created.') + result = (201, f'User "{identifier}" created.') except IntegrityError as e: DB.session.rollback() result = (403, f"Unable to create your account! Reason: {str(e)}") @@ -240,7 +240,7 @@ def signup(): result = (400, "The app is not configured to create accounts") (status, message) = result - response = jsonify({"message": message, "user_id": user_id}) + response = jsonify({"message": message}) return response, status @@ -258,7 +258,7 @@ def confirm_account(): ).one_or_none() if not user: - result = (404, "No user account / correct token found") + result = (404, "No user account / correct token found.") elif not user.token_valid(token, max_hours=24): message = ( "Can not confirm account, token has expired. " @@ -269,12 +269,15 @@ def confirm_account(): user = user.confirm_user() try: DB.session.commit() - result = (200, "Updated user") + result = (200, f"User {user.identifier} confirmed.") except SQLAlchemyError as e: DB.session.rollback() - result = (403, f"Unable to to confirm user! Reason: {str(e)}") + result = ( + 403, + f"Unable to to confirm user {user.identifier}! Reason: {str(e)}" + ) else: - result = (400, "The app is not configured to verify accounts") + result = (400, "The app is not configured to verify accounts.") status, message = result response = jsonify({"message": message}) @@ -284,11 +287,12 @@ def confirm_account(): @bp.route("/get_profile", methods=["GET"]) @asreview_login_required def get_profile(): - user = User.query.get(current_user.id) + user = User.query.filter(User.id == current_user.id).one_or_none() if user: result = ( 200, { + "identifier": user.identifier, "email": user.email, "origin": user.origin, "name": user.name, @@ -297,7 +301,7 @@ def get_profile(): }, ) else: - result = (404, "No user found") + result = (404, "No user found.") status, message = result response = jsonify({"message": message}) @@ -306,7 +310,7 @@ def get_profile(): @bp.route("/forgot_password", methods=["POST"]) def forgot_password(): - if current_app.config.get("EMAIL_VERIFICATION", False): + if current_app.config.get("EMAIL_CONFIG", False): # get email address from request email_address = request.form.get("email", "").strip() @@ -318,7 +322,7 @@ def forgot_password(): if not user: result = (404, f'User with email "{email_address}" not found.') elif user.origin != "asreview": - result = (404, f"Your account has been created with {user.origin}") + result = (404, f"Your account has been created with {user.origin}.") else: # set a token user = user.set_token_data( @@ -336,6 +340,47 @@ def forgot_password(): except SQLAlchemyError as e: DB.session.rollback() result = (403, f"Unable to to confirm user! Reason: {str(e)}") + else: + result = (404, "Forgot-password feature is not used in this app.") + + status, message = result + response = jsonify({"message": message}) + return response, status + + +@bp.route("/reset_password", methods=["POST"]) +def reset_password(): + """Resests password of user""" + if current_app.config.get("EMAIL_CONFIG", False): + + new_password = request.form.get("password", "").strip() + token = request.form.get("token", "").strip() + user_id = request.form.get("user_id", "0").strip() + user = User.query.filter(User.id == user_id).one_or_none() + + if not user: + result = ( + 404, + "User not found, try restarting the forgot-password procedure." + ) + elif not user.token_valid(token, max_hours=24): + result = ( + 404, + "Token is invalid or too old, restart the forgot-password procedure." + ) + else: + try: + user = user.reset_password(new_password) + DB.session.commit() + result = (200, "Password updated.") + except ValueError as e: + DB.session.rollback() + result = (500, f"Unable to reset your password! Reason: {str(e)}") + except SQLAlchemyError as e: + DB.session.rollback() + result = (500, f"Unable to reset your password! Reason: {str(e)}") + else: + result = (404, "Reset-password feature is not used in this app.") status, message = result response = jsonify({"message": message}) @@ -346,7 +391,7 @@ def forgot_password(): @asreview_login_required def update_profile(): """Update user profile""" - user = User.query.get(current_user.id) + user = User.query.filter(User.id == current_user.id).one_or_none() if user: email = request.form.get("email", "").strip() name = request.form.get("name", "").strip() @@ -357,7 +402,9 @@ def update_profile(): try: user = user.update_profile(email, name, affiliation, password, public) DB.session.commit() - result = (200, "User profile updated") + result = (200, "User profile updated.") + except ValueError as e: + result = (500, f"Unable to update your profile! Reason: {str(e)}") except IntegrityError as e: DB.session.rollback() result = (500, f"Unable to update your profile! Reason: {str(e)}") @@ -373,35 +420,6 @@ def update_profile(): return response, status -@bp.route("/reset_password", methods=["POST"]) -def reset_password(): - """Resests password of user""" - new_password = request.form.get("password", "").strip() - token = request.form.get("token", "").strip() - user_id = request.form.get("user_id", "0").strip() - - user = DB.session.get(User, user_id) - if not user: - result = (404, "User not found, try restarting the forgot-password procedure.") - elif not user.token_valid(token, max_hours=24): - result = (404, "Token is invalid, restart the forgot-password procedure.") - else: - try: - user = user.reset_password(new_password) - DB.session.commit() - result = (200, "Password updated") - except IntegrityError as e: - DB.session.rollback() - result = (500, f"Unable to reset your password! Reason: {str(e)}") - except SQLAlchemyError as e: - DB.session.rollback() - result = (500, f"Unable to reset your password! Reason: {str(e)}") - - status, message = result - response = jsonify({"message": message}) - return response, status - - @bp.route("/refresh", methods=["GET"]) def refresh(): if current_user and isinstance(current_user, User): @@ -425,9 +443,9 @@ def signout(): if current_user: identifier = current_user.identifier logout_user() - result = (200, f"User with identifier {identifier} has been signed out") + result = (200, f"User with identifier {identifier} has been signed out.") else: - result = (404, "No user found, no one can be signed out") + result = (404, "No user found, no one can be signed out.") status, message = result response = jsonify({"message": message}) diff --git a/asreview/webapp/api/projects.py b/asreview/webapp/api/projects.py index e796dc97f..34a8d5253 100644 --- a/asreview/webapp/api/projects.py +++ b/asreview/webapp/api/projects.py @@ -1134,9 +1134,19 @@ def api_import_project(): try: project = ASReviewProject.load( - request.files["file"], asreview_path(), safe_import=True + request.files["file"], + asreview_path(), + safe_import=True ) + # create a database entry for this project + if app_is_authenticated(current_app): + current_user.projects.append( + Project(project_id=project.config.get("id")) + ) + project.config["owner_id"] = current_user.id + DB.session.commit() + except Exception as err: logging.error(err) raise ValueError("Failed to import project.") diff --git a/asreview/webapp/api/team.py b/asreview/webapp/api/team.py index e0f3dfbee..eb268af98 100644 --- a/asreview/webapp/api/team.py +++ b/asreview/webapp/api/team.py @@ -18,7 +18,7 @@ supports_credentials=True, ) -REQUESTER_FRAUD = {"error": "request can not made by current user"} +REQUESTER_FRAUD = {"message": "Request can not made by current user."} @bp.route("/projects//users", methods=["GET"]) @@ -81,9 +81,16 @@ def end_collaboration(project_id, user_id): try: project.collaborators.remove(user) DB.session.commit() - response = jsonify({"success": True}), 200 + response = ( + jsonify({"message": "Collaborator removed from project."}), + 200 + ) + except SQLAlchemyError: - response = jsonify({"success": False}), 404 + response = ( + jsonify({"message": "Error removing collaborator."}), + 404 + ) return response @@ -119,16 +126,21 @@ def invite(project_id, user_id): response = jsonify(REQUESTER_FRAUD), 404 # get project project = Project.query.filter(Project.project_id == project_id).one_or_none() - # check if project is from current user if project and project.owner == current_user: user = DB.session.get(User, user_id) project.pending_invitations.append(user) try: DB.session.commit() - response = jsonify({"success": True}), 200 + response = ( + jsonify({"message": f'User "{user.identifier}" invited.'}), + 200 + ) except SQLAlchemyError: - response = jsonify({"success": False}), 404 + response = ( + jsonify({"message": f'User "{user.identifier}" not invited.'}), + 404, + ) return response @@ -147,9 +159,15 @@ def accept_invitation(project_id): project.collaborators.append(current_user) try: DB.session.commit() - response = jsonify({"success": True}), 200 + response = ( + jsonify({"message": "User accepted invitation for project."}), + 200 + ) except SQLAlchemyError: - response = jsonify({"success": False}), 404 + response = ( + jsonify({"message": "Error accepting invitation."}), + 404 + ) return response @@ -166,9 +184,15 @@ def reject_invitation(project_id): project.pending_invitations.remove(current_user) try: DB.session.commit() - response = jsonify({"success": True}), 200 + response = ( + jsonify({"message": "User rejected invitation for project."}), + 200 + ) except SQLAlchemyError: - response = jsonify({"success": False}), 404 + response = ( + jsonify({"message": "Error rejecting invitation."}), + 404 + ) return response @@ -187,7 +211,7 @@ def delete_invitation(project_id, user_id): project.pending_invitations.remove(user) try: DB.session.commit() - response = jsonify({"success": True}), 200 + response = jsonify({"message": "Owner deleted invitation."}), 200 except SQLAlchemyError: - response = jsonify({"success": False}), 404 + response = jsonify({"message": "Error deleting invitation."}), 404 return response diff --git a/asreview/webapp/authentication/login_required.py b/asreview/webapp/authentication/login_required.py index 3f00818f4..fb33cba98 100644 --- a/asreview/webapp/authentication/login_required.py +++ b/asreview/webapp/authentication/login_required.py @@ -32,7 +32,7 @@ def decorated_view(*args, **kwargs): pass else: if not (bool(current_user) and current_user.is_authenticated): - return jsonify({"message": "login required"}), 401 + return jsonify({"message": "Login required."}), 401 return func(*args, **kwargs) diff --git a/asreview/webapp/authentication/models.py b/asreview/webapp/authentication/models.py index b3c0f8436..0cffdee1f 100644 --- a/asreview/webapp/authentication/models.py +++ b/asreview/webapp/authentication/models.py @@ -24,6 +24,7 @@ from sqlalchemy import ForeignKey from sqlalchemy import Integer from sqlalchemy import String +from sqlalchemy import UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.orm import validates from werkzeug.security import check_password_hash @@ -43,12 +44,12 @@ class User(UserMixin, DB.Model): __tablename__ = "users" id = Column(Integer, primary_key=True) - identifier = Column(String(100), nullable=False) + identifier = Column(String(100), nullable=False, unique=True) origin = Column(String(100), nullable=False) email = Column(String(100), unique=True) name = Column(String(100)) affiliation = Column(String(100)) - hashed_password = Column(String(100), unique=True) + hashed_password = Column(String(100)) confirmed = Column(Boolean) public = Column(Boolean) token = Column(String(50)) @@ -86,21 +87,14 @@ def validate_name(self, _key, name): raise ValueError("Name must contain more than 2 characters") return name - @validates("email", "hashed_password") - def validate_password(self, key, value): + @validates("email") + def validate_email(self, key, email): if key == "email" and self.origin == "asreview": - if bool(value) is False: + if bool(email) is False: raise ValueError("Email is required when origin is 'asreview'") - elif not User.valid_email(value): - raise ValueError(f"Email address '{value}' is not valid") - - if ( - key == "hashed_password" - and self.origin == "asreview" - and bool(value) is False - ): - raise ValueError('Password is required when origin is "asreview"') - return value + elif not User.valid_email(email): + raise ValueError(f"Email address '{email}' is not valid") + return email def __init__( self, @@ -118,7 +112,7 @@ def __init__( self.email = email self.name = name self.affiliation = affiliation - if self.origin == "asreview" and bool(password): + if self.origin == "asreview": self.hashed_password = User.create_password_hash(password) self.confirmed = confirmed self.public = public @@ -127,14 +121,14 @@ def update_profile(self, email, name, affiliation, password=None, public=True): self.email = email self.name = name self.affiliation = affiliation - if self.origin == "asreview" and bool(password): + if self.origin == "asreview" and password is not None: self.hashed_password = User.create_password_hash(password) self.public = public return self def reset_password(self, new_password): - if self.origin == "asreview" and bool(new_password): + if self.origin == "asreview": self.hashed_password = User.create_password_hash(new_password) # reset token self.token = None @@ -180,13 +174,9 @@ def token_valid(self, provided_token, max_hours=24): # what is now now = dt.datetime.utcnow() # get time-difference in hours - diff = now - self.token_created_at - # give me hours and remaining seconds - [hours, r_secs] = divmod(diff.total_seconds(), 3600) + diff = (now - self.token_created_at).total_seconds() # return if token is correct and we are still before deadline - return self.token == provided_token and ( - hours <= max_hours or (hours == max_hours and r_secs < 60) - ) + return self.token == provided_token and diff <= max_hours * 3600 else: return False @@ -220,8 +210,21 @@ def __repr__(self): class Collaboration(DB.Model): __tablename__ = "collaborations" id = Column(Integer, primary_key=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="cascade")) - project_id = Column(Integer, ForeignKey("projects.id", ondelete="cascade")) + user_id = Column( + Integer, + ForeignKey("users.id", ondelete="cascade"), + nullable=False + ) + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="cascade"), + nullable=False + ) + # make sure we have unique records in this table + __table_args__ = (UniqueConstraint("project_id", "user_id", name="unique_records"),) + + def __repr__(self): + return f"" class Project(DB.Model): @@ -230,9 +233,9 @@ class Project(DB.Model): __tablename__ = "projects" id = Column(Integer, primary_key=True) project_id = Column(String(250), nullable=False, unique=True) - folder = Column(String(100), nullable=False, unique=True) owner_id = Column(Integer, ForeignKey(User.id), nullable=False) owner = relationship("User", back_populates="projects") + # do not delete cascade: we don't want to # lose users, only collaborations collaborators = relationship( @@ -263,5 +266,20 @@ class CollaborationInvitation(DB.Model): __tablename__ = "collaboration_invitations" id = Column(Integer, primary_key=True) - project_id = Column(Integer, ForeignKey("projects.id", ondelete="cascade")) - user_id = Column(Integer, ForeignKey("users.id", ondelete="cascade")) + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="cascade"), + nullable=False + ) + user_id = Column( + Integer, + ForeignKey("users.id", ondelete="cascade"), + nullable=False + ) + # make sure we have unique records in this table + __table_args__ = (UniqueConstraint("project_id", "user_id", name="unique_records"),) + + def __repr__(self): + pid = self.project_id + uid = self.user_id + return f"" diff --git a/asreview/webapp/src/HomeComponents/DashboardComponents/TableRowButton.js b/asreview/webapp/src/HomeComponents/DashboardComponents/TableRowButton.js index 80672bf23..d3d190ba0 100644 --- a/asreview/webapp/src/HomeComponents/DashboardComponents/TableRowButton.js +++ b/asreview/webapp/src/HomeComponents/DashboardComponents/TableRowButton.js @@ -79,7 +79,7 @@ export default function TableRowButton(props) { )} - { props.isOwner && ( + { false && props.isOwner && ( )} - { !props.isOwner && ( + { false && !props.isOwner && ( -$ python3 -m pytest test_asreview_database.py -s +pytest --random-order -s -v ./asreview/webapp/tests/test_api/test_projects.py -k current ``` \ No newline at end of file diff --git a/asreview/webapp/tests/temp_env_var.py b/asreview/webapp/tests/config/__init__.py similarity index 78% rename from asreview/webapp/tests/temp_env_var.py rename to asreview/webapp/tests/config/__init__.py index 4d0bbb6ad..17106ae40 100644 --- a/asreview/webapp/tests/temp_env_var.py +++ b/asreview/webapp/tests/config/__init__.py @@ -11,11 +11,3 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - -from pathlib import Path - -TMP_ENV_VARS = { - "ASREVIEW_PATH": str(Path("~", ".asreview-test").expanduser()), - "FLASK_DEBUG": "1", - "SECRET_KEY": "99Problems!", -} diff --git a/asreview/webapp/tests/config/asreview.ini b/asreview/webapp/tests/config/asreview.ini new file mode 100644 index 000000000..cfe47f511 --- /dev/null +++ b/asreview/webapp/tests/config/asreview.ini @@ -0,0 +1,17 @@ +[user1] +email=user1@asreview.nl +name=user1 +affiliation=Utrecht +password=1234User1! + +[user2] +email=user2@asreview.nl +name=user2 +affiliation=Amsterdam +password=1234User2! + +[user3] +email=user3@asreview.nl +name=user3 +affiliation=Eindhoven +password=1234User3! diff --git a/asreview/webapp/tests/config/auth_basic_config.json b/asreview/webapp/tests/config/auth_basic_config.json new file mode 100644 index 000000000..094950dbd --- /dev/null +++ b/asreview/webapp/tests/config/auth_basic_config.json @@ -0,0 +1,8 @@ +{ + "TESTING": true, + "DEBUG": true, + "SECRET_KEY": "my_very_secret_key", + "SECURITY_PASSWORD_SALT": "my_salt", + "AUTHENTICATION_ENABLED": true, + "ALLOW_ACCOUNT_CREATION": true +} \ No newline at end of file diff --git a/asreview/webapp/tests/config/auth_no_creation.json b/asreview/webapp/tests/config/auth_no_creation.json new file mode 100644 index 000000000..62c926370 --- /dev/null +++ b/asreview/webapp/tests/config/auth_no_creation.json @@ -0,0 +1,8 @@ +{ + "TESTING": true, + "DEBUG": true, + "SECRET_KEY": "my_very_secret_key", + "SECURITY_PASSWORD_SALT": "my_salt", + "AUTHENTICATION_ENABLED": true, + "ALLOW_ACCOUNT_CREATION": false +} \ No newline at end of file diff --git a/asreview/webapp/tests/configs/auth_config_verification.json b/asreview/webapp/tests/config/auth_verified_config.json similarity index 59% rename from asreview/webapp/tests/configs/auth_config_verification.json rename to asreview/webapp/tests/config/auth_verified_config.json index 1265f0d34..243a9a862 100644 --- a/asreview/webapp/tests/configs/auth_config_verification.json +++ b/asreview/webapp/tests/config/auth_verified_config.json @@ -4,20 +4,15 @@ "SECRET_KEY": "my_very_secret_key", "SECURITY_PASSWORD_SALT": "my_salt", "AUTHENTICATION_ENABLED": true, - "SESSION_COOKIE_SECURE": true, - "REMEMBER_COOKIE_SECURE": true, - "SESSION_COOKIE_SAMESITE": "Lax", - "SQLALCHEMY_TRACK_MODIFICATIONS": true, "ALLOW_ACCOUNT_CREATION": true, "EMAIL_VERIFICATION": true, "EMAIL_CONFIG": { "SERVER": "127.0.0.1", "PORT": 465, - "USERNAME": "account@test.nl", + "USERNAME": "admin@asreview.nl", "PASSWORD": "secret_password", "USE_TLS": false, "USE_SSL": true, - "REPLY_ADDRESS": "no_reply@test.nl" - }, - "OAUTH": false + "REPLY_ADDRESS": "no_reply@asreview.nl" + } } \ No newline at end of file diff --git a/asreview/webapp/tests/configs/no_auth_config.json b/asreview/webapp/tests/config/no_auth_config.json similarity index 100% rename from asreview/webapp/tests/configs/no_auth_config.json rename to asreview/webapp/tests/config/no_auth_config.json diff --git a/asreview/webapp/tests/configs/auth_config.json b/asreview/webapp/tests/configs/auth_config.json deleted file mode 100644 index 22200fe91..000000000 --- a/asreview/webapp/tests/configs/auth_config.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "TESTING": true, - "DEBUG": true, - "SECRET_KEY": "my_very_secret_key", - "SECURITY_PASSWORD_SALT": "my_salt", - "AUTHENTICATION_ENABLED": true, - "SESSION_COOKIE_SECURE": true, - "REMEMBER_COOKIE_SECURE": true, - "SESSION_COOKIE_SAMESITE": "Lax", - "SQLALCHEMY_TRACK_MODIFICATIONS": true, - "ALLOW_ACCOUNT_CREATION": true, - "EMAIL_VERIFICATION": false, - "OAUTH": false -} - - - - - diff --git a/asreview/webapp/tests/configs/auth_config_no_accounts.json b/asreview/webapp/tests/configs/auth_config_no_accounts.json deleted file mode 100644 index 7480ac69e..000000000 --- a/asreview/webapp/tests/configs/auth_config_no_accounts.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "TESTING": true, - "DEBUG": true, - "SECRET_KEY": "my_very_secret_key", - "SECURITY_PASSWORD_SALT": "my_salt", - "AUTHENTICATION_ENABLED": true, - "SESSION_COOKIE_SECURE": true, - "REMEMBER_COOKIE_SECURE": true, - "SESSION_COOKIE_SAMESITE": "Lax", - "SQLALCHEMY_TRACK_MODIFICATIONS": true, - "ALLOW_ACCOUNT_CREATION": false, - "EMAIL_VERIFICATION": false, - "OAUTH": false -} - - - - - diff --git a/asreview/webapp/tests/conftest.py b/asreview/webapp/tests/conftest.py index 1971bbf5f..084c0c1f4 100644 --- a/asreview/webapp/tests/conftest.py +++ b/asreview/webapp/tests/conftest.py @@ -14,19 +14,17 @@ import os import shutil +import tempfile from pathlib import Path import pytest -from asreview.utils import asreview_path from asreview.webapp import DB -from asreview.webapp.authentication.models import User from asreview.webapp.start_flask import create_app +from asreview.webapp.tests.utils import crud +from asreview.webapp.tests.utils import misc -try: - from .temp_env_var import TMP_ENV_VARS -except ImportError: - TMP_ENV_VARS = {} +ASREVIEW_PATH = str(tempfile.TemporaryDirectory().name) PROJECTS = [ { @@ -44,87 +42,99 @@ ] -def signup_user(client, identifier, password="!biuCrgfsiOOO6987"): - """Signs up a user through the api""" - response = client.post( - "/auth/signup", - data={ - "identifier": identifier, - "email": identifier, - "name": "Test Kees", - "password": password, - "origin": "asreview", - }, - ) - return response - - -def signin_user(client, identifier, password): - """Signs in a user through the api""" - return client.post("/auth/signin", data={"email": identifier, "password": password}) - - -def signout(client): - return client.delete("/auth/signout") - - -# TODO@{Casper}: -# Something nasty happens when execute multiple test -# modules, if one stops it takes a while before -# the teardown is actually processed: that will cause -# a problem for the still running file (emptying the -# database, removing the asreview folder...) -@pytest.fixture(scope="module") -def setup_teardown_signed_in(): - """Setup and teardown with a signed in user.""" - # setup environment variables - os.environ.update(TMP_ENV_VARS) - # load appropriate config file - root_dir = str(Path(os.path.abspath(__file__)).parent) - config_file_path = f"{root_dir}/configs/auth_config.json" - # create app and client - app = create_app(enable_auth=True, flask_configfile=config_file_path) +def _get_app(app_type="auth-basic"): + """Create and returns test flask app based on app_type""" + # set asreview path + os.environ.update({"ASREVIEW_PATH": ASREVIEW_PATH}) + # get path of appropriate flask config + base_dir = Path(__file__).resolve().parent / "config" + if app_type == "auth-basic": + config_path = str(base_dir / "auth_basic_config.json") + elif app_type == "auth-no-creation": + config_path = str(base_dir / "auth_no_creation.json") + elif app_type == "auth-verified": + config_path = str(base_dir / "auth_verified_config.json") + elif app_type == "no-auth": + config_path = str(base_dir / "no_auth_config.json") + else: + raise ValueError(f"Unknown config {app_type}") + # create app + app = create_app(flask_configfile=config_path) + # and return it + return app + + +# unauthenticated app +@pytest.fixture +def unauth_app(): + """Create an unauthenticated version of the app.""" + # create the app + app = _get_app("no-auth") with app.app_context(): - client = app.test_client() - email, password = "c.s.kaandorp@uu.nl", "123456!AbC" - # create user - signup_user(client, email, password) - # signin this user - signin_user(client, email, password) - user = DB.session.query(User).filter(User.identifier == email).one_or_none() - yield app, client, user - - try: - # remove the entire .asreview-test folder - # which also removes the database - shutil.rmtree(asreview_path()) - except Exception: - # don't care - pass - - -@pytest.fixture(scope="module") -def setup_teardown_unauthorized(): - """Standard setup and teardown, create the app without - a database and create testclient""" - # setup environment variables - os.environ.update(TMP_ENV_VARS) - # create app and client - app = create_app(enable_auth=False) - client = app.test_client() - - yield app, client - # remove the entire .asreview-test folder - shutil.rmtree(asreview_path()) + yield app +# authenticated app @pytest.fixture -def app(): - app = create_app() - return app +def auth_app(): + """Create an authenticated app, account creation + allowed.""" + # create app + app = _get_app() + with app.app_context(): + yield app + + +@pytest.fixture +def client_auth(): + """Flask client for basic authenticated app, account + creation allowed.""" + app = _get_app("auth-basic") + with app.app_context(): + yield app.test_client() + crud.delete_everything(DB) + misc.clear_asreview_path(remove_files=False) @pytest.fixture -def client(app): - """A test client for the app.""" - return app.test_client() +def client_auth_no_creation(): + """Flask client for an authenticated app, account + creation not allowed.""" + app = _get_app("auth-no-creation") + with app.app_context(): + yield app.test_client() + crud.delete_everything(DB) + misc.clear_asreview_path(remove_files=False) + + +@pytest.fixture +def client_auth_verified(): + """Flask client for an authenticated app, account + creation allowed, user accounts needs account + verification.""" + app = _get_app("auth-verified") + with app.app_context(): + yield app.test_client() + crud.delete_everything(DB) + misc.clear_asreview_path(remove_files=False) + + +@pytest.fixture +def client_no_auth(): + """Flask client for an unauthenticated app.""" + app = _get_app("no-auth") + # make sure we have the asreview_path + with app.app_context(): + yield app.test_client() + misc.clear_asreview_path() + + +@pytest.fixture(scope="session", autouse=True) +def remove_test_asreview_path(): + """Fixture that removes the entire ASReview test directory + after a session.""" + pass + yield + if Path(ASREVIEW_PATH).exists(): + shutil.rmtree(ASREVIEW_PATH) + print("\n...Removed asreview_path") diff --git a/asreview/webapp/tests/test_webapp.py b/asreview/webapp/tests/data/__init__.py similarity index 51% rename from asreview/webapp/tests/test_webapp.py rename to asreview/webapp/tests/data/__init__.py index fe2ef0d45..17106ae40 100644 --- a/asreview/webapp/tests/test_webapp.py +++ b/asreview/webapp/tests/data/__init__.py @@ -11,27 +11,3 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - - -def test_landing(client): - """Test if index.html is available. - - This test will fail if build is missing. Please run - `python setup.py compile_assets` first. - """ - response = client.get("/") - html = response.data.decode() - - assert ( - "ASReview LAB - A tool for AI-assisted systematic reviews" - in html - ) # noqa - - -def test_boot(client): - """Test if version number is available on boot.""" - response = client.get("/boot") - json_data = response.get_json() - - assert "version" in json_data - assert "status" in json_data diff --git a/asreview/webapp/tests/integration_tests/__init__.py b/asreview/webapp/tests/integration_tests/__init__.py new file mode 100644 index 000000000..17106ae40 --- /dev/null +++ b/asreview/webapp/tests/integration_tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/asreview/webapp/tests/test_api/__init__.py b/asreview/webapp/tests/test_api/__init__.py new file mode 100644 index 000000000..17106ae40 --- /dev/null +++ b/asreview/webapp/tests/test_api/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/asreview/webapp/tests/test_api/conftest.py b/asreview/webapp/tests/test_api/conftest.py new file mode 100644 index 000000000..678ea59f1 --- /dev/null +++ b/asreview/webapp/tests/test_api/conftest.py @@ -0,0 +1,78 @@ +import pytest + +import asreview.webapp.tests.utils.api_utils as au +from asreview.project import get_projects +from asreview.webapp import DB +from asreview.webapp.tests.utils import crud +from asreview.webapp.tests.utils.config_parser import get_user + + +@pytest.fixture(params=["client_auth", "client_no_auth"]) +def setup(request): + """Setup and teardown fixture that will run each test with + an authenticated version and unauthenticated version of the + app. In the authenticated version the fixture yields a Flask + client, a signed-in user plus a project belongoing to this user. + In the unauthenticated version, the fixture yields a Flask + client, and a project.""" + # get the client + client = request.getfixturevalue(request.param) + # provide a project name + project_name = "project_name" + if request.param == "client_auth": + # create, signup and signin users + user1 = au.create_and_signin_user(client, 1) + # create a project for this logged in user + au.create_project(client, project_name) + # receive project + project = user1.projects[0] + else: + # this has to be created to match the authenticated + # version of this fixture + user1 = None + # create a project + au.create_project(client, project_name) + # get all project + project = get_projects()[0] + yield client, user1, project + if request.param == "client_auth": + # cleanup database and asreview_path + crud.delete_everything(DB) + + +@pytest.fixture( + params=[ + "client_auth", + "client_auth_no_creation", + "client_auth_verified", + "client_no_auth", + ] +) +def setup_all_clients(request): + """This fixture provides 4 different Flask client (authenticated + and unauthenticated) for every test that uses it.""" + client = request.getfixturevalue(request.param) + yield client + + +@pytest.fixture() +def setup_auth(client_auth): + """This fixtures yields a Flask client for an authenticated + app, 3 user accounts (first user is signed in) and a project + belonging to the first user.""" + # create, signup and signin users + user1 = au.create_and_signin_user(client_auth, 1) + user2 = get_user(2) + user3 = get_user(3) + # signup user 2 + au.signup_user(client_auth, user2) + au.signup_user(client_auth, user3) + # get users 2 and 3 from DB + user2 = crud.get_user_by_identifier(user2.identifier) + user3 = crud.get_user_by_identifier(user3.identifier) + # create a project for this logged in user + project_name = "project_name" + au.create_project(client_auth, project_name) + yield client_auth, user1, user2, user3, user1.projects[0] + # cleanup database and asreview_path + crud.delete_everything(DB) diff --git a/asreview/webapp/tests/test_api/test_auth.py b/asreview/webapp/tests/test_api/test_auth.py new file mode 100644 index 000000000..762a53d7b --- /dev/null +++ b/asreview/webapp/tests/test_api/test_auth.py @@ -0,0 +1,547 @@ +import datetime as dt +from inspect import getfullargspec + +import pytest + +import asreview.webapp.tests.utils.api_utils as au +import asreview.webapp.tests.utils.crud as crud +from asreview.webapp import DB +from asreview.webapp.tests.utils.config_parser import get_user + +# ################### +# SIGNUP +# ################### + + +# test that creating a user when the app runs a no-creation +# policy, is impossible +def test_impossible_to_signup_when_not_allowed(client_auth_no_creation): + # get user data + user = get_user(1) + # post form data + status_code, data = au.signup_user(client_auth_no_creation, user) + # check if we get a 400 status + assert status_code == 400 + assert data["message"] == "The app is not configured to create accounts" + + +# Successful signup returns a 200 but with an unconfirmed user and +# an email token +def test_successful_signup_confirmed(client_auth_verified): + # get user data + user = get_user(1) + # post form data + status_code, data = au.signup_user(client_auth_verified, user) + # check if we get a 200 status + assert status_code == 201 + assert ( + data["message"] + == f"An email has been sent to {user.email} " + + "to verify your account. Please follow instructions." + ) + + +# test basic signing up +def test_successful_signup_no_confirmation(client_auth): + # get user data + user = get_user(1) + # post form data + status_code, data = au.signup_user(client_auth, user) + # check if we get a 201 status + assert status_code == 201 + assert data["message"] == f'User "{user.email}" created.' + + +# Adding an existing identifier must return a 404 status and +# appropriate message +def test_unique_identifier(client_auth): + # get user data + user = get_user(1) + # insert this user + crud.create_user(DB, user) + # try to create the same user again with the api + status_code, data = au.signup_user(client_auth, user) + assert status_code == 403 + assert data["message"] == f'User with email "{user.email}" already exists.' + + +# Adding an existing email must return a 404 status and +# appropriate message +def test_unique_email(client_auth): + # get user data + user1 = get_user(1) + user2 = get_user(2) + # insert user1 + crud.create_user(DB, user1) + # try to create the user2 with the same email as user1 with the api + user2.email = user1.email + status_code, data = au.signup_user(client_auth, user2) + assert status_code == 403 + assert data["message"] == f'User with email "{user2.email}" already exists.' + + +# ################### +# SIGNIN +# ################### + + +# Verified user creation: user can not signin with unconfirmed account +def test_unsuccessful_signin_with_unconfirmed_account(client_auth_verified): + # get user data + user = get_user(1) + # create user with signup + status_code, data = au.signup_user(client_auth_verified, user) + # check if we get a 201 status + assert status_code == 201 + # try to sign in + status_code, data = au.signin_user(client_auth_verified, user) + assert status_code == 404 + assert data["message"] == f"User account {user.email} is not confirmed." + + +# Successfully signing in a user must return a 200 response +def test_successful_signin(client_auth): + # get user data + user = get_user(1) + # create user with signup, no confirmation + status_code, data = au.signup_user(client_auth, user) + # check if we get a 201 status + assert status_code == 201 + # signin + status_code, data = au.signin_user(client_auth, user) + assert status_code == 200 + assert data["message"] == f"User {user.identifier} is logged in." + + +# Wrong password must return a 404 response with and an appropriate response +def test_unsuccessful_signin_wrong_password(client_auth): + # get user data + user = get_user(1) + # create user with signup, no confirmation + status_code, data = au.signup_user(client_auth, user) + # check if we get a 201 status + assert status_code == 201 + # change password + user.password = "wrong_password" + # signin + status_code, data = au.signin_user(client_auth, user) + assert status_code == 404 + assert data["message"] == f"Incorrect password for user {user.identifier}." + + +# Wrong email must return a 404 response and with an appropriate response +def test_unsuccessful_signin_wrong_email(client_auth): + # get user data + user = get_user(1) + # create user with signup, no confirmation + status_code, data = au.signup_user(client_auth, user) + # check if we get a 201 status + assert status_code == 201 + # change email and identifier + user.email = "wrong_email@asreview.nl" + user.identifier = "wrong_email@asreview.nl" + # signin + status_code, data = au.signin_user(client_auth, user) + assert status_code == 404 + assert data["message"] == f"User account {user.identifier} does not exist." + + +# ################### +# SIGNOUT +# ################### + + +# Signing out must return a 200 status and an appropriate message +def test_signout(client_auth): + # create user + user = au.create_and_signin_user(client_auth) + # signout + status_code, data = au.signout_user(client_auth) + # expect a 200 + assert status_code == 200 + assert ( + data["message"] + == f"User with identifier {user.identifier} has been signed out." + ) + + +# ################### +# CONFIRMATION +# ################### + + +# A new token is created on signup, that token is can be confirmed +# by the confirm route +def test_token_confirmation_after_signup(client_auth_verified): + # signup user + user = get_user(1) + status_code, data = au.signup_user(client_auth_verified, user) + # refresh user + user = crud.get_user_by_identifier(user.identifier) + # now we confirm this user + status_code, data = au.confirm_user(client_auth_verified, user) + assert status_code == 200 + assert data["message"] == f"User {user.identifier} confirmed." + + +# A token expires in 24 hours, test confirmation response after +# 24 hours +def test_expired_token(client_auth_verified): + # signup user + user = get_user(1) + status_code, data = au.signup_user(client_auth_verified, user) + # refresh user + user = crud.get_user_by_identifier(user.identifier) + # manipulate token_created_at + new_created_at = user.token_created_at - dt.timedelta(hours=28) + user.token_created_at = new_created_at + DB.session.commit() + # now we try to confirm this user + status_code, data = au.confirm_user(client_auth_verified, user) + assert status_code == 403 + assert "token has expired" in data["message"] + + +# Confirmation user: if the user can't be found, this route should +# return a 404 +def test_if_this_route_returns_404_user_not_found(client_auth_verified): + # signup user + user = get_user(1) + status_code, data = au.signup_user(client_auth_verified, user) + # make sure the user account is created + assert crud.count_users() == 1 + # we keep the user model as is, not retrieving it from the DB + # which ensures an id-less object that can be manipulated + user.id = 100 + # now we try to confirm this user + status_code, data = au.confirm_user(client_auth_verified, user) + assert status_code == 404 + assert data["message"] == "No user account / correct token found." + + +# If the token cant be found, this route should return a 404 +def test_if_this_route_returns_404_token_not_found(client_auth_verified): + # signup user + user = get_user(1) + status_code, data = au.signup_user(client_auth_verified, user) + # make sure the user account is created + assert crud.count_users() == 1 + # we keep the user model as is, not retrieving it from the DB + # which ensures an id-less object that can be manipulated + user.token = "wrong_token" + # now we try to confirm this user + status_code, data = au.confirm_user(client_auth_verified, user) + assert status_code == 404 + assert data["message"] == "No user account / correct token found." + + +# If we are not doing verification this route should return a 400 +def test_confirm_route_returns_400_if_app_not_verified(client_auth): + # signup user + user = get_user(1) + status_code, data = au.signup_user(client_auth, user) + # refresh user object + user = crud.get_user_by_identifier(user.identifier) + # now we try to confirm this user + status_code, data = au.confirm_user(client_auth, user) + assert status_code == 400 + assert data["message"] == "The app is not configured to verify accounts." + + +# ################### +# PROFILE +# ################### + + +# Test user data if we request is +@pytest.mark.parametrize( + "attribute", ["email", "identifier", "name", "origin", "affiliation"] +) +def test_get_profile(client_auth, attribute): + user = au.create_and_signin_user(client_auth) + # get profile + status_code, data = au.get_profile(client_auth) + assert status_code == 200 + # assert if none is blank + assert data["message"][attribute] != "" + # compare with user + assert data["message"][attribute] == getattr(user, attribute) + + +# Test profile data not returned when user id does not exists +def test_get_profile_if_user_id_does_not_exist(client_auth): + au.create_and_signin_user(client_auth) + # remove this user from the database + crud.delete_users(DB) + # get profile + status_code, data = au.get_profile(client_auth) + assert status_code == 404 + assert data["message"] == "No user found." + + +# ##################### +# FORGOT/RESET PASSWORD +# ##################### + + +# Test forgot_password can't be called if we don't run an +# email config +def test_forgot_password_no_email_config(client_auth): + # signup user + user = get_user(1) + status_code, data = au.signup_user(client_auth, user) + # forgot password + status_code, data = au.forgot_password(client_auth, user) + assert status_code == 404 + assert data["message"] == "Forgot-password feature is not used in this app." + + +# Test forgot_password works under normal circumstances +def test_forgot_password_works(client_auth_verified): + # signup user + user = get_user(1) + status_code, data = au.signup_user(client_auth_verified, user) + # forgot password + status_code, data = au.forgot_password(client_auth_verified, user) + assert status_code == 200 + assert data["message"] == f"An email has been sent to {user.email}" + + +# Test forgot_password: user not found +def test_forgot_password_no_user(client_auth_verified): + # get user, no signup + user = get_user(1) + # forgot password + status_code, data = au.forgot_password(client_auth_verified, user) + assert status_code == 404 + assert data["message"] == f'User with email "{user.email}" not found.' + + +# Test forgot_password: origin is not "asreview" +def test_forgot_password_wrong_origin(client_auth_verified): + # signup user + user = get_user(1) + status_code, data = au.signup_user(client_auth_verified, user) + # get fresh user object and change origin + user = crud.update_user(DB, user, "origin", "github") + # forgot password + status_code, data = au.forgot_password(client_auth_verified, user) + assert status_code == 404 + assert data["message"] == f"Your account has been created with {user.origin}." + + +# Test resetting password when not configured +def test_reset_password_no_email_config(client_auth): + # signup user + user = get_user(1) + status_code, data = au.signup_user(client_auth, user) + # get user + user = crud.get_user_by_identifier(user.identifier) + user.password = "NewPassword123!" + # forgot password + status_code, data = au.reset_password(client_auth, user) + assert status_code == 404 + assert data["message"] == "Reset-password feature is not used in this app." + + +# Test resetting password +def test_reset_password(client_auth_verified): + # signup user + user = get_user(1) + au.signup_user(client_auth_verified, user) + # forgot password + au.forgot_password(client_auth_verified, user) + # get user and provide new password + user = crud.get_user_by_identifier(user.identifier) + user.password = "NewPassword123!" + # reset it + status_code, data = au.reset_password(client_auth_verified, user) + assert status_code == 200 + assert data["message"] == "Password updated." + + +# Test reset password: id not found +def test_reset_password_with_wrong_user_id(client_auth_verified): + # signup user + user = get_user(1) + au.signup_user(client_auth_verified, user) + # forgot password + au.forgot_password(client_auth_verified, user) + # get user and provide new password + user = crud.get_user_by_identifier(user.identifier) + user.password = "NewPassword123!" + # and remove from database to manipulate user-not-found + crud.delete_users(DB) + # reset it + status_code, data = au.reset_password(client_auth_verified, user) + assert status_code == 404 + assert ( + data["message"] + == "User not found, try restarting the forgot-password procedure." + ) + + +# Test reset password: token is stale +def test_reset_password_with_stale_token(client_auth_verified): + # signup user + user = get_user(1) + au.signup_user(client_auth_verified, user) + # forgot password + au.forgot_password(client_auth_verified, user) + # get user and provide new password + user = crud.get_user_by_identifier(user.identifier) + user.password = "NewPassword123!" + new_created_at = user.token_created_at - dt.timedelta(hours=28) + user.token_created_at = new_created_at + DB.session.commit() + # reset password + status_code, data = au.reset_password(client_auth_verified, user) + assert status_code == 404 + assert ( + data["message"] + == "Token is invalid or too old, restart the forgot-password procedure." + ) + + # Test reset password: invalid password + # signup user + user = get_user(1) + au.signup_user(client_auth_verified, user) + # forgot password + au.forgot_password(client_auth_verified, user) + # get user and provide new password + user = crud.get_user_by_identifier(user.identifier) + user.password = "123" + # reset password + status_code, data = au.reset_password(client_auth_verified, user) + assert status_code == 500 + assert "Unable to reset your password!" in data["message"] + assert "does not meet requirements" in data["message"] + + +# ################### +# UPDATE USER PROFILE +# ################### + + +# test updating normal attributes from user profile +def test_update_user_profile_simple_attributes(client_auth): + # create and signin user + user = au.create_and_signin_user(client_auth) + # prep data + data = { + "email": "new_email@asreview.nl", + "name": "new_name", + "affiliation": "new_affiliation", + "public": int(not user.public), + } + # call update + status_code, data = au.update_user(client_auth, data) + assert status_code == 200 + assert data["message"] == "User profile updated." + + +# test updating the password +def test_update_password(client_auth): + # create and signin user + user = au.create_and_signin_user(client_auth) + # prep data + new_password = "NewPassword123#" + data = { + "email": user.email, + "name": user.name, + "affiliation": user.affiliation, + "public": int(user.public), + "password": new_password, + } + # call update + status_code, data = au.update_user(client_auth, data) + assert status_code == 200 + assert data["message"] == "User profile updated." + # Checking if new password works signout + au.signout_user(client_auth) + # signin with new password + user.password = new_password + status_code, data = au.signin_user(client_auth, user) + assert status_code == 200 + + +# test updating wrong new attribute values +@pytest.mark.parametrize( + "attribute_data", + [ + ("email", "email"), + ("email", "user2@asreview.nl"), + ("name", ""), + ("password", "abc"), + ], +) +def test_update_user_with_wrong_values(client_auth, attribute_data): + # make sure I have another user to test email duplication + user = crud.create_user(DB, 2) + # get attribute and value from parametrize + attr, wrong_value = attribute_data + # create user and signin user + user = au.create_and_signin_user(client_auth) + data = { + "email": user.email, + "name": user.name, + "affiliation": user.affiliation, + "public": int(user.public), + "password": "ABcd!1234", # valid password + } + # manipulate attribute + data[attr] = wrong_value + # update + status_code, data = au.update_user(client_auth, data) + assert status_code == 500 + assert "Unable to update your profile" in data["message"] + assert (attr.capitalize() in data["message"]) or attr in data["message"] + + +# ################### +# REFRESH +# ################### + + +# Test refresh: user signed in +def test_refresh_with_signed_in_user(client_auth): + # create and signin user + user = au.create_and_signin_user(client_auth) + # refresh + status_code, data = au.refresh(client_auth) + assert status_code == 200 + assert data["id"] == user.id + assert data["logged_in"] is True + assert data["name"] == user.name + + +# Test refresh: user NOT signed in +def test_refresh_with_signed_out_user(client_auth): + # create and signin user + au.create_and_signin_user(client_auth) + # signout + au.signout_user(client_auth) + # refresh + status_code, data = au.refresh(client_auth) + assert status_code == 200 + assert data["id"] is None + assert data["logged_in"] is False + assert data["name"] == "" + + +# ################### +# TEST LOGIN REQUIRED +# ################### + + +# User must be logged in, in order to signout, +# we expect an error if we sign out if not signed in +@pytest.mark.parametrize("api_call", [au.signout_user, au.get_profile, au.update_user]) +def test_must_be_signed_in_to_signout(client_auth, api_call): + if len(getfullargspec(api_call).args) == 1: + status_code, data = api_call(client_auth) + else: + status_code, data = api_call(client_auth, {}) + # asserts + assert status_code == 401 + assert data["message"] == "Login required." diff --git a/asreview/webapp/tests/test_api/test_projects.py b/asreview/webapp/tests/test_api/test_projects.py new file mode 100644 index 000000000..f22cbeee4 --- /dev/null +++ b/asreview/webapp/tests/test_api/test_projects.py @@ -0,0 +1,606 @@ +import inspect +import time +from typing import Union + +import pytest +from flask.testing import FlaskClient + +import asreview.webapp.tests.utils.api_utils as au +import asreview.webapp.tests.utils.crud as crud +import asreview.webapp.tests.utils.misc as misc +from asreview.project import ASReviewProject +from asreview.webapp import DB +from asreview.webapp.authentication.models import Project +from asreview.webapp.tests.utils.misc import current_app_is_authenticated +from asreview.webapp.tests.utils.misc import retrieve_project_url_github + +# NOTE: I don't see a plugin that can be used for testing +# purposes +UPLOAD_DATA = [ + {"benchmark": "benchmark:Hall_2012"}, + { + "url": "https://raw.githubusercontent.com/asreview/" + + "asreview/master/tests/demo_data/generic_labels.csv" + }, +] +IMPORT_PROJECT_URLS = retrieve_project_url_github() + +# NOTE: the setup fixture entails: a FlaskClient, 1 user (signed in), +# and a project of this user OR a project from an unauthenticated app. +# The fixture is parametrized! It runs the authenticated app and the +# unauthenticated app. + + +# Test getting all projects +def test_get_projects(setup): + client, user1, project = setup + status_code, data = au.get_all_projects(client) + assert status_code == 200 + assert len(data["result"]) == 1 + found_project = data["result"][0] + if current_app_is_authenticated(): + assert found_project["id"] == project.project_id + assert found_project["owner_id"] == user1.id + else: + assert found_project["id"] == project.config["id"] + + +# Test create a project +def test_create_projects(setup): + client, _, _ = setup + project_name = "new_project" + + status_code, data = au.create_project(client, project_name) + assert status_code == 201 + assert data["name"] == project_name + + +# Test upgrading a post v0.x project +def test_try_upgrade_a_modern_project_XXX(setup): + client, _, project = setup + # verify version + data = misc.read_project_file(project) + assert not data["version"].startswith("0") + + status_code, data = au.upgrade_project(client, project) + assert status_code == 400 + assert data["message"] == "Can only convert v0.x projects." + + +# Test upgrading a v0.x project +def test_upgrade_an_old_project_XXX(setup): + client, user, _ = setup + # get an old version from github + old_project_url = retrieve_project_url_github("v0.19") + project = misc.copy_github_project_into_asreview_folder(old_project_url) + # we need to make sure this new, old-style project can be found + # under current user if the app is authenticated + if current_app_is_authenticated(): + new_project = Project(project_id=project.config.get("id")) + project = crud.create_project(DB, user, new_project) + print(type(project)) + # try to convert + status_code, data = au.upgrade_project(client, project) + assert status_code == 200 + assert data["success"] + + +# Test importing old projects, verify ids +@pytest.mark.parametrize("url", IMPORT_PROJECT_URLS) +def test_import_project_files(setup, url): + client, user, first_project = setup + # import project + status_code, data = au.import_project(client, url) + # get contents asreview folder + folders = set(misc.get_folders_in_asreview_path()) + # asserts + assert len(folders) == 2 + assert status_code == 200 + assert isinstance(data, dict) + if current_app_is_authenticated(): + # assert it exists in the database + assert crud.count_projects() == 2 + project = crud.last_project() + assert data["id"] != first_project.project_id + assert data["id"] == project.project_id + # assert the owner is current user + assert data["owner_id"] == user.id + else: + assert data["id"] != first_project.config.get("id") + # in auth/non-auth the project folder must exist in the asreview folder + assert data["id"] in set([f.stem for f in folders]) + + +# Test get stats in setup state +def test_get_projects_stats_setup_stage(setup): + client, _, _ = setup + status_code, data = au.get_project_stats(client) + assert status_code == 200 + assert isinstance(data["result"], dict) + assert data["result"]["n_in_review"] == 0 + assert data["result"]["n_finished"] == 0 + assert data["result"]["n_setup"] == 1 + + +# Test get stats in review state +def test_get_projects_stats_review_stage(setup): + client, _, project = setup + # start the show + au.upload_label_set_and_start_model(client, project, UPLOAD_DATA[0]) + # get stats + status_code, data = au.get_project_stats(client) + assert status_code == 200 + assert isinstance(data["result"], dict) + assert data["result"]["n_in_review"] == 1 + assert data["result"]["n_finished"] == 0 + assert data["result"]["n_setup"] == 0 + + +# Test get stats in finished state +def test_get_projects_stats_finished_stage(setup): + client, _, project = setup + # start the show + au.upload_label_set_and_start_model(client, project, UPLOAD_DATA[0]) + # manually finish the project + au.set_project_status(client, project, "finished") + # get stats + status_code, data = au.get_project_stats(client) + assert status_code == 200 + assert isinstance(data["result"], dict) + assert data["result"]["n_in_review"] == 0 + assert data["result"]["n_finished"] == 1 + assert data["result"]["n_setup"] == 0 + + +# Test known demo data +@pytest.mark.parametrize("subset", ["plugin", "benchmark"]) +def test_demo_data_project(setup, subset): + client, _, _ = setup + status_code, data = au.get_demo_data(client, subset) + assert status_code == 200 + assert isinstance(data["result"], list) + + +# Test unknown demo data +def test_unknown_demo_data_project(setup): + client, _, _ = setup + status_code, data = au.get_demo_data(client, "abcdefg") + assert status_code == 400 + assert data["message"] == "demo-data-loading-failed" + + +# Test uploading benchmark data to a project +@pytest.mark.parametrize("upload_data", UPLOAD_DATA) +def test_upload_benchmark_data_to_project(setup, upload_data): + client, _, project = setup + status_code, data = au.upload_data_to_project(client, project, data=upload_data) + assert status_code == 200 + if current_app_is_authenticated(): + assert data["project_id"] == project.project_id + else: + assert data["project_id"] == project.config.get("id") + + +# Test getting the data after an upload +@pytest.mark.parametrize("upload_data", UPLOAD_DATA) +def test_get_project_data(setup, upload_data): + client, _, project = setup + au.upload_data_to_project(client, project, data=upload_data) + status_code, data = au.get_project_data(client, project) + assert status_code == 200 + assert data["filename"] == misc.extract_filename_stem(upload_data) + + +# Test get dataset writer +def test_get_dataset_writer(setup): + client, _, project = setup + # upload data + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # get dataset writer + status_code, data = au.get_project_dataset_writer(client, project) + assert status_code == 200 + assert isinstance(data["result"], list) + + +# Test updating a project +def test_update_project_info(setup): + client, _, project = setup + # update data + new_mode = "oracle" + new_name = "new name" + new_authors = "new authors" + new_description = "new description" + # request + status_code, data = au.update_project( + client, + project, + name=new_name, + mode=new_mode, + authors=new_authors, + description=new_description, + ) + assert status_code == 200 + assert data["authors"] == new_authors + assert data["description"] == new_description + assert data["mode"] == new_mode + assert data["name"] == new_name + + +# Test search data +def test_search_data(setup): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # search + status_code, data = au.search_project_data( + client, project, query="Software&n_max=10" + ) + assert status_code == 200 + assert "result" in data + assert isinstance(data["result"], list) + assert len(data["result"]) <= 10 + + +# Test get a selection of random papers to find exclusions +def test_random_prior_papers(setup): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # get random selection + status_code, data = au.get_prior_random_project_data(client, project) + assert status_code == 200 + assert "result" in data + assert isinstance(data["result"], list) + assert len(data["result"]) > 0 + + +# Test labeling of prior data +@pytest.mark.parametrize("label", [0, 1]) +def test_label_item(setup, label): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # label + status_code, data = au.label_random_project_data_record(client, project, label) + assert status_code == 200 + assert data["success"] + + +# Test getting labeled records +def test_get_labeled_project_data(setup): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # label a random record + au.label_random_project_data_record(client, project, 1) + # collect labeled records + status_code, data = au.get_labeled_project_data(client, project) + assert status_code == 200 + assert "result" in data + assert isinstance(data["result"], list) + assert len(data["result"]) == 1 + + +# Test getting labeled records stats +def test_get_labeled_stats(setup): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # label 2 random records + au.label_random_project_data_record(client, project, 1) + au.label_random_project_data_record(client, project, 0) + # collect stats + status_code, data = au.get_labeled_project_data_stats(client, project) + + assert status_code == 200 + assert isinstance(data, dict) + assert data["n"] == 2 + assert data["n_exclusions"] == 1 + assert data["n_inclusions"] == 1 + assert data["n_prior"] == 2 + + +# Test listing the available algorithms +def test_list_algorithms(setup): + client, _, _ = setup + status_code, data = au.get_project_algorithms_options(client) + assert status_code == 200 + expected_keys = [ + "balance_strategy", + "classifier", + "feature_extraction", + "query_strategy", + ] + for key in expected_keys: + assert key in data.keys() + assert isinstance(data[key], list) + for item in data[key]: + assert "name" in item.keys() + assert "label" in item.keys() + + +# Test setting the algorithms +def test_set_project_algorithms(setup): + client, _, project = setup + data = misc.choose_project_algorithms() + status_code, data = au.set_project_algorithms(client, project, data=data) + assert status_code == 200 + assert data["success"] + + +# Test getting the project algorithms +def test_get_project_algorithms(setup): + client, _, project = setup + data = misc.choose_project_algorithms() + au.set_project_algorithms(client, project, data=data) + # get the project algorithms + status_code, resp_data = au.get_project_algorithms(client, project) + assert status_code == 200 + assert resp_data["balance_strategy"] == data["balance_strategy"] + assert resp_data["feature_extraction"] == data["feature_extraction"] + assert resp_data["model"] == data["model"] + assert resp_data["query_strategy"] == data["query_strategy"] + + +# Test starting the model +def test_start_and_model_ready(setup): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # label 2 random records + au.label_random_project_data_record(client, project, 1) + au.label_random_project_data_record(client, project, 0) + # select a model + data = misc.choose_project_algorithms() + au.set_project_algorithms(client, project, data=data) + # start the model + status_code, data = au.start_project_algorithms(client, project) + assert status_code == 200 + assert data["success"] + # make sure model is done + time.sleep(10) + + +# Test status of project +@pytest.mark.parametrize( + ("state_name", "expected_state"), + [ + ("creation", None), + ("setup", "setup"), + ("review", "review"), + ("finish", "finished"), + ], +) +def test_status_project(setup, state_name, expected_state): + client, _, project = setup + # call these progression steps + if state_name in ["setup", "review", "finish"]: + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # label 2 records + au.label_random_project_data_record(client, project, 1) + au.label_random_project_data_record(client, project, 0) + # select a model + data = misc.choose_project_algorithms() + au.set_project_algorithms(client, project, data=data) + if state_name in ["review", "finish"]: + # start the model + au.start_project_algorithms(client, project) + time.sleep(10) + if state_name == "finish": + # mark project as finished + au.set_project_status(client, project, "finished") + + status_code, data = au.get_project_status(client, project) + assert status_code == 200 + assert data["status"] == expected_state + + +# Test exporting the results +@pytest.mark.parametrize("format", ["csv", "tsv", "xlsx"]) +def test_export_result(setup, format): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + au.label_random_project_data_record(client, project, 1) + au.label_random_project_data_record(client, project, 0) + # request + status_code, _ = au.export_project_dataset(client, project, format) + assert status_code == 200 + + +# Test exporting the entire project +def test_export_project(setup): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + au.label_random_project_data_record(client, project, 1) + au.label_random_project_data_record(client, project, 0) + # request + status_code, _ = au.export_project(client, project) + assert status_code == 200 + + +# Test setting the project status +@pytest.mark.parametrize("status", ["review", "finished"]) +def test_set_project_status(setup, status): + client, _, project = setup + # start the show + au.upload_label_set_and_start_model(client, project, UPLOAD_DATA[0]) + # when setting the status to "review", the project must have another + # status then "review" + if status == "review": + au.set_project_status(client, project, "finished") + # set project status + status_code, data = au.set_project_status(client, project, status) + assert status_code == 200 + assert data["success"] + + +# Test get progress info +def test_get_progress_info(setup): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # label 2 random records + au.label_random_project_data_record(client, project, 1) + au.label_random_project_data_record(client, project, 0) + # get progress + status_code, data = au.get_project_progress(client, project) + assert status_code == 200 + assert isinstance(data, dict) + assert data["n_excluded"] == 1 + assert data["n_included"] == 1 + assert data["n_pool"] == data["n_papers"] - 2 + + +# Test get progress density on the article +def test_get_progress_density(setup): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # request progress density + status_code, data = au.get_project_progress_density(client, project) + assert status_code == 200 + assert isinstance(data, dict) + assert isinstance(data["relevant"], list) + assert isinstance(data["irrelevant"], list) + + +# Test progress recall +def test_get_progress_recall(setup): + client, _, project = setup + # upload dataset + au.upload_data_to_project(client, project, data=UPLOAD_DATA[0]) + # get recall + status_code, data = au.get_project_progress_recall(client, project) + assert status_code == 200 + assert isinstance(data, dict) + assert isinstance(data["asreview"], list) + assert isinstance(data["random"], list) + + +# Test retrieve documents in order to review +def test_retrieve_document_for_review(setup): + client, _, project = setup + # start the show + au.upload_label_set_and_start_model(client, project, UPLOAD_DATA[0]) + # get a document + status_code, data = au.get_project_current_document(client, project) + assert status_code == 200 + assert isinstance(data, dict) + assert not data["pool_empty"] + assert isinstance(data["result"], dict) + assert isinstance(data["result"]["doc_id"], int) + + +# Test label a document after the model has been started +def test_label_a_document_with_running_model(setup): + client, _, project = setup + # start the show + au.upload_label_set_and_start_model(client, project, UPLOAD_DATA[0]) + # get a document + _, data = au.get_project_current_document(client, project) + # get id + doc_id = data["result"]["doc_id"] + # label it + status_code, data = au.label_project_record( + client, project, doc_id, label=1, prior=0, note="note" + ) + assert status_code == 200 + assert data["success"] + time.sleep(10) + + +# Test update label of a document after the model has been started +def test_update_label_of_document_with_running_model(setup): + client, _, project = setup + # start the show + au.upload_label_set_and_start_model(client, project, UPLOAD_DATA[0]) + # get a document + _, data = au.get_project_current_document(client, project) + # get id + doc_id = data["result"]["doc_id"] + # label it + au.label_project_record(client, project, doc_id, label=1, prior=0, note="note") + # change label + status_code, data = au.update_label_project_record( + client, project, doc_id, label=0, prior=0, note="changed note" + ) + assert status_code == 200 + assert data["success"] + time.sleep(10) + + +# Test deleting a project +def test_delete_project(setup): + client, _, project = setup + # delete project + status_code, data = au.delete_project(client, project) + assert status_code == 200 + assert data["success"] + + +@pytest.mark.parametrize( + "api_call", + [ + au.get_all_projects, + au.create_project, + au.create_project_from_dict, + au.update_project, + au.upgrade_project, + au.get_project_stats, + au.get_demo_data, + au.upload_data_to_project, + au.get_project_data, + au.get_project_dataset_writer, + au.search_project_data, + au.get_prior_random_project_data, + au.label_project_record, + au.update_label_project_record, + au.get_labeled_project_data, + au.get_labeled_project_data_stats, + au.get_project_algorithms_options, + au.set_project_algorithms, + au.get_project_algorithms, + au.start_project_algorithms, + au.get_project_status, + au.set_project_status, + au.export_project_dataset, + au.export_project, + au.get_project_progress, + au.get_project_progress_density, + au.get_project_progress_recall, + au.get_project_current_document, + au.delete_project, + ], +) +def test_unauthorized_use_of_api_calls(setup, api_call): + client, user, project = setup + if current_app_is_authenticated(): + # signout the client + au.signout_user(client) + # inspect function + sig = inspect.signature(api_call) + # form parameters + parms = [] + for par in sig.parameters.keys(): + annotation = sig.parameters[par].annotation + if annotation == FlaskClient: + parms.append(client) + elif annotation == Union[Project, ASReviewProject]: + parms.append(project) + elif annotation == int: + parms.append(1) + elif annotation == str: + parms.append("abc") + elif annotation == dict: + parms.append({}) + + # make the api call + status_code, data = api_call(*parms) + assert status_code == 401 + assert data["message"] == "Login required." + else: + # no asserts in an unauthenticated app + pass diff --git a/asreview/webapp/tests/test_api/test_teams.py b/asreview/webapp/tests/test_api/test_teams.py new file mode 100644 index 000000000..ed99aaaba --- /dev/null +++ b/asreview/webapp/tests/test_api/test_teams.py @@ -0,0 +1,277 @@ +from inspect import getfullargspec + +import pytest + +import asreview.webapp.tests.utils.api_utils as au + +# NOTE: user 1 is signed in and has a single project, invites +# other users who accept and reject + + +# Test sending an invitation +def test_user1_sends_invitation(setup_auth): + client, _, user2, _, project = setup_auth + # invite + status_code, resp_data = au.invite(client, project, user2) + assert status_code == 200 + assert resp_data["message"] == 'User "user2@asreview.nl" invited.' + + +# Testing listing invitations +def test_user2_list_invitations(setup_auth): + client, user1, user2, _, project = setup_auth + # invite + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 2 + au.signin_user(client, user2) + # get all invitations + status_code, resp_data = au.list_invitations(client) + invitations = resp_data["invited_for_projects"] + assert status_code == 200 + assert len(invitations) == 1 + assert invitations[0]["project_id"] == project.project_id + assert invitations[0]["owner_id"] == user1.id + + +# Testing accepting an invitation +def test_user2_accept_invitation(setup_auth): + client, _, user2, _, project = setup_auth + # invite + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 2 + au.signin_user(client, user2) + # accept invitation + status_code, resp_data = au.accept_invitation(client, project) + assert status_code == 200 + assert resp_data["message"] == "User accepted invitation for project." + + +# Test rejecting invitation +def test_user2_rejects_invitation(setup_auth): + client, _, user2, _, project = setup_auth + # invite + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 2 + au.signin_user(client, user2) + # reject invitation + status_code, resp_data = au.reject_invitation(client, project) + assert status_code == 200 + assert resp_data["message"] == "User rejected invitation for project." + + +# Test owner removes invitation +def test_owner_deletes_invitation(setup_auth): + client, _, user2, _, project = setup_auth + # invite + au.invite(client, project, user2) + # remove invitation + status_code, resp_data = au.delete_invitation(client, project, user2) + assert status_code == 200 + assert resp_data["message"] == "Owner deleted invitation." + + +# Test owner views collaboration team +def test_view_collaboration_team_with_pending_invitation(setup_auth): + client, _, user2, _, project = setup_auth + # invite + au.invite(client, project, user2) + # checks team + status_code, resp_data = au.list_collaborators(client, project) + assert status_code == 200 + assert resp_data["collaborators"] == [] + assert resp_data["invitations"] == [user2.id] + + +# Test owner views collaboration team +def test_view_collaboration_team_with_accepted_invitation(setup_auth): + client, user1, user2, _, project = setup_auth + # invite + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 2 + au.signin_user(client, user2) + # accept invitation and signs out + au.accept_invitation(client, project) + au.signout_user(client) + # user 1 signs up + au.signin_user(client, user1) + # checks team + status_code, resp_data = au.list_collaborators(client, project) + assert status_code == 200 + assert resp_data["collaborators"] == [user2.id] + assert resp_data["invitations"] == [] + + +# Test owner removes collaboration +def test_owner_deletes_collaboration(setup_auth): + client, user1, user2, _, project = setup_auth + # invite + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 2 + au.signin_user(client, user2) + # accept invitation and signs out + au.accept_invitation(client, project) + au.signout_user(client) + # user 1 signs up + au.signin_user(client, user1) + # remove from team + status_code, resp_data = au.delete_collaboration(client, project, user2) + assert status_code == 200 + assert resp_data["message"] == "Collaborator removed from project." + + +# Test collaborator withdraws from collaboration +def test_collaborator_withdrawal(setup_auth): + client, _, user2, _, project = setup_auth + # invite + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 2 + au.signin_user(client, user2) + # accept invitation and signs out + au.accept_invitation(client, project) + # withdrawal + status_code, resp_data = au.delete_collaboration(client, project, user2) + assert status_code == 200 + assert resp_data["message"] == "Collaborator removed from project." + + +# ################### +# TEST LOGIN REQUIRED +# ################### + + +@pytest.mark.parametrize( + "api_call", + [ + au.invite, + au.list_invitations, + au.list_collaborators, + au.accept_invitation, + au.reject_invitation, + au.delete_invitation, + au.delete_collaboration, + ], +) +# Test login required for all api routes +def test_login_required(setup_auth, api_call): + client, _, user2, _, project = setup_auth + au.signout_user(client) + number_of_params = len(getfullargspec(api_call).args) + if number_of_params == 1: + status_code, resp_data = api_call(client) + elif number_of_params == 2: + status_code, resp_data = api_call(client, project) + elif number_of_params == 3: + status_code, resp_data = api_call(client, project, user2) + # all calls must return a 401: + assert status_code == 401 + assert resp_data["message"] == "Login required." + + +# ################### +# TEST NO PERMISSION +# ################### + + +# Test user3 can't see invite from user 1 to user 2 +def test_user3_cant_see_other_invites(setup_auth): + client, _, user2, user3, project = setup_auth + # invite to make sure we have an invitation (user1 is signed in) + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 3 (not invited) + au.signin_user(client, user3) + # get all invitations + status_code, resp_data = au.list_invitations(client) + assert status_code == 200 + assert resp_data["invited_for_projects"] == [] + + +# Test user3 can't accept invite to user 2 +def test_user3_cant_reject_invite_of_user_2(setup_auth): + client, _, user2, user3, project = setup_auth + # invite to make sure we have an invitation (user1 is signed in) + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 3 (not invited) + au.signin_user(client, user3) + status_code, resp_data = au.accept_invitation(client, project) + assert status_code == 404 + assert resp_data["message"] == "Request can not made by current user." + + +# Test user3 can't reject invite to user 2 +def test_user3_cant_accept_invite_of_user_2(setup_auth): + client, _, user2, user3, project = setup_auth + # invite to make sure we have an invitation (user1 is signed in) + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 3 (not invited) + au.signin_user(client, user3) + status_code, resp_data = au.reject_invitation(client, project) + assert status_code == 404 + assert resp_data["message"] == "Request can not made by current user." + + +# Test user3 can't delete invitation +def test_user3_cant_delete_invitation(setup_auth): + client, _, user2, user3, project = setup_auth + # invite + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 3 (not invited) + au.signin_user(client, user3) + # remove invitation + status_code, resp_data = au.delete_invitation(client, project, user2) + assert status_code == 404 + assert resp_data["message"] == "Request can not made by current user." + + +# Test user3 can't see collaboration team of user 1 +def test_user3_cant_see_collaboration_team(setup_auth): + client, _, user2, user3, project = setup_auth + # invite + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 3 (not invited) + au.signin_user(client, user3) + # check team + status_code, resp_data = au.list_collaborators(client, project) + assert status_code == 404 + assert resp_data["message"] == "Request can not made by current user." + + +# Test user3 can't remove collaboration +def test_user3_cant_delete_collaboration(setup_auth): + client, _, user2, user3, project = setup_auth + # invite + au.invite(client, project, user2) + # signout user 1 + au.signout_user(client) + # signin user 2 + au.signin_user(client, user2) + # accept invitation and signs out + au.accept_invitation(client, project) + au.signout_user(client) + # user 3 signs up + au.signin_user(client, user3) + # remove from team + status_code, resp_data = au.delete_collaboration(client, project, user2) + assert status_code == 404 + assert resp_data["message"] == "Request can not made by current user." diff --git a/asreview/webapp/tests/test_api/test_webapp.py b/asreview/webapp/tests/test_api/test_webapp.py new file mode 100644 index 000000000..f27948141 --- /dev/null +++ b/asreview/webapp/tests/test_api/test_webapp.py @@ -0,0 +1,38 @@ +from flask import current_app + +import asreview.webapp.tests.utils.api_utils as au +from asreview.webapp.tests.utils import misc + + +# Test if index.html is available! +# Note: This test will fail if build is missing. Please run +# `python setup.py compile_assets` first. +def test_landing(setup): + client, _, _ = setup + + status_code, _, html = au.call_root_url(client) + assert status_code == 200 + assert ( + "ASReview LAB - A tool for AI-assisted systematic reviews" + in html + ) # noqa + + +# Test boot data! +def test_boot(setup_all_clients): + status_code, data = au.call_boot_url(setup_all_clients) + assert status_code == 200 + assert isinstance(data, dict) + assert "authentication" in data.keys() + assert "status" in data.keys() + assert "version" in data.keys() + if misc.current_app_is_authenticated(): + assert data["authentication"] + assert data["allow_account_creation"] == current_app.config.get( + "ALLOW_ACCOUNT_CREATION" + ) + assert data["email_verification"] == current_app.config.get( + "EMAIL_VERIFICATION", False + ) + else: + assert not data["authentication"] diff --git a/asreview/webapp/tests/test_asreview_database.py b/asreview/webapp/tests/test_asreview_database.py deleted file mode 100644 index b2183bdb3..000000000 --- a/asreview/webapp/tests/test_asreview_database.py +++ /dev/null @@ -1,558 +0,0 @@ -# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import shutil -from pathlib import Path - -import pytest -from sqlalchemy.exc import IntegrityError - -from asreview.utils import asreview_path -from asreview.webapp import DB -from asreview.webapp.authentication.models import Collaboration -from asreview.webapp.authentication.models import CollaborationInvitation -from asreview.webapp.authentication.models import Project -from asreview.webapp.authentication.models import User -from asreview.webapp.start_flask import create_app - -try: - from .temp_env_var import TMP_ENV_VARS -except ImportError: - TMP_ENV_VARS = {} - - -@pytest.fixture(scope="module", autouse=True) -def remove_test_folder(): - """This fixture ensures the destruction of the asreview - test folder (which includes the database)""" - yield - shutil.rmtree(asreview_path()) - - -@pytest.fixture(scope="function", autouse=True) -def setup_teardown_standard(): - """Standard setup and teardown, create the app and - make sure the database is cleaned up after running - each and every test""" - # setup environment variables - os.environ.update(TMP_ENV_VARS) - - root_dir = str(Path(os.path.abspath(__file__)).parent) - config_file_path = f"{root_dir}/configs/auth_config.json" - # create app and client - app = create_app(enable_auth=True, flask_configfile=config_file_path) - # clean database - with app.app_context(): - yield app - DB.session.query(Project).delete() - DB.session.query(User).delete() - DB.session.query(Collaboration).delete() - DB.session.query(CollaborationInvitation).delete() - DB.session.commit() - - -TEST_USER_IDENTIFIER = "c.s.kaandorp@uu.nl" - - -def create_test_user(): - return User( - TEST_USER_IDENTIFIER, - email=TEST_USER_IDENTIFIER, - name="Casper Kaandorp", - password="Onyx123!", - ) - - -def create_team_user(name): - email = f"{name}@test.nl" - return User(email, email=email, name=name, password="Onyx123!") - - -def test_add_user_record(): - """Verify if I can add a user account""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - - user = create_test_user() - DB.session.add(user) - DB.session.commit() - - # verify we have 1 record - assert len(User.query.all()) == 1 - # verify we have added a record - user = User.query.filter(User.identifier == TEST_USER_IDENTIFIER).one() - - -def test_email_is_unique(): - """Verify we can not add two users with the same email""" - user = create_test_user() - DB.session.add(user) - DB.session.commit() - - # verify we have 1 record - assert len(User.query.all()) == 1 - user = User.query.filter(User.identifier == TEST_USER_IDENTIFIER).one() - - new_user = create_test_user() - DB.session.add(new_user) - with pytest.raises(IntegrityError): - DB.session.commit() - - DB.session.rollback() - # verify we have 1 record - assert len(User.query.all()) == 1 - - -def test_if_user_has_projects_property(): - """Make sure a User object has access to his/her projects""" - user = create_test_user() - DB.session.add(user) - DB.session.commit() - user = User.query.filter(User.identifier == TEST_USER_IDENTIFIER).one() - # check if user points to empty list - assert user.projects == [] - - -def test_creating_a_project_without_user(): - """Create a project without a user must be impossible""" - # verify no records in projects and users tables - assert len(Project.query.all()) == 0 - assert len(User.query.all()) == 0 - - # create project with a non-existent user - project = Project(project_id="my-project") - DB.session.add(project) - with pytest.raises(IntegrityError): - DB.session.commit() - - DB.session.rollback() - assert len(Project.query.all()) == 0 - assert len(User.query.all()) == 0 - - -def test_creating_a_project_with_user(): - """Create a project with a valid user""" - user = create_test_user() - DB.session.add(user) - DB.session.commit() - - assert len(Project.query.all()) == 0 - assert len(User.query.all()) == 1 - - user.projects.append(Project(project_id="my-project")) - DB.session.commit() - - assert len(Project.query.all()) == 1 - assert len(User.query.all()) == 1 - - -def test_uniqueness_of_project_id(): - """Create a project with a valid user""" - user = create_test_user() - DB.session.add(user) - DB.session.commit() - assert len(Project.query.all()) == 0 - assert len(User.query.all()) == 1 - - user.projects.append(Project(project_id="my-project")) - DB.session.commit() - assert len(Project.query.all()) == 1 - - # add project with same id - user.projects.append(Project(project_id="my-project")) - with pytest.raises(IntegrityError): - DB.session.commit() - - DB.session.rollback() - assert len(Project.query.all()) == 1 - assert len(User.query.all()) == 1 - - -def test_project_path_and_folder(): - """Test full path of project and project folder""" - user = create_test_user() - DB.session.add(user) - DB.session.commit() - - id = "my-project" - user.projects.append(Project(project_id=id)) - DB.session.commit() - assert len(Project.query.all()) == 1 - assert user.projects[0].project_path == Path(asreview_path(), id) - assert user.projects[0].folder == id - - -def test_updating_a_project(): - """Update a project, just see if it works and how it - should be done. This is not a very valuable test.""" - user = create_test_user() - user.projects.append(Project(project_id="my-project")) - DB.session.add(user) - DB.session.commit() - assert len(Project.query.all()) == 1 - assert len(User.query.all()) == 1 - - new_project_id = "my-other-project" - Project.query.filter(Project.owner_id == user.id).update( - {"project_id": new_project_id} - ) - DB.session.commit() - - # check if project_id has been changed - project = Project.query.filter(Project.owner_id == user.id).one() - assert project.project_id == new_project_id - - -def test_deleting_a_project_no_collaboration(): - """Delete a single project from a user. Again, not a valuable - test, just seeing if it works and how it is done.""" - user = create_test_user() - user.projects.append(Project(project_id="my-project")) - user.projects.append(Project(project_id="my-other-project")) - user.projects.append(Project(project_id="my-other-other-project")) - DB.session.add(user) - DB.session.commit() - assert len(Project.query.all()) == 3 - assert len(User.query.all()) == 1 - - Project.query.filter(Project.project_id == "my-project").delete() - DB.session.commit() - assert len(Project.query.all()) == 2 - assert len(User.query.all()) == 1 - - names = set([p.project_id for p in Project.query.all()]) - assert names == set(["my-other-project", "my-other-other-project"]) - - -def test_deleting_a_user_with_projections_no_collaboration(): - """When I destroy a user, all projects have to be destroyed""" - user = create_test_user() - user.projects.append(Project(project_id="my-project")) - user.projects.append(Project(project_id="my-other-project")) - user.projects.append(Project(project_id="my-other-other-project")) - DB.session.add(user) - DB.session.commit() - assert len(Project.query.all()) == 3 - assert len(User.query.all()) == 1 - - DB.session.delete(user) - DB.session.commit() - assert len(User.query.all()) == 0 - assert len(Project.query.all()) == 0 - - -def test_deleting_a_project(): - """Destroy a project of a user, no rocket science here""" - user = create_test_user() - user.projects.append(Project(project_id="my-project")) - user.projects.append(Project(project_id="my-other-project")) - user.projects.append(Project(project_id="my-other-other-project")) - DB.session.add(user) - DB.session.commit() - assert len(Project.query.all()) == 3 - assert len(User.query.all()) == 1 - - project = Project.query.filter(Project.project_id == "my-project").one() - DB.session.delete(project) - DB.session.commit() - assert len(Project.query.all()) == 2 - assert len(User.query.all()) == 1 - - -def test_add_collaboration(): - """Verify if I can add a collaborator's user account to a project""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - owner = create_test_user() - coll1 = create_team_user("collabo1") - coll2 = create_team_user("collabo2") - owner.projects.append(Project(project_id="my-project")) - DB.session.add_all([owner, coll1, coll2]) - DB.session.commit() - - # verify we have 1 record - assert len(User.query.all()) == 3 - assert len(Project.query.all()) == 1 - - # Now I want to add coll as a collaborator - project = owner.projects[0] - # assert there are no collaborators - assert len(project.collaborators) == 0 - project.collaborators.append(coll1) - project.collaborators.append(coll2) - DB.session.commit() - - # assert there are collaborators - assert len(project.collaborators) == 2 - assert coll1 in owner.projects[0].collaborators - assert coll2 in owner.projects[0].collaborators - - -def test_list_projects_in_which_i_am_collaborating(): - """Verify I can list projects in which a user is collaborating""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - assert len(Project.query.all()) == 0 - assert len(Collaboration.query.all()) == 0 - - owner = create_test_user() - coll1 = create_team_user("collabo1") - - owner.projects.append(Project(project_id="my-project")) - coll1.projects.append(Project(project_id="other-project")) - DB.session.add_all([owner, coll1]) - DB.session.commit() - - # verify we have 2 users and 2 projects - assert len(User.query.all()) == 2 - assert len(Project.query.all()) == 2 - - # add coll1 to my-project - project = owner.projects[-1] - project.collaborators.append(coll1) - DB.session.commit() - - # check if coll1 can reach this project - assert coll1.involved_in == [project] - assert coll1.involved_in[0].project_id == "my-project" - assert coll1.projects[0].project_id == "other-project" - - -def test_removing_a_collaborator(): - """Verify if I can remove a collaborator from a project""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - assert len(Project.query.all()) == 0 - assert len(Collaboration.query.all()) == 0 - - owner = create_test_user() - coll1 = create_team_user("collabo1") - coll2 = create_team_user("collabo2") - owner.projects.append(Project(project_id="my-project")) - DB.session.add_all([owner, coll1, coll2]) - DB.session.commit() - - # verify we have 1 record - assert len(User.query.all()) == 3 - assert len(Project.query.all()) == 1 - - # assert there are no collaborators - project = owner.projects[-1] - assert len(project.collaborators) == 0 - # add collaborators - project.collaborators.append(coll1) - project.collaborators.append(coll2) - DB.session.commit() - - # assert there are 2 collaborators - assert len(project.collaborators) == 2 - - # remove collaborator 2 - owner.projects[0].collaborators.remove(coll2) - DB.session.commit() - - # assert one collaborator is gone - assert len(project.collaborators) == 1 - # and the remaining collaborators is still there - assert project.collaborators == [coll1] - - -def test_removing_project_removes_collaborations(): - """If a project is destroyed, all collaborator links should be removed""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - assert len(Project.query.all()) == 0 - assert len(Collaboration.query.all()) == 0 - - # create project and add collaborators - owner = create_test_user() - coll1 = create_team_user("collabo1") - coll2 = create_team_user("collabo2") - owner.projects.append(Project(project_id="my-project")) - DB.session.add_all([owner, coll1, coll2]) - owner.projects[-1].collaborators = [coll1, coll2] - DB.session.commit() - - # assert we have to 2 collaborations - assert len(owner.projects[-1].collaborators) == 2 - - # now remove the project - DB.session.delete(owner.projects[-1]) - - # assert we still have 3 users - assert len(User.query.all()) == 3 - # and no collaborations - assert len(Collaboration.query.all()) == 0 - - -def test_removing_project_removes_invites(): - """If a project is destroyed, all invitations links should be removed""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - assert len(Project.query.all()) == 0 - assert len(Collaboration.query.all()) == 0 - - # create project and add collaborators - owner = create_test_user() - coll1 = create_team_user("collabo1") - coll2 = create_team_user("collabo2") - owner.projects.append(Project(project_id="my-project")) - DB.session.add_all([owner, coll1, coll2]) - owner.projects[-1].pending_invitations = [coll1, coll2] - DB.session.commit() - - # assert we have to 2 collaborations - assert len(owner.projects[-1].pending_invitations) == 2 - - # now remove the project - DB.session.delete(owner.projects[-1]) - - # assert we still have 3 users - assert len(User.query.all()) == 3 - # and no collaborations - assert len(CollaborationInvitation.query.all()) == 0 - - -####################### -# COLLABO invitations # -####################### - - -def test_add_collaboration_invite(): - """Verify if I can add a collaboration invite user account to a project""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - owner = create_test_user() - coll1 = create_team_user("collabo1") - coll2 = create_team_user("collabo2") - owner.projects.append(Project(project_id="my-project")) - DB.session.add_all([owner, coll1, coll2]) - DB.session.commit() - - assert len(User.query.all()) == 3 - assert len(Project.query.all()) == 1 - - # Now I want to add coll as a collaborator - project = owner.projects[0] - # assert there are no collaborators - assert len(project.collaborators) == 0 - project.pending_invitations.append(coll1) - project.pending_invitations.append(coll2) - DB.session.commit() - - # assert there are collaborators - assert len(project.pending_invitations) == 2 - assert coll1 in owner.projects[0].pending_invitations - assert coll2 in owner.projects[0].pending_invitations - - -def test_list_projects_in_which_i_am_invited(): - """Verify I can list projects in which a user is invited for""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - assert len(Project.query.all()) == 0 - assert len(Collaboration.query.all()) == 0 - assert len(CollaborationInvitation.query.all()) == 0 - - owner = create_test_user() - coll1 = create_team_user("collabo1") - - owner.projects.append(Project(project_id="my-project")) - coll1.projects.append(Project(project_id="other-project")) - DB.session.add_all([owner, coll1]) - DB.session.commit() - - # verify we have 2 users and 2 projects - assert len(User.query.all()) == 2 - assert len(Project.query.all()) == 2 - - # invite coll1 to my-project - project = owner.projects[-1] - project.pending_invitations.append(coll1) - DB.session.commit() - - # check if coll1 can reach this project - assert coll1.pending_invitations == [project] - assert coll1.pending_invitations[0].project_id == "my-project" - assert coll1.projects[0].project_id == "other-project" - - -def test_removing_an_invitation(): - """Verify if I can remove an invitation from a project""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - assert len(Project.query.all()) == 0 - assert len(Collaboration.query.all()) == 0 - assert len(CollaborationInvitation.query.all()) == 0 - - owner = create_test_user() - coll1 = create_team_user("collabo1") - coll2 = create_team_user("collabo2") - owner.projects.append(Project(project_id="my-project")) - DB.session.add_all([owner, coll1, coll2]) - DB.session.commit() - - # verify we have project 1 record - assert len(User.query.all()) == 3 - assert len(Project.query.all()) == 1 - - # assert there are no collaborators - project = owner.projects[-1] - assert len(project.pending_invitations) == 0 - # add collaborators - project.pending_invitations.append(coll1) - project.pending_invitations.append(coll2) - DB.session.commit() - - # assert there are 2 collaborators - assert len(project.pending_invitations) == 2 - - # remove collaborator 2 - owner.projects[0].pending_invitations.remove(coll2) - DB.session.commit() - - # assert one collaborator is gone - assert len(project.pending_invitations) == 1 - # and the remaining collaborators is still there - assert project.pending_invitations == [coll1] - - -def test_removing_project_removes_invitations(): - """If a project is destroyed, all invitations should be removed""" - # verify we start with a clean database - assert len(User.query.all()) == 0 - assert len(Project.query.all()) == 0 - assert len(Collaboration.query.all()) == 0 - assert len(CollaborationInvitation.query.all()) == 0 - - # create project and add collaborators - owner = create_test_user() - coll1 = create_team_user("collabo1") - coll2 = create_team_user("collabo2") - owner.projects.append(Project(project_id="my-project")) - DB.session.add_all([owner, coll1, coll2]) - owner.projects[-1].pending_invitations = [coll1, coll2] - DB.session.commit() - - # assert we have to 2 collaborations - assert len(owner.projects[-1].pending_invitations) == 2 - - # now remove the project - DB.session.delete(owner.projects[-1]) - - # assert we still have 3 users - assert len(User.query.all()) == 3 - # and no collaborations - assert len(CollaborationInvitation.query.all()) == 0 diff --git a/asreview/webapp/tests/test_auth.py b/asreview/webapp/tests/test_auth.py deleted file mode 100644 index 58c456445..000000000 --- a/asreview/webapp/tests/test_auth.py +++ /dev/null @@ -1,437 +0,0 @@ -# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import datetime as dt -import json -import os -import time -from pathlib import Path - -import pytest - -from asreview.webapp import DB -from asreview.webapp.authentication.models import User -from asreview.webapp.start_flask import create_app -from asreview.webapp.tests.conftest import signin_user -from asreview.webapp.tests.conftest import signup_user - -try: - from .temp_env_var import TMP_ENV_VARS -except ImportError: - TMP_ENV_VARS = {} - - -@pytest.fixture(scope="function", name="setup_teardown_standard", autouse=True) -def setup_teardown_standard(request): - """Standard setup and teardown, create the app and - make sure the database is cleaned up after running - each and every test""" - # setup environment variables - os.environ.update(TMP_ENV_VARS) - - # find config file - root_dir = str(Path(os.path.abspath(__file__)).parent) - if hasattr(request, "param") and request.param == "user_creation_not_allowed": - # no user creation allowed - config_file_path = f"{root_dir}/configs/auth_config_no_accounts.json" - elif hasattr(request, "param") and request.param == "verified_user_creation": - # user creation WITH email verification - config_file_path = f"{root_dir}/configs/auth_config_verification.json" - else: - # user creation WITHOUT email verification - config_file_path = f"{root_dir}/configs/auth_config.json" - - # create app and client - app = create_app(enable_auth=True, flask_configfile=config_file_path) - client = app.test_client() - # clean database - with app.app_context(): - yield client - DB.session.query(User).delete() - DB.session.commit() - - -def create_user(identifier, email=None, confirmed=True, password=None): - return User( - identifier, - email=(email if email is not None else identifier), - name="Whatever", - confirmed=confirmed, - password=(password if password is not None else "127635!yguyW"), - ) - - -def get_user(identifier): - """Gets a user by email, only works in app context""" - return DB.session.query(User).filter(User.identifier == identifier).one_or_none() - - -# ################### -# USER CREATION -# ################### - - -# force different config file that doesn't allow user -# creation -@pytest.mark.parametrize( - "setup_teardown_standard", ["user_creation_not_allowed"], indirect=True -) -def test_impossible_to_signup_when_not_allowed(setup_teardown_standard): - """UNSuccessful signup when account creation is not allowed""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - response = signup_user(client, "test1@uu.nl", "Wdas32d!") - # check if we get a 400 status - assert response.status_code == 400 - assert len(User.query.all()) == 0 - - -@pytest.mark.parametrize( - "setup_teardown_standard", ["verified_user_creation"], indirect=True -) -def test_successful_signup_confirmed(setup_teardown_standard): - """Successful signup returns a 200 but with an unconfirmed - user and a email token""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - response = signup_user(client, "test1@uu.nl", "Wdas32d!") - # check if we get a 200 status - assert response.status_code == 200 - # get user - user = User.query.first() - assert not user.confirmed - assert bool(user.token) - assert bool(user.token_created_at) - - -def test_successful_signup_no_confirmation(setup_teardown_standard): - """Successful signup returns a 200""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - response = signup_user(client, "test1@uu.nl", "Wdas32d!") - # check if we get a 201 status - assert response.status_code == 201 - # get user - user = User.query.first() - assert user.confirmed - assert not bool(user.token) - assert not bool(user.token_created_at) - - -def test_unique_identifier_api(setup_teardown_standard): - """Adding an existing identifier must return a 404 status and - appropriate message""" - client = setup_teardown_standard - identifier = "test4@uu.nl" - DB.session.add(create_user(identifier)) - DB.session.commit() - # try to create the same user again with the api - response = signup_user(client, identifier, "3434rwq") - assert response.status_code == 403 - assert f'"{identifier}" already exists' in json.loads(response.data)["message"] - - -def test_unique_email_api(setup_teardown_standard): - """Adding an existing email must return a 404 status and - appropriate message""" - client = setup_teardown_standard - email = "test4@uu.nl" - DB.session.add(create_user(email + "001", email)) - DB.session.commit() - # try to create the same user again with the api - response = signup_user(client, email, "3434rwq") - assert response.status_code == 403 - assert f'"{email}" already exists' in json.loads(response.data)["message"] - - -def test_unique_emails_db(setup_teardown_standard): - """Trying to add an existing user must not create a user record""" - client = setup_teardown_standard - # create user - identifier = "test5@uu.nl" - DB.session.add(create_user(identifier)) - DB.session.commit() - # count initial amount of records - count = DB.session.query(User).count() - # try to create the same user again with the api - signup_user(client, identifier, "123456!AbC") - # recount - new_count = DB.session.query(User).count() - assert new_count == count - - -# ################### -# SIGNIN -# ################### - - -@pytest.mark.parametrize( - "setup_teardown_standard", ["verified_user_creation"], indirect=True -) -def test_unsuccessful_signin_with_unconfirmed_account(setup_teardown_standard): - """User can not sign in with uncomfirmed account""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - email, password = "test1@uu.nl", "Wdas32d!" - response = signup_user(client, email, password) - # check if we get a 200 status - assert response.status_code == 200 - # get user - user = User.query.first() - assert not user.confirmed - # try to sign in - response = signin_user(client, email, password) - assert response.status_code == 404 - assert f"User account {email} is not confirmed" in response.text - - -def test_successful_signin_api(setup_teardown_standard): - """Successfully signing in a user must return a 200 response""" - client = setup_teardown_standard - # create user - email = "test6@uu.nl" - password = "123456Ab@" - DB.session.add(create_user(email, password=password)) - DB.session.commit() - response = signin_user(client, email, password) - assert response.status_code == 200 - - -def test_unsuccessful_signin_wrong_password_api(setup_teardown_standard): - """Wrong password must return a 404 response - and an appropriate response""" - client = setup_teardown_standard - # create user - email = "test7@uu.nl" - password = "123456Ab@" - DB.session.add(create_user(email, password=password)) - DB.session.commit() - response = signin_user(client, email, "wrong_password") - assert response.status_code == 404 - assert "Incorrect password" in json.loads(response.data)["message"] - - -def test_unsuccessful_signin_wrong_email_api(setup_teardown_standard): - """Wrong email must return a 404 response - and an appropriate response""" - client = setup_teardown_standard - # create user - email = "test8@uu.nl" - password = "123456Ab@" - DB.session.add(create_user(email, password=password)) - DB.session.commit() - response = signin_user(client, "TedjevanEs", password) - assert response.status_code == 404 - assert "does not exist" in json.loads(response.data)["message"] - - -# ################### -# SIGNOUT -# ################### - - -def test_must_be_signed_in_to_signout(setup_teardown_standard): - """User must be logged in, in order to signout, - we expect an error if we sign out if not signed in""" - client = setup_teardown_standard - # make sure any signed-in user is signed out - client.delete("/auth/signout") - # and do it again - response = client.delete("/auth/signout") - assert response.status_code == 401 - - -def test_signout(setup_teardown_standard): - """Signing out must return a 200 status and an - appropriate message""" - client = setup_teardown_standard - # create user - email = "test9@uu.nl" - password = "123456Ab@" - DB.session.add(create_user(email, password=password)) - DB.session.commit() - # signin - signin_user(client, email, password) - # make sure any signed-in user is signed out - response = client.delete("/auth/signout") - # expect a 200 - assert response.status_code == 200 - assert "signed out" in json.loads(response.data)["message"] - - -# ################### -# CONFIRMATION -# ################### - - -@pytest.mark.parametrize( - "setup_teardown_standard", ["verified_user_creation"], indirect=True -) -def token_confirmation_after_signup(setup_teardown_standard): - """A new token is created on signup, that token is can - be confirmed by the confirm route""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - response = signup_user(client, "test1@uu.nl", "Wdas32d!") - # check if we get a 200 status - assert response.status_code == 200 - # get user - user = User.query.first() - assert not user.confirmed - assert bool(user.token) - assert bool(user.token_created_at) - # now we confirm this user - response = client.get( - f"/auth/confirm?user_id={user.id}&token={user.token}", - ) - assert response.status_code == 200 - # get user again - user = User.query.first() - assert user.confirmed - assert not bool(user.token) - assert not bool(user.token_created_at) - - -@pytest.mark.parametrize( - "setup_teardown_standard", ["verified_user_creation"], indirect=True -) -def test_expired_token(setup_teardown_standard): - """A token expires in 24 hours""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - response = signup_user(client, "test1@uu.nl", "Wdas32d!") - # check if we get a 200 status - assert response.status_code == 200 - # get user - user = User.query.first() - # change token created at - token = user.token - new_created_at = user.token_created_at - dt.timedelta(hours=28) - user.token_created_at = new_created_at - DB.session.commit() - # try to confirm this account - # now we confirm this user - response = client.post( - "/auth/confirm_account", data={"user_id": user.id, "token": user.token} - ) - assert response.status_code == 403 - assert "token has expired" in response.text - # get user again - user = User.query.first() - # user is not confirmed yet - assert not user.confirmed - # token is unchanged - assert user.token == token - - -@pytest.mark.parametrize( - "setup_teardown_standard", ["verified_user_creation"], indirect=True -) -def test_if_this_route_returns_404_user_not_found(setup_teardown_standard): - """If the user cant be found, this route should return a 404""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - response = signup_user(client, "test1@uu.nl", "Wdas32d!") - # check if we get a 200 status - assert response.status_code == 200 - # get user - user = User.query.first() - # confirm with wrong user id - response = client.post( - "/auth/confirm_account", data={"user_id": user.id - 1, "token": user.token} - ) - assert response.status_code == 404 - assert "No user account / correct token found" in response.text - - -@pytest.mark.parametrize( - "setup_teardown_standard", ["verified_user_creation"], indirect=True -) -def test_if_this_route_returns_404_token_not_found(setup_teardown_standard): - """If the token cant be found, this route should return a 404""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - response = signup_user(client, "test1@uu.nl", "Wdas32d!") - # check if we get a 200 status - assert response.status_code == 200 - # get user - user = User.query.first() - # confirm with wrong token - response = client.post( - "/auth/confirm_account", - data={"user_id": user.id - 1, "token": "A" + user.token + "A"}, - ) - assert response.status_code == 404 - assert "No user account / correct token found" in response.text - - -def test_if_this_route_returns_404_if_app_not_verified(setup_teardown_standard): - """If we are not doing verification this route should return a 404""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - response = signup_user(client, "test1@uu.nl", "Wdas32d!") - # check if we get a 201 status - assert response.status_code == 201 - # get user - user = User.query.first() - # try to confirm - response = client.post( - "/auth/confirm_account", data={"user_id": user.id, "token": user.token} - ) - assert response.status_code == 400 - assert "not configured to verify accounts" in response.text - - -# ################### -# FORGOT PASSWORD -# ################### - - -@pytest.mark.parametrize( - "setup_teardown_standard", ["verified_user_creation"], indirect=True -) -def test_token_creation_if_forgot_password(setup_teardown_standard): - """A new token is created when forgot password is requested""" - client = setup_teardown_standard - assert len(User.query.all()) == 0 - # post form data - response = signup_user(client, "test1@uu.nl", "Wdas32d!") - # check if we get a 200 status - assert response.status_code == 200 - # get user - user = User.query.first() - # get initial token - old_token = user.token - old_token_created_at = user.token_created_at - # confirm this user - user.confirmed = True - DB.session.commit() - time.sleep(1.5) - # forgot password - response = client.post("/auth/forgot_password", data={"email": user.email}) - # get latest version of user - user = User.query.first() - # asserts - assert bool(user.token) - assert user.token != old_token - assert user.token_created_at > old_token_created_at diff --git a/asreview/webapp/tests/test_auth_conversion.py b/asreview/webapp/tests/test_auth_conversion.py deleted file mode 100644 index ed90c7ac9..000000000 --- a/asreview/webapp/tests/test_auth_conversion.py +++ /dev/null @@ -1,317 +0,0 @@ -# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import shutil -from pathlib import Path - -import pytest - -from asreview.entry_points.auth_tool import insert_project -from asreview.utils import asreview_path -from asreview.webapp import DB -from asreview.webapp.authentication.models import Project -from asreview.webapp.authentication.models import User -from asreview.webapp.start_flask import create_app -from asreview.webapp.tests.conftest import signin_user -from asreview.webapp.tests.conftest import signout -from asreview.webapp.tests.conftest import signup_user - -try: - from .temp_env_var import TMP_ENV_VARS -except ImportError: - TMP_ENV_VARS = {} - -PROJECTS = [ - { - "mode": "explore", - "name": "project_1", - "authors": "user 1", - "description": "project 1", - }, - { - "mode": "explore", - "name": "project_2", - "authors": "user 2", - "description": "project 2", - }, -] - - -def create_the_app(config_file): - os.environ.update(TMP_ENV_VARS) - root_dir = str(Path(os.path.abspath(__file__)).parent) - config_file_path = f"{root_dir}/configs/{config_file}" - return create_app(flask_configfile=config_file_path) - - -@pytest.fixture(scope="class") -def no_auth_fixture(request): - """This fixture starts a non-authenticated version of ASReview""" - app = create_the_app("no_auth_config.json") - client = app.test_client() - request.cls.app = app - request.cls.client = client - request.cls.asreview_folder = Path(asreview_path()) - yield request.cls - - -@pytest.fixture(scope="class") -def no_auth_fixture_with_folder_removal(no_auth_fixture): - init_fixture = no_auth_fixture() - yield - shutil.rmtree(init_fixture.asreview_folder) - - -@pytest.fixture(scope="class") -def auth_fixture(request): - app = create_the_app("auth_config.json") - with app.app_context(): - client = app.test_client() - request.cls.app = app - request.cls.client = client - request.cls.asreview_folder = Path(asreview_path()) - request.cls.emails = ["test1@uu.nl", "test2@uu.nl"] - request.cls.password = "A123Bb!!" - yield request.cls - - -@pytest.mark.usefixtures("no_auth_fixture") -class TestNoAuthentication: - """We start with an inital ASReview instance without authentication.""" - - def test_existence_empty_test_folder(self): - # make sure we start with a clean slate - for path in Path(asreview_path()).glob("**/*"): - if path.is_file(): - path.unlink() - elif path.is_dir(): - shutil.rmtree(path) - # check existence folder - assert self.asreview_folder.exists() is True - # check if folder is empty - assert list(self.asreview_folder.glob("*")) == [] - - def test_creating_2_projects(self): - # create first project - response_project_1 = self.client.post( - "/api/projects/info", - data=PROJECTS[0], - ) - # create second project - response_project_2 = self.client.post( - "/api/projects/info", - data=PROJECTS[1], - ) - - json_data_project_1 = response_project_1.get_json() - json_data_project_2 = response_project_2.get_json() - - PROJECTS[0]["id"] = json_data_project_1["id"] - PROJECTS[1]["id"] = json_data_project_2["id"] - - # check if asreview folder contain 2 projects - assert len(list(self.asreview_folder.glob("*"))) == 2 - # check if projects are there - folders = [f.name for f in self.asreview_folder.glob("*")] - for p in PROJECTS: - project_id = p["id"] - assert project_id in folders - - def test_listing_the_projects(self): - # use api to get all projects - response = self.client.get("/api/projects") - json_data = response.get_json() - assert response.status_code == 200 - assert len(json_data["result"]) == 2 - names = [p["name"] for p in PROJECTS] - for p in json_data["result"]: - assert p["name"] in names - - def test_accessing_projects(self): - for p in PROJECTS: - project_id = p["id"] - response = self.client.get(f"/api/projects/{project_id}/info") - json_data = response.get_json() - assert response.status_code == 200 - assert json_data["name"] == p["name"] - - -# ------------------------------------------ -# NOW WE CONVERT TO AN AUTHENTICATED VERSION -# ------------------------------------------ - - -@pytest.mark.usefixtures("auth_fixture") -class TestConvertToAuthentication: - """Now we move on and start the thing in authenticated mode.""" - - def test_if_we_still_have_our_projects_and_a_sqlite_db(self): - """Start the app with authentication, we still have the - folder structure of the unauthenticated app.""" - # check if asreview folder contain 2 projects - assert len(list(self.asreview_folder.glob("*"))) == 3 - # check if projects are there - folder_content = [f.name for f in self.asreview_folder.glob("*")] - for p in PROJECTS: - project_id = p["id"] - assert project_id in folder_content - # check for the database - assert "asreview.test.sqlite" in folder_content - - def test_adding_users_into_the_users_table_and_convert(self): - """Convert to authenticated folder structure.""" - self.password = "A123Bb!!" - self.emails = ["test1@uu.nl", "test2@uu.nl"] - # create users - for email in self.emails: - signup_user(self.client, email, self.password) - assert len(User.query.all()) == 2 - - # we want to assign project 1 to user 1 and project 2 to user 2 - mapping = [ - { - "user_id": user.id, - "project_id": PROJECTS[i]["id"], - } - for i, user in enumerate(User.query.order_by(User.id.asc()).all()) - ] - - # execute converter with this mapping - for project in mapping: - insert_project(DB.session, project) - - # check if projects are linked to the correct user - for link in mapping: - user = DB.session.get(User, link["owner_id"]) - project_id = link["project_id"] - - # check project in database and if it's linked to the user - project = Project.query.filter(Project.project_id == project_id).first() - assert project.owner_id == user.id - - def test_projects_of_user_1(self): - """Checkout projects of user 1.""" - # get user 1 - user_1 = DB.session.get(User, 1) - # signin user - signin_user(self.client, user_1.identifier, self.password) - # check projects of user_1 - response = self.client.get("/api/projects") - json_data = response.get_json() - # get the result data - projects = json_data.get("result", []) - # there should be 1 project, and it has to be the first - assert len(projects) == 1 - project = projects[0] - assert project["name"] == PROJECTS[0]["name"] - assert project["authors"] == PROJECTS[0]["authors"] - assert project["description"] == PROJECTS[0]["description"] - # access project - project = user_1.projects[0] - response = self.client.get(f"/api/projects/{project.project_id}/info") - json_data = response.get_json() - assert response.status_code == 200 - assert json_data["name"] == PROJECTS[0]["name"] - # update new project id - PROJECTS[0]["id"] = json_data["id"] - # signout - signout(self.client) - - def test_projects_of_user_2(self): - """Checkout projects of user 2.""" - # get user 2 - user_2 = DB.session.get(User, 2) - # signin user - signin_user(self.client, user_2.identifier, self.password) - # check projects of user_2 - response = self.client.get("/api/projects") - json_data = response.get_json() - # get the result data - projects = json_data.get("result", []) - # there should be 1 project, and it has to be the first - assert len(projects) == 1 - project = projects[0] - assert project["name"] == PROJECTS[1]["name"] - assert project["authors"] == PROJECTS[1]["authors"] - assert project["description"] == PROJECTS[1]["description"] - # access project - project = user_2.projects[0] - response = self.client.get(f"/api/projects/{project.project_id}/info") - json_data = response.get_json() - assert response.status_code == 200 - assert json_data["name"] == PROJECTS[1]["name"] - # update new project id - PROJECTS[1]["id"] = json_data["id"] - # signout - signout(self.client) - - def test_if_user_1_cant_see_project_2(self): - """Check if user_1 cant see project 2.""" - # get user 1 - user_1 = DB.session.get(User, 1) - # signin user - signin_user(self.client, user_1.identifier, self.password) - # try to get project 2, we need the id first - project_2_id = PROJECTS[1]["id"] - # user_1 tries to see project 2 - response = self.client.get(f"/api/projects/{project_2_id}/info") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - # signout - signout(self.client) - - def test_if_user_2_cant_see_project_1(self): - """Check if user_1 cant see project 2.""" - # get user 1 - user_2 = DB.session.get(User, 2) - # signin user - signin_user(self.client, user_2.identifier, self.password) - # try to get project 2, we need the id first - project_1_id = PROJECTS[0]["id"] - # user_2 tries to see project 1 - response = self.client.get(f"/api/projects/{project_1_id}/info") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - # signout - signout(self.client) - - -# ------------------------------------------ -# NOW WE CONVERT TO AN AUTHENTICATED VERSION -# ------------------------------------------ - - -@pytest.mark.usefixtures("no_auth_fixture_with_folder_removal") -class TestBackToNoAuthentication: - def test_projects_after_unauthentication(self): - # test listing the projects throught the api - response = self.client.get("/api/projects") - json_data = response.get_json() - assert response.status_code == 200 - assert len(json_data["result"]) == 2 - names = [p["name"] for p in PROJECTS] - for p in json_data["result"]: - assert p["name"] in names - - # check what's in the asreview-folder to get ids - ids = [f.name for f in asreview_path().glob("*") if f.is_dir()] - for id in ids: - # accessing the projects!!! - response = self.client.get(f"/api/projects/{id}/info") - json_data = response.get_json() - assert response.status_code == 200 - assert json_data["name"] in names diff --git a/asreview/webapp/tests/test_database_and_models/__init__.py b/asreview/webapp/tests/test_database_and_models/__init__.py new file mode 100644 index 000000000..17106ae40 --- /dev/null +++ b/asreview/webapp/tests/test_database_and_models/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/asreview/webapp/tests/test_database_and_models/conftest.py b/asreview/webapp/tests/test_database_and_models/conftest.py new file mode 100644 index 000000000..302d44d91 --- /dev/null +++ b/asreview/webapp/tests/test_database_and_models/conftest.py @@ -0,0 +1,48 @@ +import shutil + +import pytest + +import asreview.webapp.tests.utils.crud as crud +from asreview.utils import asreview_path +from asreview.webapp import DB + + +@pytest.fixture(autouse=True) +def setup_teardown(auth_app): + """A fixture for an authenticated app that ensures tests are + started with no users and projects.""" + assert crud.count_users() == 0 + assert crud.count_projects() == 0 + yield + crud.delete_everything(DB) + + +@pytest.fixture() +def test_data(auth_app): + """A fixture for an authenticated app, creates 3 users, first user + has created 2 projects.""" + user1, _ = crud.create_user1_with_2_projects(DB) + user2 = crud.create_user(DB, user=2) + user3 = crud.create_user(DB, user=3) + assert crud.count_projects() == 2 + assert crud.count_users() == 3 + data = {"user1": user1, "user2": user2, "user3": user3} + yield data + crud.delete_everything(DB) + + +@pytest.fixture() +def user(auth_app): + """A fixture for an authenticated app, creates a single user.""" + assert crud.count_projects() == 0 + user = crud.create_user(DB, 1) + assert crud.count_users() == 1 + yield user + crud.delete_everything(DB) + + +@pytest.fixture +def cleanup_asreview_path(): + """Cleanup fixture, removes entire ASReview folder.""" + if asreview_path().exists(): + shutil.rmtree(asreview_path()) diff --git a/asreview/webapp/tests/test_database_and_models/test_collaboration_models.py b/asreview/webapp/tests/test_database_and_models/test_collaboration_models.py new file mode 100644 index 000000000..ef6b27de3 --- /dev/null +++ b/asreview/webapp/tests/test_database_and_models/test_collaboration_models.py @@ -0,0 +1,120 @@ +import pytest +from sqlalchemy.exc import IntegrityError + +import asreview.webapp.tests.utils.crud as crud +from asreview.webapp import DB +from asreview.webapp.authentication.models import Collaboration +from asreview.webapp.authentication.models import CollaborationInvitation + + +class TestInvitations: + """Testing invitations on database level.""" + + # ############# + # CREATE + # ############# + + # test adding an invitation + def test_adding_an_invitation(self, test_data): + # get project + project = test_data["user1"].projects[0] + # invite user 2 + project.pending_invitations.append(test_data["user2"]) + DB.session.commit() + + assert crud.count_invitations() == 1 + # get fresh object + invite = crud.last_invitation() + # asserts Invitations + assert invite.project_id == project.id + assert invite.user_id == test_data["user2"].id + + # test uniqueness of invitations + def test_uniqueness_of_invitations(self, test_data): + user1 = test_data["user1"] + user2 = test_data["user2"] + project = user1.projects[0] + crud.create_invitation(DB, project, user2) + assert crud.count_invitations() == 1 + + # create identical invitation + with pytest.raises(IntegrityError): + crud.create_invitation(DB, project, user2) + # if all is well, we can't add the same invitation + assert crud.count_invitations() == 1 + + # test missing user is not permitted + def test_missing_user_in_invitation(self, test_data): + project = test_data["user1"].projects[0] + invite = CollaborationInvitation(project_id=project.id, user_id=None) + DB.session.add(invite) + with pytest.raises(IntegrityError): + DB.session.commit() + DB.session.rollback() + assert crud.count_invitations() == 0 + + # test missing project is not permitted + def test_missing_project_in_invitation(self, test_data): + user = test_data["user1"] + invite = CollaborationInvitation(project_id=None, user_id=user.id) + DB.session.add(invite) + with pytest.raises(IntegrityError): + DB.session.commit() + DB.session.rollback() + assert crud.count_invitations() == 0 + + +class TestCollaborations: + """Testing collaboration on database level.""" + + # ############# + # CREATE + # ############# + + # test adding a collaboration + def test_create_collaboration(self, test_data): + # get project + project = test_data["user1"].projects[0] + # collaboration user 2 + project.collaborators.append(test_data["user2"]) + DB.session.commit() + assert crud.count_collaborations() == 1 + # get fresh objects + collab = crud.last_collaboration() + # asserts collaboration + assert collab.project_id == project.id + assert collab.user_id == test_data["user2"].id + + # test uniqueness of collaboration + def test_uniqueness_of_collaboration(self, test_data): + user1 = test_data["user1"] + user2 = test_data["user2"] + project = user1.projects[0] + crud.create_collaboration(DB, project, user2) + assert crud.count_collaborations() == 1 + + # create identical invitation + with pytest.raises(IntegrityError): + crud.create_collaboration(DB, project, user2) + # if all is well, we can't add the same invitation + assert crud.count_collaborations() == 1 + + # test missing user is not permitted + def test_missing_user_in_collaboration(self, test_data): + project = test_data["user1"].projects[0] + invite = Collaboration(project_id=project.id, user_id=None) + DB.session.add(invite) + with pytest.raises(IntegrityError): + DB.session.commit() + DB.session.rollback() + assert crud.count_collaborations() == 0 + + # test missing project is not permitted + def test_missing_project_in_collaboration(self, test_data): + user = test_data["user1"] + invite = Collaboration(project_id=None, user_id=user.id) + DB.session.add(invite) + with pytest.raises(IntegrityError): + DB.session.commit() + DB.session.rollback() + assert crud.count_collaborations() == 0 diff --git a/asreview/webapp/tests/test_database_and_models/test_database_creation.py b/asreview/webapp/tests/test_database_and_models/test_database_creation.py new file mode 100644 index 000000000..69ffba474 --- /dev/null +++ b/asreview/webapp/tests/test_database_and_models/test_database_creation.py @@ -0,0 +1,36 @@ +# GOAL: test database creation if app is started with authentication +from pathlib import Path + +import pytest +import sqlalchemy +from sqlalchemy import create_engine + +from asreview.utils import asreview_path + + +def get_db_path(): + return Path(asreview_path() / "asreview.test.sqlite") + + +# checks if asreview path does not contain a database if app +# is unauthenticated +def test_database_is_not_created_if_unauth_app(cleanup_asreview_path, unauth_app): + assert Path(asreview_path()).exists() + assert get_db_path().exists() is False + + +# checks is asreview path contains database if app is +# authenticated +def test_database_exists_after_starting_auth_app(auth_app): + assert Path(asreview_path()).exists() + assert get_db_path().exists() + + +# checks if all tables were created +@pytest.mark.parametrize( + "table", ["collaboration_invitations", "collaborations", "projects", "users"] +) +def test_if_db_table_exists(auth_app, table): + engine = create_engine(f"sqlite:///{str(get_db_path())}") + table_names = sqlalchemy.inspect(engine).get_table_names() + assert table in table_names diff --git a/asreview/webapp/tests/test_database_and_models/test_project_model.py b/asreview/webapp/tests/test_database_and_models/test_project_model.py new file mode 100644 index 000000000..73c749144 --- /dev/null +++ b/asreview/webapp/tests/test_database_and_models/test_project_model.py @@ -0,0 +1,156 @@ +from pathlib import Path + +import pytest +from sqlalchemy.exc import IntegrityError + +import asreview.webapp.tests.utils.crud as crud +from asreview.utils import asreview_path +from asreview.webapp import DB +from asreview.webapp.authentication.models import Project + +# NOTE: projects are created from a user account + +# ############# +# CREATE +# ############# + + +# test uniqueness of project id +def test_uniqueness_of_project_id(user): + project_id = "my-project" + crud.create_project(DB, user, Project(project_id=project_id)) + assert crud.count_projects() == 1 + with pytest.raises(IntegrityError): + crud.create_project(DB, user, Project(project_id=project_id)) + + +# insert a project successfully +def test_inserting_project(user): + project_id = "my-project" + project = Project(project_id=project_id) + crud.create_project(DB, user, project) + assert crud.count_projects() == 1 + # get project + project = crud.last_project() + assert project.project_id == project_id + assert project.owner_id == user.id + + +# ############# +# DELETE +# ############# + + +# deleting a project won't delete its owner +def test_not_delete_user_after_deletion_project(user): + crud.create_project(DB, user, Project(project_id="project")) + assert crud.count_users() == 1 + assert crud.count_projects() == 1 + # get project + project = crud.last_project() + # delete + DB.session.delete(project) + DB.session.commit() + assert crud.count_users() == 1 + assert crud.count_projects() == 0 + + +# deleting a project will remove invitations +def test_project_removal_invitations(user): + project = crud.create_project(DB, user, Project(project_id="my-project")) + user2 = crud.create_user(DB, user=2) + assert crud.count_users() == 2 + assert crud.count_projects() == 1 + assert crud.count_invitations() == 0 + # invite + project.pending_invitations.append(user2) + DB.session.commit() + assert crud.count_invitations() == 1 + DB.session.delete(project) + DB.session.commit() + assert crud.count_projects() == 0 + assert crud.count_invitations() == 0 + + +# deleting a project will remove collaboration links +def test_project_removal_collaborations(user): + project = crud.create_project(DB, user, Project(project_id="my-project")) + user2 = crud.create_user(DB, user=2) + assert crud.count_users() == 2 + assert crud.count_projects() == 1 + assert crud.count_collaborations() == 0 + # invite + project.collaborators.append(user2) + DB.session.commit() + assert crud.count_collaborations() == 1 + DB.session.delete(project) + DB.session.commit() + assert crud.count_projects() == 0 + assert crud.count_collaborations() == 0 + + +# ############# +# PROPERTIES +# ############# + + +# test getting a user from project +def test_getting_user_from_project(user): + project_id = "my-project" + project = Project(project_id=project_id) + crud.create_project(DB, user, project) + assert crud.count_projects() == 1 + # get project + project = crud.last_project() + assert project.owner == user + + +# test project_folder +def test_project_folder(user): + project_id = "my-project" + crud.create_project(DB, user, Project(project_id=project_id)) + assert crud.count_projects() == 1 + # get project + project = crud.last_project() + assert project.folder == project_id + + +# test project_path +def test_project_path(user): + project_id = "my-project" + crud.create_project(DB, user, Project(project_id=project_id)) + assert crud.count_projects() == 1 + # get project + project = crud.last_project() + assert project.project_path == Path(asreview_path() / project_id) + + +# test pending invites +def test_pending_invites(user): + project = crud.create_project(DB, user, Project(project_id="my-project")) + user2 = crud.create_user(DB, user=2) + assert crud.count_users() == 2 + assert crud.count_projects() == 1 + # invite + project.pending_invitations.append(user2) + DB.session.commit() + # fresh object + project = crud.last_project() + # asserts + assert user2 in project.pending_invitations + + +# test collaboration +def test_collaboration(user): + project = crud.create_project(DB, user, Project(project_id="my-project")) + user2 = crud.create_user(DB, user=2) + assert crud.count_users() == 2 + assert crud.count_projects() == 1 + assert crud.count_collaborations() == 0 + # invite + project.collaborators.append(user2) + DB.session.commit() + assert crud.count_collaborations() == 1 + # start with a fresh object + project = crud.last_project() + assert user2 in project.collaborators diff --git a/asreview/webapp/tests/test_database_and_models/test_user_model.py b/asreview/webapp/tests/test_database_and_models/test_user_model.py new file mode 100644 index 000000000..bc7780e71 --- /dev/null +++ b/asreview/webapp/tests/test_database_and_models/test_user_model.py @@ -0,0 +1,308 @@ +from datetime import datetime as dt +from datetime import timedelta + +import pytest +from sqlalchemy.exc import IntegrityError + +import asreview.webapp.tests.utils.config_parser as cp +import asreview.webapp.tests.utils.crud as crud +from asreview.webapp import DB +from asreview.webapp.authentication.models import User + +# ############# +# CREATE +# ############# + + +# test identifier validation +def test_user_must_have_identifier(setup_teardown): + user = crud.create_user(DB) + with pytest.raises(ValueError): + user.identifier = None + + with pytest.raises(ValueError): + user.identifier = "" + + +# test uniqueness of identifier +def test_uniqueness_of_identifier(setup_teardown): + user1 = crud.create_user(DB) + assert crud.count_users() == 1 + # create second user with identical identifier + user2 = cp.get_user(2) + # set to an existing identifier + user2.identifier = user1.identifier + with pytest.raises(IntegrityError): + crud.create_user(DB, user2) + + +# test origin validation +def test_user_must_have_origin(setup_teardown): + user = crud.create_user(DB) + with pytest.raises(ValueError): + user.origin = None + + with pytest.raises(ValueError): + user.origin = "" + + +# test name validation +def test_user_must_have_name(setup_teardown): + user = crud.create_user(DB) + with pytest.raises(ValueError, match="Name is required"): + user.name = None + + with pytest.raises(ValueError, match="Name is required"): + user.name = "" + + with pytest.raises(ValueError, match="Name must contain more than 2 characters"): + user.name = "a" + + with pytest.raises(ValueError, match="Name must contain more than 2 characters"): + user.name = "ab" + + +# test if email is not blank if origin is "asreview" +def test_email_validation_1(setup_teardown): + user = crud.create_user(DB) + user.origin = "asreview" + with pytest.raises(ValueError, match="Email is required when origin is 'asreview'"): + user.email = None + + with pytest.raises(ValueError, match="Email is required when origin is 'asreview'"): + user.email = "" + + +# test if all fails when email is invalid +def test_email_validation_2(setup_teardown): + user_data = crud.create_user(DB) + invalid_email = "invalid" + + with pytest.raises( + ValueError, match=f"Email address '{invalid_email}' is not valid" + ): + User( + invalid_email, + email=invalid_email, + name=user_data.name, + origin="asreview", + password="ABCd1234!", + ) + + +# test uniqueness of email +def test_uniqueness_of_email(setup_teardown): + user1 = crud.create_user(DB) + assert crud.count_users() == 1 + # create second user with identical email + user2 = cp.get_user(2) + # set to an existing identifier + user2.email = user1.email + with pytest.raises(IntegrityError): + crud.create_user(DB, user2) + + +# test if all fails when password doesn't meet requirements +@pytest.mark.parametrize( + "password", ["", None, "a1!", "aaaaaaaaaaaaa", "1111111111111"] +) +def test_password_validation(setup_teardown, password): + with pytest.raises( + ValueError, match=f'Password "{str(password)}" does not meet requirements' + ): + User( + "admin@asreview.nl", + email="admin@asreview.nl", + name="Casper", + origin="asreview", + password=password, + ) + + +# Verify we can add a user record +def test_add_user_record(setup_teardown): + user = crud.create_user(DB) + # verify we have 1 record + assert crud.count_users() == 1 + assert crud.last_user() == user + + +# ############# +# UPDATE +# ############# + + +# Verify we can update a user record +def test_update_user_record(setup_teardown): + user = crud.create_user(DB) + old_hashed_password = user.hashed_password + + new_email = "new_email@asreview.nl" + new_name = "New Name" + new_affiliation = "New Affiliation" + new_password = "NewPassword@123" + new_public = False + + user.update_profile( + email=new_email, + name=new_name, + affiliation=new_affiliation, + password=new_password, + public=new_public, + ) + DB.session.commit() + + # verify we have 1 record + assert crud.count_users() == 1 + updated_user = crud.last_user() + # assert identifier remained the same + assert updated_user.identifier != new_email + # assert changes + assert updated_user.email == new_email + assert updated_user.affiliation == new_affiliation + assert updated_user.hashed_password != old_hashed_password + assert updated_user.public == new_public + + +# verify reset password +def test_update_password(setup_teardown): + user = crud.create_user(DB) + old_hashed_password = user.hashed_password + + new_password = "NewPassword@123" + user.reset_password(new_password) + DB.session.commit() + + # verify we have 1 record + assert crud.count_users() == 1 + updated_user = crud.last_user() + assert updated_user.hashed_password != old_hashed_password + + +# verify setting token and salt +def test_set_token(setup_teardown): + user = crud.create_user(DB) + + assert user.token is None + assert user.token_created_at is None + + user.set_token_data("secret", "salt") + DB.session.commit() + + # verify we have 1 record + assert crud.count_users() == 1 + updated_user = crud.last_user() + + assert updated_user.token is not None + assert updated_user.token_created_at is not None + assert type(updated_user.token_created_at) == dt + + +# verify token validity, by default token is 24 hours valid +@pytest.mark.parametrize( + "subtract_time", [(10, 0, True), (23, 59, True), (24, 1, False), (25, 0, False)] +) +def test_token_validity(setup_teardown, subtract_time): + subtract_hours, subtract_mins, validity = subtract_time + user = crud.create_user(DB) + user.set_token_data("secret", "salt") + DB.session.commit() + # verify we have 1 record + assert crud.count_users() == 1 + + # assert token is valid + token = user.token + token_created_at = user.token_created_at + assert user.token_valid(token) + + # now subtract hours + new_token_created_time = token_created_at - timedelta( + hours=subtract_hours, minutes=subtract_mins + ) + # update token_created_at + user.token_created_at = new_token_created_time + + # assert token validity + assert user.token_valid(token) == validity + + +# test confirming a user +def test_confirm_user(setup_teardown): + user = crud.create_user(DB) + # create a token for good measures + user.set_token_data("secret", "salt") + + assert user.confirmed is False + assert bool(user.token) + assert bool(user.token_created_at) + + # now lets confirm + user.confirm_user() + + assert user.confirmed + assert user.token is None + assert user.token_created_at is None + + +# ############# +# DELETE +# ############# + + +# test deleting a user means deleting all projects +def test_deleting_user(setup_teardown): + user, projects = crud.create_user1_with_2_projects(DB) + assert crud.count_users() == 1 + assert crud.count_projects() == 2 + # remove the user + DB.session.delete(user) + DB.session.commit() + assert crud.count_users() == 0 + # projects should be gone as well + assert crud.count_projects() == 0 + + +# ############# +# PROPERTIES +# ############# + + +# test projects +def test_projects_of_user(setup_teardown): + crud.create_user1_with_2_projects(DB) + assert crud.count_users() == 1 + assert crud.count_projects() == 2 + # get user + user = crud.last_user() + projects = crud.list_projects() + assert set(user.projects) == set(projects) + + +# test pending invitations +def test_pending_invitations(setup_teardown): + user1, _ = crud.create_user1_with_2_projects(DB) + user2 = crud.create_user(DB, user=2) + assert crud.count_users() == 2 + assert crud.count_projects() == 2 + user1 = crud.get_user_by_id(user1.id) + project = user1.projects[0] + project.pending_invitations.append(user2) + DB.session.commit() + # fresh object + user2 = crud.get_user_by_id(user2.id) + assert project in user2.pending_invitations + + +# test collaborations +def test_collaboration(setup_teardown): + user1, _ = crud.create_user1_with_2_projects(DB) + user2 = crud.create_user(DB, user=2) + assert crud.count_users() == 2 + assert crud.count_projects() == 2 + user1 = crud.get_user_by_id(user1.id) + project = user1.projects[0] + project.collaborators.append(user2) + DB.session.commit() + # fresh object + user2 = crud.get_user_by_id(user2.id) + assert project in user2.involved_in diff --git a/asreview/webapp/tests/test_extensions/__init__.py b/asreview/webapp/tests/test_extensions/__init__.py new file mode 100644 index 000000000..17106ae40 --- /dev/null +++ b/asreview/webapp/tests/test_extensions/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/asreview/webapp/tests/test_extensions/test_auth_tool.py b/asreview/webapp/tests/test_extensions/test_auth_tool.py new file mode 100644 index 000000000..01c01b4ce --- /dev/null +++ b/asreview/webapp/tests/test_extensions/test_auth_tool.py @@ -0,0 +1,542 @@ +import json +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch +from uuid import uuid4 + +import pytest + +import asreview.entry_points.auth_tool as tool +from asreview.entry_points.auth_tool import AuthTool +from asreview.state.sql_converter import upgrade_asreview_project_file +from asreview.utils import asreview_path +from asreview.webapp import DB +from asreview.webapp.tests.utils import api_utils as au +from asreview.webapp.tests.utils import config_parser as cp +from asreview.webapp.tests.utils import crud +from asreview.webapp.tests.utils import misc + + +def get_auth_tool_object(namespace): + """This function returns an AuthTool object in which + the session feature is set to the test database and + the args feature is set with the input + parameter.""" + tool = AuthTool() + # manipulate the DB session + tool.session = DB.session + tool.args = namespace + return tool + + +def interactive_user_data(): + """This function returns a list of strings that can be + used to trigger an interactively created user account.""" + user_data = cp.get_user_data(1) + return [ + "Y", + user_data["email"], + user_data["name"], + user_data["affiliation"], + user_data["password"], + "n", + ] + + +def import_2_unauthenticated_projects(with_upgrade=True): + """This function retrieves 2 zipped project (version 0.x) + files from github and copies them in the asreview folder. + To use them in tests they need to be upgraded. Both projects + are returned.""" + # get 2 unauthenticated projects + url1 = misc.retrieve_project_url_github("v0.19") + url2 = misc.retrieve_project_url_github("v0.18") + # import projects + proj1 = misc.copy_github_project_into_asreview_folder(url1) + proj2 = misc.copy_github_project_into_asreview_folder(url2) + if with_upgrade: + # update these projects to a 1.x-ish config + upgrade_asreview_project_file(proj1.project_path) + upgrade_asreview_project_file(proj2.project_path) + return proj1, proj2 + + +# Test verifying uuid: correct +def test_verify_id_correct_id(): + id = uuid4().hex + assert tool.verify_id(id) + + +# Test verifying uuid: incorrect id +def test_verify_id_incorrect_id(): + id = "incorrect-id" + assert not tool.verify_id(id) + + +# Test inserting a user into the database +def test_insert_user(client_auth): + # count users + assert crud.count_users() == 0 + # get some user credentials + user_data = cp.get_user_data(1) + # insert the returned dictionary + tool.insert_user(DB.session, user_data) + # count users again + assert crud.count_users() == 1 + # get user + user = crud.last_user() + assert user.email == user_data["email"] + assert user.identifier == user_data["email"] + assert user.origin == "asreview" + assert user.name == user_data["name"] + assert user.affiliation == user_data["affiliation"] + assert user.confirmed + + +# Test inserting a duplicate +def test_insert_user_duplicate(client_auth): + # count users + assert crud.count_users() == 0 + # get some user credentials + user_data = cp.get_user_data(1) + # insert the returned dictionary + tool.insert_user(DB.session, user_data) + # verify user has been created + assert crud.count_users() == 1 + # and again + result = tool.insert_user(DB.session, user_data) + # asserts + assert not result + # no inserts, count remains 1 + assert crud.count_users() == 1 + + +# Test inserting a project record in the database +def test_inserting_a_project_record(client_auth): + # count projects + assert crud.count_projects() == 0 + # insert this data + data = {"project_id": uuid4().hex, "owner_id": 2} + tool.insert_project(DB.session, data) + # count again + assert crud.count_projects() == 1 + # get last record + project = crud.last_project() + assert project.project_id == data["project_id"] + assert project.owner_id == data["owner_id"] + + +# Test updating a project record in the database +def test_updating_a_project_record(client_auth): + # count projects + assert crud.count_projects() == 0 + # insert this data + data = {"project_id": uuid4().hex, "owner_id": 2} + tool.insert_project(DB.session, data) + # count again + assert crud.count_projects() == 1 + # change owner id + data["owner_id"] = 3 + tool.insert_project(DB.session, data) + # count again, no inserts, count remains 1 + assert crud.count_projects() == 1 + project = crud.last_project() + assert project.project_id == data["project_id"] + assert project.owner_id == data["owner_id"] + + +# Test get users +def test_get_users(client_auth): + # create 2 users + user1 = crud.create_user(DB, 1) + user2 = crud.create_user(DB, 2) + print(user1, user2) + assert crud.count_users() == 2 + # test function + result = tool.get_users(DB.session) + assert set(result) == set([user1, user2]) + + +# #################### +# Test AuthTool Object +# #################### + + +# insert users with json string +def test_auth_tool_add_users_with_json(client_auth): + # assert we have no users + assert crud.count_users() == 0 + # build appropriate json argument + user_data = cp.get_user_data(1) + namespace = Namespace(json=f"{json.dumps([user_data])}") + # get auth_tool object + auth_tool = get_auth_tool_object(namespace) + # execute add users function + auth_tool.add_users() + # assert we now have a user + assert crud.count_users() == 1 + user = crud.last_user() + # quick asserts, we have already tested this in + # test_insert_user + assert user.email == user_data["email"] + assert user.identifier == user_data["email"] + + +# insert users interactively, correct input +def test_auth_tool_add_users_interact(client_auth): + # assert we have no users + assert crud.count_users() == 0 + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # build interactive input + answers = interactive_user_data() + with patch('builtins.input', side_effect=answers): + auth_tool.add_users() + # assert we now have a users + assert crud.count_users() == 1 + + +# insert users interactively, incorrect email +def test_auth_tool_add_users_interact_incorr_email(client_auth, capsys): + """This test interactively inserts a user account, but + a non valid email address is provided""" + # assert we have no users + assert crud.count_users() == 0 + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # build interactive input + answers = interactive_user_data() + # add in a faulty email address + answers.insert(1, "abcd@") + with patch('builtins.input', side_effect=answers): + auth_tool.add_users() + _, err = capsys.readouterr() + assert "Entered email address is not recognized" in err + # assert we now have a users + assert crud.count_users() == 1 + + +# insert users interactively, name too short +def test_auth_tool_add_users_interact_incorr_name(client_auth, capsys): + """This test interactively inserts a user account, but + a short name is provided""" + # assert we have no users + assert crud.count_users() == 0 + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # build interactive input + answers = interactive_user_data() + # add in a name that is too short + answers.insert(2, "ab") + with patch('builtins.input', side_effect=answers): + auth_tool.add_users() + _, err = capsys.readouterr() + assert "Full name must contain more than 2" in err + # assert we now have a users + assert crud.count_users() == 1 + + +# insert users interactively, bad password +def test_auth_tool_add_users_interact_incorr_passw(client_auth, capsys): + """This test interactively inserts a user account, but + a non valid password is provided""" + # assert we have no users + assert crud.count_users() == 0 + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # build interactive input + answers = interactive_user_data() + # add in a name that is too short + answers.insert(4, "1111") + with patch('builtins.input', side_effect=answers): + auth_tool.add_users() + _, err = capsys.readouterr() + assert "Use 8 or more characters with a mix" in err + # assert we now have a users + assert crud.count_users() == 1 + + +# Test validity check. Note: this and the next test can not +# be parametrized because of the tested function: it -needs- +# to be finished with a correct value +def test_validity_function_valid(capsys): + """Tests the _ensure_valid_value_for method, expects + no error messages if the input value respects the + lambda function.""" + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # define a correct value + correct = "a" + hint = "Test hint" + # run function with patched input + with patch('builtins.input', side_effect=[correct]): + # run validity function + auth_tool._ensure_valid_value_for( + "test", lambda x: x == correct, hint=hint + ) + out, err = capsys.readouterr() + assert not bool(out) + assert not bool(err) + + +# Test validity check, see remark previous test if you notice +# the repetition +def test_validity_function_invalid(capsys): + """Tests the _ensure_valid_value_for method, expects + error messages if the input value does not respect the + lambda function.""" + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # define a correct value + correct = "a" + incorrect = "b" + hint = "Test hint" + # run function with patched input + with patch('builtins.input', side_effect=[incorrect, correct]): + # run validity function + auth_tool._ensure_valid_value_for( + "test", lambda x: x == correct, hint=hint + ) + out, err = capsys.readouterr() + assert not bool(out) + assert err == hint + + +# Test printing a project +def test_print_project(capsys): + keys = ["folder", "version", "project_id", "name", "authors", "created"] + data = {k: uuid4().hex for k in keys} + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # run function + auth_tool._print_project(data) + out, _ = capsys.readouterr() + assert f"* {data['folder']}" in out + assert f"version: {data['version']}" in out + assert f"id: {data['project_id']}" in out + assert f"name: {data['name']}" in out + assert f"authors: {data['authors']}" in out + assert f"created: {data['created']}" in out + + +# Test printing a user with affiliation +def test_print_user_with_affiliation(client_auth, capsys): + user = crud.create_user(DB, 1) + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # run function + auth_tool._print_user(user) + out, _ = capsys.readouterr() + expected = f"{user.id} - {user.email} ({user.name}), {user.affiliation}" + assert out.strip() == expected + + +# Test printing a user without affiliation +def test_print_user_without_affiliation(client_auth, capsys): + user = crud.create_user(DB, 1) + user.affiliation = None + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # run function + auth_tool._print_user(user) + out, _ = capsys.readouterr() + expected = f"{user.id} - {user.email} ({user.name})" + assert out.strip() == expected + + +# Testing _get_projects +def test_get_projects(client_no_auth): + # create a project + _, data = au.create_project(client_no_auth, "test") + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # run function + result = auth_tool._get_projects() + assert isinstance(result, list) + assert len(result) == 1 + result = result[0] + assert result["folder"] == data["id"] + assert result["version"] == data["version"] + assert result["project_id"] == data["id"] + assert result["name"] == data["name"] + assert result["authors"] == data["authors"] + assert result["created"] == data["datetimeCreated"] + assert result["owner_id"] == 0 + + +# Test listing users +def test_list_users(client_auth, capsys): + # create 2 users + u1 = crud.create_user(DB, 1) + u2 = crud.create_user(DB, 2) + assert crud.count_users() == 2 + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # run function + auth_tool.list_users() + out, _ = capsys.readouterr() + exp1 = f"{u1.id} - {u1.email} ({u1.name}), {u1.affiliation}" + exp2 = f"{u2.id} - {u2.email} ({u2.name}), {u2.affiliation}" + assert exp1 in out + assert exp2 in out + + +# Test list projects: no json data +def test_list_projects_no_json(client_no_auth, capsys): + # create two projects + _, data1 = au.create_project(client_no_auth, "test1") + _, data2 = au.create_project(client_no_auth, "test2") + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # run function + auth_tool.list_projects() + out, _ = capsys.readouterr() + # we have already tested _print_project, so I will keep + # it short + assert f"* {data1['id']}" in out + assert f"* {data2['id']}" in out + assert f"name: {data1['name']}" in out + assert f"name: {data2['name']}" in out + + +# Test list projects: output is a json string +def test_list_projects_with_json(client_no_auth, capsys): + # create two projects + _, data1 = au.create_project(client_no_auth, "test1") + _, data2 = au.create_project(client_no_auth, "test2") + data = { + data1.get("id"): data1, + data2.get("id"): data2 + } + # get auth_tool object + auth_tool = get_auth_tool_object(Namespace(json=True)) + # run function + auth_tool.list_projects() + out, _ = capsys.readouterr() + # this loads the out json string into a list of dicts + out = json.loads(json.loads(out)) + assert isinstance(out, list) + assert len(out) == 2 + for proj in out: + expected = data[proj["project_id"]] + assert proj["folder"] == expected["id"] + assert proj["version"] == expected["version"] + assert proj["project_id"] == expected["id"] + assert proj["name"] == expected["name"] + assert proj["authors"] == expected["authors"] + assert proj["created"] == expected["datetimeCreated"] + assert proj["owner_id"] == 0 + + +# Test linking projects to users with a json string +# Note: We can not simulate a conversion from an unauthenticated +# app into an authenticated one. To overcome this problem, 2 old +# project zip files (version 0.x) are copied from Github into the +# asreview folder and upgraded. This is done without the help of +# the API, ensuring they can't be linked to a User account. +def test_link_project_with_json_string(client_auth, capsys): + # import projects + proj1, proj2 = import_2_unauthenticated_projects() + # create 2 users + user1 = crud.create_user(DB, 1) + user2 = crud.create_user(DB, 2) + # check database + assert crud.count_users() == 2 + assert crud.count_projects() == 0 + # check if we have 2 folders in asreview path + assert len(misc.get_folders_in_asreview_path()) == 2 + # get from the auth tool a json string + auth_tool = get_auth_tool_object(Namespace(json=True)) + auth_tool.list_projects() + out, _ = capsys.readouterr() + # we replace the owner ids with the ids of the users + json_string = out.replace(": 0", f": {user1.id}", 1) + json_string = json_string.replace(": 0", f": {user2.id}", 1) + + # use this string to run the function with a new AuthTool + auth_tool = get_auth_tool_object(Namespace(json=json.loads(json_string))) + auth_tool.link_projects() + # check database and check if the users own the correct project + assert crud.count_projects() == 2 + project_dict = { + proj["owner_id"]: proj + for proj in json.loads(json.loads(json_string)) + } + for user in [user1, user2]: + expected_proj = project_dict[user.id] + assert len(user.projects) == 1 + assert user.projects[0].project_id == expected_proj["folder"] + assert user.projects[0].project_id == expected_proj["project_id"] + # check also on the file-system + assert Path(asreview_path() / expected_proj["folder"]).exists() + + +# Test linking projects interactively +def test_link_projects_interactively(client_auth): + # import projects + proj1, proj2 = import_2_unauthenticated_projects() + project_data = {p.config.get("id"): p for p in [proj1, proj2]} + # create a user + user = crud.create_user(DB, 1) + # check the database + assert crud.count_users() == 1 + assert crud.count_projects() == 0 + # create AuthTool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # run function with patched input + with patch('builtins.input', side_effect=[user.id, user.id]): + # link project to user + auth_tool.link_projects() + # check database again + assert crud.count_projects() == 2 + # make sure the user has 2 different projects + assert len([p.project_id for p in user.projects]) == 2 + # check user projects + for project in user.projects: + org_data = project_data[project.project_id] + assert org_data.config.get("id") == project.project_id + assert Path(asreview_path() / project.project_id).exists() + + +# Test linking projects with a typo +def test_link_projects_interactively_with_typo(client_auth): + # import projects + proj1, proj2 = import_2_unauthenticated_projects() + # create a user + user = crud.create_user(DB, 1) + # check the database + assert crud.count_users() == 1 + assert crud.count_projects() == 0 + # create AuthTool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # run function with patched input (there is a wrong id in there) + with patch('builtins.input', side_effect=[user.id, str(-5), user.id]): + # link project to user + auth_tool.link_projects() + # check database again + assert crud.count_projects() == 2 + + +# Test failure of anything related to projects if a project is older +# than version 0.x. +@pytest.mark.parametrize( + "method", + [ + "_generate_project_links", + "list_projects", + "link_projects" + ] +) +def test_projects_with_0x_projects(client_auth, method): + # import projects + proj1, proj2 = import_2_unauthenticated_projects(with_upgrade=False) + # make sure these projects exist + assert len(misc.get_folders_in_asreview_path()) == 2 + # create AuthTool object + auth_tool = get_auth_tool_object(Namespace(json=None)) + # try to link project to user + with pytest.raises(RuntimeError) as error: + func = getattr(auth_tool, method) + func() + assert "Version of project with id" in str(error.value) + assert "too old" in str(error.value) diff --git a/asreview/webapp/tests/test_project.py b/asreview/webapp/tests/test_project.py deleted file mode 100644 index d62ab15d0..000000000 --- a/asreview/webapp/tests/test_project.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -from io import BytesIO -from urllib.request import urlopen - -import pytest - -from asreview.webapp.tests.utils import retrieve_project_url_github - -# Retrieve urls to .asreview files exported from previous versions -project_urls = retrieve_project_url_github() - - -@pytest.mark.parametrize("url", project_urls) -def test_project_file(tmp_path, client, url): - """Test import and continue a project created in previous versions.""" - - # change default folder for projects - os.environ["ASREVIEW_PATH"] = str(tmp_path) - - # Test import uploaded project - with urlopen(url) as project_file: - response_import = client.post( - "/api/projects/import_project", - data={"file": (BytesIO(project_file.read()), "project.asreview")}, - ) - json_data_import = response_import.get_json() - assert response_import.status_code == 200 - - project_id = json_data_import["id"] - api_url = f"/api/projects/{project_id}" - - # Test get dashboard analytics - response_stats = client.get("/api/projects/stats") - json_data_stats = response_stats.get_json() - - assert "result" in json_data_stats - assert "n_in_review" in json_data_stats["result"] - assert "n_finished" in json_data_stats["result"] - assert isinstance(json_data_stats["result"], dict) - - # Test get projects - response_projects = client.get("/api/projects") - json_data_projects = response_projects.get_json() - assert "result" in json_data_projects - assert any(item["id"] == project_id for item in json_data_projects["result"]) - - # Test export project before upgrade - response_export_old_project = client.get(f"{api_url}/export_project") - assert response_export_old_project.status_code == 200 - - # Test upgrade project if old - response_upgrade_if_old = client.get(f"{api_url}/upgrade_if_old") - assert response_upgrade_if_old.status_code == 200 - - # Test get info on the project - response_get_info = client.get(f"{api_url}/info") - json_data_get_info = response_get_info.get_json() - assert json_data_get_info["id"] == project_id - - # Test get dataset writer - response_get_writer = client.get(f"{api_url}/dataset_writer") - json_data_get_writer = response_get_writer.get_json() - assert isinstance(json_data_get_writer["result"], list) - - # Test update info of the project - response_update_info = client.put( - f"{api_url}/info", - data={ - "mode": "explore", - "name": json_data_get_info["name"], - "authors": json_data_get_info["authors"], - "description": "Hoi Elas", - }, - ) - assert response_update_info.status_code == 200 - - # Test get progress info on the article - response_progress = client.get(f"{api_url}/progress") - json_data_progress = response_progress.get_json() - assert isinstance(json_data_progress, dict) - - # Test get progress density of the project - response_progress_density = client.get(f"{api_url}/progress_density") - json_data_progress_density = response_progress_density.get_json() - assert "relevant" in json_data_progress_density - assert "irrelevant" in json_data_progress_density - assert isinstance(json_data_progress_density, dict) - - # Test get cumulative number of inclusions by ASReview/at random - response_progress_recall = client.get(f"{api_url}/progress_recall") - json_data_progress_recall = response_progress_recall.get_json() - assert "asreview" in json_data_progress_recall - assert "random" in json_data_progress_recall - assert isinstance(json_data_progress_recall, dict) - - # Test retrieve documents in order of review - response_get_document = client.get(f"{api_url}/get_document") - json_data_get_document = response_get_document.get_json() - assert "result" in json_data_get_document - assert isinstance(json_data_get_document, dict) - - # get doc_id from the queue and label the item - doc_id = json_data_get_document["result"]["doc_id"] - - # Test retrieve classification result - response_classify_instance = client.post( - f"{api_url}/record/{doc_id}", - data={ - "doc_id": doc_id, - "label": 1, - }, - ) - assert response_classify_instance.status_code == 200 - - # Test update classification result - response_update_classify = client.put( - f"{api_url}/record/{doc_id}", - data={ - "doc_id": doc_id, - "label": 0, - }, - ) - assert response_update_classify.status_code == 200 - - # Test retrieve review history - response_prior = client.get(f"{api_url}/labeled") - json_data_prior = response_prior.get_json() - assert "result" in json_data_prior - assert isinstance(json_data_prior["result"], list) - - # Test export result - response_export_result_csv = client.get(f"{api_url}/export_dataset?file_format=csv") - response_export_result_tsv = client.get(f"{api_url}/export_dataset?file_format=tsv") - response_export_result_excel = client.get( - f"{api_url}/export_dataset?file_format=xlsx" - ) - assert response_export_result_csv.status_code == 200 - assert response_export_result_tsv.status_code == 200 - assert response_export_result_excel.status_code == 200 - - # Test export project - response_export_project = client.get(f"{api_url}/export_project") - assert response_export_project.status_code == 200 - - # Test get project status - response_status = client.get(f"{api_url}/status") - json_data_status = response_status.get_json() - assert "status" in json_data_status - assert isinstance(json_data_status, dict) - - # Test mark project as finished - response_finish = client.put(f"{api_url}/status", data={"status": "finished"}) - assert response_finish.status_code == 200 - - # Test delete project - response_delete = client.delete(f"{api_url}/delete") - assert response_delete.status_code == 200 diff --git a/asreview/webapp/tests/test_project_api_authenticated.py b/asreview/webapp/tests/test_project_api_authenticated.py deleted file mode 100644 index d89a27f2c..000000000 --- a/asreview/webapp/tests/test_project_api_authenticated.py +++ /dev/null @@ -1,747 +0,0 @@ -# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import time -from pathlib import Path - -from asreview.project import PATH_FEATURE_MATRICES -from asreview.utils import asreview_path -from asreview.webapp.authentication.models import Project -from asreview.webapp.authentication.models import User -from asreview.webapp.tests.conftest import PROJECTS -from asreview.webapp.tests.conftest import signin_user -from asreview.webapp.tests.conftest import signout -from asreview.webapp.tests.conftest import signup_user - -PASSWORD = "1234ABC!" -USER_2 = "user2@authtest.nl" - - -def test_get_projects(setup_teardown_signed_in): - """Test get projects.""" - _, client, _ = setup_teardown_signed_in - - response = client.get("/api/projects") - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data["result"], list) - - -def test_init_project(setup_teardown_signed_in): - """Test create project.""" - _, client, user = setup_teardown_signed_in - - # verify we have 0 projects in the database and 1 user - assert len(User.query.all()) == 1 - assert len(Project.query.all()) == 0 - - response = client.post( - "/api/projects/info", - data=PROJECTS[0], - ) - json_data = response.get_json() - - # make sure a folder is created - new_project_id = json_data["id"] - assert Path(asreview_path(), new_project_id).exists() - assert Path(asreview_path(), new_project_id, "data").exists() - assert Path(asreview_path(), new_project_id, "reviews").exists() - assert Path(asreview_path(), new_project_id, PATH_FEATURE_MATRICES).exists() - - # make sure the project can be found in the database as well - assert len(Project.query.all()) == 1 - # get project - project = Project.query.filter(Project.project_id == new_project_id).one() - assert project.project_id == new_project_id - assert project.folder == new_project_id - assert project.project_path == Path(asreview_path(), new_project_id) - assert project.owner_id == user.id - - assert response.status_code == 201 - assert "name" in json_data - assert isinstance(json_data, dict) - - -def test_upgrade_project_if_old(setup_teardown_signed_in): - """Test upgrade project if it is v0.x""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/upgrade_if_old") - assert response.status_code == 400 - - -def test_get_projects_stats(setup_teardown_signed_in): - """Test get dashboard statistics of all projects""" - - _, client, _ = setup_teardown_signed_in - - response = client.get("/api/projects/stats") - json_data = response.get_json() - - assert "n_in_review" in json_data["result"] - assert "n_finished" in json_data["result"] - assert isinstance(json_data["result"], dict) - - -def test_demo_data_project(setup_teardown_signed_in): - """Test retrieve plugin and benchmark datasets""" - _, client, _ = setup_teardown_signed_in - - response_plugin = client.get("/api/datasets?subset=plugin") - response_benchmark = client.get("/api/datasets?subset=benchmark") - json_plugin_data = response_plugin.get_json() - json_benchmark_data = response_benchmark.get_json() - - assert "result" in json_plugin_data - assert "result" in json_benchmark_data - assert isinstance(json_plugin_data["result"], list) - assert isinstance(json_benchmark_data["result"], list) - - -def test_upload_data_to_project(setup_teardown_signed_in): - """Test upload data to project.""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.post( - f"/api/projects/{project.project_id}/data", - data={"benchmark": "benchmark:Hall_2012"}, - ) - assert response.status_code == 200 - - -def test_get_project_data(setup_teardown_signed_in): - """Test get info on the data""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/data") - json_data = response.get_json() - assert json_data["filename"] == "Hall_2012" - - -def test_get_dataset_writer(setup_teardown_signed_in): - """Test get dataset writer""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/dataset_writer") - json_data = response.get_json() - assert isinstance(json_data["result"], list) - - -def test_update_project_info(setup_teardown_signed_in): - """Test update project info""" - _, client, user = setup_teardown_signed_in - - # assert if we still have one project in the database - assert len(Project.query.all()) == 1 - - project = Project.query.one() - response = client.put( - f"/api/projects/{project.project_id}/info", - data=PROJECTS[1], - ) - assert response.status_code == 200 - - -def test_get_project_info(setup_teardown_signed_in): - """Test get info on the project""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/info") - json_data = response.get_json() - assert json_data["name"] == "another demo project" - assert json_data["dataset_path"] == "Hall_2012.csv" - - -def test_search_data(setup_teardown_signed_in): - """Test search for papers""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get( - f"/api/projects/{project.project_id}/search?q=Software&n_max=10" - ) - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data["result"], list) - - -def test_random_prior_papers(setup_teardown_signed_in): - """Test get a selection of random papers to find exclusions""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/prior_random") - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data["result"], list) - - -def test_label_item(setup_teardown_signed_in): - """Test label item""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response_irrelevant = client.post( - f"/api/projects/{project.project_id}/record/5509", - data={"doc_id": 5509, "label": 0, "is_prior": 1}, - ) - response_relevant = client.post( - f"/api/projects/{project.project_id}/record/58", - data={"doc_id": 58, "label": 1, "is_prior": 1}, - ) - - assert response_irrelevant.status_code == 200 - assert response_relevant.status_code == 200 - - -def test_get_labeled(setup_teardown_signed_in): - """Test get all papers classified as labeled documents""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/labeled") - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data["result"], list) - - -def test_get_labeled_stats(setup_teardown_signed_in): - """Test get all papers classified as prior documents""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/labeled_stats") - json_data = response.get_json() - - assert isinstance(json_data, dict) - assert "n_prior" in json_data - assert json_data["n_prior"] == 2 - - -def test_list_algorithms(setup_teardown_signed_in): - """Test get list of active learning models""" - _, client, _ = setup_teardown_signed_in - - response = client.get("/api/algorithms") - json_data = response.get_json() - - assert "classifier" in json_data.keys() - assert "name" in json_data["classifier"][0].keys() - assert isinstance(json_data, dict) - - -def test_set_algorithms(setup_teardown_signed_in): - """Test set active learning model""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.post( - f"/api/projects/{project.project_id}/algorithms", - data={ - "model": "svm", - "query_strategy": "max_random", - "balance_strategy": "double", - "feature_extraction": "tfidf", - }, - ) - assert response.status_code == 200 - - -def test_get_algorithms(setup_teardown_signed_in): - """Test active learning model selection""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/algorithms") - json_data = response.get_json() - - assert "model" in json_data - assert "query_strategy" in json_data - assert "svm" in json_data["model"] - assert "random" in json_data["query_strategy"] - assert isinstance(json_data, dict) - - -def test_start_and_model_ready(setup_teardown_signed_in): - """Test start training the model""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.post(f"/api/projects/{project.project_id}/start") - assert response.status_code == 200 - - # wait the model ready - time.sleep(10) - - response = client.get(f"/api/projects/{project.project_id}/status") - json_data = response.get_json() - assert json_data["status"] == "review" - - -def test_export_result(setup_teardown_signed_in): - """Test export result""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response_csv = client.get( - f"/api/projects/{project.project_id}/export_dataset?file_format=csv" - ) - response_tsv = client.get( - f"/api/projects/{project.project_id}/export_dataset?file_format=tsv" - ) - response_excel = client.get( - f"/api/projects/{project.project_id}/export_dataset?file_format=xlsx" - ) - assert response_csv.status_code == 200 - assert response_tsv.status_code == 200 - assert response_excel.status_code == 200 - - -def test_export_project(setup_teardown_signed_in): - """Test export the project file""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/export_project") - assert response.status_code == 200 - - -def test_finish_project(setup_teardown_signed_in): - """Test mark a project as finished or not""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.put( - f"/api/projects/{project.project_id}/status", data={"status": "finished"} - ) - assert response.status_code == 200 - - response = client.put( - f"/api/projects/{project.project_id}/status", data={"status": "review"} - ) - assert response.status_code == 200 - - response = client.put( - f"/api/projects/{project.project_id}/status", data={"status": "finished"} - ) - assert response.status_code == 200 - - -def test_get_progress_info(setup_teardown_signed_in): - """Test get progress info on the article""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/progress") - json_data = response.get_json() - assert isinstance(json_data, dict) - - -def test_get_progress_density(setup_teardown_signed_in): - """Test get progress density on the article""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/progress_density") - json_data = response.get_json() - assert "relevant" in json_data - assert "irrelevant" in json_data - assert isinstance(json_data, dict) - - -def test_get_progress_recall(setup_teardown_signed_in): - """Test get cumulative number of inclusions by ASReview/at random""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/progress_recall") - json_data = response.get_json() - assert "asreview" in json_data - assert "random" in json_data - assert isinstance(json_data, dict) - - -def test_get_document(setup_teardown_signed_in): - """Test retrieve documents in order of review""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/get_document") - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data, dict) - - doc_id = json_data["result"]["doc_id"] - - # Test retrieve classification result - response = client.post( - f"/api/projects/{project.project_id}/record/{doc_id}", - data={ - "doc_id": doc_id, - "label": 1, - }, - ) - assert response.status_code == 200 - - # Test update classification result - response = client.put( - f"/api/projects/{project.project_id}/record/{doc_id}", - data={ - "doc_id": doc_id, - "label": 0, - }, - ) - assert response.status_code == 200 - - time.sleep(10) - - -def test_delete_project(setup_teardown_signed_in): - """Test get info on the article""" - _, client, user = setup_teardown_signed_in - - project = Project.query.one() - response = client.delete(f"/api/projects/{project.project_id}/delete") - assert response.status_code == 200 - - # assert folder is gone - assert Path(asreview_path(), project.project_id).exists() is False - - -# ------------------------ -# Test improper use of api -# ------------------------ - - -def test_adding_a_second_user_and_projects(setup_teardown_signed_in): - """Adding a second user and a project of that user""" - _, client, user = setup_teardown_signed_in - # get number of projects in database - old_projects = Project.query.all() - # signout current user - signout(client) - # create new user - signup_user(client, USER_2, PASSWORD) - # assert if we have 2 users now - assert len(User.query.all()) == 2 - # signin user 2 - signin_user(client, USER_2, PASSWORD) - # create project - client.post( - "/api/projects/info", - data=PROJECTS[0], - ) - # assert we have this project - assert len(Project.query.all()) == len(old_projects) + 1 - user = User.query.filter(User.identifier == USER_2).first() - assert len(user.projects) == 1 - - -def test_accessing_project_that_is_no_permission(setup_teardown_signed_in): - """Test if user 1 can reach a project of user 2""" - _, client, user = setup_teardown_signed_in - - # ------------------------------------------ - # explicitly sign in with user 1 credentials - # ------------------------------------------ - signin_user(client, "c.s.kaandorp@uu.nl", "123456!AbC") - - # get user 2 - user = User.query.filter(User.identifier == USER_2).first() - assert len(user.projects) == 1 - # this is the project from user 2 - project = user.projects[0] - # user 1 tries to reach project 2 - response = client.get(f"/api/projects/{project.project_id}/info") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_old_upgrade_no_permission(setup_teardown_signed_in): - """Test upgrade project if it is v0.x""" - _, client, _ = setup_teardown_signed_in - - project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/upgrade_if_old") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_update_no_permission(setup_teardown_signed_in): - """Test update project info -without- changing the project name""" - _, client, user = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.put( - f"/api/projects/{project.project_id}/info", - data=PROJECTS[1], - ) - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_upload_data_no_permission(setup_teardown_signed_in): - """Test upload data to project.""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.post( - f"/api/projects/{project.project_id}/data", - data={"benchmark": "benchmark:Hall_2012"}, - ) - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_get_project_data_no_permission(setup_teardown_signed_in): - """Test get info on the data""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/data") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_get_dataset_writer_no_permission(setup_teardown_signed_in): - """Test get dataset writer""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/dataset_writer") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_search_data_no_permission(setup_teardown_signed_in): - """Test search for papers""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get( - f"/api/projects/{project.project_id}/search?q=Software&n_max=10" - ) - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_get_labeled_no_permission(setup_teardown_signed_in): - """Test get all papers classified as labeled documents""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/labeled") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_get_labeled_stats_no_permission(setup_teardown_signed_in): - """Test get all papers classified as prior documents""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/labeled_stats") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_random_prior_papers_no_permission(setup_teardown_signed_in): - """Test get a selection of random papers to find exclusions""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/prior_random") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_list_algorithms_no_permission(setup_teardown_signed_in): - """Test get list of active learning models""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/algorithms") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_set_algorithms_no_permission(setup_teardown_signed_in): - """Test set active learning model""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.post( - f"/api/projects/{project.project_id}/algorithms", - data={ - "model": "svm", - "query_strategy": "max_random", - "balance_strategy": "double", - "feature_extraction": "tfidf", - }, - ) - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_start_model_ready_no_permission(setup_teardown_signed_in): - """Test start training the model""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.post(f"/api/projects/{project.project_id}/start") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_get_model_status_no_permission(setup_teardown_signed_in): - """Test start training the model""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/status") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_finish_project_no_permission(setup_teardown_signed_in): - """Test mark a project as finished or not""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.put( - f"/api/projects/{project.project_id}/status", data={"status": "finished"} - ) - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_export_result_no_permission(setup_teardown_signed_in): - """Test export result""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get( - f"/api/projects/{project.project_id}/export_dataset?file_format=csv" - ) - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_export_project_no_permission(setup_teardown_signed_in): - """Test export the project file""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/export_project") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_get_progress_info_no_permission(setup_teardown_signed_in): - """Test get progress info on the article""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/progress") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_get_progress_density_no_permission(setup_teardown_signed_in): - """Test get progress density on the article""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/progress_density") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_get_progress_recall_no_permission(setup_teardown_signed_in): - """Test get cumulative number of inclusions by ASReview/at random""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/progress_recall") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_classify_instance_no_permission(setup_teardown_signed_in): - """Test retrieve documents in order of review""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - # Test retrieve classification result - response = client.post( - f"/api/projects/{project.project_id}/record/1234", - data={ - "doc_id": 4567, - "label": 1, - }, - ) - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_get_document_no_permission(setup_teardown_signed_in): - """Test retrieve documents in order of review""" - _, client, _ = setup_teardown_signed_in - - project = project = Project.query.one() - response = client.get(f"/api/projects/{project.project_id}/get_document") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" - - -def test_delete_project_no_permission(setup_teardown_signed_in): - """Test get info on the article""" - _, client, user = setup_teardown_signed_in - project = project = Project.query.one() - response = client.delete(f"/api/projects/{project.project_id}/delete") - json_data = response.get_json() - assert response.status_code == 403 - assert json_data["message"] == "no permission" diff --git a/asreview/webapp/tests/test_project_api_unauthenticated.py b/asreview/webapp/tests/test_project_api_unauthenticated.py deleted file mode 100644 index eb9286fb3..000000000 --- a/asreview/webapp/tests/test_project_api_unauthenticated.py +++ /dev/null @@ -1,403 +0,0 @@ -# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import time -from pathlib import Path - -from asreview.project import PATH_FEATURE_MATRICES -from asreview.utils import asreview_path -from asreview.webapp.tests.conftest import PROJECTS - - -def test_get_projects(setup_teardown_unauthorized): - """Test get projects.""" - _, client = setup_teardown_unauthorized - - response = client.get("/api/projects") - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data["result"], list) - - -def test_init_project(setup_teardown_unauthorized): - """Test create project. Check name of created subfolder""" - _, client = setup_teardown_unauthorized - - response = client.post( - "/api/projects/info", - data=PROJECTS[0], - ) - json_data = response.get_json() - - # make sure a folder is created - project_id = json_data["id"] - assert Path(asreview_path(), project_id).exists() - assert Path(asreview_path(), project_id, "data").exists() - assert Path(asreview_path(), project_id, "reviews").exists() - assert Path(asreview_path(), project_id, PATH_FEATURE_MATRICES).exists() - - # test the response - assert response.status_code == 201 - assert "name" in json_data - assert isinstance(json_data, dict) - - # store project id for later use - PROJECTS[0]["id"] = project_id - - -def test_upgrade_project_if_old(setup_teardown_unauthorized): - """Test upgrade project if it is v0.x""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/upgrade_if_old") - assert response.status_code == 400 - - -def test_get_projects_stats(setup_teardown_unauthorized): - """Test get dashboard statistics of all projects""" - _, client = setup_teardown_unauthorized - - response = client.get("/api/projects/stats") - json_data = response.get_json() - - assert "n_in_review" in json_data["result"] - assert "n_finished" in json_data["result"] - assert isinstance(json_data["result"], dict) - - -def test_demo_data_project(setup_teardown_unauthorized): - """Test retrieve plugin and benchmark datasets""" - _, client = setup_teardown_unauthorized - - response_plugin = client.get("/api/datasets?subset=plugin") - response_benchmark = client.get("/api/datasets?subset=benchmark") - json_plugin_data = response_plugin.get_json() - json_benchmark_data = response_benchmark.get_json() - - assert "result" in json_plugin_data - assert "result" in json_benchmark_data - assert isinstance(json_plugin_data["result"], list) - assert isinstance(json_benchmark_data["result"], list) - - -def test_upload_data_to_project(setup_teardown_unauthorized): - """Test add data to project.""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.post( - f"/api/projects/{project_id}/data", - data={"benchmark": "benchmark:Hall_2012"}, - ) - assert response.status_code == 200 - - -def test_get_project_data(setup_teardown_unauthorized): - """Test get info on the data""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/data") - json_data = response.get_json() - assert json_data["filename"] == "Hall_2012" - - -def test_get_dataset_writer(setup_teardown_unauthorized): - """Test get dataset writer""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/dataset_writer") - json_data = response.get_json() - assert isinstance(json_data["result"], list) - - -def test_update_project_info(setup_teardown_unauthorized): - """Test update project info without changing the project name""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.put( - f"/api/projects/{project_id}/info", - data=PROJECTS[1], - ) - assert response.status_code == 200 - - -def test_get_project_info(setup_teardown_unauthorized): - """Test get info on the project""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/info") - json_data = response.get_json() - assert json_data["authors"] == "asreview team" - assert json_data["dataset_path"] == "Hall_2012.csv" - - -def test_search_data(setup_teardown_unauthorized): - """Test search for papers""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/search?q=Software&n_max=10") - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data["result"], list) - - -def test_random_prior_papers(setup_teardown_unauthorized): - """Test get a selection of random papers to find exclusions""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/prior_random") - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data["result"], list) - - -def test_label_item(setup_teardown_unauthorized): - """Test label item""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response_irrelevant = client.post( - f"/api/projects/{project_id}/record/5509", - data={"doc_id": 5509, "label": 0, "is_prior": 1}, - ) - response_relevant = client.post( - f"/api/projects/{project_id}/record/58", - data={"doc_id": 58, "label": 1, "is_prior": 1}, - ) - - assert response_irrelevant.status_code == 200 - assert response_relevant.status_code == 200 - - -def test_get_labeled(setup_teardown_unauthorized): - """Test get all papers classified as labeled documents""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/labeled") - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data["result"], list) - - -def test_get_labeled_stats(setup_teardown_unauthorized): - """Test get all papers classified as prior documents""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/labeled_stats") - json_data = response.get_json() - - assert isinstance(json_data, dict) - assert "n_prior" in json_data - assert json_data["n_prior"] == 2 - - -def test_list_algorithms(setup_teardown_unauthorized): - """Test get list of active learning models""" - _, client = setup_teardown_unauthorized - - response = client.get("/api/algorithms") - json_data = response.get_json() - - assert "classifier" in json_data.keys() - assert "name" in json_data["classifier"][0].keys() - assert isinstance(json_data, dict) - - -def test_set_algorithms(setup_teardown_unauthorized): - """Test set active learning model""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.post( - f"/api/projects/{project_id}/algorithms", - data={ - "model": "svm", - "query_strategy": "max_random", - "balance_strategy": "double", - "feature_extraction": "tfidf", - }, - ) - assert response.status_code == 200 - - -def test_get_algorithms(setup_teardown_unauthorized): - """Test active learning model selection""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/algorithms") - json_data = response.get_json() - - assert "model" in json_data - assert "query_strategy" in json_data - assert "svm" in json_data["model"] - assert "random" in json_data["query_strategy"] - assert isinstance(json_data, dict) - - -def test_start_and_model_ready(setup_teardown_unauthorized): - """Test start training the model""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.post(f"/api/projects/{project_id}/start") - assert response.status_code == 200 - - # wait until the model is ready - time.sleep(10) - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/status") - json_data = response.get_json() - assert json_data["status"] == "review" - - -def test_export_result(setup_teardown_unauthorized): - """Test export result""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response_csv = client.get( - f"/api/projects/{project_id}/export_dataset?file_format=csv" - ) - response_tsv = client.get( - f"/api/projects/{project_id}/export_dataset?file_format=tsv" - ) - response_excel = client.get( - f"/api/projects/{project_id}/export_dataset?file_format=xlsx" - ) - assert response_csv.status_code == 200 - assert response_tsv.status_code == 200 - assert response_excel.status_code == 200 - - -def test_export_project(setup_teardown_unauthorized): - """Test export the project file""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/export_project") - assert response.status_code == 200 - - -def test_finish_project(setup_teardown_unauthorized): - """Test mark a project as finished or not""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.put( - f"/api/projects/{project_id}/status", data={"status": "finished"} - ) - assert response.status_code == 200 - - response = client.put( - f"/api/projects/{project_id}/status", data={"status": "review"} - ) - assert response.status_code == 200 - - response = client.put( - f"/api/projects/{project_id}/status", data={"status": "finished"} - ) - assert response.status_code == 200 - - -def test_get_progress_info(setup_teardown_unauthorized): - """Test get progress info on the article""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/progress") - json_data = response.get_json() - assert isinstance(json_data, dict) - - -def test_get_progress_density(setup_teardown_unauthorized): - """Test get progress density on the article""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/progress_density") - json_data = response.get_json() - assert "relevant" in json_data - assert "irrelevant" in json_data - assert isinstance(json_data, dict) - - -def test_get_progress_recall(setup_teardown_unauthorized): - """Test get cumulative number of inclusions by ASReview/at random""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/progress_recall") - json_data = response.get_json() - assert "asreview" in json_data - assert "random" in json_data - assert isinstance(json_data, dict) - - -def test_get_document(setup_teardown_unauthorized): - """Test retrieve documents in order of review""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.get(f"/api/projects/{project_id}/get_document") - json_data = response.get_json() - - assert "result" in json_data - assert isinstance(json_data, dict) - - doc_id = json_data["result"]["doc_id"] - - # Test retrieve classification result - response = client.post( - f"/api/projects/{project_id}/record/{doc_id}", - data={ - "doc_id": doc_id, - "label": 1, - }, - ) - assert response.status_code == 200 - - # Test update classification result - response = client.put( - f"/api/projects/{project_id}/record/{doc_id}", - data={ - "doc_id": doc_id, - "label": 0, - }, - ) - assert response.status_code == 200 - time.sleep(10) - - -def test_delete_project(setup_teardown_unauthorized): - """Test get info on the article""" - _, client = setup_teardown_unauthorized - - project_id = PROJECTS[0]["id"] - response = client.delete(f"/api/projects/{project_id}/delete") - assert response.status_code == 200 diff --git a/asreview/webapp/tests/test_teams_api.py b/asreview/webapp/tests/test_teams_api.py deleted file mode 100644 index db663931e..000000000 --- a/asreview/webapp/tests/test_teams_api.py +++ /dev/null @@ -1,445 +0,0 @@ -# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import os -import shutil - -import pytest - -from asreview.utils import asreview_path -from asreview.webapp import DB -from asreview.webapp.authentication.models import Collaboration -from asreview.webapp.authentication.models import CollaborationInvitation -from asreview.webapp.authentication.models import Project -from asreview.webapp.authentication.models import User -from asreview.webapp.tests.conftest import signin_user -from asreview.webapp.tests.conftest import signout -from asreview.webapp.tests.conftest import signup_user - -password_main_user = "A12bcdefg!!" -password_coll1 = "B12345*6" -password_coll2 = "C1@2a3456" - - -@pytest.fixture -def populate(setup_teardown_signed_in): - # we are not going to use the user created in setup_.... - # since it will be created only once and removed after - # the first test - app, client, _ = setup_teardown_signed_in - with app.app_context(): - # create other users - signup_user(client, "main@test.org", password_main_user) - signup_user(client, "coll1@test.org", password_coll1) - signup_user(client, "coll2@test.org", password_coll2) - - # signin main user - signin_user(client, "main@test.org", password_main_user) - - # create project for users - client.post( - "/api/projects/info", - data={ - "mode": "oracle", - "name": "Test Project", - "description": "blabla", - "authors": "bunch", - }, - ) - # get project - assert len(Project.query.all()) == 1 - project = Project.query.first() - # return yield - owner = User.query.filter(User.identifier == "main@test.org").first() - coll1 = User.query.filter(User.identifier == "coll1@test.org").first() - coll2 = User.query.filter(User.identifier == "coll2@test.org").first() - yield (client, owner, coll1, coll2, project) - - try: - # cleanup the database - for model in [Collaboration, CollaborationInvitation, Project, User]: - DB.session.query(model).delete() - DB.session.commit() - # clean up project folders - project_folders = [ - f for f in os.listdir(asreview_path()) if not (f.endswith(".sqlite")) - ] - # remove subfolders - for f in project_folders: - shutil.rmtree(f"{asreview_path()}/{f}") - - except Exception: - # don't care - pass - - -def test_if_fixtures_work(populate): - """Test if my database is populated.""" - _, owner, _, _, _ = populate - - # check if we have a project from signed in user - assert len(Project.query.all()) == 1 - project = Project.query.first() - assert project.owner == owner - - -def test_get_team(populate): - """Test if owner can get a user overview of his/her team""" - client, _, coll1, coll2, project = populate - - # assert I have 0 invitations - invites = CollaborationInvitation.query.all() - assert len(invites) == 0 - - # assert system has 0 collaborations - collabs = Collaboration.query.all() - assert len(collabs) == 0 - - # invite 2 - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - url = f"/api/invitations/projects/{project.project_id}/users/{coll2.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # signout owner, signin coll1 - signout(client) - signin_user(client, "coll1@test.org", password_coll1) - # coll1 accepts invitation - url = f"/api/invitations/projects/{project.project_id}/accept" - resp = client.post(url) - assert resp.status == "200 OK" - - # back to owner - signout(client) - resp = signin_user(client, "main@test.org", password_main_user) - - # owner wants overview - url = f"/api/projects/{project.project_id}/users" - resp = client.get(url) - assert resp.status == "200 OK" - - data = json.loads(resp.text) - assert sorted(list(data.keys())) == ["all_users", "collaborators", "invitations"] - assert data["collaborators"] == [coll1.id] - assert data["invitations"] == [coll2.id] - assert sorted([d["id"] for d in data["all_users"]]) == [2, 3] - - -def test_owner_removes_collaborator(populate): - """Test owner removes a collaborator""" - client, _, coll1, _, project = populate - - # invite - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # signout owner, signin coll1 - signout(client) - signin_user(client, "coll1@test.org", password_coll1) - # coll1 accepts invitation - url = f"/api/invitations/projects/{project.project_id}/accept" - resp = client.post(url) - assert resp.status == "200 OK" - - # back to owner - signout(client) - resp = signin_user(client, "main@test.org", password_main_user) - - # assert we have a collaboration - collabs = Collaboration.query.all() - assert len(collabs) == 1 - - # remove collaborator - url = f"/api/projects/{project.project_id}/users/{coll1.id}" - resp = client.delete(url) - assert resp.status == "200 OK" - - # assert we have 0 collaborations - collabs = Collaboration.query.all() - assert len(collabs) == 0 - - -def test_collaborator_ends_collaboration(populate): - """Test collaborator ends collaboration""" - client, _, coll1, _, project = populate - - # invite - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # signout owner, signin coll1 - signout(client) - signin_user(client, "coll1@test.org", password_coll1) - # coll1 accepts invitation - url = f"/api/invitations/projects/{project.project_id}/accept" - resp = client.post(url) - assert resp.status == "200 OK" - - # assert we have a collaboration - collabs = Collaboration.query.all() - assert len(collabs) == 1 - - # remove collaborator - url = f"/api/projects/{project.project_id}/users/{coll1.id}" - resp = client.delete(url) - assert resp.status == "200 OK" - - # assert we have 0 collaborations - collabs = Collaboration.query.all() - assert len(collabs) == 0 - - -def test_invitation_overview(populate): - """Test if invitee sees invitation""" - client, _, coll1, _, project = populate - - # invite - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # signout owner, signin coll1 - signout(client) - signin_user(client, "coll1@test.org", password_coll1) - - # coll1 wants to see invitations - url = "/api/invitations" - resp = client.get(url) - assert resp.status == "200 OK" - - data = json.loads(resp.text) - assert "invited_for_projects" in data.keys() - assert len(data["invited_for_projects"]) == 1 - assert data["invited_for_projects"][0]["id"] == project.id - - -def test_owner_send_invitation(populate): - """Test if owner can invite""" - client, _, coll1, _, project = populate - # assert I have 0 invitations - invites = CollaborationInvitation.query.all() - assert len(invites) == 0 - - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # assert I have 1 invitation - invites = CollaborationInvitation.query.all() - assert len(invites) == 1 - invite = invites[0] - assert invite.user_id == coll1.id - assert invite.project_id == project.id - - -def test_accept_team_invitation(populate): - """Test collaborator accepts invitation""" - client, owner, coll1, _, project = populate - - # assert system has 0 collaborations - collabs = Collaboration.query.all() - assert len(collabs) == 0 - - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # signout owner, signin coll1 - signout(client) - signin_user(client, "coll1@test.org", password_coll1) - # accept invitation - url = f"/api/invitations/projects/{project.project_id}/accept" - resp = client.post(url) - assert resp.status == "200 OK" - - # assert system has 0 invitations - invites = CollaborationInvitation.query.all() - assert len(invites) == 0 - - # assert system has 1 collaboration - collabs = Collaboration.query.all() - assert len(collabs) == 1 - assert collabs[0].user_id == coll1.id - assert collabs[0].project_id == project.id - - -def test_reject_team_invitation(populate): - """Test collaborator rejects invitation""" - client, _, coll1, _, project = populate - - # assert system has 0 collaborations - collabs = Collaboration.query.all() - assert len(collabs) == 0 - - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # assert system has 1 invitations - invites = CollaborationInvitation.query.all() - assert len(invites) == 1 - - # signout owner, signin coll1 - signout(client) - signin_user(client, "coll1@test.org", password_coll1) - # reject invitation - url = f"/api/invitations/projects/{project.project_id}/reject" - resp = client.delete(url) - assert resp.status == "200 OK" - - # assert system has 0 invitations - invites = CollaborationInvitation.query.all() - assert len(invites) == 0 - - # assert system has 0 collaborations - collabs = Collaboration.query.all() - assert len(collabs) == 0 - - -def test_owner_deletes_invitation(populate): - """Test owner retracts invitation""" - client, _, coll1, _, project = populate - - # assert system has 0 collaborations - collabs = Collaboration.query.all() - assert len(collabs) == 0 - - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # assert system has 1 invitations - invites = CollaborationInvitation.query.all() - assert len(invites) == 1 - - # remove invitation - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.delete(url) - assert resp.status == "200 OK" - - # assert system has 0 invitations - invites = CollaborationInvitation.query.all() - assert len(invites) == 0 - - # assert system has 0 collaborations - collabs = Collaboration.query.all() - assert len(collabs) == 0 - - -# -# TEST IMPROPER USE OF TEAMS API -# - - -def test_improper_get_team(populate): - """Test if owner can get a user overview of his/her team""" - client, _, coll1, _, project = populate - - # signout owner, signin coll1 - signout(client) - signin_user(client, "coll1@test.org", password_coll1) - - # owner wants overview - url = f"/api/projects/{project.project_id}/users" - resp = client.get(url) - assert resp.status == "404 NOT FOUND" - - -def outsider_can_not_remove_collaborator(populate): - """Test owner removes a collaborator""" - client, _, coll1, _, project = populate - - # invite - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # signout owner, signin coll2 - signout(client) - signin_user(client, "coll2@test.org", password_coll2) - - # coll1 tries to remove collaborator - url = f"/api/projects/{project.project_id}/users/{coll1.id}" - resp = client.delete(url) - assert resp.status == "404 NOT FOUND" - - -def test_improper_owner_send_invitation(populate): - """Test if outsider can not invite""" - client, _, coll1, _, project = populate - - # signout owner, signin coll2 - signout(client) - signin_user(client, "coll2@test.org", password_coll2) - - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "404 NOT FOUND" - - -def test_improper_accept_team_invitation(populate): - """Test outsider can not accept invitation""" - client, _, coll1, _, project = populate - - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # signout owner, signin coll2 - signout(client) - signin_user(client, "coll2@test.org", password_coll2) - # accept invitation - url = f"/api/invitations/projects/{project.project_id}/accept" - resp = client.post(url) - assert resp.status == "404 NOT FOUND" - - -def test_improper_reject_team_invitation(populate): - """Test outsider can not reject invitation""" - client, _, coll1, _, project = populate - - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # signout owner, signin coll2 - signout(client) - signin_user(client, "coll2@test.org", password_coll2) - # accept invitation - url = f"/api/invitations/projects/{project.project_id}/reject" - resp = client.delete(url) - assert resp.status == "404 NOT FOUND" - - -def test_improper_owner_deletes_invitation(populate): - """Test owner retracts invitation""" - client, _, coll1, _, project = populate - - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.post(url) - assert resp.status == "200 OK" - - # signout owner, signin coll2 - signout(client) - signin_user(client, "coll2@test.org", password_coll2) - - # remove invitation - url = f"/api/invitations/projects/{project.project_id}/users/{coll1.id}" - resp = client.delete(url) - assert resp.status == "404 NOT FOUND" diff --git a/asreview/webapp/tests/utils.py b/asreview/webapp/tests/utils.py deleted file mode 100644 index fbd75fbbe..000000000 --- a/asreview/webapp/tests/utils.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -from urllib.request import urlopen - - -def retrieve_project_url_github(major=None): - """Retrieve .asreview file url from - asreview-project-files-testing GitHub repository""" - - repo = "/asreview/asreview-project-files-testing" - repo_api_url = "https://api.github.com/repos" + repo + "/git/trees/master" - repo_url = "https://github.com" + repo + "/blob/master" - file_type = "startreview.asreview?raw=true" - - json_file = json.loads(urlopen(repo_api_url).read().decode("utf-8"))["tree"] - - version_tags = [] - project_urls = [] - - for file in json_file: - if file["type"] == "tree": - version_tags.append(file["path"]) - - for tag in version_tags: - file_version = f"/{tag}/asreview-project-{tag.replace('.', '-')}-" - - if major is None or int(tag[1]) == major: - project_urls.append(repo_url + file_version + file_type) - - return project_urls diff --git a/asreview/webapp/tests/utils/__init__.py b/asreview/webapp/tests/utils/__init__.py new file mode 100644 index 000000000..17106ae40 --- /dev/null +++ b/asreview/webapp/tests/utils/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2019-2022 The ASReview Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/asreview/webapp/tests/utils/api_utils.py b/asreview/webapp/tests/utils/api_utils.py new file mode 100644 index 000000000..af3ce1991 --- /dev/null +++ b/asreview/webapp/tests/utils/api_utils.py @@ -0,0 +1,466 @@ +import random +import time +from io import BytesIO +from typing import Union +from urllib.request import urlopen + +from flask.testing import FlaskClient + +import asreview.webapp.tests.utils.crud as crud +import asreview.webapp.tests.utils.misc as misc +from asreview.project import ASReviewProject +from asreview.webapp.authentication.models import Project +from asreview.webapp.tests.utils.config_parser import all_users +from asreview.webapp.tests.utils.config_parser import get_user +from asreview.webapp.tests.utils.misc import get_project_id + + +def process_response(response): + """Breaks response in a tuple containing the status code + and json data.""" + return (response.status_code, response.json) + + +# ######################## +# General API calls +# ######################## + + +def call_root_url(client): + response = client.get("/") + status_code, data = process_response(response) + return (status_code, data, response.text) + + +def call_boot_url(client): + response = client.get("/boot") + return process_response(response) + + +# ######################## +# Authentication API calls +# ######################## + + +def signin_user(client, user): + """Signs in a user through the api""" + # If a password is not set, we need to get it + if not hasattr(user, "password"): + users = all_users() + user.password = users[user.identifier].password + # request + response = client.post( + "/auth/signin", data={"email": user.identifier, "password": user.password} + ) + return process_response(response) + + +def signup_user(client, user): + """Signs up a user through the api""" + response = client.post( + "/auth/signup", + data={ + "identifier": user.email, + "email": user.email, + "name": user.name, + "password": user.password, + "affiliation": user.affiliation, + "origin": "asreview", + }, + ) + return process_response(response) + + +def signout_user(client): + """Sign out user""" + response = client.delete("/auth/signout") + return process_response(response) + + +def confirm_user(client, user): + response = client.post( + "/auth/confirm_account", data={"user_id": user.id, "token": user.token} + ) + return process_response(response) + + +def forgot_password(client, user): + response = client.post("/auth/forgot_password", data={"email": user.email}) + return process_response(response) + + +def reset_password(client, user): + response = client.post( + "/auth/reset_password", + data={"password": user.password, "token": user.token, "user_id": user.id}, + ) + return process_response(response) + + +def update_user(client, data): + response = client.post("/auth/update_profile", data=data) + return process_response(response) + + +def refresh(client): + response = client.get("/auth/refresh") + return process_response(response) + + +def get_profile(client: FlaskClient): + response = client.get("/auth/get_profile") + return process_response(response) + + +# ######################## +# Teams API calls +# ######################## + + +def invite(client, project, user): + url = f"/api/invitations/projects/{get_project_id(project)}/users/{user.id}" + response = client.post(url) + return process_response(response) + + +def list_invitations(client): + response = client.get("/api/invitations") + return process_response(response) + + +def list_collaborators(client, project): + response = client.get(f"/api/projects/{get_project_id(project)}/users") + return process_response(response) + + +def accept_invitation(client, project): + response = client.post( + f"/api/invitations/projects/{get_project_id(project)}/accept", data={} + ) + return process_response(response) + + +def reject_invitation(client, project): + response = client.delete( + f"/api/invitations/projects/{get_project_id(project)}/reject", data={} + ) + return process_response(response) + + +def delete_invitation(client, project, user): + response = client.delete( + f"/api/invitations/projects/{get_project_id(project)}/users/{user.id}", data={} + ) + return process_response(response) + + +def delete_collaboration(client, project, user): + response = client.delete( + f"/api/projects/{get_project_id(project)}/users/{user.id}", data={} + ) + return process_response(response) + + +# ######################## +# Project API calls +# ######################## + + +def get_all_projects(client: FlaskClient): + response = client.get("/api/projects") + return process_response(response) + + +def create_project( + client: FlaskClient, + project_name: str, + mode: str = "explore", + authors: str = "authors", + description: str = "description", +): + response = client.post( + "/api/projects/info", + data={ + "mode": mode, + "name": project_name, + "authors": authors, + "description": description, + }, + ) + return process_response(response) + + +def create_project_from_dict(client: FlaskClient, data: dict): + response = client.post( + "/api/projects/info", + data=data, + ) + return process_response(response) + + +def update_project( + client: FlaskClient, + project: Union[Project, ASReviewProject], + name: str = "name", + mode: str = "explore", + authors: str = "authors", + description: str = "description", +): + response = client.put( + f"/api/projects/{get_project_id(project)}/info", + data={ + "mode": mode, + "name": name, + "authors": authors, + "description": description, + }, + ) + return process_response(response) + + +def upgrade_project(client: FlaskClient, project: Union[Project, ASReviewProject]): + response = client.get(f"/api/projects/{get_project_id(project)}/upgrade_if_old") + return process_response(response) + + +def import_project(client: FlaskClient, url: str): + with urlopen(url) as project_file: + response = client.post( + "/api/projects/import_project", + data={"file": (BytesIO(project_file.read()), "project.asreview")}, + ) + return process_response(response) + + +def get_project_stats(client: FlaskClient): + response = client.get("/api/projects/stats") + return process_response(response) + + +def get_demo_data(client: FlaskClient, subset: str): + response = client.get(f"/api/datasets?subset={subset}") + return process_response(response) + + +def upload_data_to_project( + client: FlaskClient, project: Union[Project, ASReviewProject], data: dict +): + response = client.post( + f"/api/projects/{get_project_id(project)}/data", + data=data, + ) + return process_response(response) + + +def get_project_data(client: FlaskClient, project: Union[Project, ASReviewProject]): + response = client.get(f"/api/projects/{get_project_id(project)}/data") + return process_response(response) + + +def get_project_dataset_writer( + client: FlaskClient, project: Union[Project, ASReviewProject] +): + response = client.get(f"/api/projects/{get_project_id(project)}/dataset_writer") + return process_response(response) + + +def search_project_data( + client: FlaskClient, project: Union[Project, ASReviewProject], query: str +): + response = client.get(f"/api/projects/{get_project_id(project)}/search?q={query}") + return process_response(response) + + +def get_prior_random_project_data( + client: FlaskClient, project: Union[Project, ASReviewProject] +): + response = client.get(f"/api/projects/{get_project_id(project)}/prior_random") + return process_response(response) + + +def label_random_project_data_record( + client: FlaskClient, project: Union[Project, ASReviewProject], label: int +): + # get random data + _, data = get_prior_random_project_data(client, project) + # select a specific record + record = random.choice(data["result"]) + doc_id = record["id"] + return label_project_record(client, project, doc_id, label, note="") + + +def label_project_record( + client: FlaskClient, + project: Union[Project, ASReviewProject], + doc_id: int, + label: str, + prior: int = 1, + note: str = "", +): + response = client.post( + f"/api/projects/{get_project_id(project)}/record/{doc_id}", + data={"doc_id": doc_id, "label": label, "is_prior": prior, "note": note}, + ) + return process_response(response) + + +def update_label_project_record( + client: FlaskClient, + project: Union[Project, ASReviewProject], + doc_id: int, + label: str, + prior: int = 1, + note: str = "", +): + response = client.put( + f"/api/projects/{get_project_id(project)}/record/{doc_id}", + data={"doc_id": doc_id, "label": label, "is_prior": prior, "note": note}, + ) + return process_response(response) + + +def get_labeled_project_data( + client: FlaskClient, project: Union[Project, ASReviewProject] +): + response = client.get(f"/api/projects/{get_project_id(project)}/labeled") + return process_response(response) + + +def get_labeled_project_data_stats( + client: FlaskClient, project: Union[Project, ASReviewProject] +): + response = client.get(f"/api/projects/{get_project_id(project)}/labeled_stats") + return process_response(response) + + +def get_project_algorithms_options(client: FlaskClient): + response = client.get("/api/algorithms") + return process_response(response) + + +def set_project_algorithms( + client: FlaskClient, project: Union[Project, ASReviewProject], data: dict +): + response = client.post( + f"/api/projects/{get_project_id(project)}/algorithms", data=data + ) + return process_response(response) + + +def get_project_algorithms( + client: FlaskClient, project: Union[Project, ASReviewProject] +): + response = client.get(f"/api/projects/{get_project_id(project)}/algorithms") + return process_response(response) + + +def start_project_algorithms( + client: FlaskClient, project: Union[Project, ASReviewProject] +): + response = client.post(f"/api/projects/{get_project_id(project)}/start") + return process_response(response) + + +def get_project_status(client: FlaskClient, project: Union[Project, ASReviewProject]): + response = client.get(f"/api/projects/{get_project_id(project)}/status") + return process_response(response) + + +def set_project_status( + client: FlaskClient, project: Union[Project, ASReviewProject], status: str +): + response = client.put( + f"/api/projects/{get_project_id(project)}/status", data={"status": status} + ) + return process_response(response) + + +def export_project_dataset( + client: FlaskClient, project: Union[Project, ASReviewProject], format: str +): + id = get_project_id(project) + response = client.get(f"/api/projects/{id}/export_dataset?file_format={format}") + return process_response(response) + + +def export_project( + client: FlaskClient, + project: Union[Project, ASReviewProject], +): + response = client.get(f"/api/projects/{get_project_id(project)}/export_project") + return process_response(response) + + +def get_project_progress( + client: FlaskClient, + project: Union[Project, ASReviewProject], +): + response = client.get(f"/api/projects/{get_project_id(project)}/progress") + return process_response(response) + + +def get_project_progress_density( + client: FlaskClient, + project: Union[Project, ASReviewProject], +): + response = client.get(f"/api/projects/{get_project_id(project)}/progress_density") + return process_response(response) + + +def get_project_progress_recall( + client: FlaskClient, + project: Union[Project, ASReviewProject], +): + response = client.get(f"/api/projects/{get_project_id(project)}/progress_recall") + return process_response(response) + + +def get_project_current_document( + client: FlaskClient, + project: Union[Project, ASReviewProject], +): + response = client.get(f"/api/projects/{get_project_id(project)}/get_document") + return process_response(response) + + +def delete_project( + client: FlaskClient, + project: Union[Project, ASReviewProject], +): + response = client.delete(f"/api/projects/{get_project_id(project)}/delete") + return process_response(response) + + +# ######################## +# General procedures +# ######################## + + +def create_and_signin_user(client, test_user_id=1): + """Creates a user account and signs in with that account.""" + # signup user + user = get_user(test_user_id) + signup_user(client, user) + # refresh user + stored_user = crud.get_user_by_identifier(user.identifier) + # signin user + signin_user(client, user) + # return the user + return stored_user + + +def upload_label_set_and_start_model(client, project, dataset): + """Uploads a dataset to a created project and adds and starts + a random model.""" + # upload dataset + upload_data_to_project(client, project, data=dataset) + # label 2 random records + label_random_project_data_record(client, project, 1) + label_random_project_data_record(client, project, 0) + # select a model + model_data = misc.choose_project_algorithms() + set_project_algorithms(client, project, data=model_data) + # start the model + start_project_algorithms(client, project) + # make sure model is done + time.sleep(10) diff --git a/asreview/webapp/tests/utils/config_parser.py b/asreview/webapp/tests/utils/config_parser.py new file mode 100644 index 000000000..31caeaa4a --- /dev/null +++ b/asreview/webapp/tests/utils/config_parser.py @@ -0,0 +1,55 @@ +import configparser +from pathlib import Path + +from asreview.webapp.authentication.models import User + +config_file = "asreview.ini" +config_dir = "config" + +config = configparser.ConfigParser() +BASE_DIR = Path(__file__).resolve().parent.parent + +CONFIG_FILE = BASE_DIR.joinpath(config_dir).joinpath(config_file) + +config.read(CONFIG_FILE) + + +# get user (1 of 3) +def get_user(test_user_id): + """Returns a User model based on a test user + account that can be found in the config file. + The test_user_id refers to the position of the + user account credentials in the .ini file + (1, 2, or 3)""" + section = config[f"user{test_user_id}"] + # create user + user = User( + section["email"], + email=section["email"], + name=section["name"], + affiliation=section["affiliation"], + password=section["password"], + ) + # store password + user.password = section["password"] + return user + + +def get_user_data(test_user_id): + """Returns the data for a user account as a + dictionary.""" + section = config[f"user{test_user_id}"] + return { + "email": section["email"], + "name": section["name"], + "affiliation": section["affiliation"], + "password": section["password"] + } + + +# get all users +def all_users(): + """Returns a dictionary containing User models, + the keys are identifiers in the .ini file.""" + users = [get_user(id) for id in [1, 2, 3]] + return {u.identifier: u for u in users} diff --git a/asreview/webapp/tests/utils/crud.py b/asreview/webapp/tests/utils/crud.py new file mode 100644 index 000000000..52b51219f --- /dev/null +++ b/asreview/webapp/tests/utils/crud.py @@ -0,0 +1,160 @@ +import asreview.webapp.tests.utils.config_parser as cp +from asreview.webapp.authentication.models import Collaboration +from asreview.webapp.authentication.models import CollaborationInvitation +from asreview.webapp.authentication.models import Project +from asreview.webapp.authentication.models import User + + +def create_user(DB, user=1): + if type(user) == int: + user = cp.get_user(user) + try: + DB.session.add(user) + DB.session.commit() + user = User.query.order_by(User.id.desc()).first() + except Exception as exception: + user = False + DB.session.rollback() + DB.session.flush() + raise exception + return user + + +def get_user_by_id(id): + return User.query.filter_by(id=id).one() + + +def get_user_by_identifier(id): + return User.query.filter_by(identifier=id).one() + + +def list_users(): + return User.query.all() + + +def count_users(): + return len(User.query.with_entities(User.id).all()) + + +def update_user(DB, user, attribute, value): + user = get_user_by_identifier(user.identifier) + setattr(user, attribute, value) + DB.session.commit() + return user + + +def last_user(): + return User.query.order_by(User.id.desc()).first() + + +def delete_users(DB): + DB.session.query(User).delete() + DB.session.commit() + + +def delete_collaborations(DB): + DB.session.query(Collaboration).delete() + DB.session.commit() + + +def delete_invitations(DB): + DB.session.query(CollaborationInvitation).delete() + DB.session.commit() + + +def delete_projects(DB): + DB.session.query(Project).delete() + DB.session.commit() + + +def delete_everything(DB): + DB.drop_all() + + +def create_project(DB, user, project): + try: + user.projects.append(project) + DB.session.commit() + id = project.project_id + project = Project.query.filter_by(project_id=id).one() + except Exception as exception: + project = False + DB.session.rollback() + DB.session.flush() + raise exception + return project + + +def get_project_by_project_id(id): + return Project.query.filter_by(project_id=id).one() + + +def list_projects(): + return Project.query.all() + + +def count_projects(): + return len(Project.query.with_entities(Project.id).all()) + + +def last_project(): + return Project.query.order_by(Project.id.desc()).first() + + +def create_invitation(DB, project, user): + try: + inv = CollaborationInvitation(project_id=project.id, user_id=user.id) + DB.session.add(inv) + DB.session.commit() + except Exception as exception: + DB.session.rollback() + DB.session.flush() + raise exception + + +def list_invitations(): + return CollaborationInvitation.query.all() + + +def last_invitation(): + return CollaborationInvitation.query.order_by( + CollaborationInvitation.id.desc() + ).first() + + +def count_invitations(): + return len( + CollaborationInvitation.query.with_entities(CollaborationInvitation.id).all() + ) + + +def create_collaboration(DB, project, user): + try: + coll = Collaboration(project_id=project.id, user_id=user.id) + DB.session.add(coll) + DB.session.commit() + except Exception as exception: + DB.session.rollback() + DB.session.flush() + raise exception + + +def list_collaborations(): + return Collaboration.query.all() + + +def last_collaboration(): + return Collaboration.query.order_by(Collaboration.id.desc()).first() + + +def count_collaborations(): + return len(Collaboration.query.with_entities(Collaboration.id).all()) + + +def create_user1_with_2_projects(DB): + user = create_user(DB) + project_ids = ["project-1", "project-2"] + projects = [Project(project_id=p) for p in project_ids] + user.projects = projects + DB.session.commit() + return user, projects diff --git a/asreview/webapp/tests/utils/misc.py b/asreview/webapp/tests/utils/misc.py new file mode 100644 index 000000000..cf8ddfaf4 --- /dev/null +++ b/asreview/webapp/tests/utils/misc.py @@ -0,0 +1,138 @@ +import io +import json +import random +import re +import shutil +from pathlib import Path +from typing import Union +from urllib.request import urlopen + +import requests +from flask import current_app + +from asreview.project import ASReviewProject +from asreview.utils import asreview_path + + +def current_app_is_authenticated(): + return current_app.config.get("AUTHENTICATION_ENABLED") + + +def get_project_id(project): + """Get a project id from either a Project model + (authenticated app) or an ASReviewProject object + (unauthenticated app).""" + id = None + if current_app_is_authenticated(): + id = project.project_id + else: + id = project.config["id"] + return id + + +def clear_asreview_path(remove_files=True): + """Removes all folders and optional files from the + ASReview folder. The latter has everything to do with + the sqlite3 files.""" + for item in Path(asreview_path()).glob("*"): + if item.is_dir(): + shutil.rmtree(item) + if remove_files and item.is_file(): + item.unlink() + + +def read_project_file(project): + """Loads the data from the project.json file.""" + id = get_project_id(project) + with open(asreview_path() / id / "project.json", "r") as f: + data = json.load(f) + return data + + +def manipulate_project_file(project, key, value): + """Updates key value pairs in the project.json file.""" + id = get_project_id(project) + data = read_project_file(project) + data[key] = value + with open(asreview_path() / id / "project.json", "w+") as f: + json.dump(data, f) + return True + return False + + +def _extract_stem(path: Union[str, Path]): + """Extracts a stem from a path or URL containing a filename.""" + return Path(re.split(":|/", str(path))[-1]).stem + + +def extract_filename_stem(upload_data): + """Helper function to get the stem part of a filename from a + Path or URL contaning a filename.""" + # upload data is a dict with a single key value pair + value = list(upload_data.values())[0] + # split this value on either / or : + return _extract_stem(value) + + +def choose_project_algorithms(): + """Randomly chooses a model plus the appropriate feature + extraction, query strategy and balance strategy.""" + model = random.choice(["svm", "nb", "logistic"]) + feature_extraction = random.choice(["tfidf"]) + data = { + "model": model, + "feature_extraction": feature_extraction, + "query_strategy": random.choice( + ["cluster", "max", "max_random", "max_uncertainty", "random", "uncertainty"] + ), + "balance_strategy": random.choice(["double", "simple", "undersample"]), + } + return data + + +def retrieve_project_url_github(version=None): + """Retrieve .asreview file(s) url from asreview-project-files-testing + GitHub repository. When version is not None, the function resturns + a single URL, otherwise a list containing URLs.""" + + repo = "asreview/asreview-project-files-testing" + repo_api_url = f"https://api.github.com/repos/{repo}/git/trees/master" + repo_url = f"https://github.com/{repo}/blob/master" + file_type = "startreview.asreview?raw=true" + + json_file = json.loads(urlopen(repo_api_url).read().decode("utf-8"))["tree"] + + version_tags = [] + project_urls = [] + + for file in json_file: + if file["type"] == "tree": + version_tags.append(file["path"]) + + for tag in version_tags: + file_version = f"/{tag}/asreview-project-{tag.replace('.', '-')}-" + url = repo_url + file_version + file_type + + if version is None: + project_urls.append(url) + else: + return url + + return project_urls + + +def copy_github_project_into_asreview_folder(url): + """This function copies a, on Github stored, ASReview project + into the asreview folder.""" + response = requests.get(url) + return ASReviewProject.load( + io.BytesIO(response.content), + asreview_path(), + safe_import=True + ) + + +def get_folders_in_asreview_path(): + """This function returns the amount of folders located + in the asreview folder.""" + return [f for f in asreview_path().glob("*") if f.is_dir()] diff --git a/setup.py b/setup.py index 0a64cdc89..08023c8d3 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ def get_long_description(): "doc2vec": ["gensim"], "tensorflow": ["tensorflow~=2.0"], "dev": ["black", "check-manifest", "flake8", "flake8-isort", "isort"], - "test": ["coverage", "pytest"], + "test": ["coverage", "pytest", "pytest-random-order"], } DEPS["all"] = DEPS["sbert"] + DEPS["doc2vec"] DEPS["all"] += DEPS["tensorflow"]