From 563c3386fa9ff8b5db5085dc1d27f065a3633074 Mon Sep 17 00:00:00 2001 From: Jonathan Capalbo Date: Mon, 24 Aug 2026 02:01:16 -0400 Subject: [PATCH 1/4] Repair authentication, migrations, tests, and delivery --- .dockerignore | 15 + .github/workflows/test.yml | 118 ++--- .gitignore | 4 +- Dockerfile | 43 +- README.md | 437 +++++-------------- alembic.ini | 2 + app/auth/dependencies.py | 77 +--- app/auth/jwt.py | 89 ++-- app/auth/redis.py | 21 - app/core/config.py | 25 +- app/database.py | 70 ++- app/main.py | 51 ++- app/models/calculation.py | 86 ++-- app/models/user.py | 60 ++- app/schemas/calculation.py | 23 +- app/schemas/user.py | 2 + docker-compose.yml | 2 - migrations/env.py | 2 + migrations/versions/0001_initial_schema.py | 80 ++++ requirements-dev.txt | 9 + requirements.txt | 77 +--- templates/dashboard.html | 17 +- templates/edit_calculation.html | 36 +- templates/register.html | 9 +- templates/view_calculation.html | 5 +- tests/conftest.py | 51 +-- tests/e2e/test_fastapi_calculator.py | 31 ++ tests/integration/test_calculation.py | 21 +- tests/integration/test_calculation_schema.py | 16 + tests/integration/test_dependencies.py | 136 +++--- tests/integration/test_user_auth.py | 32 +- tests/unit/test_main_branches.py | 17 + tests/unit/test_main_inprocess.py | 18 + 33 files changed, 785 insertions(+), 897 deletions(-) create mode 100644 .dockerignore delete mode 100644 app/auth/redis.py create mode 100644 migrations/versions/0001_initial_schema.py create mode 100644 requirements-dev.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5989c3c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.venv +venv +__pycache__ +*.py[cod] +*.sqlite +.coverage +.pytest_cache +htmlcov +.env +docs +tests +requirements-dev.txt +README.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1fecb18..5adbf43 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,105 +2,105 @@ name: CI/CD on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] + +permissions: + contents: read jobs: test: runs-on: ubuntu-latest services: postgres: - image: postgres:latest + image: postgres:17 env: POSTGRES_USER: user POSTGRES_PASSWORD: password - POSTGRES_DB: mytestdb # <-- Dedicated test DB name + POSTGRES_DB: mytestdb ports: - 5432:5432 options: >- - --health-cmd pg_isready + --health-cmd "pg_isready -U user -d mytestdb" --health-interval 10s --health-timeout 5s --health-retries 5 + env: + TEST_DATABASE_URL: postgresql://user:password@localhost:5432/mytestdb + JWT_SECRET_KEY: ci-access-secret-key-with-at-least-32-characters + JWT_REFRESH_SECRET_KEY: ci-refresh-secret-key-with-at-least-32-characters steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - - uses: actions/cache@v3 + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - + python-version: "3.13" + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + - name: Install dependencies - env: - DATABASE_URL: postgresql://user:password@localhost:5432/mytestdb run: | - python -m venv venv - source venv/bin/activate - pip install --upgrade pip - pip install -r requirements.txt - playwright install # if you use Playwright for e2e tests - - - name: Run tests + python -m pip install --upgrade pip + python -m pip install -r requirements-dev.txt + python -m playwright install --with-deps chromium + + - name: Audit Python dependencies + run: python -m pip_audit -r requirements-dev.txt --progress-spinner off + + - name: Verify migrations env: - DATABASE_URL: postgresql://user:password@localhost:5432/mytestdb - run: | - source venv/bin/activate - - # 1) Unit tests - pytest tests/unit/ --cov=src --junitxml=test-results/junit.xml - - # 2) Integration tests - pytest tests/integration/ - - # 3) E2E or other tests - pytest tests/e2e/ + DATABASE_URL: ${{ env.TEST_DATABASE_URL }} + run: alembic upgrade head + + - name: Run unit and integration tests + run: pytest tests/unit tests/integration --junitxml=junit.xml + + - name: Run end-to-end tests + run: pytest tests/e2e --e2e security: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@v7 + - name: Build image run: docker build -t app:test . - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master + + - name: Scan image for known vulnerabilities + uses: aquasecurity/trivy-action@0.36.0 with: - image-ref: 'app:test' - format: 'table' - exit-code: '1' + image-ref: app:test + format: table + exit-code: "1" ignore-unfixed: true - severity: 'CRITICAL,HIGH' - + severity: CRITICAL,HIGH + deploy: needs: security - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v4 - - - uses: docker/setup-buildx-action@v3 - - - uses: docker/login-action@v3 + - uses: actions/checkout@v7 + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - - uses: docker/build-push-action@v5 + + - uses: docker/build-push-action@v7 with: + context: . push: true tags: | - jonathancapalbo1/assignment14:latest - jonathancapalbo1/assignment14:${{ github.sha }} + jonathancapalbo1/finalproject:latest + jonathancapalbo1/finalproject:${{ github.sha }} platforms: linux/amd64,linux/arm64 - cache-from: type=registry,ref=jonathancapalbo1/assignment14:cache + cache-from: type=registry,ref=jonathancapalbo1/finalproject:cache cache-to: type=inline,mode=max diff --git a/.gitignore b/.gitignore index 814d032..5f60186 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ venv/ +.venv/ __pycache__/ *.pyc .DS_Store .coverage .pytest_cache htmlcov -.env \ No newline at end of file +.env +*.sqlite diff --git a/Dockerfile b/Dockerfile index 2010f75..01f598d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,41 +1,24 @@ -FROM python:3.10-slim +FROM python:3.13-slim -# Set environment variables for Python ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 WORKDIR /app -# Install system dependencies -RUN apt-get update && \ - apt-get upgrade -y && \ - apt-get install -y --no-install-recommends gcc python3-dev libssl-dev curl && \ - rm -rf /var/lib/apt/lists/* +RUN groupadd --system appgroup \ + && useradd --system --gid appgroup --create-home appuser -# Upgrade pip and essential Python tools -RUN python -m pip install --upgrade pip setuptools>=70.0.0 wheel +COPY requirements.txt ./ +RUN python -m pip install --upgrade pip \ + && python -m pip install -r requirements.txt -# Create non-root user -RUN groupadd -r appgroup && \ - useradd -r -g appgroup appuser +COPY --chown=appuser:appgroup . . -# Copy dependencies and install them -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Ensure correct ownership -RUN chown -R appuser:appgroup /app - -# Switch to non-root user USER appuser -# Health check for the service -HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8000/health || exit 1 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)" || exit 1 -# Run database initialization before starting the app -CMD python -m app.database_init && \ - uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 +CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4"] diff --git a/README.md b/README.md index 6994676..9ce304a 100644 --- a/README.md +++ b/README.md @@ -1,399 +1,172 @@ +# Calculator API +A FastAPI calculator with a browser interface, PostgreSQL persistence, JWT authentication, Alembic migrations, and Docker-based delivery. ---- +## Features -````markdown -# ๐Ÿ“ Calculator API +- Registration and login with bcrypt password hashing +- Access and refresh JWTs with separate signing secrets +- User-scoped calculation history +- Addition, subtraction, multiplication, division, and modulus +- Create, list, view, update, and delete operations +- PostgreSQL in Docker and isolated SQLite tests by default +- Unit, integration, and Playwright end-to-end tests +- Dependency and container vulnerability checks in CI -A FastAPI-based calculator service with **user authentication**, **PostgreSQL** database, and **JWT token-based security**. -It supports basic arithmetic and advanced calculations, securely stores user history, and provides a modern developer experience with CI/CD, Docker, and Alembic migrations. +## Quick start with Docker ---- +Requirements: Docker Desktop or Docker Engine with Compose. -## โœจ Features - -- User registration & login (JWT authentication) -- Secure password hashing with bcrypt -- Access & refresh token system -- CRUD operations on calculation history -- Supported calculation types: - - addition - - subtraction - - multiplication - - division - - modulus -- PostgreSQL database with Alembic migrations -- Fully containerized with Docker & Docker Compose -- pgAdmin for DB management -- CI/CD pipeline with tests, security scans, and Docker Hub deployments +```bash +docker compose up --build +``` ---- +Once the services are healthy: -## ๐Ÿ“ก API Endpoints +- App: +- Swagger UI: +- ReDoc: +- pgAdmin: -When running locally: +The local pgAdmin login is `admin@example.com` / `admin`. These development credentials must not be used for a public deployment. -- Swagger UI โ†’ http://localhost:8000/docs -- ReDoc โ†’ http://localhost:8000/redoc +Stop the stack with: ---- +```bash +docker compose down +``` -## ๐Ÿ“„ API Usage Examples +Add `-v` only when you intentionally want to delete the local database volumes. -### 1. Register a User +## Local development -**Request** -```http -POST /auth/register -Content-Type: application/json -```` +Python 3.13 is used by CI and the production image. -```json -{ - "first_name": "John", - "last_name": "Doe", - "email": "john.doe@example.com", - "username": "johndoe", - "password": "SecurePass123!", - "confirm_password": "SecurePass123!" -} +```bash +python -m venv .venv ``` -**Response** +Activate the environment: -```http -201 Created -``` +```bash +# macOS or Linux +source .venv/bin/activate -```json -{ - "id": "123e4567-e89b-12d3-a456-426614174000", - "username": "johndoe", - "email": "john.doe@example.com", - "first_name": "John", - "last_name": "Doe", - "is_active": true, - "is_verified": false, - "created_at": "2025-01-01T00:00:00", - "updated_at": "2025-01-01T00:00:00" -} +# Windows PowerShell +.venv\Scripts\Activate.ps1 ``` ---- - -### 2. Login +Install the development dependencies: -**Request** - -```http -POST /auth/login -Content-Type: application/json -``` - -```json -{ - "username": "johndoe", - "password": "SecurePass123!" -} +```bash +python -m pip install --upgrade pip +python -m pip install -r requirements-dev.txt ``` -**Response** +Set the application configuration. PowerShell uses `$env:NAME="value"`; the example below uses POSIX shell syntax. -```http -200 OK -``` - -```json -{ - "access_token": "", - "refresh_token": "", - "token_type": "bearer", - "expires_at": "2025-01-01T00:00:00", - "user_id": "123e4567-e89b-12d3-a456-426614174000", - "username": "johndoe", - "email": "john.doe@example.com", - "first_name": "John", - "last_name": "Doe", - "is_active": true, - "is_verified": false -} +```bash +export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fastapi_db" +export JWT_SECRET_KEY="replace-with-a-random-secret-at-least-32-characters" +export JWT_REFRESH_SECRET_KEY="replace-with-another-random-secret-at-least-32-characters" ``` ---- - -### 3. Create a Calculation +Apply migrations and start the server: -**Request** - -```http -POST /calculations -Authorization: Bearer -Content-Type: application/json -``` - -```json -{ - "type": "addition", - "inputs": [10.5, 3, 2] -} +```bash +alembic upgrade head +uvicorn app.main:app --reload --port 8000 ``` -**Response** +## Tests -```http -201 Created -``` +Unit and integration tests use an isolated in-memory SQLite database unless `TEST_DATABASE_URL` is explicitly set. They do not fall back to `DATABASE_URL`. -```json -{ - "id": "123e4567-e89b-12d3-a456-426614174999", - "type": "addition", - "inputs": [10.5, 3, 2], - "result": 15.5, - "user_id": "123e4567-e89b-12d3-a456-426614174000", - "created_at": "2025-01-01T00:00:00", - "updated_at": "2025-01-01T00:00:00" -} +```bash +pytest tests/unit tests/integration ``` ---- - -### 4. List Calculations +To run the same tests against PostgreSQL: -**Request** - -```http -GET /calculations -Authorization: Bearer +```bash +export TEST_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fastapi_test_db" +pytest tests/unit tests/integration ``` -**Response** +Install Chromium and run the end-to-end suite: -```http -200 OK +```bash +playwright install chromium +pytest tests/e2e --e2e ``` -```json -[ - { - "id": "123e4567-e89b-12d3-a456-426614174999", - "type": "addition", - "inputs": [10.5, 3, 2], - "result": 15.5, - "user_id": "123e4567-e89b-12d3-a456-426614174000", - "created_at": "2025-01-01T00:00:00", - "updated_at": "2025-01-01T00:00:00" - } -] -``` - ---- - -### 5. Update a Calculation - -**Request** +Audit the complete dependency set: -```http -PUT /calculations/123e4567-e89b-12d3-a456-426614174999 -Authorization: Bearer -Content-Type: application/json +```bash +python -m pip_audit -r requirements-dev.txt ``` -```json -{ - "inputs": [42, 7] -} -``` +## API -**Response** +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/auth/register` | Register an account | +| `POST` | `/auth/login` | Return access and refresh tokens | +| `POST` | `/auth/token` | OAuth2 form login | +| `POST` | `/auth/refresh` | Exchange a refresh token for a fresh pair | +| `GET` | `/auth/me` | Return the current database user | +| `POST` | `/calculations` | Create a calculation | +| `GET` | `/calculations` | List the current user's calculations | +| `GET` | `/calculations/{id}` | View one calculation | +| `PUT` | `/calculations/{id}` | Update a calculation's inputs | +| `DELETE` | `/calculations/{id}` | Delete a calculation | +| `GET` | `/health` | Process health check | +| `GET` | `/health/db` | Database health check | -```http -200 OK -``` +Protected endpoints require `Authorization: Bearer `. Resource queries are scoped to the authenticated user's database ID. + +Example calculation request: ```json { - "id": "123e4567-e89b-12d3-a456-426614174999", "type": "addition", - "inputs": [42, 7], - "result": 49, - "user_id": "123e4567-e89b-12d3-a456-426614174000", - "created_at": "2025-01-01T00:00:00", - "updated_at": "2025-01-02T00:00:00" + "inputs": [10.5, 3, 2] } ``` ---- - -### 6. Delete a Calculation - -**Request** - -```http -DELETE /calculations/123e4567-e89b-12d3-a456-426614174999 -Authorization: Bearer -``` - -**Response** - -```http -204 No Content -``` - ---- - -## ๐Ÿš€ Run with Docker Compose - -**Requirements:** - -* Docker -* Docker Compose - -``` -docker compose up --build -``` - -Services: - -* API โ†’ [http://localhost:8000](http://localhost:8000) -* Swagger UI โ†’ [http://localhost:8000/docs](http://localhost:8000/docs) -* ReDoc โ†’ [http://localhost:8000/redoc](http://localhost:8000/redoc) -* pgAdmin โ†’ [http://localhost:5050](http://localhost:5050) (email: `admin@example.com`, password: `admin`) - -Stop and remove: - -``` -docker compose down -docker compose down -v -``` - ---- - -## ๐Ÿ–ฅ Run without Docker - -**1. Start PostgreSQL** and create: - -* `fastapi_db` -* `fastapi_test_db` - -**2. Set environment variables:** - -``` -export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fastapi_db" -export TEST_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fastapi_test_db" -export JWT_SECRET_KEY="replace-with-32-chars-min" -export JWT_REFRESH_SECRET_KEY="replace-with-32-chars-min" -``` - -**3. Install dependencies:** - -``` -python -m venv .venv -source .venv/bin/activate # Windows: .venv\Scripts\activate -pip install --upgrade pip -pip install -r requirements.txt -``` +## Migrations -**4. Run Alembic migrations:** - -``` +```bash alembic upgrade head +alembic downgrade -1 +alembic revision --autogenerate -m "describe the change" +alembic check ``` -**5. Start the API:** - -``` -uvicorn app.main:app --reload --port 8000 -``` - ---- +The Docker Compose `migrate` service applies migrations before the development web service starts. The production image also applies pending migrations before launching Uvicorn. -## ๐Ÿงช Run Tests +## Container image -**Inside Docker** +The CI workflow publishes successful `main` builds to: -``` -docker compose up -d -docker compose exec web pytest -q +```text +jonathancapalbo1/finalproject:latest +jonathancapalbo1/finalproject: ``` -**With coverage** +For a standalone container, point `DATABASE_URL` to an accessible PostgreSQL server and supply unique JWT secrets: -``` -docker compose exec web coverage run -m pytest -docker compose exec web coverage report -m -``` - -**On Host** - -``` -export TEST_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fastapi_test_db" -pytest -q -``` - -**Playwright setup (for e2e tests)** - -``` -playwright install -pytest tests/e2e/ -``` - ---- - -## ๐Ÿ“ฆ Docker Hub - -**[jonathancapalbo1/finalproject](https://hub.docker.com/r/jonathancapalbo1/finalproject)** - -**Pull & run** - -``` -docker pull jonathancapalbo1/finalproject:latest -docker run -p 8000:8000 \ +```bash +docker run --rm -p 8000:8000 \ -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/fastapi_db" \ - -e JWT_SECRET_KEY="replace-with-32-chars-min" \ - -e JWT_REFRESH_SECRET_KEY="replace-with-32-chars-min" \ + -e JWT_SECRET_KEY="replace-with-a-random-secret-at-least-32-characters" \ + -e JWT_REFRESH_SECRET_KEY="replace-with-another-random-secret-at-least-32-characters" \ jonathancapalbo1/finalproject:latest ``` ---- - -## ๐Ÿ”ง Common Commands - -**Apply migrations** - -``` -docker compose run --rm migrate -``` - -**Shell inside container** +## Security notes -``` -docker compose exec web bash -``` - -**Logs** - -``` -docker compose logs -f web -docker compose logs -f db -``` - -**Create Alembic migration** - -``` -alembic revision --autogenerate -m "describe changes" -alembic upgrade head -``` - ---- - -## โš  Security Notes - -* Use strong 32+ character secrets for JWT keys -* Never commit secrets โ€” use env vars or GitHub Secrets -* Run a local Trivy scan: - -``` -docker build -t app:test . -trivy image --severity HIGH,CRITICAL --exit-code 1 app:test -``` +- Never commit `.env` or production secrets. +- Use separate, randomly generated access and refresh signing keys. +- Configure `CORS_ORIGINS` for the deployed frontend rather than allowing arbitrary origins. +- The bundled browser UI stores tokens in browser storage and is intended as a project interface. A public production frontend should use hardened cookie and CSRF controls. diff --git a/alembic.ini b/alembic.ini index cf2b29d..f3dab44 100644 --- a/alembic.ini +++ b/alembic.ini @@ -25,11 +25,13 @@ qualname = level = WARN handlers = console qualname = sqlalchemy.engine +propagate = 0 [logger_alembic] level = INFO handlers = console qualname = alembic +propagate = 0 [handler_console] class = StreamHandler diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 1e023f1..57460e2 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -1,20 +1,25 @@ -from datetime import datetime from uuid import UUID + from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer -from app.schemas.user import UserResponse +from sqlalchemy.orm import Session + +from app.auth.jwt import decode_token +from app.database import get_db from app.models.user import User +from app.schemas.token import TokenType -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") def get_current_user( - token: str = Depends(oauth2_scheme) -) -> UserResponse: + token: str = Depends(oauth2_scheme), + db: Session = Depends(get_db), +) -> User: """ - Dependency to get the current user from the JWT token without a database lookup. - This function supports two types of payloads: - - A full payload as a dict containing user info. - - A minimal payload, either as a dict with only a 'sub' key or directly as a UUID. + Resolve an access token to the current database user. + + Loading the user on each request prevents deleted, disabled, or fabricated + token subjects from being treated as active accounts. """ credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -22,60 +27,26 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) - token_data = User.verify_token(token) - if token_data is None: - raise credentials_exception - + payload = decode_token(token, TokenType.ACCESS) try: - # If the token data is a dictionary: - if isinstance(token_data, dict): - # If the payload contains a full set of user fields, use them directly. - if "username" in token_data: - return UserResponse(**token_data) - # Otherwise, assume it is a minimal payload with only the 'sub' key. - elif "sub" in token_data: - return UserResponse( - id=token_data["sub"], - username="unknown", - email="unknown@example.com", - first_name="Unknown", - last_name="User", - is_active=True, - is_verified=False, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), - ) - else: - raise credentials_exception - - # If the token data is directly a UUID (minimal payload): - elif isinstance(token_data, UUID): - return UserResponse( - id=token_data, - username="unknown", - email="unknown@example.com", - first_name="Unknown", - last_name="User", - is_active=True, - is_verified=False, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), - ) - else: - raise credentials_exception + user_id = UUID(payload["sub"]) + except (KeyError, TypeError, ValueError): + raise credentials_exception - except Exception: + user = db.get(User, user_id) + if user is None: raise credentials_exception + return user def get_current_active_user( - current_user: UserResponse = Depends(get_current_user) -) -> UserResponse: + current_user: User = Depends(get_current_user) +) -> User: """ Dependency to ensure that the current user is active. """ if not current_user.is_active: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" ) return current_user diff --git a/app/auth/jwt.py b/app/auth/jwt.py index 8e89385..fdbb6a2 100644 --- a/app/auth/jwt.py +++ b/app/auth/jwt.py @@ -1,38 +1,36 @@ # app/auth/jwt.py +import secrets from datetime import datetime, timedelta, timezone from typing import Any, Optional, Union from uuid import UUID -import secrets -from fastapi import HTTPException, status, Depends -from fastapi.security import OAuth2PasswordBearer -from jose import jwt -from jose.exceptions import ExpiredSignatureError, JWTError # <-- correct import -from passlib.context import CryptContext -from sqlalchemy.orm import Session +import bcrypt +import jwt +from fastapi import HTTPException, status +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from app.core.config import get_settings -from app.auth.redis import add_to_blacklist, is_blacklisted # keep if you use elsewhere from app.schemas.token import TokenType -from app.database import get_db -from app.models.user import User settings = get_settings() -pwd_context = CryptContext( - schemes=["bcrypt"], - deprecated="auto", - bcrypt__rounds=settings.BCRYPT_ROUNDS, -) - -# Prefer leading slash for FastAPI docs/flows -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") - def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) + try: + return bcrypt.checkpw( + plain_password.encode("utf-8"), + hashed_password.encode("utf-8"), + ) + except (TypeError, ValueError): + return False def get_password_hash(password: str) -> str: - return pwd_context.hash(password) + password_bytes = password.encode("utf-8") + if len(password_bytes) > 72: + raise ValueError("Password must not exceed 72 UTF-8 bytes") + return bcrypt.hashpw( + password_bytes, + bcrypt.gensalt(rounds=settings.BCRYPT_ROUNDS), + ).decode("utf-8") def create_token( user_id: Union[str, UUID], @@ -73,13 +71,13 @@ def create_token( detail=f"Could not create token: {e}", ) -async def decode_token( +def decode_token( token: str, token_type: TokenType, verify_exp: bool = True, ) -> dict[str, Any]: """ - Decode and verify a JWT token. Returns payload on success. + Decode and verify a JWT token. Return its payload on success. Raises HTTPException(401) on any auth error. """ secret = ( @@ -93,10 +91,12 @@ async def decode_token( token, secret, algorithms=[settings.ALGORITHM], - options={"verify_exp": verify_exp}, + options={ + "verify_exp": verify_exp, + "require": ["exp", "iat", "jti", "sub", "type"], + }, ) - # Basic claim checks if payload.get("type") != token_type.value: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -104,22 +104,6 @@ async def decode_token( headers={"WWW-Authenticate": "Bearer"}, ) - # Ensure required claims exist - if "sub" not in payload or "jti" not in payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token claims", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Blacklist check (async) - if await is_blacklisted(payload["jti"]): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token has been revoked", - headers={"WWW-Authenticate": "Bearer"}, - ) - return payload except ExpiredSignatureError: @@ -128,30 +112,9 @@ async def decode_token( detail="Token has expired", headers={"WWW-Authenticate": "Bearer"}, ) - except JWTError: - # any other signature/format/claims issue + except InvalidTokenError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) - -async def get_current_user( - token: str = Depends(oauth2_scheme), - db: Session = Depends(get_db), -) -> User: - """ - Resolve the current user from a valid ACCESS token. - """ - # Let decode_token raise clean 401s; don't wrap into generic 401 with str(e) - payload = await decode_token(token, TokenType.ACCESS) - user_id = payload["sub"] - - user = db.query(User).filter(User.id == user_id).first() - if user is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - - if user.is_active is not True: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user") - - return user diff --git a/app/auth/redis.py b/app/auth/redis.py deleted file mode 100644 index 86d5bf0..0000000 --- a/app/auth/redis.py +++ /dev/null @@ -1,21 +0,0 @@ -import redis.asyncio as aioredis -from app.core.config import get_settings - -settings = get_settings() - -async def get_redis(): - if not hasattr(get_redis, "redis"): - get_redis.redis = await aioredis.from_url( - settings.REDIS_URL or "redis://localhost" - ) - return get_redis.redis - -async def add_to_blacklist(jti: str, exp: int): - """Add a token's JTI to the blacklist""" - redis = await get_redis() - await redis.set(f"blacklist:{jti}", "1", ex=exp) - -async def is_blacklisted(jti: str) -> bool: - """Check if a token's JTI is blacklisted""" - redis = await get_redis() - return await redis.exists(f"blacklist:{jti}") \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 4e23e15..d329a3e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,11 +1,12 @@ # app/config.py from functools import lru_cache -from pydantic_settings import BaseSettings -from typing import Optional, List +from typing import List, Optional + +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - # Database settings (keeping your existing default) DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/fastapi_db" + TEST_DATABASE_URL: Optional[str] = None # JWT Settings JWT_SECRET_KEY: str = "your-super-secret-key-change-this-in-production" @@ -16,19 +17,17 @@ class Settings(BaseSettings): # Security BCRYPT_ROUNDS: int = 12 - CORS_ORIGINS: List[str] = ["*"] - - # Redis (optional, for token blacklisting) - REDIS_URL: Optional[str] = "redis://localhost:6379/0" + CORS_ORIGINS: List[str] = [ + "http://localhost:8000", + "http://127.0.0.1:8000", + "http://localhost:3000", + ] - class Config: - env_file = ".env" - case_sensitive = True + model_config = SettingsConfigDict(env_file=".env", case_sensitive=True) -# Create a global settings instance settings = Settings() -# Optional: Add cached settings getter + @lru_cache() def get_settings() -> Settings: - return Settings() \ No newline at end of file + return Settings() diff --git a/app/database.py b/app/database.py index c1f5b22..3398641 100644 --- a/app/database.py +++ b/app/database.py @@ -1,35 +1,44 @@ # app/database.py -from __future__ import annotations - -import os from typing import Generator from sqlalchemy import create_engine from sqlalchemy.orm import declarative_base, sessionmaker, Session +from sqlalchemy.pool import StaticPool + from app.core.config import settings SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL -# Common engine kwargs for reliability -_ENGINE_KW = dict( - pool_pre_ping=True, # drops dead connections before use - pool_recycle=1800, # recycle connections every 30 min - pool_size=5, # tune to your app footprint - max_overflow=10, - pool_timeout=30, # wait for a free conn before raising -) +def _normalize_database_url(database_url: str) -> str: + """Normalize legacy provider URLs for SQLAlchemy 2.x.""" + if database_url.startswith("postgres://"): + return database_url.replace("postgres://", "postgresql://", 1) + return database_url + + +def _engine_options(database_url: str) -> dict: + """Return safe engine options for PostgreSQL or SQLite.""" + if database_url.startswith("sqlite"): + options = {"connect_args": {"check_same_thread": False}} + if database_url.endswith(":memory:"): + options["poolclass"] = StaticPool + return options -# Add Postgres connect timeout if applicable -_connect_args = {} -if SQLALCHEMY_DATABASE_URL.startswith(("postgresql://", "postgres://")): - # psycopg2 respects connect_timeout in seconds - _connect_args["connect_timeout"] = 10 + options = { + "pool_pre_ping": True, + "pool_recycle": 1800, + "pool_size": 5, + "max_overflow": 10, + "pool_timeout": 30, + } + if database_url.startswith("postgresql://"): + options["connect_args"] = {"connect_timeout": 10} + return options # Create the default engine and sessionmaker engine = create_engine( - SQLALCHEMY_DATABASE_URL, - **_ENGINE_KW, - connect_args=_connect_args, + _normalize_database_url(SQLALCHEMY_DATABASE_URL), + **_engine_options(_normalize_database_url(SQLALCHEMY_DATABASE_URL)), ) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) @@ -51,28 +60,9 @@ def get_engine(database_url: str = SQLALCHEMY_DATABASE_URL): """ Create a new SQLAlchemy engine with hardened pool settings. """ - connect_args = {} - if database_url.startswith(("postgresql://", "postgres://")): - connect_args["connect_timeout"] = 10 - return create_engine(database_url, **_ENGINE_KW, connect_args=connect_args) + normalized_url = _normalize_database_url(database_url) + return create_engine(normalized_url, **_engine_options(normalized_url)) def get_sessionmaker(engine): """Factory to create a new sessionmaker bound to the given engine.""" return sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False) - - -# Optional: single retry helper for transient DB restarts -from sqlalchemy.exc import OperationalError, DisconnectionError # noqa: E402 - -def retry_once_on_disconnect(fn): - """ - Decorator to retry a DB action once if the pool has stale connections. - """ - def wrapper(*args, **kwargs): - try: - return fn(*args, **kwargs) - except (OperationalError, DisconnectionError): - # Drop pool and try once more - engine.dispose() - return fn(*args, **kwargs) - return wrapper diff --git a/app/main.py b/app/main.py index 34d532c..ca5c8be 100644 --- a/app/main.py +++ b/app/main.py @@ -36,6 +36,7 @@ from app.schemas.token import TokenResponse from app.schemas.user import UserCreate, UserResponse, UserLogin from app.database import Base, get_db, engine +from app.core.config import settings # ------------------------------------------------------------------------------ # Lifespan: create tables on startup for dev @@ -74,7 +75,7 @@ async def lifespan(app: FastAPI): # ------------------------------------------------------------------------------ app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:8000", "http://127.0.0.1:8000", "http://localhost:3000"], + allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type"], @@ -101,27 +102,35 @@ async def add_security_headers(request: Request, call_next): # ------------------------------------------------------------------------------ @app.get("/", response_class=HTMLResponse, tags=["web"]) def read_index(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) + return templates.TemplateResponse(request=request, name="index.html") @app.get("/login", response_class=HTMLResponse, tags=["web"]) def login_page(request: Request): - return templates.TemplateResponse("login.html", {"request": request}) + return templates.TemplateResponse(request=request, name="login.html") @app.get("/register", response_class=HTMLResponse, tags=["web"]) def register_page(request: Request): - return templates.TemplateResponse("register.html", {"request": request}) + return templates.TemplateResponse(request=request, name="register.html") @app.get("/dashboard", response_class=HTMLResponse, tags=["web"]) def dashboard_page(request: Request): - return templates.TemplateResponse("dashboard.html", {"request": request}) + return templates.TemplateResponse(request=request, name="dashboard.html") @app.get("/dashboard/view/{calc_id}", response_class=HTMLResponse, tags=["web"]) def view_calculation_page(request: Request, calc_id: str): - return templates.TemplateResponse("view_calculation.html", {"request": request, "calc_id": calc_id}) + return templates.TemplateResponse( + request=request, + name="view_calculation.html", + context={"calc_id": calc_id}, + ) @app.get("/dashboard/edit/{calc_id}", response_class=HTMLResponse, tags=["web"]) def edit_calculation_page(request: Request, calc_id: str): - return templates.TemplateResponse("edit_calculation.html", {"request": request, "calc_id": calc_id}) + return templates.TemplateResponse( + request=request, + name="edit_calculation.html", + context={"calc_id": calc_id}, + ) # ------------------------------------------------------------------------------ # Health @@ -137,15 +146,15 @@ def db_health(db: Session = Depends(get_db)): db.execute(text("SELECT 1")) # TextClause is Executable # db.execute(select(1)) # Select is Executable return {"status": "ok", "db": "connected"} - except OperationalError as e: - raise HTTPException(status_code=503, detail=f"DB not reachable: {str(e)}") + except SQLAlchemyError as exc: + raise HTTPException(status_code=503, detail="Database is not reachable") from exc # ------------------------------------------------------------------------------ # Auth # ------------------------------------------------------------------------------ @app.post("/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED, tags=["auth"]) def register(user_create: UserCreate, db: Session = Depends(get_db)): - user_data = user_create.dict(exclude={"confirm_password"}) + user_data = user_create.model_dump(exclude={"confirm_password"}) try: user = User.register(db, user_data) db.commit() @@ -200,13 +209,13 @@ def login_form(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = D detail="Invalid username or password", headers={"WWW-Authenticate": "Bearer"}, ) + db.commit() return {"access_token": auth_result["access_token"], "token_type": "bearer"} @app.post("/auth/refresh", response_model=TokenResponse, tags=["auth"]) def refresh_token(refresh_token: str = Body(..., embed=True), db: Session = Depends(get_db)): """ - Exchange a valid refresh token for a new access token. - Assumes User.refresh_access_token implements verification and rotation. + Exchange a valid refresh token for a fresh access and refresh token pair. """ try: new_tokens = User.refresh_access_token(db, refresh_token) @@ -324,14 +333,18 @@ def update_calculation( if not calculation: raise HTTPException(status_code=404, detail="Calculation not found.") - if calculation_update.inputs is not None: - calculation.inputs = calculation_update.inputs - calculation.result = calculation.get_result() + try: + if calculation_update.inputs is not None: + calculation.inputs = calculation_update.inputs + calculation.result = calculation.get_result() - calculation.updated_at = datetime.now(timezone.utc) - db.commit() - db.refresh(calculation) - return calculation + calculation.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(calculation) + return calculation + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc @app.delete("/calculations/{calc_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["calculations"]) def delete_calculation( diff --git a/app/models/calculation.py b/app/models/calculation.py index 3a37a50..fc1eebb 100644 --- a/app/models/calculation.py +++ b/app/models/calculation.py @@ -15,10 +15,11 @@ """ import uuid +import math +from numbers import Real from typing import List -from sqlalchemy import Column, String, DateTime, ForeignKey, JSON, Float, func -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import Column, String, DateTime, ForeignKey, JSON, Float, Uuid, func from sqlalchemy.orm import relationship, declared_attr from app.database import Base @@ -38,7 +39,7 @@ def __tablename__(cls): @declared_attr def id(cls): return Column( - UUID(as_uuid=True), + Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4, nullable=False, @@ -47,7 +48,7 @@ def id(cls): @declared_attr def user_id(cls): return Column( - UUID(as_uuid=True), + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, @@ -107,11 +108,37 @@ def create(cls, calculation_type: str, user_id: uuid.UUID, inputs: List[float]) "division": Division, "modulus": Modulus, } + if hasattr(calculation_type, "value"): + calculation_type = calculation_type.value + if not isinstance(calculation_type, str): + raise ValueError("Calculation type must be a string.") + calculation_class = calculation_classes.get(calculation_type.lower()) if not calculation_class: raise ValueError(f"Unsupported calculation type: {calculation_type}") return calculation_class(user_id=user_id, inputs=inputs) + @staticmethod + def validated_inputs(inputs: List[float]) -> List[float]: + if not isinstance(inputs, list): + raise ValueError("Inputs must be a list of numbers.") + if len(inputs) < 2: + raise ValueError("Inputs must be a list with at least two numbers.") + if any( + isinstance(value, bool) + or not isinstance(value, Real) + or not math.isfinite(value) + for value in inputs + ): + raise ValueError("Inputs must contain only finite numbers.") + return inputs + + @staticmethod + def validated_result(result: float) -> float: + if not math.isfinite(result): + raise ValueError("Calculation result must be a finite number.") + return result + def get_result(self) -> float: raise NotImplementedError @@ -130,55 +157,43 @@ class Addition(Calculation): __mapper_args__ = {"polymorphic_identity": "addition"} def get_result(self) -> float: - if not isinstance(self.inputs, list): - raise ValueError("Inputs must be a list of numbers.") - if len(self.inputs) < 2: - raise ValueError("Inputs must be a list with at least two numbers.") - return sum(self.inputs) + inputs = self.validated_inputs(self.inputs) + return self.validated_result(sum(inputs)) class Subtraction(Calculation): __mapper_args__ = {"polymorphic_identity": "subtraction"} def get_result(self) -> float: - if not isinstance(self.inputs, list): - raise ValueError("Inputs must be a list of numbers.") - if len(self.inputs) < 2: - raise ValueError("Inputs must be a list with at least two numbers.") - result = self.inputs[0] - for value in self.inputs[1:]: + inputs = self.validated_inputs(self.inputs) + result = inputs[0] + for value in inputs[1:]: result -= value - return result + return self.validated_result(result) class Multiplication(Calculation): __mapper_args__ = {"polymorphic_identity": "multiplication"} def get_result(self) -> float: - if not isinstance(self.inputs, list): - raise ValueError("Inputs must be a list of numbers.") - if len(self.inputs) < 2: - raise ValueError("Inputs must be a list with at least two numbers.") + inputs = self.validated_inputs(self.inputs) result = 1 - for value in self.inputs: + for value in inputs: result *= value - return result + return self.validated_result(result) class Division(Calculation): __mapper_args__ = {"polymorphic_identity": "division"} def get_result(self) -> float: - if not isinstance(self.inputs, list): - raise ValueError("Inputs must be a list of numbers.") - if len(self.inputs) < 2: - raise ValueError("Inputs must be a list with at least two numbers.") - result = self.inputs[0] - for value in self.inputs[1:]: + inputs = self.validated_inputs(self.inputs) + result = inputs[0] + for value in inputs[1:]: if value == 0: raise ValueError("Cannot divide by zero.") result /= value - return result + return self.validated_result(result) class Modulus(Calculation): @@ -188,11 +203,10 @@ class Modulus(Calculation): __mapper_args__ = {"polymorphic_identity": "modulus"} def get_result(self) -> float: - if not isinstance(self.inputs, list): - raise ValueError("Inputs must be a list of numbers.") - if len(self.inputs) < 2: - raise ValueError("Inputs must be a list with at least two numbers.") - result = self.inputs[0] - for value in self.inputs[1:]: + inputs = self.validated_inputs(self.inputs) + result = inputs[0] + for value in inputs[1:]: + if value == 0: + raise ValueError("Cannot calculate modulus by zero.") result %= value - return result + return self.validated_result(result) diff --git a/app/models/user.py b/app/models/user.py index d473bd7..0c5b3e6 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -17,8 +17,7 @@ import uuid from datetime import datetime, timezone, timedelta -from sqlalchemy import Column, String, Boolean, DateTime, or_ -from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy import Column, String, Boolean, DateTime, Uuid, or_, true, false from sqlalchemy.orm import relationship, Mapped, mapped_column from app.core.config import get_settings from app.database import Base @@ -54,7 +53,7 @@ class User(Base): # Primary key and identifying fields id = Column( - PG_UUID(as_uuid=True), + Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4, unique=True, @@ -71,8 +70,8 @@ class User(Base): last_name = Column(String(50), nullable=False) # Status flags for account management - is_active = Column(Boolean, default=True) - is_verified = Column(Boolean, default=False) + is_active = Column(Boolean, default=True, server_default=true(), nullable=False) + is_verified = Column(Boolean, default=False, server_default=false(), nullable=False) # Timestamps created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False) @@ -171,8 +170,8 @@ def register(cls, db, user_data: dict): ValueError: If password is invalid or username/email already exists. """ password = user_data.get("password") - if not password or len(password) < 6: - raise ValueError("Password must be at least 6 characters long") + if not password or len(password) < 8: + raise ValueError("Password must be at least 8 characters long") # Check for duplicate email or username existing_user = db.query(cls).filter( @@ -211,7 +210,7 @@ def authenticate(cls, db, username_or_email: str, password: str): or_(cls.username == username_or_email, cls.email == username_or_email) ).first() - if not user or not user.verify_password(password): + if not user or not user.is_active or not user.verify_password(password): return None # Update last_login @@ -261,6 +260,31 @@ def create_refresh_token(cls, data: dict) -> str: from app.schemas.token import TokenType return create_token(data["sub"], TokenType.REFRESH) + @classmethod + def refresh_access_token(cls, db, refresh_token: str): + """Exchange a valid refresh token for a fresh token pair.""" + from fastapi import HTTPException + + from app.auth.jwt import decode_token + from app.schemas.token import TokenType + + try: + payload = decode_token(refresh_token, TokenType.REFRESH) + user_id = uuid.UUID(payload["sub"]) + except (HTTPException, KeyError, TypeError, ValueError) as exc: + raise ValueError("Invalid or expired refresh token") from exc + + user = db.get(cls, user_id) + if user is None or not user.is_active: + raise ValueError("Invalid or expired refresh token") + + return { + "access_token": cls.create_access_token({"sub": str(user.id)}), + "refresh_token": cls.create_refresh_token({"sub": str(user.id)}), + "expires_at": utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + "user": user, + } + @classmethod def verify_token(cls, token: str): """ @@ -272,19 +296,13 @@ def verify_token(cls, token: str): Returns: UUID | None: User ID if token is valid, None otherwise. """ - from app.core.config import settings - from jose import jwt - from jose.exceptions import JWTError + from fastapi import HTTPException + + from app.auth.jwt import decode_token + from app.schemas.token import TokenType try: - alg = getattr(settings, "JWT_ALGORITHM", None) or getattr(settings, "ALGORITHM", "HS256") - payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[alg]) - sub = payload.get("sub") - if sub is None: - return None - try: - return uuid.UUID(sub) - except (ValueError, TypeError): - return None - except JWTError: + payload = decode_token(token, TokenType.ACCESS) + return uuid.UUID(payload["sub"]) + except (HTTPException, KeyError, TypeError, ValueError): return None diff --git a/app/schemas/calculation.py b/app/schemas/calculation.py index 6742656..32a10bd 100644 --- a/app/schemas/calculation.py +++ b/app/schemas/calculation.py @@ -3,13 +3,12 @@ """ from enum import Enum +import math from pydantic import BaseModel, Field, ConfigDict, model_validator, field_validator from typing import List, Optional from uuid import UUID from datetime import datetime -from app.models.calculation import Modulus - class CalculationType(str, Enum): ADDITION = "addition" SUBTRACTION = "subtraction" @@ -43,15 +42,20 @@ def validate_type(cls, v): def check_inputs_is_list(cls, v): if not isinstance(v, list): raise ValueError("Input should be a valid list") + if any(isinstance(value, bool) for value in v): + raise ValueError("Inputs must contain only numbers") return v @model_validator(mode="after") def validate_inputs(self) -> "CalculationBase": if len(self.inputs) < 2: raise ValueError("At least two numbers are required for calculation") - if self.type == CalculationType.DIVISION: + if any(not math.isfinite(value) for value in self.inputs): + raise ValueError("Inputs must contain only finite numbers") + if self.type in (CalculationType.DIVISION, CalculationType.MODULUS): if any(x == 0 for x in self.inputs[1:]): - raise ValueError("Cannot divide by zero") + operation = "divide" if self.type == CalculationType.DIVISION else "calculate modulus" + raise ValueError(f"Cannot {operation} by zero") return self model_config = ConfigDict( @@ -91,10 +95,21 @@ class CalculationUpdate(BaseModel): min_length=2, ) + @field_validator("inputs", mode="before") + @classmethod + def check_inputs(cls, value): + if value is not None and not isinstance(value, list): + raise ValueError("Input should be a valid list") + if value is not None and any(isinstance(item, bool) for item in value): + raise ValueError("Inputs must contain only numbers") + return value + @model_validator(mode="after") def validate_inputs(self) -> "CalculationUpdate": if self.inputs is not None and len(self.inputs) < 2: raise ValueError("At least two numbers are required for calculation") + if self.inputs is not None and any(not math.isfinite(value) for value in self.inputs): + raise ValueError("Inputs must contain only finite numbers") return self model_config = ConfigDict( diff --git a/app/schemas/user.py b/app/schemas/user.py index 6261614..fea5880 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -74,6 +74,8 @@ def validate_password_strength(self) -> "UserCreate": raise ValueError("Password must contain at least one digit") if not any(char in "!@#$%^&*()_+-=[]{}|;:,.<>?" for char in password): raise ValueError("Password must contain at least one special character") + if len(password.encode("utf-8")) > 72: + raise ValueError("Password must not exceed 72 UTF-8 bytes") return self model_config = ConfigDict( diff --git a/docker-compose.yml b/docker-compose.yml index d082a27..d1dbd29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: db: image: postgres:17 diff --git a/migrations/env.py b/migrations/env.py index a79533f..deabfd9 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -18,6 +18,8 @@ # Import your app config and metadata from app.core.config import get_settings from app.database import Base +from app.models.calculation import Calculation # noqa: F401 +from app.models.user import User # noqa: F401 # Alembic Config object, provides access to values in alembic.ini config = context.config diff --git a/migrations/versions/0001_initial_schema.py b/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..701f857 --- /dev/null +++ b/migrations/versions/0001_initial_schema.py @@ -0,0 +1,80 @@ +"""Create users and calculations tables. + +Revision ID: 0001_initial +Revises: +Create Date: 2026-08-24 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Earlier releases created tables directly from SQLAlchemy metadata. The + # checks below let Alembic adopt those existing development databases while + # still creating the complete schema for new installations. + existing_tables = set(sa.inspect(op.get_bind()).get_table_names()) + + if "users" not in existing_tables: + op.create_table( + "users", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("username", sa.String(length=50), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("password", sa.String(length=255), nullable=False), + sa.Column("first_name", sa.String(length=50), nullable=False), + sa.Column("last_name", sa.String(length=50), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=sa.true(), nullable=False), + sa.Column("is_verified", sa.Boolean(), server_default=sa.false(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_login", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_users_id", "users", ["id"], unique=True) + op.create_index("ix_users_username", "users", ["username"], unique=True) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + if "calculations" not in existing_tables: + op.create_table( + "calculations", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("type", sa.String(length=50), nullable=False), + sa.Column("inputs", sa.JSON(), nullable=False), + sa.Column("result", sa.Float(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_calculations_type", "calculations", ["type"], unique=False) + op.create_index("ix_calculations_user_id", "calculations", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_calculations_user_id", table_name="calculations") + op.drop_index("ix_calculations_type", table_name="calculations") + op.drop_table("calculations") + op.drop_index("ix_users_email", table_name="users") + op.drop_index("ix_users_username", table_name="users") + op.drop_index("ix_users_id", table_name="users") + op.drop_table("users") diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..15971b8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +-r requirements.txt + +Faker==40.37.0 +httpx2==2.12.0 +pip-audit==2.10.1 +playwright==1.62.0 +pytest==9.1.1 +pytest-cov==7.1.0 +requests==2.34.2 diff --git a/requirements.txt b/requirements.txt index ad19ed2..cdbcbe9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,66 +1,13 @@ -annotated-types==0.7.0 -anyio==4.8.0 -alembic==1.13.1 -async-timeout==5.0.1 -bcrypt==4.2.1 -certifi==2025.1.31 -cffi==1.17.1 -charset-normalizer==3.4.1 -click==8.1.8 -coverage==7.6.11 -cryptography==44.0.0 -dill==0.3.9 -dnspython==2.7.0 -ecdsa==0.19.1 -email_validator==2.2.0 -exceptiongroup==1.2.2 -Faker==36.1.0 -fastapi==0.115.8 -greenlet==3.1.1 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -idna==3.10 -iniconfig==2.0.0 -isort==5.13.2 +alembic==1.19.1 +bcrypt==5.0.0 +email-validator==2.3.0 +fastapi==0.141.1 Jinja2==3.1.6 -MarkupSafe==3.0.2 -mccabe==0.7.0 -packaging==24.2 -passlib==1.7.4 -platformdirs==4.3.6 -playwright==1.50.0 -pluggy==1.5.0 -psycopg2-binary==2.9.10 -pyasn1==0.6.1 -pycparser==2.22 -pydantic==2.10.6 -pydantic-settings==2.7.1 -pydantic_core==2.27.2 -pyee==12.1.1 -PyJWT==2.10.1 -pylint==3.3.1 -pytest==8.3.4 -pytest-cov==6.0.0 -pytest-cover==3.0.0 -pytest-coverage==0.0 -pytest-pylint==0.21.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.0.1 -python-jose==3.5.0 -python-multipart==0.0.20 -redis>=4.2.0 -requests==2.32.4 -rsa==4.9 -six>=1.16.0 -sniffio==1.3.1 -SQLAlchemy==2.0.38 -starlette==0.45.3 -tenacity==9.0.0 -tomli==2.2.1 -tomlkit==0.13.2 -typing_extensions==4.12.2 -tzdata==2025.1 -urllib3==2.5.0 -uvicorn==0.34.0 -setuptools>=65.5.0 +psycopg2-binary==2.9.12 +pydantic==2.13.4 +pydantic-settings==2.15.0 +PyJWT==2.13.0 +python-dotenv==1.2.3 +python-multipart==0.0.32 +SQLAlchemy==2.0.52 +uvicorn==0.52.4 diff --git a/templates/dashboard.html b/templates/dashboard.html index d78b28a..a5b78f3 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -406,12 +406,12 @@

