Spaces:
Runtime error
Runtime error
feat(auth): password hashing, JWT, and signup (#3)
Browse files* feat(security): add bcrypt hashing and jwt helpers
* feat(schemas): add user create and response schemas with byte validation
* feat(auth): add signup endpoint and register auth router
* test(auth): cover signup, case-insensitivity, and password limits
* chore: add missing error tests and alembic boilerplate
- alembic/README +1 -0
- alembic/script.py.mako +28 -0
- app/main.py +5 -13
- app/models.py +76 -0
- app/routers/auth.py +42 -0
- app/schemas.py +24 -0
- app/security.py +26 -0
- tests/test_auth.py +61 -0
- tests/test_errors.py +47 -0
alembic/README
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Generic single-database configuration.
|
alembic/script.py.mako
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
${upgrades if upgrades else "pass"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def downgrade() -> None:
|
| 27 |
+
"""Downgrade schema."""
|
| 28 |
+
${downgrades if downgrades else "pass"}
|
app/main.py
CHANGED
|
@@ -4,6 +4,7 @@ from fastapi import FastAPI, Request
|
|
| 4 |
from fastapi.exceptions import RequestValidationError
|
| 5 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 6 |
from app.errors import AppError, error_response
|
|
|
|
| 7 |
|
| 8 |
logger = logging.getLogger("uvicorn.error")
|
| 9 |
|
|
@@ -15,7 +16,9 @@ def create_app() -> FastAPI:
|
|
| 15 |
redoc_url=None,
|
| 16 |
)
|
| 17 |
|
| 18 |
-
#
|
|
|
|
|
|
|
| 19 |
@app.middleware("http")
|
| 20 |
async def catch_unhandled(request: Request, call_next):
|
| 21 |
try:
|
|
@@ -24,7 +27,6 @@ def create_app() -> FastAPI:
|
|
| 24 |
logger.exception("unhandled error", extra={"request_id": getattr(request.state, "request_id", "unknown")})
|
| 25 |
return error_response(request, 500, "INTERNAL_ERROR", "Unexpected server error.")
|
| 26 |
|
| 27 |
-
# 2. Request-ID middleware
|
| 28 |
@app.middleware("http")
|
| 29 |
async def add_request_id(request: Request, call_next):
|
| 30 |
request.state.request_id = str(uuid.uuid4())
|
|
@@ -32,7 +34,6 @@ def create_app() -> FastAPI:
|
|
| 32 |
response.headers["X-Request-ID"] = request.state.request_id
|
| 33 |
return response
|
| 34 |
|
| 35 |
-
# 3. Exception Handlers
|
| 36 |
@app.exception_handler(AppError)
|
| 37 |
async def app_error_handler(request: Request, exc: AppError):
|
| 38 |
return error_response(request, exc.status_code, exc.code, exc.message, exc.details, exc.headers)
|
|
@@ -41,23 +42,14 @@ def create_app() -> FastAPI:
|
|
| 41 |
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
| 42 |
details = []
|
| 43 |
for error in exc.errors():
|
| 44 |
-
# error["loc"] looks like ("body", "name")
|
| 45 |
loc = error.get("loc", [])
|
| 46 |
field = ".".join(str(x) for x in loc) if loc else "unknown"
|
| 47 |
-
|
| 48 |
details.append({
|
| 49 |
"field": field,
|
| 50 |
"reason": error.get("type", "invalid"),
|
| 51 |
"message": error.get("msg", "Invalid value")
|
| 52 |
})
|
| 53 |
-
|
| 54 |
-
return error_response(
|
| 55 |
-
request,
|
| 56 |
-
422,
|
| 57 |
-
"VALIDATION_ERROR",
|
| 58 |
-
"Request validation failed.",
|
| 59 |
-
details=details
|
| 60 |
-
)
|
| 61 |
|
| 62 |
@app.exception_handler(StarletteHTTPException)
|
| 63 |
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
|
|
|
| 4 |
from fastapi.exceptions import RequestValidationError
|
| 5 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 6 |
from app.errors import AppError, error_response
|
| 7 |
+
from app.routers import auth # <-- IMPORT THE ROUTER
|
| 8 |
|
| 9 |
logger = logging.getLogger("uvicorn.error")
|
| 10 |
|
|
|
|
| 16 |
redoc_url=None,
|
| 17 |
)
|
| 18 |
|
| 19 |
+
# Register the router
|
| 20 |
+
app.include_router(auth.router, prefix="/api/v1")
|
| 21 |
+
|
| 22 |
@app.middleware("http")
|
| 23 |
async def catch_unhandled(request: Request, call_next):
|
| 24 |
try:
|
|
|
|
| 27 |
logger.exception("unhandled error", extra={"request_id": getattr(request.state, "request_id", "unknown")})
|
| 28 |
return error_response(request, 500, "INTERNAL_ERROR", "Unexpected server error.")
|
| 29 |
|
|
|
|
| 30 |
@app.middleware("http")
|
| 31 |
async def add_request_id(request: Request, call_next):
|
| 32 |
request.state.request_id = str(uuid.uuid4())
|
|
|
|
| 34 |
response.headers["X-Request-ID"] = request.state.request_id
|
| 35 |
return response
|
| 36 |
|
|
|
|
| 37 |
@app.exception_handler(AppError)
|
| 38 |
async def app_error_handler(request: Request, exc: AppError):
|
| 39 |
return error_response(request, exc.status_code, exc.code, exc.message, exc.details, exc.headers)
|
|
|
|
| 42 |
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
| 43 |
details = []
|
| 44 |
for error in exc.errors():
|
|
|
|
| 45 |
loc = error.get("loc", [])
|
| 46 |
field = ".".join(str(x) for x in loc) if loc else "unknown"
|
|
|
|
| 47 |
details.append({
|
| 48 |
"field": field,
|
| 49 |
"reason": error.get("type", "invalid"),
|
| 50 |
"message": error.get("msg", "Invalid value")
|
| 51 |
})
|
| 52 |
+
return error_response(request, 422, "VALIDATION_ERROR", "Request validation failed.", details=details)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
@app.exception_handler(StarletteHTTPException)
|
| 55 |
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
app/models.py
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from sqlalchemy import Column, String, Boolean, Integer, DateTime, ForeignKey, UniqueConstraint, LargeBinary
|
| 4 |
+
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
| 5 |
+
from pgvector.sqlalchemy import Vector
|
| 6 |
+
from app.db import Base
|
| 7 |
+
|
| 8 |
+
class User(Base):
|
| 9 |
+
__tablename__ = "users"
|
| 10 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 11 |
+
email = Column(String(320), nullable=False)
|
| 12 |
+
password_hash = Column(String(255), nullable=False)
|
| 13 |
+
role = Column(String(20), nullable=False, default="owner")
|
| 14 |
+
is_active = Column(Boolean, nullable=False, default=True)
|
| 15 |
+
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 16 |
+
|
| 17 |
+
class Hub(Base):
|
| 18 |
+
__tablename__ = "hubs"
|
| 19 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 20 |
+
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
| 21 |
+
name = Column(String(120), nullable=False)
|
| 22 |
+
description = Column(String(500), nullable=True)
|
| 23 |
+
ai_summary = Column(String, nullable=True)
|
| 24 |
+
suggested_questions = Column(JSONB, nullable=False, default=list)
|
| 25 |
+
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 26 |
+
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 27 |
+
|
| 28 |
+
__table_args__ = (
|
| 29 |
+
UniqueConstraint("owner_id", "name", name="uq_hub_name_per_owner"),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
class Document(Base):
|
| 33 |
+
__tablename__ = "documents"
|
| 34 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 35 |
+
hub_id = Column(UUID(as_uuid=True), ForeignKey("hubs.id", ondelete="CASCADE"), nullable=False)
|
| 36 |
+
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
| 37 |
+
origin = Column(String(20), nullable=False)
|
| 38 |
+
filename = Column(String(255), nullable=False)
|
| 39 |
+
mime_type = Column(String(100), nullable=False)
|
| 40 |
+
size_bytes = Column(Integer, nullable=False)
|
| 41 |
+
checksum_sha256 = Column(String(64), nullable=False)
|
| 42 |
+
status = Column(String(20), nullable=False, default="pending")
|
| 43 |
+
error_message = Column(String, nullable=True)
|
| 44 |
+
file_bytes = Column(LargeBinary, nullable=True)
|
| 45 |
+
process_claim = Column(UUID(as_uuid=True), nullable=True)
|
| 46 |
+
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 47 |
+
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 48 |
+
|
| 49 |
+
__table_args__ = (
|
| 50 |
+
UniqueConstraint("hub_id", "checksum_sha256", name="uq_doc_checksum_per_hub"),
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
class Chunk(Base):
|
| 54 |
+
__tablename__ = "chunks"
|
| 55 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 56 |
+
document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), nullable=False)
|
| 57 |
+
hub_id = Column(UUID(as_uuid=True), ForeignKey("hubs.id", ondelete="CASCADE"), nullable=False)
|
| 58 |
+
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
| 59 |
+
chunk_index = Column(Integer, nullable=False)
|
| 60 |
+
text = Column(String, nullable=False)
|
| 61 |
+
token_count = Column(Integer, nullable=False)
|
| 62 |
+
embedding = Column(Vector(768), nullable=False)
|
| 63 |
+
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 64 |
+
|
| 65 |
+
class IdempotencyKey(Base):
|
| 66 |
+
__tablename__ = "idempotency_keys"
|
| 67 |
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
| 68 |
+
key = Column(UUID(as_uuid=True), primary_key=True)
|
| 69 |
+
request_method = Column(String(10), nullable=False)
|
| 70 |
+
request_path = Column(String(255), nullable=False)
|
| 71 |
+
request_hash = Column(String(64), nullable=False)
|
| 72 |
+
status = Column(String(20), nullable=False, default="in_progress")
|
| 73 |
+
response_status = Column(Integer, nullable=True)
|
| 74 |
+
response_body = Column(JSONB, nullable=True)
|
| 75 |
+
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 76 |
+
expires_at = Column(DateTime(timezone=True), nullable=False)
|
app/routers/auth.py
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from sqlalchemy.exc import IntegrityError
|
| 4 |
+
from app.db import get_db
|
| 5 |
+
from app.models import User
|
| 6 |
+
from app.schemas import UserCreate, UserResponse
|
| 7 |
+
from app.security import hash_password
|
| 8 |
+
from app.errors import AppError
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 11 |
+
|
| 12 |
+
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
| 13 |
+
def signup(user_data: UserCreate, db: Session = Depends(get_db)):
|
| 14 |
+
# 1. Check if email exists (using lower() for case-insensitivity)
|
| 15 |
+
existing_user = db.query(User).filter(User.email.ilike(user_data.email)).first()
|
| 16 |
+
if existing_user:
|
| 17 |
+
raise AppError(
|
| 18 |
+
status_code=409,
|
| 19 |
+
code="EMAIL_ALREADY_REGISTERED",
|
| 20 |
+
message="An account with this email already exists.",
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# 2. Hash password and create user
|
| 24 |
+
new_user = User(
|
| 25 |
+
email=user_data.email,
|
| 26 |
+
password_hash=hash_password(user_data.password)
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
db.add(new_user)
|
| 31 |
+
db.commit()
|
| 32 |
+
db.refresh(new_user)
|
| 33 |
+
except IntegrityError:
|
| 34 |
+
# Catch race conditions where two requests hit at the exact same time
|
| 35 |
+
db.rollback()
|
| 36 |
+
raise AppError(
|
| 37 |
+
status_code=409,
|
| 38 |
+
code="EMAIL_ALREADY_REGISTERED",
|
| 39 |
+
message="An account with this email already exists.",
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
return new_user
|
app/schemas.py
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from uuid import UUID
|
| 3 |
+
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_validator
|
| 4 |
+
|
| 5 |
+
class UserCreate(BaseModel):
|
| 6 |
+
model_config = ConfigDict(extra="forbid")
|
| 7 |
+
email: EmailStr
|
| 8 |
+
password: str = Field(min_length=8, max_length=128)
|
| 9 |
+
|
| 10 |
+
@field_validator("password")
|
| 11 |
+
@classmethod
|
| 12 |
+
def password_strength(cls, v: str) -> str:
|
| 13 |
+
if not any(c.isdigit() for c in v) or not any(c.isalpha() for c in v):
|
| 14 |
+
raise ValueError("must contain at least one letter and one digit")
|
| 15 |
+
if len(v.encode("utf-8")) > 72:
|
| 16 |
+
raise ValueError("must be at most 72 bytes when encoded as UTF-8")
|
| 17 |
+
return v
|
| 18 |
+
|
| 19 |
+
class UserResponse(BaseModel):
|
| 20 |
+
model_config = ConfigDict(from_attributes=True)
|
| 21 |
+
id: UUID
|
| 22 |
+
email: EmailStr
|
| 23 |
+
role: str
|
| 24 |
+
created_at: datetime
|
app/security.py
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import bcrypt
|
| 2 |
+
import jwt
|
| 3 |
+
from datetime import datetime, timedelta, timezone
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
from app.config import settings
|
| 6 |
+
|
| 7 |
+
# A dummy hash used to prevent timing attacks when an email doesn't exist
|
| 8 |
+
_DUMMY_HASH = bcrypt.hashpw(b"not-a-real-password", bcrypt.gensalt()).decode()
|
| 9 |
+
|
| 10 |
+
def hash_password(plain: str) -> str:
|
| 11 |
+
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
| 12 |
+
|
| 13 |
+
def verify_password(plain: str, stored_hash: str | None) -> bool:
|
| 14 |
+
# Always runs one bcrypt comparison, even if stored_hash is None
|
| 15 |
+
ok = bcrypt.checkpw(plain.encode(), (stored_hash or _DUMMY_HASH).encode())
|
| 16 |
+
return ok and stored_hash is not None
|
| 17 |
+
|
| 18 |
+
def create_access_token(user_id: UUID, role: str) -> str:
|
| 19 |
+
now = datetime.now(timezone.utc)
|
| 20 |
+
payload = {
|
| 21 |
+
"sub": str(user_id),
|
| 22 |
+
"role": role,
|
| 23 |
+
"iat": now,
|
| 24 |
+
"exp": now + timedelta(minutes=60),
|
| 25 |
+
}
|
| 26 |
+
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
tests/test_auth.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def test_signup_valid(client):
|
| 2 |
+
response = client.post("/api/v1/auth/signup", json={
|
| 3 |
+
"email": "test@example.com",
|
| 4 |
+
"password": "ValidPass123"
|
| 5 |
+
})
|
| 6 |
+
assert response.status_code == 201
|
| 7 |
+
data = response.json()
|
| 8 |
+
assert data["email"] == "test@example.com"
|
| 9 |
+
assert data["role"] == "owner"
|
| 10 |
+
assert "password_hash" not in data
|
| 11 |
+
assert "id" in data
|
| 12 |
+
|
| 13 |
+
def test_signup_duplicate_email_case_insensitive(client):
|
| 14 |
+
# First signup
|
| 15 |
+
client.post("/api/v1/auth/signup", json={
|
| 16 |
+
"email": "Test@Example.com",
|
| 17 |
+
"password": "ValidPass123"
|
| 18 |
+
})
|
| 19 |
+
# Second signup with different casing
|
| 20 |
+
response = client.post("/api/v1/auth/signup", json={
|
| 21 |
+
"email": "test@example.com",
|
| 22 |
+
"password": "ValidPass123"
|
| 23 |
+
})
|
| 24 |
+
assert response.status_code == 409
|
| 25 |
+
assert response.json()["error"]["code"] == "EMAIL_ALREADY_REGISTERED"
|
| 26 |
+
|
| 27 |
+
def test_signup_weak_password(client):
|
| 28 |
+
response = client.post("/api/v1/auth/signup", json={
|
| 29 |
+
"email": "weak@example.com",
|
| 30 |
+
"password": "abc"
|
| 31 |
+
})
|
| 32 |
+
assert response.status_code == 422
|
| 33 |
+
data = response.json()
|
| 34 |
+
assert data["error"]["code"] == "VALIDATION_ERROR"
|
| 35 |
+
# Check that the field is mapped correctly
|
| 36 |
+
fields = [d["field"] for d in data["error"]["details"]]
|
| 37 |
+
assert "body.password" in fields
|
| 38 |
+
|
| 39 |
+
def test_signup_password_over_72_bytes(client):
|
| 40 |
+
# 40 emojis * 4 bytes = 160 bytes
|
| 41 |
+
emoji_password = "😀" * 40
|
| 42 |
+
response = client.post("/api/v1/auth/signup", json={
|
| 43 |
+
"email": "emoji@example.com",
|
| 44 |
+
"password": emoji_password
|
| 45 |
+
})
|
| 46 |
+
assert response.status_code == 422
|
| 47 |
+
data = response.json()
|
| 48 |
+
fields = [d["field"] for d in response.json()["error"]["details"]]
|
| 49 |
+
assert "body.password" in fields
|
| 50 |
+
|
| 51 |
+
def test_signup_extra_field_forbidden(client):
|
| 52 |
+
response = client.post("/api/v1/auth/signup", json={
|
| 53 |
+
"email": "hacker@example.com",
|
| 54 |
+
"password": "ValidPass123",
|
| 55 |
+
"role": "admin" # This should be forbidden!
|
| 56 |
+
})
|
| 57 |
+
assert response.status_code == 422
|
| 58 |
+
data = response.json()
|
| 59 |
+
assert data["error"]["code"] == "VALIDATION_ERROR"
|
| 60 |
+
fields = [d["field"] for d in data["error"]["details"]]
|
| 61 |
+
assert "body.role" in fields
|
tests/test_errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def test_404_returns_envelope(client):
|
| 2 |
+
response = client.get("/nope")
|
| 3 |
+
assert response.status_code == 404
|
| 4 |
+
|
| 5 |
+
data = response.json()
|
| 6 |
+
assert "error" in data
|
| 7 |
+
assert data["error"]["code"] == "ROUTE_NOT_FOUND"
|
| 8 |
+
assert data["error"]["status"] == 404
|
| 9 |
+
assert "request_id" in data["error"]
|
| 10 |
+
|
| 11 |
+
def test_422_returns_field_details(client):
|
| 12 |
+
# We will create a temporary endpoint just for this test to trigger a 422
|
| 13 |
+
from app.main import app
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
+
|
| 16 |
+
class DummySchema(BaseModel):
|
| 17 |
+
email: str
|
| 18 |
+
|
| 19 |
+
@app.post("/test-validation")
|
| 20 |
+
def test_route(data: DummySchema):
|
| 21 |
+
return {"ok": True}
|
| 22 |
+
|
| 23 |
+
response = client.post("/test-validation", json={"wrong_field": "value"})
|
| 24 |
+
assert response.status_code == 422
|
| 25 |
+
|
| 26 |
+
data = response.json()
|
| 27 |
+
assert data["error"]["code"] == "VALIDATION_ERROR"
|
| 28 |
+
assert data["error"]["details"][0]["field"] == "body.email"
|
| 29 |
+
assert data["error"]["details"][0]["reason"] == "missing"
|
| 30 |
+
|
| 31 |
+
def test_request_id_header_present(client):
|
| 32 |
+
response = client.get("/healthz")
|
| 33 |
+
assert "X-Request-ID" in response.headers
|
| 34 |
+
|
| 35 |
+
def test_unhandled_exception_returns_500_envelope(client):
|
| 36 |
+
from app.main import app
|
| 37 |
+
@app.get("/test-crash")
|
| 38 |
+
def crash_route():
|
| 39 |
+
raise ValueError("Boom!")
|
| 40 |
+
|
| 41 |
+
response = client.get("/test-crash")
|
| 42 |
+
assert response.status_code == 500
|
| 43 |
+
data = response.json()
|
| 44 |
+
assert data["error"]["code"] == "INTERNAL_ERROR"
|
| 45 |
+
# Ensure no traceback leaks
|
| 46 |
+
assert "Traceback" not in response.text
|
| 47 |
+
assert "ValueError" not in response.text
|