diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 685e99bc4..f0520083c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,4 +43,4 @@ jobs: export MYSQL_DATABASE=fmlibdb export MYSQL_USER=fmlibuser export MYSQL_PASSWORD=fmlibpass - pytest app/tests/units.py + pytest app/blueprints/ diff --git a/Dockerfile.dev b/Dockerfile.dev index 2e0426cad..c2166395e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -4,8 +4,8 @@ FROM python:3.11-alpine # Set this environment variable to suppress the "Running as root" warning from pip ENV PIP_ROOT_USER_ACTION=ignore -# Install the MySQL client to be able to use it in the standby script. -RUN apk add --no-cache mysql-client +# Install the MariaDB client to be able to use it in the standby script. +RUN apk add --no-cache mariadb-client # Set the working directory in the container to /app WORKDIR /app @@ -19,6 +19,9 @@ COPY requirements.txt . # Copy the wait-for-db.sh script and set execution permissions COPY --chmod=+x scripts/wait-for-db.sh ./scripts/ +# Copy the init-db.sh script and set execution permissions +COPY --chmod=+x scripts/init-db.sh ./scripts/ + # Install any needed packages specified in requirements.txt RUN pip install --no-cache-dir -r requirements.txt @@ -32,4 +35,4 @@ RUN pip install --no-cache-dir --upgrade pip EXPOSE 5000 # Sets the CMD command to correctly execute the wait-for-db.sh script -CMD sh ./scripts/wait-for-db.sh && flask db upgrade && flask run --host=0.0.0.0 --port=5000 --reload --debug +CMD sh ./scripts/wait-for-db.sh && sh ./scripts/init-db.sh && flask db upgrade && flask run --host=0.0.0.0 --port=5000 --reload --debug diff --git a/Dockerfile.mariadb b/Dockerfile.mariadb new file mode 100644 index 000000000..03a58892a --- /dev/null +++ b/Dockerfile.mariadb @@ -0,0 +1,5 @@ +FROM mariadb:latest + +RUN apt-get update && \ + apt-get install -y mariadb-client && \ + rm -rf /var/lib/apt/lists/* diff --git a/app/__init__.py b/app/__init__.py index 0dc231826..6dd06d712 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -17,27 +17,52 @@ migrate = Migrate() -def create_app(config_name=None): - app = Flask(__name__) - app.secret_key = secrets.token_bytes() - - # Database configuration - app.config['SQLALCHEMY_DATABASE_URI'] = ( +class Config: + SECRET_KEY = os.getenv('SECRET_KEY', secrets.token_bytes()) + SQLALCHEMY_DATABASE_URI = ( f"mysql+pymysql://{os.getenv('MARIADB_USER', 'default_user')}:" f"{os.getenv('MARIADB_PASSWORD', 'default_password')}@" f"{os.getenv('MARIADB_HOSTNAME', 'localhost')}:" f"{os.getenv('MARIADB_PORT', '3306')}/" f"{os.getenv('MARIADB_DATABASE', 'default_db')}" ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + TIMEZONE = 'Europe/Madrid' + TEMPLATES_AUTO_RELOAD = True + + +class DevelopmentConfig(Config): + DEBUG = True + + +class TestingConfig(Config): + TESTING = True + SECRET_KEY = os.getenv('SECRET_KEY', 'secret_test_key') + SQLALCHEMY_DATABASE_URI = ( + f"mysql+pymysql://{os.getenv('MARIADB_USER', 'default_user')}:" + f"{os.getenv('MARIADB_PASSWORD', 'default_password')}@" + f"{os.getenv('MARIADB_HOSTNAME', 'localhost')}:" + f"{os.getenv('MARIADB_PORT', '3306')}/" + f"{os.getenv('MARIADB_TEST_DATABASE', 'default_db')}" + ) + UPLOAD_FOLDER = 'uploads' + WTF_CSRF_ENABLED = False - # Timezone - app.config['TIMEZONE'] = 'Europe/Madrid' - # Templates configuration - app.config['TEMPLATES_AUTO_RELOAD'] = True +class ProductionConfig(Config): + pass + + +def create_app(config_name='development'): + app = Flask(__name__) - # Uploads feature models configuration - app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'uploads') + # Load configuration + if config_name == 'testing': + app.config.from_object(TestingConfig) + elif config_name == 'production': + app.config.from_object(ProductionConfig) + else: + app.config.from_object(DevelopmentConfig) # Initialize SQLAlchemy and Migrate with the app db.init_app(app) @@ -45,7 +70,8 @@ def create_app(config_name=None): # Register blueprints register_blueprints(app) - print_registered_blueprints(app) + if config_name == 'development': + print_registered_blueprints(app) from flask_login import LoginManager login_manager = LoginManager() diff --git a/app/blueprints/__init__.py b/app/blueprints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/blueprints/auth/models.py b/app/blueprints/auth/models.py index dc1ad6645..b70f14b3d 100644 --- a/app/blueprints/auth/models.py +++ b/app/blueprints/auth/models.py @@ -16,6 +16,11 @@ class User(db.Model, UserMixin): data_sets = db.relationship('DataSet', backref='user', lazy=True) profile = db.relationship('UserProfile', backref='user', uselist=False) + def __init__(self, **kwargs): + super(User, self).__init__(**kwargs) + if 'password' in kwargs: + self.set_password(kwargs['password']) + def __repr__(self): return f'' diff --git a/app/blueprints/auth/routes.py b/app/blueprints/auth/routes.py index 212621922..26ddc57b0 100644 --- a/app/blueprints/auth/routes.py +++ b/app/blueprints/auth/routes.py @@ -1,8 +1,9 @@ -from flask import (render_template, redirect, url_for) +from flask import (render_template, redirect, url_for, flash, request) from flask_login import current_user, login_user, logout_user from app.blueprints.auth import auth_bp from app.blueprints.auth.forms import SignupForm, LoginForm +from app.blueprints.auth.services import AuthenticationService from app.blueprints.profile.models import UserProfile @@ -45,16 +46,15 @@ def login(): if current_user.is_authenticated: return redirect(url_for('public.index')) form = LoginForm() - if form.validate_on_submit(): - from app.blueprints.auth.models import User - user = User.get_by_email(form.email.data) - - if user is not None and user.check_password(form.password.data): - login_user(user, remember=form.remember_me.data) - return redirect(url_for('public.index')) - else: - error = 'Invalid credentials' - return render_template("auth/login_form.html", form=form, error=error) + if request.method == 'POST': + if form.validate_on_submit(): + email = form.email.data + password = form.password.data + if AuthenticationService.login(email, password): + return redirect(url_for('public.index')) + else: + error = 'Invalid credentials' + return render_template("auth/login_form.html", form=form, error=error) return render_template('auth/login_form.html', form=form) diff --git a/app/blueprints/auth/services.py b/app/blueprints/auth/services.py new file mode 100644 index 000000000..0032bbc6b --- /dev/null +++ b/app/blueprints/auth/services.py @@ -0,0 +1,14 @@ +from flask_login import login_user + +from app.blueprints.auth.models import User + + +class AuthenticationService: + + @staticmethod + def login(email, password, remember=True): + user = User.get_by_email(email) + if user is not None and user.check_password(password): + login_user(user, remember=remember) + return True + return False diff --git a/app/blueprints/profile/tests/__init__.py b/app/blueprints/profile/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/blueprints/profile/tests/test_unit.py b/app/blueprints/profile/tests/test_unit.py new file mode 100644 index 000000000..d14ddcc6a --- /dev/null +++ b/app/blueprints/profile/tests/test_unit.py @@ -0,0 +1,102 @@ +import pytest +from flask import url_for +from app import create_app, db +from app.blueprints.auth.models import User + + +@pytest.fixture(scope='module') +def test_client(): + flask_app = create_app('testing') + + with flask_app.test_client() as testing_client: + with flask_app.app_context(): + db.create_all() + + user_test = User(email='test@example.com') + user_test.set_password('test1234') + db.session.add(user_test) + db.session.commit() + + yield testing_client + + db.session.remove() + db.drop_all() + + +def login(test_client, email, password): + """ + Authenticates the user with the credentials provided. + + Args: + test_client: Flask test client. + email (str): User's email address. + password (str): User's password. + + Returns: + response: POST login request response. + """ + response = test_client.post('/login', data=dict( + email=email, + password=password + ), follow_redirects=True) + return response + + +def logout(test_client): + """ + Logs out the user. + + Args: + test_client: Flask test client. + + Returns: + response: Response to GET request to log out. + """ + return test_client.get('/logout', follow_redirects=True) + + +def test_login_success(test_client): + response = test_client.post('/login', data=dict( + email='test@example.com', + password='test1234' + ), follow_redirects=True) + + assert response.request.path != url_for('auth.login'), "Login was unsuccessful" + + test_client.get('/logout', follow_redirects=True) + + +def test_login_unsuccessful_bad_email(test_client): + response = test_client.post('/login', data=dict( + email='bademail@example.com', + password='test1234' + ), follow_redirects=True) + + assert response.request.path == url_for('auth.login'), "Login was unsuccessful" + + test_client.get('/logout', follow_redirects=True) + + +def test_login_unsuccessful_bad_password(test_client): + response = test_client.post('/login', data=dict( + email='test@example.com', + password='basspassword' + ), follow_redirects=True) + + assert response.request.path == url_for('auth.login'), "Login was unsuccessful" + + test_client.get('/logout', follow_redirects=True) + + +def test_edit_profile_page_get(test_client): + """ + Tests access to the profile editing page via a GET request. + """ + login_response = login(test_client, 'test@example.com', 'test1234') + assert login_response.status_code == 200, "Login was unsuccessful." + + response = test_client.get('/profile/edit') + assert response.status_code == 200, "The profile editing page could not be accessed." + assert b"Edit profile" in response.data, "The expected content is not present on the page" + + logout(test_client) diff --git a/app/blueprints/public/routes.py b/app/blueprints/public/routes.py index 5832855b3..40c3a7109 100644 --- a/app/blueprints/public/routes.py +++ b/app/blueprints/public/routes.py @@ -1,4 +1,7 @@ import logging + +from flask_login import login_required + import app from flask import render_template @@ -23,3 +26,9 @@ def index(): datasets=latest_datasets, datasets_counter=datasets_counter, feature_models_counter=feature_models_counter) + + +@public_bp.route('/secret') +@login_required +def secret(): + return "Esto es secreto!" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 44aae76c1..fb7a3e68c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,22 +8,28 @@ services: context: . dockerfile: Dockerfile.dev volumes: - - .:/app + - type: bind + source: . + target: /app expose: - "5000" environment: FLASK_ENV: development MARIADB_HOSTNAME: ${MARIADB_HOSTNAME} + MARIADB_DATABASE: ${MARIADB_DATABASE} + MARIADB_TEST_DATABASE: ${MARIADB_TEST_DATABASE} MARIADB_PORT: ${MARIADB_PORT} MARIADB_USER: ${MARIADB_USER} MARIADB_PASSWORD: ${MARIADB_PASSWORD} + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD} depends_on: - db db: container_name: mariadb_container - image: mariadb:latest - command: --default-authentication-plugin=mysql_native_password + build: + context: ./ + dockerfile: Dockerfile.mariadb restart: always environment: MARIADB_DATABASE: ${MARIADB_DATABASE} @@ -39,7 +45,9 @@ services: container_name: nginx_web_server image: nginx:latest volumes: - - ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf + - type: bind + source: ./nginx/nginx.dev.conf + target: /etc/nginx/nginx.conf ports: - "80:80" depends_on: diff --git a/scripts/init-db.sh b/scripts/init-db.sh new file mode 100644 index 000000000..c9b115c21 --- /dev/null +++ b/scripts/init-db.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "(testing) Hostname: $MARIADB_HOSTNAME, Port: $MARIADB_PORT, User: $MARIADB_USER, Test DB: $MARIADB_TEST_DATABASE" + +echo "MariaDB is up - creating test database if it doesn't exist" + +# Create the test database if it does not exist +mariadb -h "$MARIADB_HOSTNAME" -P "$MARIADB_PORT" -u root -p"$MARIADB_ROOT_PASSWORD" -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_TEST_DATABASE}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON \`${MARIADB_TEST_DATABASE}\`.* TO '$MARIADB_USER'@'%'; FLUSH PRIVILEGES;" + + +echo "Test database created and privileges granted" diff --git a/scripts/wait-for-db.sh b/scripts/wait-for-db.sh index 6b478e655..c4b9e4e82 100644 --- a/scripts/wait-for-db.sh +++ b/scripts/wait-for-db.sh @@ -2,11 +2,10 @@ echo "Hostname: $MARIADB_HOSTNAME, Port: $MARIADB_PORT, User: $MARIADB_USER" -until mysql -h "$MARIADB_HOSTNAME" -P "$MARIADB_PORT" -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e 'SELECT 1' &> /dev/null +until mariadb -h "$MARIADB_HOSTNAME" -P "$MARIADB_PORT" -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e 'SELECT 1' &> /dev/null do echo "MariaDB is unavailable - sleeping" sleep 1 done echo "MariaDB is up - executing command" -exec "$@"