const submitButton = form.querySelector('button[type="submit"]'); const originalButtonContent = submitButton.innerHTML; - const inputsVal = document.getElementById('calcInputs').value; - const inputs = inputsVal.split(',') - .map(num => parseFloat(num.trim())) - .filter(num => !isNaN(num)); + const inputParts = document.getElementById('calcInputs').value + .split(',') + .map(value => value.trim()); + const inputs = inputParts.map(Number); - if (inputs.length < 2) { + if (inputs.length < 2 || inputParts.some(value => value === '') || inputs.some(value => !Number.isFinite(value))) { showError('Please enter at least two valid numbers, separated by commas'); // Highlight the input field with an error state @@ -431,6 +431,11 @@

inputs }; + if ((newCalc.type === 'division' || newCalc.type === 'modulus') && inputs.slice(1).includes(0)) { + showError(`${newCalc.type === 'division' ? 'Division' : 'Modulus'} by zero is not allowed.`); + return; + } + // Show loading state in the button submitButton.disabled = true; submitButton.innerHTML = ' Calculating...'; @@ -518,4 +523,4 @@

}); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/edit_calculation.html b/templates/edit_calculation.html index 0452129..0a13cf6 100644 --- a/templates/edit_calculation.html +++ b/templates/edit_calculation.html @@ -294,6 +294,12 @@

