feat(phase-4a): backend skeleton, auth, health, tests

This commit is contained in:
CRM Bot
2026-06-03 20:52:01 +00:00
commit 955607f730
39 changed files with 2709 additions and 0 deletions
+59
View File
@@ -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/
+34
View File
@@ -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
View File
@@ -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/
+143
View File
@@ -0,0 +1,143 @@
# CRM System v1.0
> Self-hosted CRM for small sales teams (525 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
View File
@@ -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
+86
View File
@@ -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()
+29
View File
@@ -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"}
+103
View File
@@ -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")
+3
View File
@@ -0,0 +1,3 @@
"""CRM System Application Package."""
__version__ = "1.0.0"
+1
View File
@@ -0,0 +1 @@
"""API routers package."""
+1
View File
@@ -0,0 +1 @@
"""API v1 routers."""
+156
View File
@@ -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()
+69
View File
@@ -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__,
}
+155
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Core module: configuration, database, security, dependencies."""
+86
View File
@@ -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]
+69
View File
@@ -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()
+68
View File
@@ -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
+101
View File
@@ -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
View File
@@ -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()
+15
View File
@@ -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",
]
+52
View File
@@ -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"]
+34
View File
@@ -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}>"
+50
View File
@@ -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}>"
+1
View File
@@ -0,0 +1 @@
"""Pydantic schemas for request/response validation."""
+61
View File
@@ -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()
+56
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Business logic service layer."""
+126
View File
@@ -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)
+120
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Static frontend assets (Phase 4c fills this)."""
+56
View File
@@ -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"]
+14
View File
@@ -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
+28
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Test package for the CRM system."""
+144
View File
@@ -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"],
}
+247
View File
@@ -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)}"
)
+47
View File
@@ -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"
+195
View File
@@ -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"