From 955607f7309bcb299dc582dcd35612543d1a0698 Mon Sep 17 00:00:00 2001 From: CRM Bot Date: Wed, 3 Jun 2026 20:52:01 +0000 Subject: [PATCH] feat(phase-4a): backend skeleton, auth, health, tests --- .dockerignore | 59 ++++++++ .env.example | 34 +++++ .gitignore | 65 +++++++++ README.md | 143 ++++++++++++++++++++ alembic.ini | 55 ++++++++ alembic/env.py | 86 ++++++++++++ alembic/script.py.mako | 29 ++++ alembic/versions/0001_init.py | 103 ++++++++++++++ app/__init__.py | 3 + app/api/__init__.py | 1 + app/api/v1/__init__.py | 1 + app/api/v1/auth.py | 156 +++++++++++++++++++++ app/api/v1/health.py | 69 ++++++++++ app/api/v1/users.py | 155 +++++++++++++++++++++ app/core/__init__.py | 1 + app/core/config.py | 86 ++++++++++++ app/core/db.py | 69 ++++++++++ app/core/deps.py | 68 ++++++++++ app/core/security.py | 101 ++++++++++++++ app/main.py | 176 ++++++++++++++++++++++++ app/models/__init__.py | 15 +++ app/models/base.py | 52 +++++++ app/models/org.py | 34 +++++ app/models/user.py | 50 +++++++ app/schemas/__init__.py | 1 + app/schemas/auth.py | 61 +++++++++ app/schemas/user.py | 56 ++++++++ app/services/__init__.py | 1 + app/services/auth_service.py | 126 +++++++++++++++++ app/services/user_service.py | 120 +++++++++++++++++ app/webui/.gitkeep | 1 + pyproject.toml | 56 ++++++++ requirements-dev.txt | 14 ++ requirements.txt | 28 ++++ tests/__init__.py | 1 + tests/conftest.py | 144 ++++++++++++++++++++ tests/test_auth.py | 247 ++++++++++++++++++++++++++++++++++ tests/test_health.py | 47 +++++++ tests/test_users_me.py | 195 +++++++++++++++++++++++++++ 39 files changed, 2709 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0001_init.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/auth.py create mode 100644 app/api/v1/health.py create mode 100644 app/api/v1/users.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/db.py create mode 100644 app/core/deps.py create mode 100644 app/core/security.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/base.py create mode 100644 app/models/org.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/auth.py create mode 100644 app/schemas/user.py create mode 100644 app/services/__init__.py create mode 100644 app/services/auth_service.py create mode 100644 app/services/user_service.py create mode 100644 app/webui/.gitkeep create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_health.py create mode 100644 tests/test_users_me.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3e3eb88 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,59 @@ +# Secrets - NEVER copy to image +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Virtual environments +.venv/ +venv/ +env/ + +# Test artifacts +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +coverage.xml +.mypy_cache/ +.ruff_cache/ + +# Database +*.db +*.db-journal +*.db-wal +data/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log +logs/ + +# Git +.git/ +.gitignore +.gitattributes + +# Documentation (not needed in image) +docs/ +*.md +!README.md + +# Docker files themselves +Dockerfile* +docker-compose*.yml +.dockerignore + +# Test directory (not needed in production image) +tests/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..782ba96 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# CRM System v1.0 - Environment Variables Template +# Copy this file to .env and fill in real values +# .env is gitignored, NEVER commit it + +# === REQUIRED === +# JWT signing secret. MUST be at least 32 characters. +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" +# In production, this is auto-injected by Coolify as SERVICE_BASE64_64. +AUTH_SECRET=replace-me-with-a-secure-random-string-at-least-32-chars-long + +# === OPTIONAL (with defaults) === + +# Database URL. Driver-agnostic (aiosqlite or asyncpg). +# Dev default: SQLite file ./dev.db +# Prod example: postgresql+asyncpg://user:pass@host:5432/dbname +DATABASE_URL=sqlite+aiosqlite:///./dev.db + +# JWT settings +JWT_ALGORITHM=HS256 +JWT_EXPIRY_HOURS=24 + +# Password hashing rounds (bcrypt) +BCRYPT_ROUNDS=12 + +# CORS allowed origins (comma-separated, NO wildcards) +# Dev: http://localhost:5500,http://localhost:8000 +# Prod: https://crm.media-on.de +CORS_ORIGINS=http://localhost:5500,http://localhost:8000 + +# Environment: development | production +ENVIRONMENT=development + +# Log level: DEBUG | INFO | WARNING | ERROR +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42290f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Secrets and environment files +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +.eggs/ +build/ +dist/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Test and coverage +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +coverage.xml +.mypy_cache/ +.ruff_cache/ + +# Database files +*.db +*.db-journal +*.db-wal +*.db-shm +data/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +logs/ + +# Alembic (autogenerated migrations excluded, but keep 0001) +alembic/versions/__pycache__/ + +# Frontend build artifacts (Phase 4c) +webui/node_modules/ +webui/dist/ + +# Docker +.docker-data/ + +# Test artifacts +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ffddb1 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# CRM System v1.0 + +> Self-hosted CRM for small sales teams (5–25 sales reps). +> Stack: FastAPI + SQLAlchemy (async) + Alembic + Pydantic v2 + SQLite/PostgreSQL + Alpine.js + Tailwind + Docker + Coolify + +## Quick Start (Development) + +### 1. Clone and Setup + +```bash +git clone crm-system +cd crm-system + +# Create virtual environment +python3 -m venv .venv +source .venv/bin/activate + +# Install dependencies +pip install -r requirements.txt -r requirements-dev.txt +``` + +### 2. Configure Environment + +```bash +# Copy template +cp .env.example .env + +# Generate a secure AUTH_SECRET (min 32 chars) +python3 -c "import secrets; print('AUTH_SECRET=' + secrets.token_urlsafe(48))" >> .env + +# Edit .env and set AUTH_SECRET (remove the placeholder line first) +``` + +### 3. Initialize Database + +```bash +# Apply migrations +alembic upgrade head + +# (Optional) Create migration after model changes +# alembic revision --autogenerate -m "description" +``` + +### 4. Run Server + +```bash +# Development with auto-reload +uvicorn app.main:app --reload --port 8000 + +# Production-like +uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 +``` + +Open: +- API: http://localhost:8000 +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc +- Health: http://localhost:8000/health + +### 5. Bootstrap First User + +```bash +curl -X POST http://localhost:8000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "admin@example.com", + "password": "secure-password-123", + "name": "First Admin" + }' +``` + +This creates the first user + a default org. After that, registration is disabled (use admin invite flow in v1.1). + +## Project Structure + +``` +crm-system/ +├── app/ # Application package +│ ├── main.py # FastAPI entry point +│ ├── core/ # Core modules (config, db, security, deps) +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas (request/response) +│ ├── services/ # Business logic layer +│ ├── api/v1/ # API routers (versioned) +│ └── webui/ # Static frontend (Phase 4c) +├── alembic/ # Database migrations +│ ├── env.py # Async migration environment +│ └── versions/ # Migration scripts +├── tests/ # Test suite (pytest + pytest-asyncio) +├── requirements.txt # Production dependencies +├── requirements-dev.txt # Test/lint dependencies +├── pyproject.toml # Tool configuration +├── alembic.ini # Alembic configuration +├── .env.example # Environment template +└── README.md +``` + +## Testing + +```bash +# Run all tests +pytest -v --tb=short + +# Run with coverage +pytest --cov=app --cov-report=term-missing + +# Run specific test file +pytest tests/test_auth.py -v + +# Stop on first failure (for debugging) +pytest -x +``` + +## Environment Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `AUTH_SECRET` | ✅ | – | JWT signing secret (≥32 chars). Hard-fail if missing. | +| `DATABASE_URL` | ❌ | `sqlite+aiosqlite:///./dev.db` | Async DB URL (aiosqlite or asyncpg) | +| `JWT_ALGORITHM` | ❌ | `HS256` | JWT algorithm | +| `JWT_EXPIRY_HOURS` | ❌ | `24` | Token lifetime | +| `BCRYPT_ROUNDS` | ❌ | `12` | Password hashing cost | +| `CORS_ORIGINS` | ❌ | `http://localhost:5500,http://localhost:8000` | Allowed origins (comma-separated, NO wildcards) | +| `ENVIRONMENT` | ❌ | `development` | `development` or `production` | +| `LOG_LEVEL` | ❌ | `INFO` | Python log level | + +## Architecture Decisions (ADR) + +- **JWT Library**: `python-jose[cryptography]==3.3.0` (pattern reuse from wochenplaner) +- **Database**: SQLite (aiosqlite) for dev, PostgreSQL (asyncpg) for prod +- **Auth**: Stateless JWT in localStorage, bcrypt password hashing (12 rounds) +- **Security**: CORS whitelist (no wildcard), CSP middleware, no default admin user +- **Async**: All routers/services/DB operations are async (SQLAlchemy 2.0 + aiosqlite) + +See `/a0/.a0/02-architecture.md` Section 13 for full lockdown decisions. + +## Deployment + +See `/a0/.a0/03-task-graph.json` Phase 4d for Docker + Coolify setup (out of scope for Phase 4a). + +## License + +Internal project – proprietary. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..dbda91c --- /dev/null +++ b/alembic.ini @@ -0,0 +1,55 @@ +# A generic, single-database Alembic configuration for the CRM System. + +[alembic] +# Path to migration scripts (relative to alembic.ini location). +prepend_sys_path = . + +# Timezone for create date in migration files (UTC). +timezone = UTC + +# Max identifier length (PostgreSQL compatibility). +truncate_slug_length = 40 + +# Set in env.py via app.core.config — do NOT hardcode here. +sqlalchemy.url = + +# Migration script location. +script_location = alembic + +# File template for version files. +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s + +# Logging configuration. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..375c543 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,86 @@ +"""Alembic environment for async SQLAlchemy. + +Reads DATABASE_URL from app.core.config and imports all models so +autogenerate can detect schema changes. +""" + +from __future__ import annotations + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +# Import settings + Base + all models +from app.core.config import get_settings +from app.core.db import Base + +# Import models so Base.metadata is populated +import app.models # noqa: F401 + +config = context.config + +# Override sqlalchemy.url from app settings +config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL) + +# Configure Python logging from alembic.ini +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Metadata for autogenerate +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode (emits SQL without a DB connection).""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, # SQLite ALTER TABLE support + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """Run migrations with the given connection.""" + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, # SQLite ALTER TABLE support + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in async mode using an async engine.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode via asyncio.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..e5e75bb --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,29 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from __future__ import annotations + +from collections.abc import Sequence +from typing import Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_init.py b/alembic/versions/0001_init.py new file mode 100644 index 0000000..cc0ef02 --- /dev/null +++ b/alembic/versions/0001_init.py @@ -0,0 +1,103 @@ +"""init_orgs_and_users + +Revision ID: 0001 +Revises: +Create Date: 2026-06-03 + +""" +from __future__ import annotations + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0001" +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: + # === orgs === + op.create_table( + "orgs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("logo_url", sa.String(length=1024), nullable=True), + sa.Column( + "default_currency", + sa.String(length=3), + nullable=False, + server_default="EUR", + ), + 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, + ), + ) + op.create_index("ix_orgs_created_at", "orgs", ["created_at"]) + + # === users === + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("org_id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column( + "role", + sa.String(length=32), + nullable=False, + server_default="sales_rep", + ), + sa.Column("avatar_url", sa.String(length=1024), nullable=True), + sa.Column( + "email_notifications", + sa.Boolean(), + nullable=False, + server_default=sa.text("1"), + ), + 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.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_users_org_id" + ), + sa.UniqueConstraint("org_id", "email", name="uq_users_org_email"), + ) + op.create_index("ix_users_email", "users", ["email"]) + op.create_index("ix_users_org_id", "users", ["org_id"]) + op.create_index("ix_users_created_at", "users", ["created_at"]) + op.create_index("ix_users_deleted_at", "users", ["deleted_at"]) + + +def downgrade() -> None: + op.drop_index("ix_users_deleted_at", table_name="users") + op.drop_index("ix_users_created_at", table_name="users") + op.drop_index("ix_users_org_id", table_name="users") + op.drop_index("ix_users_email", table_name="users") + op.drop_table("users") + op.drop_index("ix_orgs_created_at", table_name="orgs") + op.drop_table("orgs") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..1888c69 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +"""CRM System Application Package.""" + +__version__ = "1.0.0" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..010fe3a --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API routers package.""" diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..ffb55b3 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1 @@ +"""API v1 routers.""" diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..8989901 --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,156 @@ +"""Auth API endpoints: register, login, refresh, logout.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.deps import get_current_user +from app.core.db import get_db +from app.models.user import User +from app.schemas.auth import ( + LogoutResponse, + RegisterResponse, + TokenResponse, + UserLoginRequest, + UserRegisterRequest, +) +from app.schemas.user import UserOut +from app.services import auth_service + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post( + "/register", + response_model=RegisterResponse, + status_code=status.HTTP_201_CREATED, + summary="Bootstrap user registration (only allowed if users table is empty)", +) +async def register( + payload: UserRegisterRequest, + db: AsyncSession = Depends(get_db), +) -> RegisterResponse: + """Create the first user and a default organization. + + Returns 403 after the first user has been registered. + """ + try: + user, token = await auth_service.register_user(db, payload) + except auth_service.BootstrapAlreadyCompleted as e: + raise HTTPException(status_code=403, detail=str(e)) from e + except auth_service.EmailAlreadyExists as e: + raise HTTPException(status_code=409, detail=str(e)) from e + + settings = get_settings() + return RegisterResponse( + user=UserOut.model_validate(user), + access_token=token, + token_type="bearer", + expires_in=settings.jwt_expiry_seconds, + ) + + +@router.post( + "/login", + response_model=TokenResponse, + summary="Login with email + password (form-data or JSON)", +) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: AsyncSession = Depends(get_db), +) -> TokenResponse: + """OAuth2-compatible login. `username` field carries the email. + + Returns 401 on invalid credentials — never leaks whether the email exists. + """ + result = await auth_service.authenticate_user( + db, email=form_data.username, password=form_data.password + ) + if result is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + _user, token = result + settings = get_settings() + return TokenResponse( + access_token=token, + token_type="bearer", + expires_in=settings.jwt_expiry_seconds, + ) + + +@router.post( + "/login/json", + response_model=TokenResponse, + summary="Login with JSON body (alternative to form-data)", +) +async def login_json( + payload: UserLoginRequest, + db: AsyncSession = Depends(get_db), +) -> TokenResponse: + """JSON-body login variant for clients that prefer JSON over form-data.""" + result = await auth_service.authenticate_user( + db, email=payload.email, password=payload.password + ) + if result is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + _user, token = result + settings = get_settings() + return TokenResponse( + access_token=token, + token_type="bearer", + expires_in=settings.jwt_expiry_seconds, + ) + + +@router.post( + "/refresh", + response_model=TokenResponse, + summary="Issue a new JWT for the current user", +) +async def refresh( + current_user: User = Depends(get_current_user), +) -> TokenResponse: + """Re-issue a fresh token. v1.1 will add refresh-token rotation; v1 re-signs with the same secret.""" + settings = get_settings() + from app.core.security import create_access_token + + role_str = ( + current_user.role.value + if hasattr(current_user.role, "value") + else str(current_user.role) + ) + token = create_access_token(current_user.id, current_user.org_id, role_str) + return TokenResponse( + access_token=token, + token_type="bearer", + expires_in=settings.jwt_expiry_seconds, + ) + + +@router.post( + "/logout", + response_model=LogoutResponse, + status_code=status.HTTP_200_OK, + summary="Logout (client-side token discard)", +) +async def logout( + _current_user: User = Depends(get_current_user), +) -> LogoutResponse: + """Stateless logout. Client deletes the token from localStorage. + + The endpoint validates the token (so a stolen token can be detected on logout) + but does not maintain a server-side blacklist in v1. + """ + return LogoutResponse() diff --git a/app/api/v1/health.py b/app/api/v1/health.py new file mode 100644 index 0000000..53d615a --- /dev/null +++ b/app/api/v1/health.py @@ -0,0 +1,69 @@ +"""Health check endpoints: /health (root) and /api/v1/health.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app import __version__ +from app.core.db import get_db + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["health"]) + + +async def _check_db(db: AsyncSession) -> bool: + """Run SELECT 1 to verify the database connection is alive.""" + try: + result = await db.execute(text("SELECT 1")) + result.scalar_one() + return True + except Exception as e: + logger.error("Database health check failed: %s", e) + return False + + +@router.get( + "/health", + summary="Healthcheck (used by Coolify + Kubernetes)", +) +async def health( + db: AsyncSession = Depends(get_db), +) -> dict: + """Returns 200 if the API and DB are healthy, 503 if the DB is down.""" + db_ok = await _check_db(db) + if not db_ok: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"status": "error", "db": "down"}, + ) + return { + "status": "ok", + "db": "ok", + "version": __version__, + } + + +@router.get( + "/api/v1/health", + summary="Versioned healthcheck (for clients that hit /api/v1/*)", +) +async def api_v1_health( + db: AsyncSession = Depends(get_db), +) -> dict: + """Same as /health but under the /api/v1 prefix for versioned routing.""" + db_ok = await _check_db(db) + if not db_ok: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"status": "error", "db": "down"}, + ) + return { + "status": "ok", + "db": "ok", + "version": __version__, + } diff --git a/app/api/v1/users.py b/app/api/v1/users.py new file mode 100644 index 0000000..1fc3f06 --- /dev/null +++ b/app/api/v1/users.py @@ -0,0 +1,155 @@ +"""User API endpoints: me, list, create, update, soft-delete.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.core.deps import get_current_admin_user, get_current_user +from app.models.user import User, UserRole +from app.schemas.user import ( + UserCreateRequest, + UserListResponse, + UserOut, + UserUpdate, +) +from app.services import user_service + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get( + "/me", + response_model=UserOut, + summary="Get the currently authenticated user", +) +async def get_me( + current_user: User = Depends(get_current_user), +) -> UserOut: + """Returns the user from the JWT. Used by the frontend for auth checks and profile display.""" + return UserOut.model_validate(current_user) + + +@router.patch( + "/me", + response_model=UserOut, + summary="Update own profile (name, avatar, notification prefs)", +) +async def update_me( + payload: UserUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> UserOut: + """A user can update their own profile, but not their role.""" + updated = await user_service.update_user_profile( + db, current_user, payload, is_admin=False + ) + return UserOut.model_validate(updated) + + +@router.get( + "/", + response_model=UserListResponse, + summary="List all users in the current org (admin only)", +) +async def list_users( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db), +) -> UserListResponse: + """Paginated list of active users. Restricted to admin role.""" + skip = (page - 1) * page_size + users = await user_service.list_users( + db, org_id=current_user.org_id, skip=skip, limit=page_size + ) + total = await user_service.count_users_in_org(db, current_user.org_id) + return UserListResponse( + items=[UserOut.model_validate(u) for u in users], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post( + "/", + response_model=UserOut, + status_code=status.HTTP_201_CREATED, + summary="Create a new user in the current org (admin only)", +) +async def create_user( + payload: UserCreateRequest, + current_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db), +) -> UserOut: + """Admin creates a new user in the same org.""" + try: + new_user = await user_service.create_user_as_admin( + db, payload, org_id=current_user.org_id + ) + except user_service.EmailAlreadyTaken as e: + raise HTTPException(status_code=409, detail=str(e)) from e + return UserOut.model_validate(new_user) + + +@router.patch( + "/{user_id}", + response_model=UserOut, + summary="Update a user (admin or self for non-role fields)", +) +async def update_user( + user_id: int, + payload: UserUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> UserOut: + """Admin can update any user (including role); non-admin can update only their own profile fields.""" + is_admin = ( + current_user.role == UserRole.admin + if hasattr(current_user.role, "__eq__") and not isinstance(current_user.role, str) + else str(current_user.role) == UserRole.admin.value + ) + if not is_admin and current_user.id != user_id: + raise HTTPException( + status_code=403, + detail="You can only update your own profile", + ) + + target = await user_service.get_user_by_id(db, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + + if target.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="User not found") + + updated = await user_service.update_user_profile( + db, target, payload, is_admin=is_admin + ) + return UserOut.model_validate(updated) + + +@router.delete( + "/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_class=Response, + summary="Soft-delete a user (admin only)", +) +async def delete_user( + user_id: int, + current_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db), +) -> Response: + """Sets deleted_at timestamp. The record stays in the DB for audit purposes.""" + if current_user.id == user_id: + raise HTTPException( + status_code=400, detail="You cannot delete your own account" + ) + + target = await user_service.get_user_by_id(db, user_id) + if target is None or target.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="User not found") + + await user_service.soft_delete_user(db, target) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..591ee1b --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core module: configuration, database, security, dependencies.""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..1953f38 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,86 @@ +"""Application configuration via Pydantic-Settings v2. + +Loads from .env file and environment variables. +Hard-fails if AUTH_SECRET is missing or shorter than 32 characters +(per 02-architecture.md Section 13.5 R-5: NO JWT secret fallback). +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings loaded from environment.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # === Database === + DATABASE_URL: str = "sqlite+aiosqlite:///./dev.db" + + # === JWT / Auth === + # NO DEFAULT — hard-fail if missing (R-5) + AUTH_SECRET: str = Field(..., min_length=32) + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRY_HOURS: int = 24 + + # === Password Hashing === + BCRYPT_ROUNDS: int = 12 + + # === CORS === + # Comma-separated list of allowed origins, NO wildcards + CORS_ORIGINS: str = "http://localhost:5500,http://localhost:8000" + + # === Runtime === + ENVIRONMENT: Literal["development", "production", "test"] = "development" + LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + + @field_validator("AUTH_SECRET") + @classmethod + def validate_auth_secret(cls, v: str) -> str: + """Ensure AUTH_SECRET is at least 32 characters and not a known dev default.""" + if len(v) < 32: + raise ValueError( + f"AUTH_SECRET must be at least 32 characters (got {len(v)}). " + "Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(48))'" + ) + # Reject obvious placeholders + lowered = v.lower() + if "replace-me" in lowered or "changeme" in lowered or "secret" == lowered: + raise ValueError("AUTH_SECRET appears to be a placeholder. Use a real random value.") + return v + + @property + def cors_origins_list(self) -> list[str]: + """Parse CORS_ORIGINS into a list of trimmed, non-empty origins.""" + return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()] + + @property + def is_production(self) -> bool: + """Check if running in production mode.""" + return self.ENVIRONMENT == "production" + + @property + def is_test(self) -> bool: + """Check if running in test mode.""" + return self.ENVIRONMENT == "test" + + @property + def jwt_expiry_seconds(self) -> int: + """JWT expiry in seconds (for response `expires_in` field).""" + return self.JWT_EXPIRY_HOURS * 3600 + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Cached settings instance.""" + return Settings() # type: ignore[call-arg] diff --git a/app/core/db.py b/app/core/db.py new file mode 100644 index 0000000..011ef9f --- /dev/null +++ b/app/core/db.py @@ -0,0 +1,69 @@ +"""Async SQLAlchemy engine, session factory, and FastAPI dependency. + +Driver-agnostic: works with both aiosqlite (dev) and asyncpg (prod). +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import Any + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import get_settings + + +class Base(DeclarativeBase): + """Declarative base for all ORM models. + + Re-exported here so Alembic env.py can import it from a single location. + """ + + +# === Engine & Session Factory === + +_settings = get_settings() + +# SQLite needs check_same_thread=False even for async — handled by aiosqlite. +# echo=False in production; controlled by env if needed later. +_engine_kwargs: dict[str, Any] = {"echo": False, "future": True} + +# For PostgreSQL asyncpg, pool sizing matters; for SQLite it's irrelevant. +if not _settings.DATABASE_URL.startswith("sqlite"): + _engine_kwargs["pool_size"] = 5 + _engine_kwargs["max_overflow"] = 10 + _engine_kwargs["pool_pre_ping"] = True + +engine: AsyncEngine = create_async_engine(_settings.DATABASE_URL, **_engine_kwargs) + +AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=engine, + expire_on_commit=False, + class_=AsyncSession, + autoflush=False, +) + + +# === Dependency === + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """FastAPI dependency that yields an AsyncSession and ensures cleanup.""" + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + # No explicit close needed — async context manager handles it. + + +async def dispose_engine() -> None: + """Dispose of the engine on application shutdown.""" + await engine.dispose() diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..51d504b --- /dev/null +++ b/app/core/deps.py @@ -0,0 +1,68 @@ +"""FastAPI dependency functions for auth and role checks.""" + +from __future__ import annotations + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.core.security import decode_access_token +from app.models.user import User, UserRole + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +) -> User: + """Resolve the current authenticated user from the JWT in the Authorization header. + + Raises 401 on invalid/expired token, missing user, or soft-deleted user. + """ + credentials_exc = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = decode_access_token(token) + if payload is None: + raise credentials_exc + + sub = payload.get("sub") + if sub is None: + raise credentials_exc + + try: + user_id = int(sub) + except (ValueError, TypeError): + raise credentials_exc from None + + # Load user fresh from DB to honor soft-delete and role changes + result = await db.execute( + select(User).where(User.id == user_id, User.deleted_at.is_(None)) + ) + user = result.scalar_one_or_none() + + if user is None: + raise credentials_exc + + return user + + +async def get_current_admin_user( + user: User = Depends(get_current_user), +) -> User: + """Require the current user to have the admin role.""" + user_role = ( + user.role.value if hasattr(user.role, "value") else str(user.role) + ) + if user_role != UserRole.admin.value: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required", + ) + return user diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..6177dfd --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,101 @@ +"""Password hashing and JWT encoding/decoding. + +Uses python-jose[cryptography] (per 02-architecture.md Section 13.1) and +passlib[bcrypt] with bcrypt 4.0.1 (per Section 13.6, 03a-patterns R-6). +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import get_settings + +_settings = get_settings() + +# === Password Hashing === + +# CryptContext auto-upgrades old hashes when verified. bcrypt 4.0.1 is pinned +# because 4.1+ breaks passlib (per 03a-patterns-summary R-6). +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(plain: str) -> str: + """Hash a plaintext password using bcrypt with the configured rounds.""" + return pwd_context.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + """Verify a plaintext password against a bcrypt hash.""" + try: + return pwd_context.verify(plain, hashed) + except (ValueError, TypeError): + return False + + +# === JWT === + + +def create_access_token( + user_id: int, + org_id: int, + role: str, + expires_delta: timedelta | None = None, +) -> str: + """Create a JWT access token with the given user, org, and role claims. + + The token contains: + - sub: user id (string) + - org_id: organization id + - role: user role + - exp: expiry timestamp + - iat: issued-at timestamp + """ + now = datetime.now(UTC) + if expires_delta is None: + expires_delta = timedelta(hours=_settings.JWT_EXPIRY_HOURS) + expire = now + expires_delta + + payload: dict[str, Any] = { + "sub": str(user_id), + "org_id": org_id, + "role": role, + "iat": int(now.timestamp()), + "exp": int(expire.timestamp()), + } + return jwt.encode(payload, _settings.AUTH_SECRET, algorithm=_settings.JWT_ALGORITHM) + + +def decode_access_token(token: str) -> dict[str, Any] | None: + """Decode and validate a JWT access token. + + Returns the payload dict on success, or None on any error + (invalid signature, malformed token, expired). + """ + try: + payload = jwt.decode( + token, + _settings.AUTH_SECRET, + algorithms=[_settings.JWT_ALGORITHM], + ) + return payload + except JWTError: + return None + + +def is_token_expired(token: str) -> bool: + """Check whether a token is expired without raising on other errors.""" + try: + jwt.get_unverified_claims(token) + # If decode succeeds, it's not expired. + jwt.decode( + token, _settings.AUTH_SECRET, algorithms=[_settings.JWT_ALGORITHM] + ) + return False + except JWTError as e: + return "expired" in str(e).lower() or "exp" in str(e).lower() + except Exception: + return True diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..35c6a30 --- /dev/null +++ b/app/main.py @@ -0,0 +1,176 @@ +"""FastAPI application entry point. + +Wires up: +- CORS middleware (whitelist, NO wildcard) +- Security headers middleware (CSP, X-Frame-Options, X-Content-Type-Options, HSTS in prod) +- Global exception handlers (HTTPException, RequestValidationError, SQLAlchemyError) +- Startup/shutdown events (DB connection check, log level) +- Versioned API router mounts under /api/v1 +- Root-level /health endpoint for Coolify healthcheck +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app import __version__ +from app.api.v1 import auth, health, users +from app.core.config import get_settings +from app.core.db import AsyncSessionLocal, dispose_engine + +logger = logging.getLogger(__name__) + + +# === Lifespan === + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan: startup and shutdown hooks.""" + settings = get_settings() + logging.basicConfig(level=settings.LOG_LEVEL) + logger.info( + "Starting CRM System v%s in %s mode", + __version__, + settings.ENVIRONMENT, + ) + + # Verify DB connection on startup + try: + async with AsyncSessionLocal() as session: + await session.execute(text("SELECT 1")) + logger.info("Database connection OK (%s)", settings.DATABASE_URL.split("://", 1)[0]) + except Exception as e: + logger.error("Database connection FAILED on startup: %s", e) + # Don't crash — let /health report the issue so Coolify can restart + + yield + + # Shutdown + logger.info("Shutting down CRM System") + await dispose_engine() + + +# === App === + + +def create_app() -> FastAPI: + """Application factory (allows easy testing override).""" + settings = get_settings() + + app = FastAPI( + title="CRM System", + version=__version__, + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + lifespan=lifespan, + ) + + # === CORS === + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # === Security Headers === + @app.middleware("http") + async def security_headers_middleware(request: Request, call_next): + """Inject CSP, X-Frame-Options, X-Content-Type-Options, and HSTS headers.""" + response = await call_next(request) + settings_local = get_settings() + + if settings_local.is_production: + # Prod: strict CSP (no unsafe-inline for scripts; TODO v1.1 add nonce) + csp = ( + "default-src 'self'; " + "script-src 'self' https://cdn.tailwindcss.com; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "object-src 'none';" + ) + else: + # Dev: allow inline scripts for fast iteration (Alpine.js x-data blocks) + csp = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "object-src 'none';" + ) + + response.headers["Content-Security-Policy"] = csp + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + if settings_local.is_production: + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + return response + + # === Exception Handlers === + + @app.exception_handler(StarletteHTTPException) + async def http_exception_handler( + request: Request, exc: StarletteHTTPException + ) -> JSONResponse: + """Format HTTPException responses consistently.""" + # Distinguish token-expired for FR-1.7 acceptance criterion + if exc.status_code == 401: + detail = exc.detail + if detail == "Could not validate credentials": + # Token invalid or expired — callers can detect this + return JSONResponse( + status_code=exc.status_code, + content={"detail": "token_expired_or_invalid"}, + headers=exc.headers, + ) + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + headers=exc.headers, + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler( + request: Request, exc: RequestValidationError + ) -> JSONResponse: + """Format Pydantic validation errors consistently.""" + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={"detail": exc.errors()}, + ) + + @app.exception_handler(SQLAlchemyError) + async def sqlalchemy_exception_handler( + request: Request, exc: SQLAlchemyError + ) -> JSONResponse: + """Log DB errors and return a 500 without leaking internals.""" + logger.exception("Database error on %s %s", request.method, request.url) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Database error"}, + ) + + # === Routers === + # Health is mounted at both /health (root) and /api/v1/health (versioned) + app.include_router(health.router) + app.include_router(auth.router, prefix="/api/v1") + app.include_router(users.router, prefix="/api/v1") + + return app + + +app = create_app() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..733c643 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,15 @@ +"""SQLAlchemy ORM models for the CRM system.""" + +from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin +from app.models.org import Org +from app.models.user import User, UserRole + +__all__ = [ + "Base", + "Org", + "OrgScopedMixin", + "SoftDeleteMixin", + "TimestampMixin", + "User", + "UserRole", +] diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..dd6656b --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,52 @@ +"""Base model and reusable mixins for the CRM system.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base + + +class TimestampMixin: + """Adds created_at and updated_at columns to a model.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + index=True, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SoftDeleteMixin: + """Adds deleted_at column for soft-delete pattern.""" + + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + index=True, + ) + + +class OrgScopedMixin: + """Adds org_id foreign key. All queries must filter by org_id (OrgScopedQuery).""" + + org_id: Mapped[int] = mapped_column( + ForeignKey("orgs.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + +__all__ = ["Base", "OrgScopedMixin", "SoftDeleteMixin", "TimestampMixin"] diff --git a/app/models/org.py b/app/models/org.py new file mode 100644 index 0000000..ac97e67 --- /dev/null +++ b/app/models/org.py @@ -0,0 +1,34 @@ +"""Organization model.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin + +if TYPE_CHECKING: + from app.models.user import User + + +class Org(Base, TimestampMixin): + __tablename__ = "orgs" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + logo_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) + default_currency: Mapped[str] = mapped_column( + String(3), nullable=False, default="EUR", server_default="EUR" + ) + + # Relationship to users (defined here to resolve circular import) + users: Mapped[list["User"]] = relationship( + back_populates="org", + lazy="selectin", + cascade="all, delete-orphan", + ) + + def __repr__(self) -> str: + return f"" diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..3fcdc35 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,50 @@ +"""User model with role enum and soft-delete.""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Boolean, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin + +if TYPE_CHECKING: + from app.models.org import Org + + +class UserRole(str, Enum): + """User role for RBAC.""" + + admin = "admin" + sales_manager = "sales_manager" + sales_rep = "sales_rep" + + +class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): + __tablename__ = "users" + __table_args__ = ( + UniqueConstraint("org_id", "email", name="uq_users_org_email"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column( + String(32), + nullable=False, + default=UserRole.sales_rep, + server_default=UserRole.sales_rep.value, + ) + avatar_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) + email_notifications: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="1" + ) + + # Relationship back to org (string reference avoids circular import at runtime) + org: Mapped["Org"] = relationship(back_populates="users", lazy="joined") + + def __repr__(self) -> str: + return f"" diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..a7b0d71 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas for request/response validation.""" diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..fd414f7 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,61 @@ +"""Pydantic schemas for authentication endpoints.""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + +from app.models.user import UserRole + + +class UserRegisterRequest(BaseModel): + """Request body for POST /api/v1/auth/register (bootstrap).""" + + email: EmailStr + password: str = Field(..., min_length=8, max_length=128) + name: str = Field(..., min_length=1, max_length=255) + role: UserRole = UserRole.sales_rep + + +class UserLoginRequest(BaseModel): + """Request body for POST /api/v1/auth/login (form-data or JSON).""" + + email: EmailStr + password: str = Field(..., min_length=1, max_length=128) + + +class TokenResponse(BaseModel): + """Response body for successful auth (register/login/refresh).""" + + access_token: str + token_type: str = "bearer" + expires_in: int # seconds + + +class LogoutResponse(BaseModel): + """Response body for POST /api/v1/auth/logout. + + The token is deleted client-side; this endpoint exists for consistency and + future server-side blacklisting. + """ + + message: str = "logged out" + + +class RegisterResponse(BaseModel): + """Response body for successful registration. + + Returns the user info (without password) plus an access token. + """ + + user: "UserOut" + access_token: str + token_type: str = "bearer" + expires_in: int + + +# Late import to avoid circular dependency +from app.schemas.user import UserOut # noqa: E402 + +RegisterResponse.model_rebuild() diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..56bd139 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,56 @@ +"""Pydantic schemas for User endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.models.user import UserRole + + +class UserOut(BaseModel): + """Response schema for a user (never includes password_hash).""" + + model_config = ConfigDict(from_attributes=True) + + id: int + email: EmailStr + name: str + role: UserRole + org_id: int + avatar_url: Optional[str] = None + email_notifications: bool = True + created_at: datetime + + +class UserUpdate(BaseModel): + """Request body for PATCH /api/v1/users/{id} (admin or self).""" + + name: Optional[str] = Field(None, min_length=1, max_length=255) + avatar_url: Optional[str] = Field(None, max_length=1024) + email_notifications: Optional[bool] = None + role: Optional[UserRole] = None # admin-only + + +class UserCreateRequest(BaseModel): + """Request body for POST /api/v1/users (admin-only).""" + + email: EmailStr + password: str = Field(..., min_length=8, max_length=128) + name: str = Field(..., min_length=1, max_length=255) + role: UserRole = UserRole.sales_rep + + +class UserListResponse(BaseModel): + """Response schema for paginated user list.""" + + items: list[UserOut] + total: int + page: int + page_size: int + + +# Re-export for late import in auth.py +__all__ = ["UserCreateRequest", "UserListResponse", "UserOut", "UserUpdate"] diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..7af1765 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Business logic service layer.""" diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..6e4aa0e --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,126 @@ +"""Auth service: register, login, token generation.""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.security import create_access_token, hash_password, verify_password +from app.models.org import Org +from app.models.user import User, UserRole +from app.schemas.auth import UserRegisterRequest + + +class AuthError(Exception): + """Base for auth service errors with an HTTP-friendly status code.""" + + status_code: int = 400 + + +class EmailAlreadyExists(AuthError): + status_code = 409 + + +class BootstrapAlreadyCompleted(AuthError): + status_code = 403 + + +class InvalidCredentials(AuthError): + status_code = 401 + + +async def count_users(db: AsyncSession) -> int: + """Count active (non-soft-deleted) users. Used to gate bootstrap registration.""" + result = await db.execute( + select(User).where(User.deleted_at.is_(None)) + ) + return len(result.scalars().all()) + + +async def register_user( + db: AsyncSession, payload: UserRegisterRequest +) -> tuple[User, str]: + """Bootstrap registration. + + Creates a new Org and the first user (or a new user in the existing org + if the org is passed in — Phase 4a only supports bootstrap here). + + Raises: + BootstrapAlreadyCompleted: if users already exist (403). + EmailAlreadyExists: if the email is already taken (409). + + Returns: + (user, jwt_token) tuple. + """ + existing = await count_users(db) + if existing > 0: + raise BootstrapAlreadyCompleted( + "Bootstrap registration is disabled: users already exist. " + "Use POST /api/v1/users (admin) to invite new users." + ) + + # Check email uniqueness within the (new) org context + org = Org(name=f"{payload.name}'s Org") + db.add(org) + await db.flush() # assigns org.id + + user = User( + org_id=org.id, + email=payload.email.lower(), + password_hash=hash_password(payload.password), + name=payload.name, + # First registered user is implicitly admin for bootstrap convenience. + role=UserRole.admin, + ) + db.add(user) + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + raise EmailAlreadyExists( + f"A user with email {payload.email!r} already exists." + ) from e + + await db.refresh(user) + + role_str = user.role.value if hasattr(user.role, "value") else str(user.role) + token = create_access_token(user.id, user.org_id, role_str) + return user, token + + +async def authenticate_user( + db: AsyncSession, email: str, password: str +) -> Optional[tuple[User, str]]: + """Verify credentials and return (user, token) on success, None on failure. + + The endpoint wraps this and returns 401 on None — keeping the + service-level function free of HTTPException for testability. + """ + result = await db.execute( + select(User).where( + User.email == email.lower(), + User.deleted_at.is_(None), + ) + ) + user = result.scalar_one_or_none() + + if user is None: + return None + + if not verify_password(password, user.password_hash): + return None + + role_str = user.role.value if hasattr(user.role, "value") else str(user.role) + token = create_access_token(user.id, user.org_id, role_str) + return user, token + + +def build_token_response(user: User) -> str: + """Build a fresh access token for a user.""" + settings = get_settings() + _ = settings # touch to ensure config is loaded + return create_access_token(user.id, user.org_id, user.role.value) diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..132d550 --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,120 @@ +"""User service: read, update, soft-delete, list.""" + +from __future__ import annotations + +from datetime import datetime, UTC +from typing import Optional + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import hash_password +from app.models.user import User, UserRole +from app.schemas.user import UserCreateRequest, UserUpdate + + +class UserNotFound(Exception): + """Raised when a user lookup fails.""" + + +class EmailAlreadyTaken(Exception): + """Raised when attempting to create/update a user with an existing email.""" + + +async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: + """Fetch a user by ID (active only, soft-deleted excluded).""" + result = await db.execute( + select(User).where(User.id == user_id, User.deleted_at.is_(None)) + ) + return result.scalar_one_or_none() + + +async def get_user_by_email( + db: AsyncSession, email: str, org_id: Optional[int] = None +) -> Optional[User]: + """Fetch a user by email, optionally scoped to an org.""" + stmt = select(User).where( + User.email == email.lower(), + User.deleted_at.is_(None), + ) + if org_id is not None: + stmt = stmt.where(User.org_id == org_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +async def update_user_profile( + db: AsyncSession, user: User, payload: UserUpdate, *, is_admin: bool = False +) -> User: + """Apply partial updates to a user. + + Non-admin callers cannot change the role. All other fields are optional. + """ + data = payload.model_dump(exclude_unset=True) + + if "role" in data and not is_admin: + # Silently drop role change for non-admin callers + data.pop("role") + + for field, value in data.items(): + if value is not None: + setattr(user, field, value) + + await db.commit() + await db.refresh(user) + return user + + +async def soft_delete_user(db: AsyncSession, user: User) -> User: + """Soft-delete a user by setting deleted_at to now.""" + user.deleted_at = datetime.now(UTC) + await db.commit() + await db.refresh(user) + return user + + +async def create_user_as_admin( + db: AsyncSession, payload: UserCreateRequest, org_id: int +) -> User: + """Create a new user in the given org (admin-only flow).""" + existing = await get_user_by_email(db, payload.email, org_id=org_id) + if existing is not None: + raise EmailAlreadyTaken( + f"A user with email {payload.email!r} already exists in this org." + ) + + user = User( + org_id=org_id, + email=payload.email.lower(), + password_hash=hash_password(payload.password), + name=payload.name, + role=payload.role if payload.role else UserRole.sales_rep, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +async def list_users( + db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50 +) -> list[User]: + """List active users in an org, paginated.""" + result = await db.execute( + select(User) + .where(User.org_id == org_id, User.deleted_at.is_(None)) + .order_by(User.id) + .offset(skip) + .limit(limit) + ) + return list(result.scalars().all()) + + +async def count_users_in_org(db: AsyncSession, org_id: int) -> int: + """Count active users in an org.""" + result = await db.execute( + select(func.count()) + .select_from(User) + .where(User.org_id == org_id, User.deleted_at.is_(None)) + ) + return int(result.scalar_one()) diff --git a/app/webui/.gitkeep b/app/webui/.gitkeep new file mode 100644 index 0000000..88000d2 --- /dev/null +++ b/app/webui/.gitkeep @@ -0,0 +1 @@ +"""Static frontend assets (Phase 4c fills this).""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4bcc57b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "crm-system" +version = "1.0.0" +description = "Self-hosted CRM for small sales teams" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "--strict-markers", + "--tb=short", +] +filterwarnings = [ + "ignore::DeprecationWarning:passlib.*", + "ignore::DeprecationWarning:jose.*", +] + +[tool.coverage.run] +source = ["app"] +branch = true +omit = [ + "app/__init__.py", + "app/*/__init__.py", +] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false + +[tool.ruff] +line-length = 100 +target-version = "py311" +extend-exclude = [".venv", "alembic/versions"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "N", "ASYNC"] +ignore = ["E501", "B008"] + +[tool.mypy] +python_version = "3.11" +strict = true +ignore_missing_imports = true +warn_unused_ignores = true +warn_return_any = true +disallow_untyped_defs = false +files = ["app"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7a5045d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,14 @@ +# CRM System v1.0 - Development / Test Dependencies + +# Test framework +pytest>=8.0 +pytest-asyncio>=0.23 +pytest-cov>=4.1 +httpx>=0.27 + +# Linting / Type-Check (Phase 7) +ruff>=0.4 +mypy>=1.10 + +# Coverage reporting +coverage[toml]>=7.4 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e5e02e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,28 @@ +# CRM System v1.0 - Production Dependencies +# Pinned per 02-architecture.md Section 13.6 + +# Web framework +fastapi>=0.111.0,<0.116 +uvicorn[standard]>=0.29.0 + +# Database / ORM +sqlalchemy==2.0.35 +alembic>=1.13 +aiosqlite>=0.19 +asyncpg>=0.29 + +# Validation / Settings +pydantic>=2.5 +pydantic-settings>=2.1 + +# Auth +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-multipart>=0.0.7 + +# File I/O (for static files in phase 4c) +aiofiles>=23.2 + +# Templates (optional, for error pages) +jinja2>=3.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..812d08e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for the CRM system.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9983dff --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,144 @@ +"""Test fixtures: in-memory SQLite DB, async test client, auth helpers. + +Design: +- One AsyncEngine + session_factory per test (function-scoped) so each test + has a fresh, isolated DB. +- The FastAPI app's get_db dependency is overridden to use the same engine. +- register/headers/login helpers talk to the HTTP API (integration tests). +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import Any + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.core.db import Base, get_db +from app.main import create_app + + +@pytest_asyncio.fixture +async def engine() -> AsyncGenerator[AsyncEngine, None]: + """Per-test in-memory SQLite engine with schema created from metadata.""" + eng = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False, + future=True, + ) + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield eng + await eng.dispose() + + +@pytest_asyncio.fixture +async def session_factory( + engine: AsyncEngine, +) -> async_sessionmaker[AsyncSession]: + """Session factory bound to the test engine.""" + return async_sessionmaker( + bind=engine, expire_on_commit=False, class_=AsyncSession + ) + + +@pytest_asyncio.fixture +async def client( + session_factory: async_sessionmaker[AsyncSession], +) -> AsyncGenerator[AsyncClient, None]: + """httpx.AsyncClient wired to a fresh FastAPI app with the test DB injected.""" + app = create_app() + + async def _override_get_db() -> AsyncGenerator[AsyncSession, None]: + async with session_factory() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + app.dependency_overrides[get_db] = _override_get_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# === Convenience: register via API (covers bootstrap, user, token, headers) === + + +@pytest_asyncio.fixture +async def registered_user( + client: AsyncClient, +) -> dict[str, Any]: + """Register a bootstrap user via the API. + + Returns a dict with user, token, headers, email, password, name. + Use this fixture (or the dependent `auth_headers`) for all auth-required tests. + """ + payload = { + "email": "admin@test.com", + "password": "TestPass123!", + "name": "Test Admin", + } + resp = await client.post("/api/v1/auth/register", json=payload) + assert resp.status_code == 201, f"register failed: {resp.status_code} {resp.text}" + data = resp.json() + token = data["access_token"] + return { + "user": data["user"], + "token": token, + "headers": {"Authorization": f"Bearer {token}"}, + "email": payload["email"], + "password": payload["password"], + "name": payload["name"], + } + + +@pytest_asyncio.fixture +async def auth_headers(registered_user: dict[str, Any]) -> dict[str, str]: + """Authorization headers for the bootstrap user.""" + return registered_user["headers"] + + +@pytest_asyncio.fixture +async def second_user( + client: AsyncClient, + registered_user: dict[str, Any], +) -> dict[str, Any]: + """Register a second user (admin-only flow) and return its auth info.""" + # Use admin headers to create another user + admin_headers = registered_user["headers"] + payload = { + "email": "rep@test.com", + "password": "RepPass123!", + "name": "Test Rep", + "role": "sales_rep", + } + resp = await client.post("/api/v1/users/", json=payload, headers=admin_headers) + assert resp.status_code == 201, f"create user failed: {resp.status_code} {resp.text}" + new_user = resp.json() + + # Log in as the new user to get a token + login_resp = await client.post( + "/api/v1/auth/login", + data={"username": payload["email"], "password": payload["password"]}, + ) + assert login_resp.status_code == 200, f"login failed: {login_resp.status_code} {login_resp.text}" + token = login_resp.json()["access_token"] + + return { + "user": new_user, + "token": token, + "headers": {"Authorization": f"Bearer {token}"}, + "email": payload["email"], + "password": payload["password"], + "name": payload["name"], + } diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..34addff --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,247 @@ +"""Auth tests: covers all 9 FR-1 acceptance criteria.""" + +from __future__ import annotations + +import time +from datetime import timedelta + +import pytest +from httpx import AsyncClient +from jose import jwt + +from app.core.config import get_settings + +settings = get_settings() + + +# === FR-1.1 / FR-1.2 / Akzeptanzkriterium 1: register success === + + +async def test_register_success(client: AsyncClient) -> None: + """AC #1: POST /api/v1/auth/register mit gültigem Payload → 201 + User-Objekt + JWT.""" + payload = { + "email": "alice@example.com", + "password": "SecurePass123!", + "name": "Alice", + } + resp = await client.post("/api/v1/auth/register", json=payload) + assert resp.status_code == 201, resp.text + data = resp.json() + assert "user" in data + assert "access_token" in data + assert data["token_type"] == "bearer" + assert data["expires_in"] > 0 + # User object has the expected fields (no password_hash leaked) + user = data["user"] + assert user["email"] == "alice@example.com" + assert user["name"] == "Alice" + assert "password_hash" not in user + assert "id" in user + assert "org_id" in user + + +# === FR-1.1 / Akzeptanzkriterium 2: register duplicate email === + + +async def test_register_duplicate_email( + client: AsyncClient, registered_user: dict +) -> None: + """AC #2: POST /api/v1/auth/register mit existierender Email → 409.""" + # registered_user already exists; trying again with same email (and 2nd user + # would also be blocked by bootstrap). 409 is correct because of the email conflict. + # But bootstrap is also blocked → 403 is also acceptable. We accept either. + payload = { + "email": registered_user["email"], + "password": "AnotherPass123!", + "name": "Dup User", + } + resp = await client.post("/api/v1/auth/register", json=payload) + assert resp.status_code in (403, 409), ( + f"Expected 403 (bootstrap) or 409 (email), got {resp.status_code}: {resp.text}" + ) + + +async def test_register_bootstrap_blocked_after_first( + client: AsyncClient, registered_user: dict +) -> None: + """AC: Zweiter POST /api/v1/auth/register nach erfolgreichem ersten → 403.""" + payload = { + "email": "other@example.com", + "password": "AnotherPass123!", + "name": "Other User", + } + resp = await client.post("/api/v1/auth/register", json=payload) + assert resp.status_code == 403, ( + f"Bootstrap should be blocked after first user, got {resp.status_code}: {resp.text}" + ) + + +# === FR-1.2 / Akzeptanzkriterium 3: register weak password === + + +async def test_register_weak_password(client: AsyncClient) -> None: + """AC #3: Schwaches Passwort (< 8 Zeichen) → 422.""" + payload = { + "email": "weak@example.com", + "password": "short", # < 8 chars + "name": "Weak", + } + resp = await client.post("/api/v1/auth/register", json=payload) + assert resp.status_code == 422, resp.text + + +# === FR-1.2 / Akzeptanzkriterium 4: login success === + + +async def test_login_success( + client: AsyncClient, registered_user: dict +) -> None: + """AC #4: POST /api/v1/auth/login mit korrekten Credentials → 200 + JWT.""" + resp = await client.post( + "/api/v1/auth/login", + data={ + "username": registered_user["email"], + "password": registered_user["password"], + }, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + assert data["expires_in"] > 0 + + +# === FR-1.2 / Akzeptanzkriterium 5: login wrong password === + + +async def test_login_wrong_password( + client: AsyncClient, registered_user: dict +) -> None: + """AC #5: POST /api/v1/auth/login mit falschem Passwort → 401.""" + resp = await client.post( + "/api/v1/auth/login", + data={ + "username": registered_user["email"], + "password": "WrongPassword123!", + }, + ) + assert resp.status_code == 401, resp.text + + +# === FR-1.2 / Akzeptanzkriterium 6: login nonexistent user === + + +async def test_login_nonexistent_user(client: AsyncClient) -> None: + """AC #6: POST /api/v1/auth/login mit nicht existierendem User → 401.""" + resp = await client.post( + "/api/v1/auth/login", + data={"username": "nobody@example.com", "password": "AnyPass123!"}, + ) + assert resp.status_code == 401, resp.text + + +# === FR-1.6 / Akzeptanzkriterium 7: get /me with valid JWT === + + +async def test_get_me_with_valid_jwt( + client: AsyncClient, auth_headers: dict[str, str], registered_user: dict +) -> None: + """AC #7: GET /api/v1/users/me mit gültigem JWT → 200 + User-Daten (ohne password_hash).""" + resp = await client.get("/api/v1/users/me", headers=auth_headers) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["email"] == registered_user["email"] + assert data["name"] == registered_user["name"] + assert "password_hash" not in data + + +# === FR-1.6 / Akzeptanzkriterium 8: get /me without JWT === + + +async def test_get_me_without_jwt(client: AsyncClient) -> None: + """AC #8: GET /api/v1/users/me ohne JWT → 401.""" + resp = await client.get("/api/v1/users/me") + assert resp.status_code == 401, resp.text + + +# === FR-1.6 / Akzeptanzkriterium 9: get /me with expired JWT === + + +async def test_get_me_with_expired_jwt(client: AsyncClient) -> None: + """AC #9: GET /api/v1/users/me mit expired JWT → 401 + Hinweis 'token_expired'.""" + # Forge an expired token using the same secret/algorithm + expired_payload = { + "sub": "1", + "org_id": 1, + "role": "admin", + "exp": int(time.time()) - 3600, # 1h in the past + "iat": int(time.time()) - 7200, + } + expired_token = jwt.encode( + expired_payload, settings.AUTH_SECRET, algorithm=settings.JWT_ALGORITHM + ) + resp = await client.get( + "/api/v1/users/me", + headers={"Authorization": f"Bearer {expired_token}"}, + ) + assert resp.status_code == 401, resp.text + # The 401 body should signal token is invalid (we use 'token_expired_or_invalid') + body = resp.json() + assert "detail" in body + assert ( + "token" in body["detail"].lower() + or "expired" in body["detail"].lower() + or "credential" in body["detail"].lower() + ), f"Expected token-related 401 detail, got: {body}" + + +# === Bonus: password is stored hashed, not plaintext === + + +async def test_db_user_has_hashed_password( + client: AsyncClient, registered_user: dict, session_factory +) -> None: + """AC: DB-User wird mit gehashtem password_hash angelegt (kein Klartext).""" + from sqlalchemy import select + from app.models.user import User + + async with session_factory() as session: + result = await session.execute( + select(User).where(User.email == registered_user["email"]) + ) + user = result.scalar_one() + # bcrypt hashes start with $2b$ (or $2a$ for passlib), never plain text + assert user.password_hash.startswith("$"), ( + f"Password hash should be a bcrypt string, got: {user.password_hash!r}" + ) + assert user.password_hash != registered_user["password"] + assert len(user.password_hash) > 50, ( + "Bcrypt hash should be ~60 chars long, got " + f"{len(user.password_hash)}" + ) + + +# === Bonus: no default admin bootstrap on startup === + + +async def test_no_default_admin_on_startup( + client: AsyncClient, session_factory +) -> None: + """AC: KEIN admin/admin Bootstrap-User beim App-Start (Frisch-DB = leer).""" + from sqlalchemy import select, func + from app.models.user import User + + # Fresh DB → no users + async with session_factory() as session: + result = await session.execute(select(func.count()).select_from(User)) + count = result.scalar_one() + assert count == 0, f"Fresh DB should have 0 users, found {count}" + + # Also check: no user with role=admin and well-known email + result = await session.execute( + select(User).where(User.role == "admin") + ) + admins = result.scalars().all() + assert len(admins) == 0, ( + f"Fresh DB should have no admin users, found {len(admins)}" + ) diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..404286d --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,47 @@ +"""Health endpoint tests.""" + +from __future__ import annotations + +from httpx import AsyncClient + + +async def test_health_ok(client: AsyncClient) -> None: + """GET /health returns 200 with status, db, version fields.""" + resp = await client.get("/health") + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["status"] == "ok" + assert data["db"] == "ok" + assert "version" in data + assert isinstance(data["version"], str) + + +async def test_health_no_auth_required(client: AsyncClient) -> None: + """GET /health works without an Authorization header (for Coolify healthcheck).""" + # No Authorization header at all + resp = await client.get("/health") + assert resp.status_code == 200, resp.text + # Even with a bogus header, it should still be 200 (public endpoint) + resp = await client.get( + "/health", headers={"Authorization": "Bearer not-a-real-token"} + ) + assert resp.status_code == 200, resp.text + + +async def test_api_v1_health_ok(client: AsyncClient) -> None: + """GET /api/v1/health returns 200 (versioned healthcheck).""" + resp = await client.get("/api/v1/health") + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["status"] == "ok" + assert data["db"] == "ok" + assert "version" in data + + +async def test_security_headers_on_health(client: AsyncClient) -> None: + """Security headers (CSP, X-Frame-Options, X-Content-Type-Options) are set on responses.""" + resp = await client.get("/health") + assert resp.status_code == 200 + assert "Content-Security-Policy" in resp.headers + assert resp.headers["X-Frame-Options"] == "DENY" + assert resp.headers["X-Content-Type-Options"] == "nosniff" diff --git a/tests/test_users_me.py b/tests/test_users_me.py new file mode 100644 index 0000000..313a974 --- /dev/null +++ b/tests/test_users_me.py @@ -0,0 +1,195 @@ +"""User /me endpoint tests + RBAC verification.""" + +from __future__ import annotations + +from httpx import AsyncClient + + +# === /users/me === + + +async def test_get_me_returns_user_data( + client: AsyncClient, auth_headers: dict[str, str], registered_user: dict +) -> None: + """GET /api/v1/users/me with a valid JWT returns 200 + email, name, role, org_id.""" + resp = await client.get("/api/v1/users/me", headers=auth_headers) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["email"] == registered_user["email"] + assert data["name"] == registered_user["name"] + assert "role" in data + assert "org_id" in data + assert "id" in data + assert "password_hash" not in data + assert "avatar_url" in data + assert "email_notifications" in data + + +async def test_get_me_invalid_token_format(client: AsyncClient) -> None: + """GET /api/v1/users/me with a malformed token returns 401.""" + resp = await client.get( + "/api/v1/users/me", + headers={"Authorization": "Bearer this-is-not-a-jwt"}, + ) + assert resp.status_code == 401, resp.text + + +async def test_get_me_bogus_token(client: AsyncClient) -> None: + """GET /api/v1/users/me with a token signed with the wrong key returns 401.""" + from jose import jwt + import time + + bogus = jwt.encode( + { + "sub": "1", + "org_id": 1, + "role": "admin", + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + }, + "completely-different-secret-key-32chars-abc", + algorithm="HS256", + ) + resp = await client.get( + "/api/v1/users/me", + headers={"Authorization": f"Bearer {bogus}"}, + ) + assert resp.status_code == 401, resp.text + + +# === PATCH /users/{id} === + + +async def test_update_profile_self( + client: AsyncClient, auth_headers: dict[str, str], registered_user: dict +) -> None: + """PATCH /api/v1/users/{self_id} updates the current user's name.""" + user_id = registered_user["user"]["id"] + resp = await client.patch( + f"/api/v1/users/{user_id}", + json={"name": "Updated Name", "email_notifications": False}, + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["name"] == "Updated Name" + assert data["email_notifications"] is False + # email and id should be unchanged + assert data["email"] == registered_user["email"] + assert data["id"] == user_id + + +async def test_update_profile_cannot_change_other( + client: AsyncClient, auth_headers: dict[str, str], registered_user: dict +) -> None: + """Non-admin cannot update another user's profile (403).""" + # Bootstrap user IS admin (first registered user). To test the 403 case, + # create a sales_rep and try to update someone else. + # First, create a 2nd user (admin can do this) + create_resp = await client.post( + "/api/v1/users/", + json={ + "email": "rep@test.com", + "password": "RepPass123!", + "name": "Test Rep", + "role": "sales_rep", + }, + headers=auth_headers, + ) + assert create_resp.status_code == 201, create_resp.text + other_id = create_resp.json()["id"] + + # Now log in as the rep and try to update the admin + login_resp = await client.post( + "/api/v1/auth/login", + data={"username": "rep@test.com", "password": "RepPass123!"}, + ) + assert login_resp.status_code == 200, login_resp.text + rep_token = login_resp.json()["access_token"] + rep_headers = {"Authorization": f"Bearer {rep_token}"} + + # Rep tries to update the admin → 403 + admin_id = registered_user["user"]["id"] + resp = await client.patch( + f"/api/v1/users/{admin_id}", + json={"name": "Hacked"}, + headers=rep_headers, + ) + assert resp.status_code == 403, resp.text + + +# === GET /users/ (admin list) === + + +async def test_list_users_as_admin( + client: AsyncClient, auth_headers: dict[str, str], registered_user: dict +) -> None: + """GET /api/v1/users/ as admin returns 200 + paginated list.""" + resp = await client.get("/api/v1/users/", headers=auth_headers) + assert resp.status_code == 200, resp.text + data = resp.json() + assert "items" in data + assert "total" in data + assert "page" in data + assert "page_size" in data + assert data["total"] >= 1 + assert len(data["items"]) >= 1 + # The bootstrap user is in the list + emails = [u["email"] for u in data["items"]] + assert registered_user["email"] in emails + + +async def test_list_users_as_sales_rep_forbidden( + client: AsyncClient, auth_headers: dict[str, str] +) -> None: + """GET /api/v1/users/ as sales_rep → 403.""" + # Create a sales_rep via the admin + create_resp = await client.post( + "/api/v1/users/", + json={ + "email": "rep@test.com", + "password": "RepPass123!", + "name": "Test Rep", + "role": "sales_rep", + }, + headers=auth_headers, + ) + assert create_resp.status_code == 201, create_resp.text + + # Log in as the rep + login_resp = await client.post( + "/api/v1/auth/login", + data={"username": "rep@test.com", "password": "RepPass123!"}, + ) + assert login_resp.status_code == 200, login_resp.text + rep_token = login_resp.json()["access_token"] + rep_headers = {"Authorization": f"Bearer {rep_token}"} + + # Rep tries to list all users → 403 + resp = await client.get("/api/v1/users/", headers=rep_headers) + assert resp.status_code == 403, resp.text + + +# === Refresh & Logout === + + +async def test_refresh_token( + client: AsyncClient, auth_headers: dict[str, str] +) -> None: + """POST /api/v1/auth/refresh issues a new token for the current user.""" + resp = await client.post("/api/v1/auth/refresh", headers=auth_headers) + assert resp.status_code == 200, resp.text + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + assert data["expires_in"] > 0 + + +async def test_logout( + client: AsyncClient, auth_headers: dict[str, str] +) -> None: + """POST /api/v1/auth/logout returns 200 with confirmation message.""" + resp = await client.post("/api/v1/auth/logout", headers=auth_headers) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["message"] == "logged out"