Calculation Not Found

} result = inputs.reduce((a, b, i) => i === 0 ? a : a / b, inputs[0]); break; + case 'modulus': + if (inputs.some((value, index) => index > 0 && value === 0)) { + return 'Cannot calculate modulus by zero'; + } + result = inputs.reduce((a, b, i) => i === 0 ? a : a % b, inputs[0]); + break; default: return 'Unknown operation'; } @@ -309,11 +315,10 @@

Calculation Not Found

// Function to update the preview function updatePreview() { const type = calcTypeInput.value; - const inputsString = calcInputsInput.value; - const inputs = inputsString + const inputParts = calcInputsInput.value .split(',') - .map(num => parseFloat(num.trim())) - .filter(num => !isNaN(num)); + .map(value => value.trim()); + const inputs = inputParts.map(Number); // Create appropriate operator symbol let operator; @@ -330,12 +335,15 @@

Calculation Not Found

case 'division': operator = 'รท'; break; + case 'modulus': + operator = '%'; + break; default: operator = '?'; } // Update preview section - if (inputs.length < 2) { + if (inputs.length < 2 || inputParts.some(value => value === '') || inputs.some(value => !Number.isFinite(value))) { previewResult.innerHTML = `

Enter at least two valid numbers to see the preview @@ -423,14 +431,14 @@

