Spaces:
Running
Running
Zeetay commited on
Commit ·
3f28422
1
Parent(s): 2276aab
Add login/register: JWT auth, SQLite users, protected generate, Login/Register pages and AuthContext
Browse files- .env.example +3 -0
- app.py +22 -11
- auth.py +72 -0
- database.py +75 -0
- requirements.txt +5 -0
- routers/__init__.py +1 -0
- routers/auth.py +66 -0
- tests/test_app.py +33 -15
- tests/test_auth.py +69 -0
.env.example
CHANGED
|
@@ -11,5 +11,8 @@ GROQ_API_KEY=
|
|
| 11 |
# CLAUDE_MODEL=claude-3-5-haiku-20241022
|
| 12 |
# GROQ_MODEL=llama-3.3-70b-versatile
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
# Optional: CORS origins (comma-separated). Use * for development.
|
| 15 |
# CORS_ORIGINS=https://your-frontend.fly.dev
|
|
|
|
| 11 |
# CLAUDE_MODEL=claude-3-5-haiku-20241022
|
| 12 |
# GROQ_MODEL=llama-3.3-70b-versatile
|
| 13 |
|
| 14 |
+
# Auth: secret for JWT signing (set a long random string in production)
|
| 15 |
+
SECRET_KEY=change-me-in-production
|
| 16 |
+
|
| 17 |
# Optional: CORS origins (comma-separated). Use * for development.
|
| 18 |
# CORS_ORIGINS=https://your-frontend.fly.dev
|
app.py
CHANGED
|
@@ -11,7 +11,7 @@ import os
|
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
from dotenv import load_dotenv
|
| 14 |
-
from fastapi import FastAPI, HTTPException, Request, APIRouter
|
| 15 |
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
from pydantic import BaseModel, Field
|
| 17 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
@@ -22,6 +22,9 @@ import logging
|
|
| 22 |
|
| 23 |
from ai_prompts import TEMPLATES
|
| 24 |
from utils import call_llm
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
# -----------------------------------------------------------------------------
|
| 27 |
# Environment setup
|
|
@@ -105,35 +108,43 @@ class GenerateRequest(BaseModel):
|
|
| 105 |
# API Routes
|
| 106 |
# -----------------------------------------------------------------------------
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
@app.get("/")
|
| 109 |
async def root():
|
| 110 |
"""Health/root endpoint so the backend URL doesn't return 404 when visited."""
|
| 111 |
-
return {"service": "Pseudogen API", "docs": "/docs", "generate": "POST /generate-pseudocode", "v1": "POST /v1/generate-pseudocode"}
|
| 112 |
|
| 113 |
# v1 API router (versioned endpoint; same behavior as legacy path)
|
| 114 |
v1_router = APIRouter(prefix="/v1", tags=["v1"])
|
| 115 |
|
| 116 |
@v1_router.post("/generate-pseudocode")
|
| 117 |
@limiter.limit("30/minute")
|
| 118 |
-
async def generate_v1(request: Request, req: GenerateRequest):
|
| 119 |
-
"""Same as generate; versioned under /v1."""
|
| 120 |
-
return await generate_impl(request, req)
|
| 121 |
|
| 122 |
app.include_router(v1_router)
|
| 123 |
|
| 124 |
@app.post("/generate-pseudocode")
|
| 125 |
@limiter.limit("30/minute")
|
| 126 |
-
async def generate(request: Request, req: GenerateRequest):
|
| 127 |
"""
|
| 128 |
-
Generate pseudocode from a given problem description.
|
| 129 |
-
|
| 130 |
"""
|
| 131 |
-
return await generate_impl(request, req)
|
| 132 |
|
| 133 |
|
| 134 |
-
async def generate_impl(request: Request, req: GenerateRequest):
|
| 135 |
"""Shared implementation for generate and generate_v1."""
|
| 136 |
-
plan = (
|
| 137 |
if plan != "premium" and len(req.problem_description) > FREE_MAX_INPUT_LEN:
|
| 138 |
raise HTTPException(
|
| 139 |
status_code=400,
|
|
|
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
from dotenv import load_dotenv
|
| 14 |
+
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
|
| 15 |
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
from pydantic import BaseModel, Field
|
| 17 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
|
|
| 22 |
|
| 23 |
from ai_prompts import TEMPLATES
|
| 24 |
from utils import call_llm
|
| 25 |
+
from database import init_db
|
| 26 |
+
from auth import get_current_user
|
| 27 |
+
from routers.auth import router as auth_router
|
| 28 |
|
| 29 |
# -----------------------------------------------------------------------------
|
| 30 |
# Environment setup
|
|
|
|
| 108 |
# API Routes
|
| 109 |
# -----------------------------------------------------------------------------
|
| 110 |
|
| 111 |
+
@app.on_event("startup")
|
| 112 |
+
def on_startup():
|
| 113 |
+
init_db()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
app.include_router(auth_router)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
@app.get("/")
|
| 120 |
async def root():
|
| 121 |
"""Health/root endpoint so the backend URL doesn't return 404 when visited."""
|
| 122 |
+
return {"service": "Pseudogen API", "docs": "/docs", "auth": "/auth/login", "generate": "POST /generate-pseudocode", "v1": "POST /v1/generate-pseudocode"}
|
| 123 |
|
| 124 |
# v1 API router (versioned endpoint; same behavior as legacy path)
|
| 125 |
v1_router = APIRouter(prefix="/v1", tags=["v1"])
|
| 126 |
|
| 127 |
@v1_router.post("/generate-pseudocode")
|
| 128 |
@limiter.limit("30/minute")
|
| 129 |
+
async def generate_v1(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
|
| 130 |
+
"""Same as generate; versioned under /v1. Requires auth."""
|
| 131 |
+
return await generate_impl(request, req, user)
|
| 132 |
|
| 133 |
app.include_router(v1_router)
|
| 134 |
|
| 135 |
@app.post("/generate-pseudocode")
|
| 136 |
@limiter.limit("30/minute")
|
| 137 |
+
async def generate(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
|
| 138 |
"""
|
| 139 |
+
Generate pseudocode from a given problem description. Requires Bearer token.
|
| 140 |
+
Plan (free/premium) is taken from the logged-in user.
|
| 141 |
"""
|
| 142 |
+
return await generate_impl(request, req, user)
|
| 143 |
|
| 144 |
|
| 145 |
+
async def generate_impl(request: Request, req: GenerateRequest, user: dict):
|
| 146 |
"""Shared implementation for generate and generate_v1."""
|
| 147 |
+
plan = (user.get("plan") or "free").strip().lower()
|
| 148 |
if plan != "premium" and len(req.problem_description) > FREE_MAX_INPUT_LEN:
|
| 149 |
raise HTTPException(
|
| 150 |
status_code=400,
|
auth.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/auth.py
|
| 2 |
+
"""
|
| 3 |
+
JWT and password hashing for Pseudogen. get_current_user dependency for protected routes.
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
from datetime import datetime, timedelta, timezone
|
| 7 |
+
|
| 8 |
+
import jwt
|
| 9 |
+
from fastapi import Depends, HTTPException, status
|
| 10 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 11 |
+
from passlib.context import CryptContext
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from database import get_user_by_email, get_user_by_id
|
| 16 |
+
|
| 17 |
+
load_dotenv(dotenv_path=Path(__file__).resolve().parent / ".env")
|
| 18 |
+
|
| 19 |
+
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-use-env")
|
| 20 |
+
ALGORITHM = "HS256"
|
| 21 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
| 22 |
+
|
| 23 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 24 |
+
security = HTTPBearer(auto_error=False)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def hash_password(password: str) -> str:
|
| 28 |
+
return pwd_context.hash(password)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def verify_password(plain: str, hashed: str) -> bool:
|
| 32 |
+
return pwd_context.verify(plain, hashed)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def create_access_token(data: dict) -> str:
|
| 36 |
+
to_encode = data.copy()
|
| 37 |
+
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 38 |
+
to_encode["exp"] = expire
|
| 39 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def decode_token(token: str) -> dict | None:
|
| 43 |
+
try:
|
| 44 |
+
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 45 |
+
except jwt.PyJWTError:
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def get_current_user(
|
| 50 |
+
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
| 51 |
+
) -> dict:
|
| 52 |
+
"""Dependency: require valid Bearer token and return user dict (id, email, plan)."""
|
| 53 |
+
if credentials is None:
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 56 |
+
detail="Not authenticated",
|
| 57 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 58 |
+
)
|
| 59 |
+
payload = decode_token(credentials.credentials)
|
| 60 |
+
if payload is None:
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 63 |
+
detail="Invalid or expired token",
|
| 64 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 65 |
+
)
|
| 66 |
+
user_id = payload.get("sub")
|
| 67 |
+
if not user_id:
|
| 68 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
| 69 |
+
user = get_user_by_id(int(user_id))
|
| 70 |
+
if user is None:
|
| 71 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
| 72 |
+
return user
|
database.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/database.py
|
| 2 |
+
"""
|
| 3 |
+
SQLite database and user CRUD for Pseudogen auth.
|
| 4 |
+
"""
|
| 5 |
+
import sqlite3
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
_BACKEND_DIR = Path(__file__).resolve().parent
|
| 9 |
+
DB_PATH = _BACKEND_DIR / "pseudogen.db"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_connection():
|
| 13 |
+
conn = sqlite3.connect(DB_PATH)
|
| 14 |
+
conn.row_factory = sqlite3.Row
|
| 15 |
+
return conn
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def init_db():
|
| 19 |
+
"""Create users table if it does not exist."""
|
| 20 |
+
conn = get_connection()
|
| 21 |
+
try:
|
| 22 |
+
conn.execute("""
|
| 23 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 24 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 25 |
+
email TEXT UNIQUE NOT NULL,
|
| 26 |
+
hashed_password TEXT NOT NULL,
|
| 27 |
+
plan TEXT NOT NULL DEFAULT 'free',
|
| 28 |
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
| 29 |
+
)
|
| 30 |
+
""")
|
| 31 |
+
conn.commit()
|
| 32 |
+
finally:
|
| 33 |
+
conn.close()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_user_by_email(email: str) -> dict | None:
|
| 37 |
+
conn = get_connection()
|
| 38 |
+
try:
|
| 39 |
+
row = conn.execute(
|
| 40 |
+
"SELECT id, email, hashed_password, plan, created_at FROM users WHERE email = ?",
|
| 41 |
+
(email.strip().lower(),),
|
| 42 |
+
).fetchone()
|
| 43 |
+
if row is None:
|
| 44 |
+
return None
|
| 45 |
+
return dict(row)
|
| 46 |
+
finally:
|
| 47 |
+
conn.close()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_user_by_id(user_id: int) -> dict | None:
|
| 51 |
+
conn = get_connection()
|
| 52 |
+
try:
|
| 53 |
+
row = conn.execute(
|
| 54 |
+
"SELECT id, email, plan, created_at FROM users WHERE id = ?",
|
| 55 |
+
(user_id,),
|
| 56 |
+
).fetchone()
|
| 57 |
+
if row is None:
|
| 58 |
+
return None
|
| 59 |
+
return dict(row)
|
| 60 |
+
finally:
|
| 61 |
+
conn.close()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def create_user(email: str, hashed_password: str, plan: str = "free") -> dict:
|
| 65 |
+
conn = get_connection()
|
| 66 |
+
try:
|
| 67 |
+
cursor = conn.execute(
|
| 68 |
+
"INSERT INTO users (email, hashed_password, plan) VALUES (?, ?, ?)",
|
| 69 |
+
(email.strip().lower(), hashed_password, plan),
|
| 70 |
+
)
|
| 71 |
+
conn.commit()
|
| 72 |
+
user_id = cursor.lastrowid
|
| 73 |
+
return {"id": user_id, "email": email.strip().lower(), "plan": plan}
|
| 74 |
+
finally:
|
| 75 |
+
conn.close()
|
requirements.txt
CHANGED
|
@@ -13,6 +13,11 @@ PyYAML==6.0.3
|
|
| 13 |
# Rate limiting
|
| 14 |
slowapi==0.1.9
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
# Testing
|
| 17 |
pytest==8.3.4
|
| 18 |
pytest-asyncio==0.24.0
|
|
|
|
| 13 |
# Rate limiting
|
| 14 |
slowapi==0.1.9
|
| 15 |
|
| 16 |
+
# Auth
|
| 17 |
+
passlib[bcrypt]==1.7.4
|
| 18 |
+
PyJWT==2.10.1
|
| 19 |
+
email-validator==2.2.0
|
| 20 |
+
|
| 21 |
# Testing
|
| 22 |
pytest==8.3.4
|
| 23 |
pytest-asyncio==0.24.0
|
routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# backend/routers
|
routers/auth.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/routers/auth.py
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 3 |
+
from pydantic import BaseModel, EmailStr
|
| 4 |
+
|
| 5 |
+
from database import init_db, get_user_by_email, create_user
|
| 6 |
+
from auth import hash_password, verify_password, create_access_token, get_current_user
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class RegisterRequest(BaseModel):
|
| 12 |
+
email: EmailStr
|
| 13 |
+
password: str
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class LoginRequest(BaseModel):
|
| 17 |
+
email: EmailStr
|
| 18 |
+
password: str
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TokenResponse(BaseModel):
|
| 22 |
+
access_token: str
|
| 23 |
+
token_type: str = "bearer"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class UserResponse(BaseModel):
|
| 27 |
+
id: int
|
| 28 |
+
email: str
|
| 29 |
+
plan: str
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/register", response_model=TokenResponse)
|
| 33 |
+
def register(req: RegisterRequest):
|
| 34 |
+
if len(req.password) < 8:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 37 |
+
detail="Password must be at least 8 characters",
|
| 38 |
+
)
|
| 39 |
+
if get_user_by_email(req.email):
|
| 40 |
+
raise HTTPException(
|
| 41 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 42 |
+
detail="Email already registered",
|
| 43 |
+
)
|
| 44 |
+
init_db()
|
| 45 |
+
hashed = hash_password(req.password)
|
| 46 |
+
user = create_user(req.email, hashed)
|
| 47 |
+
token = create_access_token({"sub": str(user["id"])})
|
| 48 |
+
return TokenResponse(access_token=token)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.post("/login", response_model=TokenResponse)
|
| 52 |
+
def login(req: LoginRequest):
|
| 53 |
+
init_db()
|
| 54 |
+
user = get_user_by_email(req.email)
|
| 55 |
+
if not user or not verify_password(req.password, user["hashed_password"]):
|
| 56 |
+
raise HTTPException(
|
| 57 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 58 |
+
detail="Invalid email or password",
|
| 59 |
+
)
|
| 60 |
+
token = create_access_token({"sub": str(user["id"])})
|
| 61 |
+
return TokenResponse(access_token=token)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/me", response_model=UserResponse)
|
| 65 |
+
def me(user: dict = Depends(get_current_user)):
|
| 66 |
+
return UserResponse(id=user["id"], email=user["email"], plan=user["plan"])
|
tests/test_app.py
CHANGED
|
@@ -1,14 +1,29 @@
|
|
| 1 |
"""
|
| 2 |
Unit tests for Pseudogen FastAPI app.
|
| 3 |
Tests GenerateRequest validation, style/detail handling, and generate endpoint with mocked LLM.
|
|
|
|
| 4 |
"""
|
| 5 |
import pytest
|
| 6 |
from fastapi.testclient import TestClient
|
| 7 |
from unittest.mock import patch
|
| 8 |
|
| 9 |
-
# Import app after setting up mocks so we can patch utils.call_llm
|
| 10 |
from app import app, GenerateRequest
|
|
|
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
client = TestClient(app)
|
| 13 |
|
| 14 |
|
|
@@ -132,17 +147,20 @@ def test_free_plan_rejects_input_over_4000_chars():
|
|
| 132 |
|
| 133 |
|
| 134 |
def test_premium_plan_accepts_input_up_to_12000_chars():
|
| 135 |
-
"""With
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
"
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Unit tests for Pseudogen FastAPI app.
|
| 3 |
Tests GenerateRequest validation, style/detail handling, and generate endpoint with mocked LLM.
|
| 4 |
+
Auth is overridden so generate endpoints receive a fake user.
|
| 5 |
"""
|
| 6 |
import pytest
|
| 7 |
from fastapi.testclient import TestClient
|
| 8 |
from unittest.mock import patch
|
| 9 |
|
|
|
|
| 10 |
from app import app, GenerateRequest
|
| 11 |
+
from auth import get_current_user
|
| 12 |
|
| 13 |
+
# Override auth so generate endpoints see a fake user (plan from user, not header)
|
| 14 |
+
FAKE_USER_FREE = {"id": 1, "email": "test@test.com", "plan": "free"}
|
| 15 |
+
FAKE_USER_PREMIUM = {"id": 2, "email": "premium@test.com", "plan": "premium"}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def _fake_user_free():
|
| 19 |
+
return FAKE_USER_FREE
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def _fake_user_premium():
|
| 23 |
+
return FAKE_USER_PREMIUM
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
app.dependency_overrides[get_current_user] = _fake_user_free
|
| 27 |
client = TestClient(app)
|
| 28 |
|
| 29 |
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
def test_premium_plan_accepts_input_up_to_12000_chars():
|
| 150 |
+
"""With user plan premium, input up to 12000 chars is accepted."""
|
| 151 |
+
app.dependency_overrides[get_current_user] = _fake_user_premium
|
| 152 |
+
try:
|
| 153 |
+
with patch("app.call_llm") as mock_llm:
|
| 154 |
+
mock_llm.return_value = "BEGIN\nEND"
|
| 155 |
+
r = client.post(
|
| 156 |
+
"/generate-pseudocode",
|
| 157 |
+
json={
|
| 158 |
+
"problem_description": "y" * 10000,
|
| 159 |
+
"style": "Academic",
|
| 160 |
+
"detail": "Concise",
|
| 161 |
+
},
|
| 162 |
+
)
|
| 163 |
+
assert r.status_code == 200
|
| 164 |
+
assert r.json().get("markdown") == "BEGIN\nEND"
|
| 165 |
+
finally:
|
| 166 |
+
app.dependency_overrides[get_current_user] = _fake_user_free
|
tests/test_auth.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for auth endpoints: register, login, /me.
|
| 3 |
+
Uses real SQLite DB (backend/pseudogen.db). Register uses a unique email per run to avoid conflicts.
|
| 4 |
+
"""
|
| 5 |
+
import time
|
| 6 |
+
import pytest
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
from app import app
|
| 10 |
+
|
| 11 |
+
client = TestClient(app)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _unique_email():
|
| 15 |
+
return f"test-{int(time.time() * 1000)}@example.com"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_register_returns_token():
|
| 19 |
+
email = _unique_email()
|
| 20 |
+
r = client.post("/auth/register", json={"email": email, "password": "password123"})
|
| 21 |
+
assert r.status_code == 200
|
| 22 |
+
data = r.json()
|
| 23 |
+
assert "access_token" in data
|
| 24 |
+
assert data.get("token_type") == "bearer"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_register_rejects_short_password():
|
| 28 |
+
r = client.post("/auth/register", json={"email": "a@b.com", "password": "short"})
|
| 29 |
+
assert r.status_code == 400
|
| 30 |
+
assert "8" in r.json().get("detail", "")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_register_rejects_duplicate_email():
|
| 34 |
+
email = _unique_email()
|
| 35 |
+
client.post("/auth/register", json={"email": email, "password": "password123"})
|
| 36 |
+
r = client.post("/auth/register", json={"email": email, "password": "password123"})
|
| 37 |
+
assert r.status_code == 400
|
| 38 |
+
assert "already" in r.json().get("detail", "").lower()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_login_returns_token():
|
| 42 |
+
email = _unique_email()
|
| 43 |
+
client.post("/auth/register", json={"email": email, "password": "password123"})
|
| 44 |
+
r = client.post("/auth/login", json={"email": email, "password": "password123"})
|
| 45 |
+
assert r.status_code == 200
|
| 46 |
+
assert "access_token" in r.json()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_login_rejects_wrong_password():
|
| 50 |
+
email = _unique_email()
|
| 51 |
+
client.post("/auth/register", json={"email": email, "password": "password123"})
|
| 52 |
+
r = client.post("/auth/login", json={"email": email, "password": "wrong"})
|
| 53 |
+
assert r.status_code == 401
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_me_returns_user_with_valid_token():
|
| 57 |
+
email = _unique_email()
|
| 58 |
+
reg = client.post("/auth/register", json={"email": email, "password": "password123"})
|
| 59 |
+
token = reg.json()["access_token"]
|
| 60 |
+
r = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
|
| 61 |
+
assert r.status_code == 200
|
| 62 |
+
data = r.json()
|
| 63 |
+
assert data["email"] == email
|
| 64 |
+
assert data["plan"] == "free"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_me_rejects_invalid_token():
|
| 68 |
+
r = client.get("/auth/me", headers={"Authorization": "Bearer invalid"})
|
| 69 |
+
assert r.status_code == 401
|