feat(phase-4a): backend skeleton, auth, health, tests
This commit is contained in:
@@ -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/
|
||||
@@ -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
|
||||
+65
@@ -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/
|
||||
@@ -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 <repo-url> 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.
|
||||
+55
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,3 @@
|
||||
"""CRM System Application Package."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API v1 routers."""
|
||||
@@ -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()
|
||||
@@ -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__,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Core module: configuration, database, security, dependencies."""
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
+176
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"<Org id={self.id} name={self.name!r}>"
|
||||
@@ -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"<User id={self.id} email={self.email!r} role={self.role}>"
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic schemas for request/response validation."""
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Business logic service layer."""
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Static frontend assets (Phase 4c fills this)."""
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Test package for the CRM system."""
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user