Calculation Not Found

const submitButton = e.target.querySelector('button[type="submit"]'); const originalButtonContent = submitButton.innerHTML; - // Parse input string into an array of floats - const newInputs = calcInputsInput.value + // Parse the complete input; reject malformed or non-finite values. + const inputParts = calcInputsInput.value .split(',') - .map(num => parseFloat(num.trim())) - .filter(num => !isNaN(num)); + .map(value => value.trim()); + const newInputs = inputParts.map(Number); // Validate inputs - if (newInputs.length < 2) { + if (newInputs.length < 2 || inputParts.some(value => value === '') || newInputs.some(value => !Number.isFinite(value))) { showError('Please enter at least two valid numbers separated by commas.'); // Add error class to the input @@ -458,9 +466,9 @@

Calculation Not Found

return; } - // Check for division by zero - if (calcTypeInput.value === 'division' && newInputs.some((value, index) => index > 0 && value === 0)) { - showError('Division by zero is not allowed.'); + // Check operations whose divisor cannot be zero + if ((calcTypeInput.value === 'division' || calcTypeInput.value === 'modulus') && newInputs.some((value, index) => index > 0 && value === 0)) { + showError(`${calcTypeInput.value === 'division' ? 'Division' : 'Modulus'} by zero is not allowed.`); return; } @@ -529,4 +537,4 @@

