diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a48bcb4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default reviewers / Revisores por defecto +* @jparga diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ce520b..7de0b8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,11 @@ jobs: - name: pre-commit (gitleaks, ruff, shellcheck, hadolint, yaml) uses: pre-commit/action@v3.0.1 + - name: Unit tests + run: | + pip install pytest + pytest -q tests + - name: Validate docker-compose.yml run: | cp .env.example .env diff --git a/CHANGELOG.md b/CHANGELOG.md index d42c74c..b4a2967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to this project are documented here. The format follows [Kee - Issue and PR templates. - Bilingual (English/Spanish) docs. - `SECURITY.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`. +- Unit tests for `settings.py` (pytest) and Fail2Ban filter test. +- `CODEOWNERS`. ### Changed @@ -43,4 +45,6 @@ All notable changes to this project are documented here. The format follows [Kee ### Fixed - `deploy.sh` pulled a non-existent `main` branch. +- gunicorn workers raced to run PyDAL migrations on MySQL ("Table already exists"), leaving the app unloaded in some workers; migrations now run once before the workers start. +- MySQL passwords with URL-reserved characters (e.g. from `openssl rand -base64`) broke the connection URI. - Fail2Ban could never ban: logs were not shared, wrong log format, wrong config path, and the `INPUT` chain was used instead of `DOCKER-USER`. diff --git a/CONTRIBUTING.es.md b/CONTRIBUTING.es.md index fe004eb..7dbc18f 100644 --- a/CONTRIBUTING.es.md +++ b/CONTRIBUTING.es.md @@ -27,6 +27,8 @@ pre-commit run --all-files Prueba en local antes de abrir la PR: ```bash +pip install pytest && pytest -q tests +tests/fail2ban/test_filters.sh docker compose up -d --build --wait curl http://localhost/example_app/health ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8b2d9d..03adf4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,8 @@ pre-commit run --all-files Test locally before opening the PR: ```bash +pip install pytest && pytest -q tests +tests/fail2ban/test_filters.sh docker compose up -d --build --wait curl http://localhost/example_app/health ``` diff --git a/ruff.toml b/ruff.toml index daa816b..1717ccd 100644 --- a/ruff.toml +++ b/ruff.toml @@ -3,3 +3,6 @@ line-length = 100 [lint] select = ["E", "F", "W", "I", "B", "UP", "S", "BLE"] + +[lint.per-file-ignores] +"tests/**" = ["S101"] diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..0b6adac --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,66 @@ +"""Unit tests for apps/example_app/settings.py (stdlib only, no py4web needed).""" + +import importlib.util +from pathlib import Path +from urllib.parse import unquote + +import pytest + +SETTINGS = Path(__file__).resolve().parents[1] / "apps" / "example_app" / "settings.py" +DB_VARS = ("DB_URI", "DB_HOST", "DB_PORT", "MYSQL_USER", "MYSQL_PASSWORD", "MYSQL_DATABASE") + + +def load_settings(): + spec = importlib.util.spec_from_file_location("example_app_settings", SETTINGS) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + for name in DB_VARS: + monkeypatch.delenv(name, raising=False) + + +def test_sqlite_fallback_without_mysql_vars(): + assert load_settings().DB_URI == "sqlite://storage.db" + + +def test_mysql_uri_from_environment(monkeypatch): + monkeypatch.setenv("MYSQL_USER", "app") + monkeypatch.setenv("MYSQL_PASSWORD", "secret") + monkeypatch.setenv("MYSQL_DATABASE", "py4web") + assert load_settings().DB_URI == ( + "mysql:pymysql://app:secret@db:3306/py4web?set_encoding=utf8mb4" + ) + + +@pytest.mark.parametrize("password", ["a/b+c=", "p@ss:w#rd", "x%y?z&"]) +def test_mysql_password_is_url_encoded(monkeypatch, password): + # openssl rand -base64 produces "/", "+" and "="; "@" or ":" would break the URI. + monkeypatch.setenv("MYSQL_USER", "app") + monkeypatch.setenv("MYSQL_PASSWORD", password) + monkeypatch.setenv("MYSQL_DATABASE", "py4web") + uri = load_settings().DB_URI + credentials = uri.removeprefix("mysql:pymysql://").rsplit("@", 1)[0] + user, encoded = credentials.split(":", 1) + assert "@" not in encoded and ":" not in encoded and "/" not in encoded + assert (unquote(user), unquote(encoded)) == ("app", password) + + +def test_db_uri_overrides_mysql_vars(monkeypatch): + monkeypatch.setenv("MYSQL_USER", "app") + monkeypatch.setenv("MYSQL_PASSWORD", "secret") + monkeypatch.setenv("MYSQL_DATABASE", "py4web") + monkeypatch.setenv("DB_URI", "sqlite://other.db") + assert load_settings().DB_URI == "sqlite://other.db" + + +def test_custom_host_and_port(monkeypatch): + monkeypatch.setenv("MYSQL_USER", "app") + monkeypatch.setenv("MYSQL_PASSWORD", "secret") + monkeypatch.setenv("MYSQL_DATABASE", "py4web") + monkeypatch.setenv("DB_HOST", "mysql.internal") + monkeypatch.setenv("DB_PORT", "3307") + assert "@mysql.internal:3307/" in load_settings().DB_URI