diff --git a/.github/workflows/deploy-pr.yaml b/.github/workflows/deploy-pr.yaml index c59f484..56938bd 100644 --- a/.github/workflows/deploy-pr.yaml +++ b/.github/workflows/deploy-pr.yaml @@ -9,6 +9,29 @@ permissions: pull-requests: write jobs: + test: + name: Run Python Tests + if: github.event.action != 'closed' + runs-on: ubuntu-latest + + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests with pytest + run: pytest + working-directory: ./7project/backend + build: if: github.event.action != 'closed' name: Build and push image (reusable) diff --git a/.github/workflows/deploy-prod.yaml b/.github/workflows/deploy-prod.yaml index 0d45fd9..7f99e56 100644 --- a/.github/workflows/deploy-prod.yaml +++ b/.github/workflows/deploy-prod.yaml @@ -21,6 +21,29 @@ concurrency: cancel-in-progress: false jobs: + test: + name: Run Python Tests + if: github.event.action != 'closed' + runs-on: ubuntu-latest + + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests with pytest + run: pytest + working-directory: ./7project/backend + build: name: Build and push image (reusable) uses: ./.github/workflows/build-image.yaml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..b71a6d1 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,55 @@ +name: Run Python Tests +permissions: + contents: read + +# ----------------- +# --- Triggers ---- +# ----------------- +# This section defines when the workflow will run. +on: + # Run on every push to the 'main' branch + push: + branches: [ "main", "30-create-tests-and-set-up-a-github-pipeline" ] + # Also run on every pull request that targets the 'main' branch + pull_request: + branches: [ "main" ] + +# ----------------- +# ------ Jobs ----- +# ----------------- +# A workflow is made up of one or more jobs that can run in parallel or sequentially. +jobs: + # A descriptive name for your job + build-and-test: + # Specifies the virtual machine to run the job on. 'ubuntu-latest' is a common and cost-effective choice. + runs-on: ubuntu-latest + + # ----------------- + # ----- Steps ----- + # ----------------- + # A sequence of tasks that will be executed as part of the job. + steps: + # Step 1: Check out your repository's code + # This action allows the workflow to access your code. + - name: Check out repository code + uses: actions/checkout@v4 + + # Step 2: Set up the Python environment + # This action installs a specific version of Python on the runner. + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' # Use the Python version that matches your project + + # Step 3: Install project dependencies + # Runs shell commands to install the libraries listed in your requirements.txt. + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + # Step 4: Run your tests! + # Executes the pytest command to run your test suite. + - name: Run tests with pytest + run: pytest + working-directory: ./7project/backend \ No newline at end of file diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 543707f..ae50a62 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -27,7 +27,9 @@ sentry_sdk.init( dsn=os.getenv("SENTRY_DSN"), send_default_pii=True, ) + fastApi = FastAPI() +app = fastApi # CORS for frontend dev server fastApi.add_middleware( @@ -73,7 +75,7 @@ fastApi.include_router( auth_backend, "SECRET", associate_by_email=True, - redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/mojeid/callback", + redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/mojeid/callback", ), prefix="/auth/mojeid", tags=["auth"], @@ -85,7 +87,7 @@ fastApi.include_router( auth_backend, "SECRET", associate_by_email=True, - redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/bankid/callback", + redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/bankid/callback", ), prefix="/auth/bankid", tags=["auth"], diff --git a/7project/backend/pyproject.toml b/7project/backend/pyproject.toml new file mode 100644 index 0000000..ef504fe --- /dev/null +++ b/7project/backend/pyproject.toml @@ -0,0 +1,2 @@ +[tool.pytest.ini_options] +pythonpath = "." \ No newline at end of file diff --git a/7project/backend/tests/conftest.py b/7project/backend/tests/conftest.py new file mode 100644 index 0000000..1c6dca7 --- /dev/null +++ b/7project/backend/tests/conftest.py @@ -0,0 +1,22 @@ +import sys +import types +import pytest +from fastapi.testclient import TestClient + +# Stub sentry_sdk to avoid optional dependency issues during import of app +stub = types.ModuleType("sentry_sdk") +stub.init = lambda *args, **kwargs: None +sys.modules.setdefault("sentry_sdk", stub) + +# Import the FastAPI application +from app.app import fastApi as app # noqa: E402 + + +@pytest.fixture(scope="session") +def fastapi_app(): + return app + + +@pytest.fixture(scope="session") +def client(fastapi_app): + return TestClient(fastapi_app, raise_server_exceptions=True) diff --git a/7project/backend/tests/test_e2e_auth_flow.py b/7project/backend/tests/test_e2e_auth_flow.py new file mode 100644 index 0000000..e622f9c --- /dev/null +++ b/7project/backend/tests/test_e2e_auth_flow.py @@ -0,0 +1,15 @@ +from fastapi import status + + +def test_e2e_minimal_auth_flow(client): + # 1) Service is alive + alive = client.get("/") + assert alive.status_code == status.HTTP_200_OK + + # 2) Attempt to login without payload should fail fast (validation error) + login = client.post("/auth/jwt/login") + assert login.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_CONTENT) + + # 3) Protected endpoint should not be accessible without token + me = client.get("/users/me") + assert me.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) diff --git a/7project/backend/tests/test_integration_app.py b/7project/backend/tests/test_integration_app.py new file mode 100644 index 0000000..6c8733d --- /dev/null +++ b/7project/backend/tests/test_integration_app.py @@ -0,0 +1,18 @@ +from fastapi import status +import pytest + + +def test_root_ok(client): + resp = client.get("/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"status": "ok"} + + +def test_authenticated_route_requires_auth(client): + resp = client.get("/authenticated-route") + assert resp.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) + + +def test_sentry_debug_raises_exception(client): + with pytest.raises(ZeroDivisionError): + client.get("/sentry-debug") diff --git a/7project/backend/tests/test_unit_user_service.py b/7project/backend/tests/test_unit_user_service.py new file mode 100644 index 0000000..7e89962 --- /dev/null +++ b/7project/backend/tests/test_unit_user_service.py @@ -0,0 +1,55 @@ +import types +import asyncio +import pytest + +from app.services import user_service + + +def test_get_oauth_provider_known_unknown(): + # Known providers should return a provider instance + bankid = user_service.get_oauth_provider("BankID") + mojeid = user_service.get_oauth_provider("MojeID") + assert bankid is not None + assert mojeid is not None + + # Unknown should return None + assert user_service.get_oauth_provider("DoesNotExist") is None + + +def test_get_jwt_strategy_lifetime(): + strategy = user_service.get_jwt_strategy() + assert strategy is not None + # Basic smoke check: strategy has a lifetime set to 3600 + assert getattr(strategy, "lifetime_seconds", None) in (604800,) + + +@pytest.mark.asyncio +async def test_on_after_request_verify_enqueues_email(monkeypatch): + calls = {} + + def fake_enqueue_email(to: str, subject: str, body: str): + calls.setdefault("emails", []).append({ + "to": to, + "subject": subject, + "body": body, + }) + + # Patch the enqueue_email used inside user_service + monkeypatch.setattr(user_service, "enqueue_email", fake_enqueue_email) + + class DummyUser: + def __init__(self, email): + self.email = email + + mgr = user_service.UserManager(user_db=None) # user_db not needed for this method + user = DummyUser("test@example.com") + + # Call the hook + await mgr.on_after_request_verify(user, token="abc123", request=None) + + # Verify one email has been enqueued with expected content + assert len(calls.get("emails", [])) == 1 + email = calls["emails"][0] + assert email["to"] == "test@example.com" + assert "ověření účtu" in email["subject"].lower() + assert "abc123" in email["body"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f97951f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,72 @@ +aio-pika==9.5.6 +aiormq==6.8.1 +aiosqlite==0.21.0 +alembic==1.16.5 +amqp==5.3.1 +annotated-types==0.7.0 +anyio==4.11.0 +argon2-cffi==23.1.0 +argon2-cffi-bindings==25.1.0 +asyncmy==0.2.9 +bcrypt==4.3.0 +billiard==4.2.2 +celery==5.5.3 +certifi==2025.10.5 +cffi==2.0.0 +click==8.1.8 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +cryptography==46.0.1 +dnspython==2.7.0 +email_validator==2.2.0 +exceptiongroup==1.3.0 +fastapi==0.117.1 +fastapi-users==14.0.1 +fastapi-users-db-sqlalchemy==7.0.0 +greenlet==3.2.4 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.6.4 +httpx==0.28.1 +httpx-oauth==0.16.1 +idna==3.10 +iniconfig==2.3.0 +kombu==5.5.4 +makefun==1.16.0 +Mako==1.3.10 +MarkupSafe==3.0.2 +multidict==6.6.4 +packaging==25.0 +pamqp==3.3.0 +pluggy==1.6.0 +prompt_toolkit==3.0.52 +propcache==0.3.2 +pwdlib==0.2.1 +pycparser==2.23 +pydantic==2.11.9 +pydantic_core==2.33.2 +Pygments==2.19.2 +PyJWT==2.10.1 +PyMySQL==1.1.2 +pytest==8.4.2 +pytest-asyncio==1.2.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.1 +python-multipart==0.0.20 +PyYAML==6.0.2 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.43 +starlette==0.48.0 +tomli==2.2.1 +typing-inspection==0.4.1 +typing_extensions==4.15.0 +tzdata==2025.2 +uvicorn==0.37.0 +uvloop==0.21.0 +vine==5.1.0 +watchfiles==1.1.0 +wcwidth==0.2.14 +websockets==15.0.1 +yarl==1.20.1