Calculation Not Found

loadCalculation(); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/register.html b/templates/register.html index 4664de9..63ea116 100644 --- a/templates/register.html +++ b/templates/register.html @@ -79,7 +79,7 @@

- Must be at least 8 characters and include uppercase, lowercase, and numbers + Must be at least 8 characters and include uppercase, lowercase, a number, and a special character

@@ -167,7 +167,8 @@

return password.length >= 8 && // at least 8 characters /[A-Z]/.test(password) && // at least one uppercase /[a-z]/.test(password) && // at least one lowercase - /[0-9]/.test(password); // at least one number + /[0-9]/.test(password) && // at least one number + /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password); // special character } // Helper function to add validation styling @@ -220,7 +221,7 @@

if (!isValidPassword(formData.password)) { setInputValidation(form.password, false); - if (!hasErrors) showError('Password must be at least 8 characters long and contain uppercase, lowercase, and numbers'); + if (!hasErrors) showError('Password must be at least 8 characters long and contain uppercase, lowercase, a number, and a special character'); hasErrors = true; } @@ -323,4 +324,4 @@

} }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/view_calculation.html b/templates/view_calculation.html index 7a998f1..5fb047a 100644 --- a/templates/view_calculation.html +++ b/templates/view_calculation.html @@ -211,6 +211,9 @@

