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