diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..7ccf57e --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,43 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Backend server test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +defaults: + run: + working-directory: ./server + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.13 + uses: actions/setup-python@v3 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.vscode/settings.json b/.vscode/settings.json index 04df0b8..ba850c6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,8 @@ "source.organizeImports": "explicit" }, "editor.formatOnSave": true - } + }, + "python.testing.pytestArgs": ["server"], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4eeaa86..f568804 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,21 +61,22 @@ Now you've got a virtual environment with necessary Python packages installed lo ##### SQLite setup (temporary) The application can be run using SQLite for the database. This makes the setup process a little easier, because SQLite is built-in to Python. - -To create a local SQLite database file, run the `_sqilite-setup.py` script in the server directory: + +To create a local SQLite database file, run the `db_scripts/sqilite_setup.py` script in the server directory: ```bash cd server -python _sqlite-setup.py +python -m db_scripts.sqlite_setup ``` -This should create a "local.db" file in the server directory, which the server will use as long as it retrieves `DB_URL` from `config/sqlite.py`. +This should create a "local.db" file in the server directory, which the server will use as long as it retrieves `DB_URL` from `config/sqlite.py`. A GUI database client is generally useful for backend development, it makes it easy to look up or change records/schema for debugging or prototyping. -HeidiSQL is pretty good but there are many alternatives. Here are some that support SQLite: \ -https://heidisql.com \ -https://dbeaver.io \ -https://sqlitebrowser.org +HeidiSQL is pretty good but there are many alternatives. Here are some that support SQLite: + +* https://heidisql.com +* https://dbeaver.io +* https://sqlitebrowser.org #### WIP: Backend setup for every session @@ -120,6 +121,19 @@ Now all of the Python intellisense and tooling in VSCode should work! The debug configuration for the Flask app is defined in [.vscode/launch.json](.vscode/launch.json). You can debug the Flask app in VS Code by opening the debug panel, choosing `Flask Api Server` in the dropdown, and clicking the green run button. `F5` will also start the debugger. You can then set breakpoints and step through the server code. +### Testing the backend + +The backend uses [Pytest](https://docs.pytest.org/en/stable/index.html) to test the Flask app and other services. To run the backend tests activate the Python venv as shown above and then in a terminal run + +```bash +cd server +pytest +``` + +You can also [run and debug tests](https://code.visualstudio.com/docs/python/testing#_run-tests) directly in VSCode. Click on the **Testing** panel on the left (it looks like a flask) and choose tests to run there. Small play buttons also show up in test files when you open them. The link above shows more details. + +The Flask documentation provides more info about how to [test Flask apps](https://flask.palletsprojects.com/en/stable/testing/) that is quite useful. + ### Commit messages WIP: Provide instructions on how to format commit messages. diff --git a/server/.gitignore b/server/.gitignore index f5e023c..4cb88f7 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -14,4 +14,224 @@ venv.bak/ # Flask secret key file flask_secret_key.txt +# SQLite database local.db + +# Add more based on https://github.com/github/gitignore/blob/main/Python.gitignore + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/server/api/__init__.py b/server/api/__init__.py index c4b098e..0b40e8b 100644 --- a/server/api/__init__.py +++ b/server/api/__init__.py @@ -60,10 +60,11 @@ def get_or_create_secret_key(): # Session acts as a "registry" of sessions; sessions provide an api for executing queries. # Methods for executing queries can be called on Session directly, which will # add a session to the registry if necessary and call those methods on the session object. -# Calling Session.remove() ends a session and removes it from the registry. +# Calling Session.remove() ends a session and removes it from the registry. session_factory = sessionmaker(engine) Session = scoped_session(session_factory) + # This should ensure one db session per request. @app.teardown_request def close_db_connection(_e): diff --git a/server/api/routes.py b/server/api/routes.py index 85cc6f2..0fd5d2b 100644 --- a/server/api/routes.py +++ b/server/api/routes.py @@ -15,7 +15,6 @@ from api.models import get_user_by_name, insert_user - @app.route("/") def index(): """ @@ -32,8 +31,6 @@ def index(): def login(): """ Login endpoint. Establishes a session after successful login. - - TODO fill out details --- parameters: - in: body @@ -72,8 +69,11 @@ def login(): # flask_login and this call to login_user handles the session for us if not login_user(user): - return "There was a problem logging in, your account may have been deactivated.", 401 - + return ( + "There was a problem logging in, your account may have been deactivated.", + 401, + ) + return "", 200 @@ -105,7 +105,7 @@ def add_user(): parameters: - in: body name: user_data - required: + required: - username password confirm_password @@ -138,6 +138,7 @@ def add_user(): # todo(?), return more helpful error messages to client return str(e), 400 + @app.route("/resource") @login_required def get_resource(): diff --git a/server/db_scripts/__init__.py b/server/db_scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/_sqlite-setup.py b/server/db_scripts/sqlite_setup.py similarity index 73% rename from server/_sqlite-setup.py rename to server/db_scripts/sqlite_setup.py index 3b475ca..15834b3 100644 --- a/server/_sqlite-setup.py +++ b/server/db_scripts/sqlite_setup.py @@ -1,10 +1,13 @@ -import sys +import os import sqlite3 + from config.sqlite import DB_FILENAME """ -This script can be run to create a "local.db" sqlite file, for local testing. +This script can be run to create a "local.db" sqlite file, for local testing. """ + + def execute_sql_file(sql_file_path): """ Loads SQL commands from a file and executes them in a SQLite database. @@ -17,7 +20,7 @@ def execute_sql_file(sql_file_path): conn = sqlite3.connect(DB_FILENAME) cursor = conn.cursor() - with open(sql_file_path, 'r') as f: + with open(sql_file_path, "r") as f: sql_script = f.read() cursor.executescript(sql_script) @@ -33,6 +36,13 @@ def execute_sql_file(sql_file_path): conn.close() +def init_db(): + """ + Run the script to set up the main database + """ + this_dir = os.path.dirname(os.path.abspath(__file__)) + execute_sql_file(os.path.join(this_dir, "..", "migrations", "sqlite-setup.sql")) + if __name__ == "__main__": - execute_sql_file("migrations/sqlite-setup.sql") + init_db() diff --git a/server/requirements.txt b/server/requirements.txt index 1aaa4ab..6758e31 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -5,6 +5,8 @@ Flask==3.1.1 Flask-Login==0.6.3 flask-swagger==0.2.14 flask-swagger-ui==5.21.0 +greenlet==3.2.4 +iniconfig==2.1.0 isort==6.0.1 itsdangerous==2.2.0 Jinja2==3.1.6 @@ -13,6 +15,10 @@ mypy_extensions==1.1.0 packaging==25.0 pathspec==0.12.1 platformdirs==4.3.8 +pluggy==1.6.0 +Pygments==2.19.2 +pytest==8.4.2 PyYAML==6.0.2 -sqlalchemy==2.0.43 +SQLAlchemy==2.0.43 +typing_extensions==4.15.0 Werkzeug==3.1.3 diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..437a1de --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,74 @@ +""" +Defines our setup for Pytest and fixtures shared by all of our tests + +https://docs.pytest.org/en/stable/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files +""" + +import os +import tempfile + +import flask.testing +import pytest + +# Override the database to use a temporary one +import config.sqlite + +TEST_DIR = tempfile.TemporaryDirectory() +TEST_DB = os.path.join(TEST_DIR.name, "test.db") +config.sqlite.DB_URL = f"sqlite:///{TEST_DB}" +config.sqlite.DB_FILENAME = TEST_DB + +from dataclasses import dataclass + +# The imports from api must go after the above block overriding the DB location +from api import app as _raw_flask_app +from api.models import insert_user +from db_scripts.sqlite_setup import init_db + + +@dataclass +class UserTestInfo: + """ + Simple class to transmit user info in tests + + This is used because our main UserInfo does not store the + plain text password for users to ensure security. + """ + + username: str + password: str + + +@pytest.fixture(scope="session") +def user_info() -> UserTestInfo: + """A Pytest fixture that provides data for the default test user""" + return UserTestInfo(username="test_user", password="test_pass") + + +@pytest.fixture(scope="session") +def app(user_info: UserTestInfo): + """ + A Pytest fixture that sets up our Flask app for testing. + Use the client fixture below for tests. + + This fixture sets up the database and adds the user from user_info + """ + _raw_flask_app.testing = True + + # Set up the database + init_db() + + # Add a test user + insert_user(user_info.username, user_info.password) + + # other setup can go here + + yield _raw_flask_app + + # clean up / reset resources here + + +@pytest.fixture() +def client(app) -> flask.testing.FlaskClient: + """Main Pytest fixture to get access to a test client for our Flask app""" + return app.test_client() diff --git a/server/tests/test_authentication.py b/server/tests/test_authentication.py new file mode 100644 index 0000000..5f004af --- /dev/null +++ b/server/tests/test_authentication.py @@ -0,0 +1,72 @@ +from flask.testing import FlaskClient + +from tests.conftest import UserTestInfo + + +def test_index(client: FlaskClient): + """Test basic index routing to see that we should get an HTML page""" + result = client.get("/", follow_redirects=True) + assert result.status_code == 200 + assert "" in result.get_data().decode("utf-8") + + +def test_no_login(client: FlaskClient): + """Test that we get an error from a secured endpoint without logging in""" + result = client.get("/resource") + assert result.status_code == 401 + + +def test_login(client: FlaskClient, user_info: UserTestInfo): + """ + Test that logging in allows us to access the protected resource + + client and user_info are both Pytest fixtures defined in the conftest.py file + """ + login_result = client.post( + "/login", + json={"username": user_info.username, "password": user_info.password}, + ) + assert login_result.status_code == 200 + + result = client.get("/resource") + assert result.status_code == 200 + json_data = result.json + assert json_data is not None + assert json_data == {"message": "Resource loaded"} + + +def test_logout_no_login(client: FlaskClient): + """ + Test that logging out errors when a user is not logged in + """ + bad_logout = client.post("/logout") + assert bad_logout.status_code == 401 + + +def test_logout_after_login(client: FlaskClient, user_info: UserTestInfo): + """ + Test that logging out after a successful login revokes access to protected endpoints + """ + # Ensure we're not logged in and can't access the protected resource. This just sanity + # checks that the test starts in the state that we want it to. + bad_access = client.get("/resource") + assert bad_access.status_code == 401 + + # Log in + login_result = client.post( + "/login", + json={"username": user_info.username, "password": user_info.password}, + ) + assert login_result.status_code == 200 + + # Ensure we can access the resource + good_access = client.get("/resource") + assert good_access.status_code == 200 + + # Ensure we can log out + logout_result = client.post("/logout") + assert logout_result.status_code == 200 + + # After logout we should no longer be able to access the resource + bad_access = client.get("/resource") + assert bad_access.status_code == 401 diff --git a/server/tests/test_swagger.py b/server/tests/test_swagger.py new file mode 100644 index 0000000..032616d --- /dev/null +++ b/server/tests/test_swagger.py @@ -0,0 +1,13 @@ +from flask.testing import FlaskClient + + +def test_api_spec(client: FlaskClient): + """ + Ensure that the Swagger API doc loads + + Just checking the status code is sufficient as a sanity check to ensure there are no errors. + If this test fails, there is likely a syntax error in the yaml comments for one of the routes. + Pasting the doc comment into an online YAML checker can help debug issues. + """ + result = client.get("/spec") + assert result.status_code == 200