Calculation Not Found

case 'division': operator = 'รท'; break; + case 'modulus': + operator = '%'; + break; default: operator = '?'; } @@ -388,4 +391,4 @@

${type} Operatio loadCalculation(); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index d1f5141..7fe6366 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import os import socket import subprocess +import sys import time import logging from typing import Generator, Dict, List @@ -13,10 +14,14 @@ from sqlalchemy import text from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError -from playwright.sync_api import sync_playwright, Browser, Page +from playwright.sync_api import sync_playwright, Browser + +# Tests must never fall back to the application's production database. CI can +# opt into PostgreSQL by setting TEST_DATABASE_URL explicitly. +TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") or "sqlite+pysqlite:///:memory:" +os.environ["DATABASE_URL"] = TEST_DATABASE_URL from app.database import Base, get_engine, get_sessionmaker -from app.core.config import settings # Import all model modules so Base.metadata is fully populated from app.models.user import User @@ -38,8 +43,7 @@ fake = Faker() Faker.seed(12345) -# Use settings.DATABASE_URL for now -db_url = settings.DATABASE_URL +db_url = TEST_DATABASE_URL test_engine = get_engine(database_url=db_url) TestingSessionLocal = get_sessionmaker(engine=test_engine) @@ -71,10 +75,10 @@ def wait_for_server(url: str, timeout: int = 30) -> bool: start_time = time.time() while (time.time() - start_time) < timeout: try: - r = requests.get(url) + r = requests.get(url, timeout=1) if r.status_code == 200: return True - except requests.exceptions.ConnectionError: + except requests.exceptions.RequestException: time.sleep(1) return False @@ -87,20 +91,11 @@ class ServerStartupError(Exception): @pytest.fixture(scope="session", autouse=True) def setup_test_database(request): """ - Hard reset the schema at session start and end. - Works on Postgres and falls back for other dialects. + Recreate only the application's tables at session start and end. """ logger.info("Resetting test schema at session start...") - if test_engine.dialect.name == "postgresql": - with test_engine.begin() as conn: - conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE")) - conn.execute(text("CREATE SCHEMA public")) - else: - # For sqlite and others - Base.metadata.drop_all(bind=test_engine) - - # Create tables from SQLAlchemy metadata + Base.metadata.drop_all(bind=test_engine) Base.metadata.create_all(bind=test_engine) logger.info("Tables created.") @@ -108,12 +103,7 @@ def setup_test_database(request): if not request.config.getoption("--preserve-db"): logger.info("Resetting test schema at session end...") - if test_engine.dialect.name == "postgresql": - with test_engine.begin() as conn: - conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE")) - conn.execute(text("CREATE SCHEMA public")) - else: - Base.metadata.drop_all(bind=test_engine) + Base.metadata.drop_all(bind=test_engine) logger.info("Schema reset complete.") # Truncate data before each test @@ -199,8 +189,8 @@ def fastapi_server(): logger.info(f"Starting FastAPI server on port {base_port}...") env = {**os.environ, "DATABASE_URL": db_url, "TEST_DATABASE_URL": db_url} process = subprocess.Popen( - ["uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(base_port)], - stdout=subprocess.PIPE, + [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(base_port)], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, cwd=".", @@ -209,9 +199,13 @@ def fastapi_server(): health_url = f"{server_url}health" if not wait_for_server(health_url, timeout=30): - stderr = process.stderr.read() if process.stderr is not None else "" - logger.error(f"Server failed to start. Uvicorn error: {stderr}") process.terminate() + try: + _, stderr = process.communicate(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + _, stderr = process.communicate() + logger.error(f"Server failed to start. Uvicorn error: {stderr}") raise ServerStartupError(f"Failed to start test server on {health_url}") logger.info(f"Test server running on {server_url}.") @@ -220,10 +214,11 @@ def fastapi_server(): logger.info("Stopping test server...") process.terminate() try: - process.wait(timeout=5) + process.communicate(timeout=5) logger.info("Test server stopped.") except subprocess.TimeoutExpired: process.kill() + process.communicate() logger.warning("Test server forcefully stopped.") # ====================================================================================== diff --git a/tests/e2e/test_fastapi_calculator.py b/tests/e2e/test_fastapi_calculator.py index 2808db0..2a9966e 100644 --- a/tests/e2e/test_fastapi_calculator.py +++ b/tests/e2e/test_fastapi_calculator.py @@ -2,6 +2,7 @@ from uuid import uuid4 import pytest import requests +from playwright.sync_api import expect # Import the Calculation model for direct model tests. from app.models.calculation import Calculation @@ -318,3 +319,33 @@ def test_model_division(): with pytest.raises(ValueError): calc_zero = Calculation.create("division", dummy_user_id, [100, 0]) calc_zero.get_result() + + +@pytest.mark.e2e +def test_browser_registration_login_and_calculation(page, base_url): + """Exercise the browser UI from registration through a saved result.""" + unique = uuid4().hex[:10] + username = f"browser_{unique}" + password = "SecurePass123!" + + page.goto(f"{base_url}/register") + page.locator("#username").fill(username) + page.locator("#email").fill(f"{username}@example.com") + page.locator("#first_name").fill("Browser") + page.locator("#last_name").fill("Test") + page.locator("#password").fill(password) + page.locator("#confirm_password").fill(password) + page.locator("#registrationForm button[type='submit']").click() + page.wait_for_url("**/login", timeout=10_000) + + page.locator("#username").fill(username) + page.locator("#password").fill(password) + page.locator("#loginForm button[type='submit']").click() + page.wait_for_url("**/dashboard", timeout=10_000) + + page.locator("#calcType").select_option("modulus") + page.locator("#calcInputs").fill("10, 3") + page.locator("#calculationForm button[type='submit']").click() + + expect(page.locator("#successMessage")).to_contain_text("Calculation complete: 1") + expect(page.locator("#calculationsTable")).to_contain_text("modulus") diff --git a/tests/integration/test_calculation.py b/tests/integration/test_calculation.py index bf20fca..70bc927 100644 --- a/tests/integration/test_calculation.py +++ b/tests/integration/test_calculation.py @@ -51,6 +51,25 @@ def test_modulus_get_result(): result = modulus.get_result() assert result == 1.0, f"Expected 1.0, got {result}" + +def test_modulus_by_zero(): + modulus = Modulus(user_id=dummy_user_id(), inputs=[10.0, 0.0]) + with pytest.raises(ValueError, match="Cannot calculate modulus by zero"): + modulus.get_result() + + +@pytest.mark.parametrize("invalid_value", [float("inf"), float("-inf"), float("nan"), True]) +def test_calculations_reject_non_finite_or_boolean_inputs(invalid_value): + addition = Addition(user_id=dummy_user_id(), inputs=[1.0, invalid_value]) + with pytest.raises(ValueError, match="finite numbers"): + addition.get_result() + + +def test_calculation_rejects_non_finite_result(): + multiplication = Multiplication(user_id=dummy_user_id(), inputs=[1e308, 1e308]) + with pytest.raises(ValueError, match="result must be a finite number"): + multiplication.get_result() + def test_division_by_zero(): """Test that Division.get_result raises ValueError when dividing by zero.""" inputs = [50.0, 0.0, 5.0] @@ -150,4 +169,4 @@ def test_invalid_inputs_for_modulus(): """Test that providing fewer than two numbers to Modulus.get_result raises a ValueError.""" modulus = Modulus(user_id=dummy_user_id(), inputs=[10.0]) with pytest.raises(ValueError, match="Inputs must be a list with at least two numbers."): - modulus.get_result() \ No newline at end of file + modulus.get_result() diff --git a/tests/integration/test_calculation_schema.py b/tests/integration/test_calculation_schema.py index 3c1182c..eda1161 100644 --- a/tests/integration/test_calculation_schema.py +++ b/tests/integration/test_calculation_schema.py @@ -80,6 +80,22 @@ def test_calculation_update_no_fields(): calc_update = CalculationUpdate() assert calc_update.inputs is None + +@pytest.mark.parametrize("calculation_type", ["division", "modulus"]) +def test_calculation_create_rejects_zero_divisor(calculation_type): + with pytest.raises(ValidationError): + CalculationCreate( + type=calculation_type, + inputs=[10, 0], + user_id=uuid4(), + ) + + +@pytest.mark.parametrize("inputs", [[1, float("inf")], [1, float("nan")], [1, True]]) +def test_calculation_schemas_reject_invalid_numbers(inputs): + with pytest.raises(ValidationError): + CalculationUpdate(inputs=inputs) + def test_calculation_response_valid(): """Test creating a valid CalculationResponse schema.""" data = { diff --git a/tests/integration/test_dependencies.py b/tests/integration/test_dependencies.py index 7cf78a0..d71c2ec 100644 --- a/tests/integration/test_dependencies.py +++ b/tests/integration/test_dependencies.py @@ -1,105 +1,77 @@ +from uuid import uuid4 + import pytest -from unittest.mock import patch from fastapi import HTTPException, status -from app.auth.dependencies import get_current_user, get_current_active_user -from app.schemas.user import UserResponse + +from app.auth.dependencies import get_current_active_user, get_current_user from app.models.user import User -from uuid import uuid4 -from datetime import datetime, timezone - -# Sample user data dictionaries for testing -sample_user_data = { - "id": uuid4(), - "username": "testuser", - "email": "test@example.com", - "first_name": "Test", - "last_name": "User", - "is_active": True, - "is_verified": True, - "created_at": datetime.utcnow(), - "updated_at": datetime.utcnow() -} - -inactive_user_data = { - "id": uuid4(), - "username": "inactiveuser", - "email": "inactive@example.com", - "first_name": "Inactive", - "last_name": "User", - "is_active": False, - "is_verified": False, - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc) -} - -# Fixture for mocking token verification -@pytest.fixture -def mock_verify_token(): - with patch.object(User, 'verify_token') as mock: - yield mock - -# Test get_current_user with valid token and complete payload -def test_get_current_user_valid_token_existing_user(mock_verify_token): - mock_verify_token.return_value = sample_user_data - - user_response = get_current_user(token="validtoken") - - assert isinstance(user_response, UserResponse) - assert user_response.id == sample_user_data["id"] - assert user_response.username == sample_user_data["username"] - assert user_response.email == sample_user_data["email"] - assert user_response.first_name == sample_user_data["first_name"] - assert user_response.last_name == sample_user_data["last_name"] - assert user_response.is_active == sample_user_data["is_active"] - assert user_response.is_verified == sample_user_data["is_verified"] - assert user_response.created_at == sample_user_data["created_at"] - assert user_response.updated_at == sample_user_data["updated_at"] - - mock_verify_token.assert_called_once_with("validtoken") - -# Test get_current_user with invalid token (returns None) -def test_get_current_user_invalid_token(mock_verify_token): - mock_verify_token.return_value = None + + +def _token_payload(user_id): + return {"sub": str(user_id), "type": "access"} + + +def test_get_current_user_loads_existing_user(db_session, test_user, monkeypatch): + monkeypatch.setattr( + "app.auth.dependencies.decode_token", + lambda token, token_type: _token_payload(test_user.id), + ) + + current_user = get_current_user(token="valid-token", db=db_session) + + assert isinstance(current_user, User) + assert current_user.id == test_user.id + + +def test_get_current_user_propagates_invalid_token(db_session, monkeypatch): + def reject_token(token, token_type): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + + monkeypatch.setattr("app.auth.dependencies.decode_token", reject_token) with pytest.raises(HTTPException) as exc_info: - get_current_user(token="invalidtoken") + get_current_user(token="invalid-token", db=db_session) assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED - assert exc_info.value.detail == "Could not validate credentials" - mock_verify_token.assert_called_once_with("invalidtoken") -# Test get_current_user with valid token but incomplete payload (simulate missing fields) -def test_get_current_user_valid_token_incomplete_payload(mock_verify_token): - # Return an empty dict simulating missing required fields - mock_verify_token.return_value = {} +@pytest.mark.parametrize("subject", [None, "not-a-uuid"]) +def test_get_current_user_rejects_invalid_subject(db_session, monkeypatch, subject): + monkeypatch.setattr( + "app.auth.dependencies.decode_token", + lambda token, token_type: {"sub": subject}, + ) with pytest.raises(HTTPException) as exc_info: - get_current_user(token="validtoken") + get_current_user(token="valid-token", db=db_session) assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED - assert exc_info.value.detail == "Could not validate credentials" - mock_verify_token.assert_called_once_with("validtoken") -# Test get_current_active_user with an active user -def test_get_current_active_user_active(mock_verify_token): - mock_verify_token.return_value = sample_user_data +def test_get_current_user_rejects_missing_user(db_session, monkeypatch): + monkeypatch.setattr( + "app.auth.dependencies.decode_token", + lambda token, token_type: _token_payload(uuid4()), + ) + + with pytest.raises(HTTPException) as exc_info: + get_current_user(token="valid-token", db=db_session) + + assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED - current_user = get_current_user(token="validtoken") - active_user = get_current_active_user(current_user=current_user) - assert isinstance(active_user, UserResponse) - assert active_user.is_active is True +def test_get_current_active_user_accepts_active_user(test_user): + assert get_current_active_user(current_user=test_user) is test_user -# Test get_current_active_user with an inactive user -def test_get_current_active_user_inactive(mock_verify_token): - mock_verify_token.return_value = inactive_user_data - current_user = get_current_user(token="validtoken") +def test_get_current_active_user_rejects_inactive_user(test_user): + test_user.is_active = False with pytest.raises(HTTPException) as exc_info: - get_current_active_user(current_user=current_user) + get_current_active_user(current_user=test_user) - assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN assert exc_info.value.detail == "Inactive user" diff --git a/tests/integration/test_user_auth.py b/tests/integration/test_user_auth.py index b1a62f0..5ccee03 100644 --- a/tests/integration/test_user_auth.py +++ b/tests/integration/test_user_auth.py @@ -135,7 +135,7 @@ def test_short_password_registration(db_session): } # Attempt registration with short password - with pytest.raises(ValueError, match="Password must be at least 6 characters long"): + with pytest.raises(ValueError, match="Password must be at least 8 characters long"): User.register(db_session, test_data) def test_invalid_token(): @@ -157,6 +157,34 @@ def test_token_creation_and_verification(db_session, fake_user_data): decoded_user_id = User.verify_token(token) assert decoded_user_id == user.id + +def test_refresh_token_exchange(db_session, fake_user_data): + fake_user_data["password"] = "TestPass123" + user = User.register(db_session, fake_user_data) + db_session.commit() + + original_refresh = User.create_refresh_token({"sub": str(user.id)}) + result = User.refresh_access_token(db_session, original_refresh) + + assert result["user"].id == user.id + assert result["refresh_token"] != original_refresh + assert User.verify_token(result["access_token"]) == user.id + assert User.verify_token(original_refresh) is None + + +def test_refresh_rejects_access_token(db_session, test_user): + access_token = User.create_access_token({"sub": str(test_user.id)}) + + with pytest.raises(ValueError, match="Invalid or expired refresh token"): + User.refresh_access_token(db_session, access_token) + + +def test_inactive_user_cannot_authenticate(db_session, test_user): + test_user.is_active = False + db_session.commit() + + assert User.authenticate(db_session, test_user.username, "unused") is None + def test_authenticate_with_email(db_session, fake_user_data): """Test authentication using email instead of username""" fake_user_data['password'] = "TestPass123" @@ -189,5 +217,5 @@ def test_missing_password_registration(db_session): } # Adjust the expected error message - with pytest.raises(ValueError, match="Password must be at least 6 characters long"): + with pytest.raises(ValueError, match="Password must be at least 8 characters long"): User.register(db_session, test_data) diff --git a/tests/unit/test_main_branches.py b/tests/unit/test_main_branches.py index 41e1d35..a0593c6 100644 --- a/tests/unit/test_main_branches.py +++ b/tests/unit/test_main_branches.py @@ -251,6 +251,23 @@ def override_db(): client.close() +def test_update_modulus_by_zero_returns_400(db_session): + user = create_user_in_db(db_session) + + def override_db(): + yield db_session + + client, _ = make_client(override_db, user=user) + try: + created = client.post("/calculations", json={"type": "modulus", "inputs": [10, 3]}).json() + response = client.put(f"/calculations/{created['id']}", json={"inputs": [10, 0]}) + assert response.status_code == 400 + assert "modulus by zero" in response.json()["detail"] + finally: + app.dependency_overrides.clear() + client.close() + + def test_update_calculation_no_inputs_branch(db_session): # Covers the path where inputs is None (skip the two assignment lines) user = create_user_in_db(db_session) diff --git a/tests/unit/test_main_inprocess.py b/tests/unit/test_main_inprocess.py index a2018ee..c782fd7 100644 --- a/tests/unit/test_main_inprocess.py +++ b/tests/unit/test_main_inprocess.py @@ -112,6 +112,24 @@ def fake_auth(db, u, p): assert r.json()["access_token"] == "formtoken" +def test_refresh_token_endpoint(client, db_session, fake_user_data): + fake_user_data["password"] = "ValidPass123!" + user = User.register(db_session, fake_user_data) + db_session.commit() + refresh_token = User.create_refresh_token({"sub": str(user.id)}) + + response = client.post("/auth/refresh", json={"refresh_token": refresh_token}) + + assert response.status_code == 200, response.text + assert User.verify_token(response.json()["access_token"]) == user.id + + +def test_refresh_token_endpoint_rejects_access_token(client): + access_token = User.create_access_token({"sub": str(uuid4())}) + response = client.post("/auth/refresh", json={"refresh_token": access_token}) + assert response.status_code == 401 + + def test_calc_create_valueerror_path(client, monkeypatch): # Force ValueError so the except branch returns 400 def raise_value_error(**kwargs): From 4f227abb19c9e2d24a6ab1e90d0c67d4c3a8451e Mon Sep 17 00:00:00 2001 From: Jonathan Capalbo Date: Mon, 24 Aug 2026 02:05:22 -0400 Subject: [PATCH 2/4] Fix Trivy action release tag --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5adbf43..b0b1d18 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,7 +71,7 @@ jobs: run: docker build -t app:test . - name: Scan image for known vulnerabilities - uses: aquasecurity/trivy-action@0.36.0 + uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: app:test format: table From d2b5831b37cbd61a9dd65108dc0ed800fe241e4f Mon Sep 17 00:00:00 2001 From: Jonathan Capalbo Date: Mon, 24 Aug 2026 02:12:26 -0400 Subject: [PATCH 3/4] Remove vulnerable packaging tools from runtime image --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 01f598d..a3836a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,11 @@ RUN groupadd --system appgroup \ && useradd --system --gid appgroup --create-home appuser COPY requirements.txt ./ +# Packaging tools are only needed while dependencies are installed. Removing +# them also removes pip's vendored msgpack from the runtime image. RUN python -m pip install --upgrade pip \ - && python -m pip install -r requirements.txt + && python -m pip install -r requirements.txt \ + && python -m pip uninstall --yes setuptools pip COPY --chown=appuser:appgroup . . From 16a3efd2b7e518cada48b0cce60e0a51760ffaf9 Mon Sep 17 00:00:00 2001 From: Jonathan Capalbo Date: Mon, 24 Aug 2026 02:16:00 -0400 Subject: [PATCH 4/4] Apply base image security updates --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index a3836a5..99dea30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +RUN apt-get update \ + && apt-get upgrade --yes \ + && rm -rf /var/lib/apt/lists/* + RUN groupadd --system appgroup \ && useradd --system --gid appgroup --create-home appuser