Spaces:
Running
Running
GitHub Actions commited on
Commit ·
889e86b
1
Parent(s): 5b5cb48
Sync from GitHub · 2026-09-24 23:37 UTC
Browse files- main.py +2 -11
- requirements.txt +0 -1
- routes/account.py +58 -0
- routes/admin.py +4 -12
- routes/bff.py +3 -2
- routes/chat.py +2 -2
- routes/consultation.py +3 -3
- routes/documents.py +2 -2
- routes/planning.py +2 -2
- routes/transcribe.py +2 -2
- tests/test_account.py +91 -0
- tests/test_rate_limiter.py +78 -0
- utils/rate_limiter.py +106 -0
main.py
CHANGED
|
@@ -11,13 +11,10 @@ load_dotenv()
|
|
| 11 |
from fastapi import FastAPI, Request
|
| 12 |
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
from fastapi.responses import JSONResponse
|
| 14 |
-
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 15 |
-
from slowapi.util import get_remote_address
|
| 16 |
-
from slowapi.errors import RateLimitExceeded
|
| 17 |
import firebase_admin
|
| 18 |
from firebase_admin import credentials
|
| 19 |
|
| 20 |
-
from routes import bff, chat, transcribe, documents, consultation, planning, admin
|
| 21 |
|
| 22 |
|
| 23 |
# ─── Firebase Admin SDK initialisation ───────────────────────────────────────
|
|
@@ -42,10 +39,6 @@ def _init_firebase():
|
|
| 42 |
_init_firebase()
|
| 43 |
|
| 44 |
|
| 45 |
-
# ─── Rate limiter ─────────────────────────────────────────────────────────────
|
| 46 |
-
limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
|
| 47 |
-
|
| 48 |
-
|
| 49 |
# ─── App lifecycle ────────────────────────────────────────────────────────────
|
| 50 |
@asynccontextmanager
|
| 51 |
async def lifespan(app: FastAPI):
|
|
@@ -65,9 +58,6 @@ app = FastAPI(
|
|
| 65 |
lifespan=lifespan,
|
| 66 |
)
|
| 67 |
|
| 68 |
-
app.state.limiter = limiter
|
| 69 |
-
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 70 |
-
|
| 71 |
|
| 72 |
# ─── CORS ─────────────────────────────────────────────────────────────────────
|
| 73 |
allowed_origins_str = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000")
|
|
@@ -100,6 +90,7 @@ app.include_router(documents.router, prefix="/api/documents", tags=["Docu
|
|
| 100 |
app.include_router(consultation.router, prefix="/api/consultation", tags=["Consultation"])
|
| 101 |
app.include_router(planning.router, prefix="/api/planning", tags=["Planning"])
|
| 102 |
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
# ─── Health check (unauthenticated) ──────────────────────────────────────────
|
|
|
|
| 11 |
from fastapi import FastAPI, Request
|
| 12 |
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
from fastapi.responses import JSONResponse
|
|
|
|
|
|
|
|
|
|
| 14 |
import firebase_admin
|
| 15 |
from firebase_admin import credentials
|
| 16 |
|
| 17 |
+
from routes import bff, chat, transcribe, documents, consultation, planning, admin, account
|
| 18 |
|
| 19 |
|
| 20 |
# ─── Firebase Admin SDK initialisation ───────────────────────────────────────
|
|
|
|
| 39 |
_init_firebase()
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# ─── App lifecycle ────────────────────────────────────────────────────────────
|
| 43 |
@asynccontextmanager
|
| 44 |
async def lifespan(app: FastAPI):
|
|
|
|
| 58 |
lifespan=lifespan,
|
| 59 |
)
|
| 60 |
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# ─── CORS ─────────────────────────────────────────────────────────────────────
|
| 63 |
allowed_origins_str = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000")
|
|
|
|
| 90 |
app.include_router(consultation.router, prefix="/api/consultation", tags=["Consultation"])
|
| 91 |
app.include_router(planning.router, prefix="/api/planning", tags=["Planning"])
|
| 92 |
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
| 93 |
+
app.include_router(account.router, prefix="/api/account", tags=["Account"])
|
| 94 |
|
| 95 |
|
| 96 |
# ─── Health check (unauthenticated) ──────────────────────────────────────────
|
requirements.txt
CHANGED
|
@@ -3,7 +3,6 @@ uvicorn[standard]==0.30.6
|
|
| 3 |
python-dotenv==1.0.1
|
| 4 |
firebase-admin==6.5.0
|
| 5 |
groq==0.11.0
|
| 6 |
-
slowapi==0.1.9
|
| 7 |
python-multipart==0.0.12
|
| 8 |
pydantic==2.9.2
|
| 9 |
httpx==0.27.2
|
|
|
|
| 3 |
python-dotenv==1.0.1
|
| 4 |
firebase-admin==6.5.0
|
| 5 |
groq==0.11.0
|
|
|
|
| 6 |
python-multipart==0.0.12
|
| 7 |
pydantic==2.9.2
|
| 8 |
httpx==0.27.2
|
routes/account.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Account Routes — self-service actions on the caller's own account.
|
| 3 |
+
|
| 4 |
+
Unlike routes/admin.py, these never take a target uid from the request — the
|
| 5 |
+
uid always comes from the verified Firebase ID token, so a user can only ever
|
| 6 |
+
act on their own account.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 10 |
+
from firebase_admin import auth as fb_auth, firestore
|
| 11 |
+
from auth import verify_firebase_token
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def delete_all_user_data(uid: str) -> None:
|
| 17 |
+
"""
|
| 18 |
+
Deletes every Firestore document owned by a user: each business's
|
| 19 |
+
subcollections, the business docs themselves, xpHistory, and the
|
| 20 |
+
user_data root doc. Shared by this self-delete route and
|
| 21 |
+
routes/admin.py's delete_user so the cascade is defined once.
|
| 22 |
+
"""
|
| 23 |
+
db = firestore.client()
|
| 24 |
+
user_ref = db.collection("user_data").document(uid)
|
| 25 |
+
|
| 26 |
+
for biz in user_ref.collection("businesses").stream():
|
| 27 |
+
for sub in ["bff", "profile_output", "documents", "kanban", "milestones", "sessions", "team"]:
|
| 28 |
+
for sdoc in biz.reference.collection(sub).stream():
|
| 29 |
+
sdoc.reference.delete()
|
| 30 |
+
biz.reference.delete()
|
| 31 |
+
|
| 32 |
+
for entry in user_ref.collection("xpHistory").stream():
|
| 33 |
+
entry.reference.delete()
|
| 34 |
+
|
| 35 |
+
user_ref.delete()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.delete("/me")
|
| 39 |
+
def delete_own_account(user: dict = Depends(verify_firebase_token)):
|
| 40 |
+
"""
|
| 41 |
+
Deletes the caller's own account: all Firestore data (via the Admin SDK,
|
| 42 |
+
which bypasses firestore.rules — user_data deletion is deliberately
|
| 43 |
+
backend-only, see firestore.rules' own comment) and the Firebase Auth
|
| 44 |
+
user record. The frontend re-authenticates the user with their password
|
| 45 |
+
immediately before calling this, as an extra confirmation step.
|
| 46 |
+
"""
|
| 47 |
+
uid = user.get("uid")
|
| 48 |
+
if not uid:
|
| 49 |
+
raise HTTPException(status_code=401, detail="Unauthorized.")
|
| 50 |
+
|
| 51 |
+
delete_all_user_data(uid)
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
fb_auth.delete_user(uid)
|
| 55 |
+
except fb_auth.UserNotFoundError:
|
| 56 |
+
pass # already gone — not an error for a delete
|
| 57 |
+
|
| 58 |
+
return {"success": True}
|
routes/admin.py
CHANGED
|
@@ -15,6 +15,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
|
| 15 |
from pydantic import BaseModel
|
| 16 |
from firebase_admin import auth as fb_auth, firestore
|
| 17 |
from auth import verify_firebase_token
|
|
|
|
| 18 |
|
| 19 |
router = APIRouter()
|
| 20 |
|
|
@@ -100,20 +101,11 @@ def delete_user(
|
|
| 100 |
):
|
| 101 |
admin_uid = _verify_admin(user)
|
| 102 |
if target_uid == admin_uid:
|
|
|
|
|
|
|
| 103 |
raise HTTPException(status_code=400, detail="Admins cannot delete their own account.")
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
# Delete Firestore user data (businesses subcollection + root doc)
|
| 108 |
-
user_ref = db.collection("user_data").document(target_uid)
|
| 109 |
-
biz_docs = user_ref.collection("businesses").stream()
|
| 110 |
-
for biz in biz_docs:
|
| 111 |
-
# Delete nested subcollections of each business
|
| 112 |
-
for sub in ["bff", "profile_output", "documents", "kanban", "milestones", "sessions", "team"]:
|
| 113 |
-
for sdoc in biz.reference.collection(sub).stream():
|
| 114 |
-
sdoc.reference.delete()
|
| 115 |
-
biz.reference.delete()
|
| 116 |
-
user_ref.delete()
|
| 117 |
|
| 118 |
# Delete Firebase Auth account
|
| 119 |
try:
|
|
|
|
| 15 |
from pydantic import BaseModel
|
| 16 |
from firebase_admin import auth as fb_auth, firestore
|
| 17 |
from auth import verify_firebase_token
|
| 18 |
+
from routes.account import delete_all_user_data
|
| 19 |
|
| 20 |
router = APIRouter()
|
| 21 |
|
|
|
|
| 101 |
):
|
| 102 |
admin_uid = _verify_admin(user)
|
| 103 |
if target_uid == admin_uid:
|
| 104 |
+
# Self-deletion as an admin goes through DELETE /api/account/me instead,
|
| 105 |
+
# same as any other user — this endpoint is for deleting *other* accounts.
|
| 106 |
raise HTTPException(status_code=400, detail="Admins cannot delete their own account.")
|
| 107 |
|
| 108 |
+
delete_all_user_data(target_uid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
# Delete Firebase Auth account
|
| 111 |
try:
|
routes/bff.py
CHANGED
|
@@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
|
| 3 |
from pydantic import BaseModel
|
| 4 |
from firebase_admin import firestore
|
| 5 |
from auth import verify_firebase_token
|
|
|
|
| 6 |
from utils.groq_client import call_groq_json, HEAVY_MODEL
|
| 7 |
from utils.pinecone_client import query_book_context
|
| 8 |
|
|
@@ -125,7 +126,7 @@ def _call_groq_json(system_prompt: str, user_prompt: str) -> dict:
|
|
| 125 |
@router.post("/generate-followups")
|
| 126 |
def generate_followups(
|
| 127 |
body: GenerateFollowupsRequest,
|
| 128 |
-
user: dict = Depends(
|
| 129 |
):
|
| 130 |
"""
|
| 131 |
After each BFF section is submitted, generates 3–5 personalized
|
|
@@ -195,7 +196,7 @@ Question types:
|
|
| 195 |
@router.post("/generate-profile")
|
| 196 |
def generate_profile(
|
| 197 |
body: GenerateProfileRequest,
|
| 198 |
-
user: dict = Depends(
|
| 199 |
):
|
| 200 |
"""
|
| 201 |
Reads all completed BFF sections from Firestore and generates all 4
|
|
|
|
| 3 |
from pydantic import BaseModel
|
| 4 |
from firebase_admin import firestore
|
| 5 |
from auth import verify_firebase_token
|
| 6 |
+
from utils.rate_limiter import with_rate_limit
|
| 7 |
from utils.groq_client import call_groq_json, HEAVY_MODEL
|
| 8 |
from utils.pinecone_client import query_book_context
|
| 9 |
|
|
|
|
| 126 |
@router.post("/generate-followups")
|
| 127 |
def generate_followups(
|
| 128 |
body: GenerateFollowupsRequest,
|
| 129 |
+
user: dict = Depends(with_rate_limit("generation")),
|
| 130 |
):
|
| 131 |
"""
|
| 132 |
After each BFF section is submitted, generates 3–5 personalized
|
|
|
|
| 196 |
@router.post("/generate-profile")
|
| 197 |
def generate_profile(
|
| 198 |
body: GenerateProfileRequest,
|
| 199 |
+
user: dict = Depends(with_rate_limit("generation")),
|
| 200 |
):
|
| 201 |
"""
|
| 202 |
Reads all completed BFF sections from Firestore and generates all 4
|
routes/chat.py
CHANGED
|
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
|
| 3 |
from fastapi.responses import StreamingResponse
|
| 4 |
from pydantic import BaseModel
|
| 5 |
from firebase_admin import firestore
|
| 6 |
-
from
|
| 7 |
from utils.groq_client import call_groq_stream, LIGHT_MODEL, HEAVY_MODEL
|
| 8 |
from utils.pinecone_client import query_book_context
|
| 9 |
|
|
@@ -65,7 +65,7 @@ def _get_business_context(user_id: str, business_id: str) -> str:
|
|
| 65 |
@router.post("/message")
|
| 66 |
def chat_message(
|
| 67 |
body: ChatRequest,
|
| 68 |
-
user: dict = Depends(
|
| 69 |
):
|
| 70 |
"""
|
| 71 |
Accepts a user message and returns a streaming SSE response.
|
|
|
|
| 3 |
from fastapi.responses import StreamingResponse
|
| 4 |
from pydantic import BaseModel
|
| 5 |
from firebase_admin import firestore
|
| 6 |
+
from utils.rate_limiter import with_rate_limit
|
| 7 |
from utils.groq_client import call_groq_stream, LIGHT_MODEL, HEAVY_MODEL
|
| 8 |
from utils.pinecone_client import query_book_context
|
| 9 |
|
|
|
|
| 65 |
@router.post("/message")
|
| 66 |
def chat_message(
|
| 67 |
body: ChatRequest,
|
| 68 |
+
user: dict = Depends(with_rate_limit("chat")),
|
| 69 |
):
|
| 70 |
"""
|
| 71 |
Accepts a user message and returns a streaming SSE response.
|
routes/consultation.py
CHANGED
|
@@ -17,7 +17,7 @@ from datetime import datetime
|
|
| 17 |
from fastapi import APIRouter, HTTPException, Depends
|
| 18 |
from pydantic import BaseModel
|
| 19 |
from firebase_admin import firestore
|
| 20 |
-
from
|
| 21 |
from utils.groq_client import call_groq_json, HEAVY_MODEL
|
| 22 |
from utils.pinecone_client import query_book_context
|
| 23 |
|
|
@@ -52,7 +52,7 @@ def _read_profile(user_id: str, business_id: str) -> dict:
|
|
| 52 |
@router.post("/start")
|
| 53 |
def start_consultation(
|
| 54 |
body: StartRequest,
|
| 55 |
-
user: dict = Depends(
|
| 56 |
):
|
| 57 |
"""
|
| 58 |
Reads the entrepreneur's business profile and generates a warm, structured
|
|
@@ -118,7 +118,7 @@ Return JSON:
|
|
| 118 |
@router.post("/report")
|
| 119 |
def generate_report(
|
| 120 |
body: ReportRequest,
|
| 121 |
-
user: dict = Depends(
|
| 122 |
):
|
| 123 |
"""
|
| 124 |
Takes the full session transcript and generates a structured consultation
|
|
|
|
| 17 |
from fastapi import APIRouter, HTTPException, Depends
|
| 18 |
from pydantic import BaseModel
|
| 19 |
from firebase_admin import firestore
|
| 20 |
+
from utils.rate_limiter import with_rate_limit
|
| 21 |
from utils.groq_client import call_groq_json, HEAVY_MODEL
|
| 22 |
from utils.pinecone_client import query_book_context
|
| 23 |
|
|
|
|
| 52 |
@router.post("/start")
|
| 53 |
def start_consultation(
|
| 54 |
body: StartRequest,
|
| 55 |
+
user: dict = Depends(with_rate_limit("generation")),
|
| 56 |
):
|
| 57 |
"""
|
| 58 |
Reads the entrepreneur's business profile and generates a warm, structured
|
|
|
|
| 118 |
@router.post("/report")
|
| 119 |
def generate_report(
|
| 120 |
body: ReportRequest,
|
| 121 |
+
user: dict = Depends(with_rate_limit("generation")),
|
| 122 |
):
|
| 123 |
"""
|
| 124 |
Takes the full session transcript and generates a structured consultation
|
routes/documents.py
CHANGED
|
@@ -17,7 +17,7 @@ import json
|
|
| 17 |
from fastapi import APIRouter, HTTPException, Depends
|
| 18 |
from pydantic import BaseModel
|
| 19 |
from firebase_admin import firestore
|
| 20 |
-
from
|
| 21 |
from utils.groq_client import call_groq_json, HEAVY_MODEL
|
| 22 |
from utils.pinecone_client import query_book_context
|
| 23 |
|
|
@@ -137,7 +137,7 @@ Generate a 7-slide Pitch Deck. Return JSON where each key is a slide:
|
|
| 137 |
@router.post("/generate-content")
|
| 138 |
def generate_document_content(
|
| 139 |
body: GenerateDocRequest,
|
| 140 |
-
user: dict = Depends(
|
| 141 |
):
|
| 142 |
"""
|
| 143 |
Generates AI content for Business Plan or Pitch Deck.
|
|
|
|
| 17 |
from fastapi import APIRouter, HTTPException, Depends
|
| 18 |
from pydantic import BaseModel
|
| 19 |
from firebase_admin import firestore
|
| 20 |
+
from utils.rate_limiter import with_rate_limit
|
| 21 |
from utils.groq_client import call_groq_json, HEAVY_MODEL
|
| 22 |
from utils.pinecone_client import query_book_context
|
| 23 |
|
|
|
|
| 137 |
@router.post("/generate-content")
|
| 138 |
def generate_document_content(
|
| 139 |
body: GenerateDocRequest,
|
| 140 |
+
user: dict = Depends(with_rate_limit("generation")),
|
| 141 |
):
|
| 142 |
"""
|
| 143 |
Generates AI content for Business Plan or Pitch Deck.
|
routes/planning.py
CHANGED
|
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta
|
|
| 13 |
from fastapi import APIRouter, HTTPException, Depends
|
| 14 |
from pydantic import BaseModel
|
| 15 |
from firebase_admin import firestore
|
| 16 |
-
from
|
| 17 |
from utils.groq_client import call_groq_json, HEAVY_MODEL
|
| 18 |
from utils.pinecone_client import query_book_context
|
| 19 |
|
|
@@ -47,7 +47,7 @@ def _read_context(user_id: str, business_id: str) -> dict:
|
|
| 47 |
@router.post("/suggest")
|
| 48 |
def suggest_planning_items(
|
| 49 |
body: SuggestRequest,
|
| 50 |
-
user: dict = Depends(
|
| 51 |
):
|
| 52 |
if body.type not in ("tasks", "milestones"):
|
| 53 |
raise HTTPException(status_code=400, detail="type must be 'tasks' or 'milestones'")
|
|
|
|
| 13 |
from fastapi import APIRouter, HTTPException, Depends
|
| 14 |
from pydantic import BaseModel
|
| 15 |
from firebase_admin import firestore
|
| 16 |
+
from utils.rate_limiter import with_rate_limit
|
| 17 |
from utils.groq_client import call_groq_json, HEAVY_MODEL
|
| 18 |
from utils.pinecone_client import query_book_context
|
| 19 |
|
|
|
|
| 47 |
@router.post("/suggest")
|
| 48 |
def suggest_planning_items(
|
| 49 |
body: SuggestRequest,
|
| 50 |
+
user: dict = Depends(with_rate_limit("generation")),
|
| 51 |
):
|
| 52 |
if body.type not in ("tasks", "milestones"):
|
| 53 |
raise HTTPException(status_code=400, detail="type must be 'tasks' or 'milestones'")
|
routes/transcribe.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
| 2 |
-
from
|
| 3 |
from utils.groq_client import call_groq_transcribe
|
| 4 |
|
| 5 |
router = APIRouter()
|
|
@@ -15,7 +15,7 @@ MAX_AUDIO_BYTES = 25 * 1024 * 1024 # 25 MB — Groq Whisper limit
|
|
| 15 |
@router.post("/transcribe")
|
| 16 |
async def transcribe_audio(
|
| 17 |
audio: UploadFile = File(...),
|
| 18 |
-
user: dict = Depends(
|
| 19 |
):
|
| 20 |
"""
|
| 21 |
Accepts a browser-recorded audio blob, sends it to Groq Whisper,
|
|
|
|
| 1 |
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
| 2 |
+
from utils.rate_limiter import with_rate_limit
|
| 3 |
from utils.groq_client import call_groq_transcribe
|
| 4 |
|
| 5 |
router = APIRouter()
|
|
|
|
| 15 |
@router.post("/transcribe")
|
| 16 |
async def transcribe_audio(
|
| 17 |
audio: UploadFile = File(...),
|
| 18 |
+
user: dict = Depends(with_rate_limit("transcription")),
|
| 19 |
):
|
| 20 |
"""
|
| 21 |
Accepts a browser-recorded audio blob, sends it to Groq Whisper,
|
tests/test_account.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for routes/account.py's self-delete cascade.
|
| 3 |
+
|
| 4 |
+
Scope: verifies delete_all_user_data's Firestore traversal logic (every business's
|
| 5 |
+
subcollections, xpHistory, the root doc) using a fake Firestore client, per
|
| 6 |
+
docs/qa/test-strategy.md's unit layer. The actual Firebase Auth deletion
|
| 7 |
+
(fb_auth.delete_user) and the FastAPI route wiring are integration-level concerns,
|
| 8 |
+
not covered here.
|
| 9 |
+
"""
|
| 10 |
+
from unittest.mock import MagicMock
|
| 11 |
+
from routes.account import delete_all_user_data
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _make_fake_db():
|
| 15 |
+
"""
|
| 16 |
+
Builds a fake Firestore client deep enough to exercise delete_all_user_data:
|
| 17 |
+
one business with two subcollections each holding one doc, plus one
|
| 18 |
+
xpHistory entry. Returns (db, deleted_refs) where deleted_refs collects
|
| 19 |
+
every .delete() call made, in call order.
|
| 20 |
+
"""
|
| 21 |
+
deleted = []
|
| 22 |
+
|
| 23 |
+
def make_doc(name):
|
| 24 |
+
doc = MagicMock(name=f"doc:{name}")
|
| 25 |
+
doc.reference = MagicMock(name=f"ref:{name}")
|
| 26 |
+
doc.reference.delete = MagicMock(side_effect=lambda n=name: deleted.append(n))
|
| 27 |
+
return doc
|
| 28 |
+
|
| 29 |
+
biz_doc = make_doc("business:biz1")
|
| 30 |
+
|
| 31 |
+
def sub_collection(name):
|
| 32 |
+
coll = MagicMock(name=f"collection:{name}")
|
| 33 |
+
coll.stream = MagicMock(return_value=iter([make_doc(f"{name}:item1")]))
|
| 34 |
+
return coll
|
| 35 |
+
|
| 36 |
+
biz_doc.reference.collection = MagicMock(side_effect=sub_collection)
|
| 37 |
+
|
| 38 |
+
businesses_collection = MagicMock(name="collection:businesses")
|
| 39 |
+
businesses_collection.stream = MagicMock(return_value=iter([biz_doc]))
|
| 40 |
+
|
| 41 |
+
xp_history_collection = MagicMock(name="collection:xpHistory")
|
| 42 |
+
xp_history_collection.stream = MagicMock(return_value=iter([make_doc("xpHistory:entry1")]))
|
| 43 |
+
|
| 44 |
+
user_ref = MagicMock(name="ref:user_data/uid")
|
| 45 |
+
user_ref.delete = MagicMock(side_effect=lambda: deleted.append("user_data/uid"))
|
| 46 |
+
|
| 47 |
+
def user_ref_collection(name):
|
| 48 |
+
return {"businesses": businesses_collection, "xpHistory": xp_history_collection}[name]
|
| 49 |
+
|
| 50 |
+
user_ref.collection = MagicMock(side_effect=user_ref_collection)
|
| 51 |
+
|
| 52 |
+
user_data_collection = MagicMock(name="collection:user_data")
|
| 53 |
+
user_data_collection.document = MagicMock(return_value=user_ref)
|
| 54 |
+
|
| 55 |
+
db = MagicMock(name="firestore_client")
|
| 56 |
+
db.collection = MagicMock(return_value=user_data_collection)
|
| 57 |
+
|
| 58 |
+
return db, deleted
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_deletes_every_business_subcollection_doc_and_the_business_itself(monkeypatch):
|
| 62 |
+
db, deleted = _make_fake_db()
|
| 63 |
+
monkeypatch.setattr("routes.account.firestore.client", lambda: db)
|
| 64 |
+
|
| 65 |
+
delete_all_user_data("some-uid")
|
| 66 |
+
|
| 67 |
+
# Every one of the 7 known subcollections was consulted for the business...
|
| 68 |
+
biz_doc = db.collection().document().collection("businesses").stream.return_value
|
| 69 |
+
# ...and each subcollection's one doc, plus the business doc itself, got deleted.
|
| 70 |
+
assert "bff:item1" in deleted
|
| 71 |
+
assert "documents:item1" in deleted
|
| 72 |
+
assert "team:item1" in deleted
|
| 73 |
+
assert "business:biz1" in deleted
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_deletes_xp_history_entries(monkeypatch):
|
| 77 |
+
db, deleted = _make_fake_db()
|
| 78 |
+
monkeypatch.setattr("routes.account.firestore.client", lambda: db)
|
| 79 |
+
|
| 80 |
+
delete_all_user_data("some-uid")
|
| 81 |
+
|
| 82 |
+
assert "xpHistory:entry1" in deleted
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_deletes_the_root_user_doc_last(monkeypatch):
|
| 86 |
+
db, deleted = _make_fake_db()
|
| 87 |
+
monkeypatch.setattr("routes.account.firestore.client", lambda: db)
|
| 88 |
+
|
| 89 |
+
delete_all_user_data("some-uid")
|
| 90 |
+
|
| 91 |
+
assert deleted[-1] == "user_data/uid"
|
tests/test_rate_limiter.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for utils/rate_limiter.py's pure counting/enforcement logic.
|
| 3 |
+
|
| 4 |
+
Scope: enforce_rate_limit() itself (fixed-window counting, per-tier limits,
|
| 5 |
+
429 on breach). The with_rate_limit() FastAPI dependency composition and the
|
| 6 |
+
Firestore violation-logging side effect are thin wrappers around this and are
|
| 7 |
+
not separately unit-tested (per docs/qa/test-strategy.md, the FastAPI/
|
| 8 |
+
Firestore integration belongs at the integration layer, not here).
|
| 9 |
+
"""
|
| 10 |
+
import time
|
| 11 |
+
import pytest
|
| 12 |
+
from fastapi import HTTPException
|
| 13 |
+
import utils.rate_limiter as rl
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture(autouse=True)
|
| 17 |
+
def reset_limiter_state(monkeypatch):
|
| 18 |
+
# Each test gets a clean set of counters and a violation logger that
|
| 19 |
+
# never touches Firestore (no app is initialized in this test process).
|
| 20 |
+
monkeypatch.setattr(rl, "_counters", rl.defaultdict(rl._Window))
|
| 21 |
+
monkeypatch.setattr(rl, "_violation_counts", rl.defaultdict(int))
|
| 22 |
+
monkeypatch.setattr(rl, "_log_violation", lambda *a, **k: None)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_allows_requests_within_the_limit():
|
| 26 |
+
for _ in range(8):
|
| 27 |
+
rl.enforce_rate_limit("uid-1", "generation") # 8/hour is the limit — must not raise
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_blocks_the_request_that_exceeds_the_limit():
|
| 31 |
+
for _ in range(8):
|
| 32 |
+
rl.enforce_rate_limit("uid-1", "generation")
|
| 33 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 34 |
+
rl.enforce_rate_limit("uid-1", "generation")
|
| 35 |
+
assert exc_info.value.status_code == 429
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_counters_are_isolated_per_uid():
|
| 39 |
+
for _ in range(8):
|
| 40 |
+
rl.enforce_rate_limit("uid-1", "generation")
|
| 41 |
+
# A different uid must not be affected by uid-1 exhausting its limit.
|
| 42 |
+
rl.enforce_rate_limit("uid-2", "generation")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_counters_are_isolated_per_tier():
|
| 46 |
+
for _ in range(8):
|
| 47 |
+
rl.enforce_rate_limit("uid-1", "generation")
|
| 48 |
+
# The same uid hitting a different tier must not be blocked by "generation".
|
| 49 |
+
rl.enforce_rate_limit("uid-1", "chat")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_a_tier_with_multiple_windows_enforces_the_tighter_one_first():
|
| 53 |
+
# "chat" is 10/minute and 200/day -- the per-minute window binds first.
|
| 54 |
+
for _ in range(10):
|
| 55 |
+
rl.enforce_rate_limit("uid-1", "chat")
|
| 56 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 57 |
+
rl.enforce_rate_limit("uid-1", "chat")
|
| 58 |
+
assert exc_info.value.status_code == 429
|
| 59 |
+
assert "minute" in exc_info.value.detail
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_window_resets_after_its_period_elapses(monkeypatch):
|
| 63 |
+
fake_now = [1_000_000.0]
|
| 64 |
+
monkeypatch.setattr(rl.time, "time", lambda: fake_now[0])
|
| 65 |
+
|
| 66 |
+
for _ in range(8):
|
| 67 |
+
rl.enforce_rate_limit("uid-1", "generation")
|
| 68 |
+
with pytest.raises(HTTPException):
|
| 69 |
+
rl.enforce_rate_limit("uid-1", "generation")
|
| 70 |
+
|
| 71 |
+
# Jump past the 1-hour window.
|
| 72 |
+
fake_now[0] += 3600 + 1
|
| 73 |
+
rl.enforce_rate_limit("uid-1", "generation") # must not raise — window reset
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_unknown_tier_raises_a_programmer_error_not_a_silent_pass():
|
| 77 |
+
with pytest.raises(ValueError):
|
| 78 |
+
rl.enforce_rate_limit("uid-1", "not-a-real-tier")
|
utils/rate_limiter.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Per-UID rate limiter — design settled 2026-09-09 (see
|
| 3 |
+
docs/blueprint/adr/0010-per-uid-in-memory-rate-limiting.md), built 2026-09-25.
|
| 4 |
+
|
| 5 |
+
Keys by authenticated Firebase UID, not IP — every limited route already
|
| 6 |
+
requires a verified token, and a UID can't be spoofed the way an IP/header
|
| 7 |
+
can. Counters are in-memory, per-process: safe from client/devtools
|
| 8 |
+
tampering by construction, sufficient for the current single-container
|
| 9 |
+
Hugging Face Space deployment, and reset on redeploy. If this ever needs to
|
| 10 |
+
survive across multiple replicas, move the counters into Firestore/Redis —
|
| 11 |
+
the enforce_rate_limit() call site wouldn't need to change.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import time
|
| 15 |
+
import threading
|
| 16 |
+
from collections import defaultdict
|
| 17 |
+
from fastapi import Header, HTTPException
|
| 18 |
+
from firebase_admin import firestore
|
| 19 |
+
from auth import verify_firebase_token
|
| 20 |
+
|
| 21 |
+
# (label, limit, period_seconds) per tier. Tiers are named by cost, not by
|
| 22 |
+
# route, so multiple routes can share one (see routes/*.py's with_rate_limit
|
| 23 |
+
# calls for which tier each route uses).
|
| 24 |
+
TIERS: dict[str, list[tuple[str, int, int]]] = {
|
| 25 |
+
"chat": [("minute", 10, 60), ("day", 200, 86400)],
|
| 26 |
+
"generation": [("hour", 8, 3600), ("day", 30, 86400)],
|
| 27 |
+
"transcription": [("day", 20, 86400)],
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class _Window:
|
| 32 |
+
__slots__ = ("count", "reset_at")
|
| 33 |
+
|
| 34 |
+
def __init__(self) -> None:
|
| 35 |
+
self.count = 0
|
| 36 |
+
self.reset_at = 0.0
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
_lock = threading.Lock()
|
| 40 |
+
_counters: dict[tuple[str, str, str], _Window] = defaultdict(_Window)
|
| 41 |
+
# Consecutive-violation count per UID, purely for the "log repeat offenders"
|
| 42 |
+
# requirement — never used to make an enforcement decision itself.
|
| 43 |
+
_violation_counts: dict[str, int] = defaultdict(int)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _log_violation(uid: str, tier: str, label: str, count: int, limit: int) -> None:
|
| 47 |
+
_violation_counts[uid] += 1
|
| 48 |
+
print(
|
| 49 |
+
f"[rate-limit] uid={uid} tier={tier} window={label} "
|
| 50 |
+
f"count={count}/{limit} (consecutive violations: {_violation_counts[uid]})"
|
| 51 |
+
)
|
| 52 |
+
# Best-effort — a Firestore write failing must never break enforcement itself.
|
| 53 |
+
try:
|
| 54 |
+
firestore.client().collection("rate_limit_violations").add({
|
| 55 |
+
"uid": uid,
|
| 56 |
+
"tier": tier,
|
| 57 |
+
"window": label,
|
| 58 |
+
"count": count,
|
| 59 |
+
"limit": limit,
|
| 60 |
+
"consecutiveViolations": _violation_counts[uid],
|
| 61 |
+
"at": firestore.SERVER_TIMESTAMP,
|
| 62 |
+
})
|
| 63 |
+
except Exception:
|
| 64 |
+
pass
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def enforce_rate_limit(uid: str, tier: str) -> None:
|
| 68 |
+
"""Raises HTTPException(429) if uid has exceeded any of tier's limits."""
|
| 69 |
+
if tier not in TIERS:
|
| 70 |
+
raise ValueError(f"Unknown rate limit tier: {tier!r}")
|
| 71 |
+
|
| 72 |
+
now = time.time()
|
| 73 |
+
with _lock:
|
| 74 |
+
for label, limit, period in TIERS[tier]:
|
| 75 |
+
window = _counters[(uid, tier, label)]
|
| 76 |
+
if now >= window.reset_at:
|
| 77 |
+
window.count = 0
|
| 78 |
+
window.reset_at = now + period
|
| 79 |
+
window.count += 1
|
| 80 |
+
|
| 81 |
+
if window.count > limit:
|
| 82 |
+
retry_after = max(1, int(window.reset_at - now))
|
| 83 |
+
_log_violation(uid, tier, label, window.count, limit)
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=429,
|
| 86 |
+
detail=(
|
| 87 |
+
f"You've hit the {label}ly limit for this action "
|
| 88 |
+
f"({limit} per {label}). Try again in {retry_after} seconds."
|
| 89 |
+
),
|
| 90 |
+
headers={"Retry-After": str(retry_after)},
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def with_rate_limit(tier: str):
|
| 95 |
+
"""
|
| 96 |
+
FastAPI dependency: verifies the caller's token AND enforces `tier`'s rate
|
| 97 |
+
limit in one pass. Use this in place of `Depends(verify_firebase_token)`
|
| 98 |
+
on any route that should be limited — it calls verify_firebase_token
|
| 99 |
+
itself rather than composing as a second dependency, so the token is only
|
| 100 |
+
verified once per request.
|
| 101 |
+
"""
|
| 102 |
+
async def dependency(authorization: str = Header(...)) -> dict:
|
| 103 |
+
user = await verify_firebase_token(authorization)
|
| 104 |
+
enforce_rate_limit(user["uid"], tier)
|
| 105 |
+
return user
|
| 106 |
+
return dependency
|