GitHub Actions commited on
Commit Β·
76964f5
1
Parent(s): 1521258
Deploy backend from GitHub c7b5c288e89c3d1884e6d556b62378976a19fef4
Browse files- Dockerfile +0 -2
- README.md +0 -2
- backend/app/api/auth_routes.py +0 -40
- backend/app/api/models.py +1 -1
- backend/app/api/routes.py +3 -3
- backend/app/core/auth.py +0 -43
- backend/app/core/config.py +5 -10
- backend/app/db/firestore.py +26 -90
- backend/app/main.py +0 -8
- backend/requirements.txt +0 -3
- backend/tests/test_api.py +25 -66
- backend/tests/test_auth.py +0 -60
Dockerfile
CHANGED
|
@@ -2,8 +2,6 @@
|
|
| 2 |
# HF Spaces requires the app to listen on port 7860.
|
| 3 |
#
|
| 4 |
# Required Secrets (set in Space Settings -> Repository secrets):
|
| 5 |
-
# FIREBASE_PROJECT_ID β Firebase project ID
|
| 6 |
-
# FIREBASE_CREDENTIALS_JSON β Full service account JSON as a single-line string
|
| 7 |
# REDIS_URL β Upstash Redis URL (rediss://...)
|
| 8 |
# HF_API_KEY β Hugging Face API token
|
| 9 |
# GROQ_API_KEY β Groq API key
|
|
|
|
| 2 |
# HF Spaces requires the app to listen on port 7860.
|
| 3 |
#
|
| 4 |
# Required Secrets (set in Space Settings -> Repository secrets):
|
|
|
|
|
|
|
| 5 |
# REDIS_URL β Upstash Redis URL (rediss://...)
|
| 6 |
# HF_API_KEY β Hugging Face API token
|
| 7 |
# GROQ_API_KEY β Groq API key
|
README.md
CHANGED
|
@@ -14,8 +14,6 @@ FastAPI backend for the Sentinel LLM Misuse Detection System.
|
|
| 14 |
Exposes a REST API on port 7860.
|
| 15 |
|
| 16 |
## Secrets required (Space Settings β Repository secrets)
|
| 17 |
-
- `FIREBASE_PROJECT_ID`
|
| 18 |
-
- `FIREBASE_CREDENTIALS_JSON`
|
| 19 |
- `REDIS_URL`
|
| 20 |
- `HF_API_KEY`
|
| 21 |
- `GROQ_API_KEY`
|
|
|
|
| 14 |
Exposes a REST API on port 7860.
|
| 15 |
|
| 16 |
## Secrets required (Space Settings β Repository secrets)
|
|
|
|
|
|
|
| 17 |
- `REDIS_URL`
|
| 18 |
- `HF_API_KEY`
|
| 19 |
- `GROQ_API_KEY`
|
backend/app/api/auth_routes.py
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Authentication routes for user registration and login.
|
| 3 |
-
"""
|
| 4 |
-
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
-
from sqlalchemy import select
|
| 7 |
-
|
| 8 |
-
from backend.app.api.models import AuthRequest, TokenResponse
|
| 9 |
-
from backend.app.core.auth import hash_password, verify_password, create_access_token
|
| 10 |
-
from backend.app.db.session import get_session
|
| 11 |
-
from backend.app.models.schemas import User
|
| 12 |
-
|
| 13 |
-
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
@router.post("/register", response_model=TokenResponse, status_code=201)
|
| 17 |
-
async def register(req: AuthRequest, session: AsyncSession = Depends(get_session)):
|
| 18 |
-
"""Register a new user."""
|
| 19 |
-
stmt = select(User).where(User.email == req.email)
|
| 20 |
-
existing = await session.execute(stmt)
|
| 21 |
-
if existing.scalar_one_or_none():
|
| 22 |
-
raise HTTPException(status_code=409, detail="Email already registered")
|
| 23 |
-
|
| 24 |
-
user = User(email=req.email, hashed_password=hash_password(req.password))
|
| 25 |
-
session.add(user)
|
| 26 |
-
await session.commit()
|
| 27 |
-
token = create_access_token(subject=user.id)
|
| 28 |
-
return TokenResponse(access_token=token)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
@router.post("/login", response_model=TokenResponse)
|
| 32 |
-
async def login(req: AuthRequest, session: AsyncSession = Depends(get_session)):
|
| 33 |
-
"""Login with email and password."""
|
| 34 |
-
stmt = select(User).where(User.email == req.email)
|
| 35 |
-
result = await session.execute(stmt)
|
| 36 |
-
user = result.scalar_one_or_none()
|
| 37 |
-
if not user or not verify_password(req.password, user.hashed_password):
|
| 38 |
-
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 39 |
-
token = create_access_token(subject=user.id)
|
| 40 |
-
return TokenResponse(access_token=token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/api/models.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
Pydantic request/response models for the API.
|
| 3 |
-
|
| 4 |
"""
|
| 5 |
from pydantic import BaseModel, Field
|
| 6 |
from typing import List, Optional
|
|
|
|
| 1 |
"""
|
| 2 |
Pydantic request/response models for the API.
|
| 3 |
+
Authentication is not part of the public API.
|
| 4 |
"""
|
| 5 |
from pydantic import BaseModel, Field
|
| 6 |
from typing import List, Optional
|
backend/app/api/routes.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
Main API routes for the LLM Misuse Detection system.
|
| 3 |
Endpoints: /api/analyze, /api/analyze/bulk, /api/results/{id}
|
| 4 |
-
Persistence:
|
| 5 |
"""
|
| 6 |
import hashlib
|
| 7 |
import json
|
|
@@ -117,7 +117,7 @@ async def _analyze_text(text: str, user_id: str | None = None) -> dict:
|
|
| 117 |
saved = await save_document(COLLECTION, doc.id, doc.to_dict())
|
| 118 |
result["id"] = doc.id if saved else text_hash
|
| 119 |
except Exception:
|
| 120 |
-
# Silently skip
|
| 121 |
result["id"] = text_hash
|
| 122 |
|
| 123 |
return result
|
|
@@ -168,7 +168,7 @@ async def bulk_analyze(request: BulkAnalyzeRequest):
|
|
| 168 |
|
| 169 |
@router.get("/results/{result_id}", response_model=AnalyzeResponse)
|
| 170 |
async def get_result(result_id: str):
|
| 171 |
-
"""Fetch a previously computed analysis result by
|
| 172 |
data = await get_document(COLLECTION, result_id)
|
| 173 |
if not data:
|
| 174 |
raise HTTPException(status_code=404, detail="Result not found")
|
|
|
|
| 1 |
"""
|
| 2 |
Main API routes for the LLM Misuse Detection system.
|
| 3 |
Endpoints: /api/analyze, /api/analyze/bulk, /api/results/{id}
|
| 4 |
+
Persistence: in-process result storage helpers.
|
| 5 |
"""
|
| 6 |
import hashlib
|
| 7 |
import json
|
|
|
|
| 117 |
saved = await save_document(COLLECTION, doc.id, doc.to_dict())
|
| 118 |
result["id"] = doc.id if saved else text_hash
|
| 119 |
except Exception:
|
| 120 |
+
# Silently skip persistence - it's optional
|
| 121 |
result["id"] = text_hash
|
| 122 |
|
| 123 |
return result
|
|
|
|
| 168 |
|
| 169 |
@router.get("/results/{result_id}", response_model=AnalyzeResponse)
|
| 170 |
async def get_result(result_id: str):
|
| 171 |
+
"""Fetch a previously computed analysis result by stored result ID."""
|
| 172 |
data = await get_document(COLLECTION, result_id)
|
| 173 |
if not data:
|
| 174 |
raise HTTPException(status_code=404, detail="Result not found")
|
backend/app/core/auth.py
DELETED
|
@@ -1,43 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Firebase Authentication utilities.
|
| 3 |
-
Verifies Firebase ID tokens issued by the frontend (Firebase Auth SDK).
|
| 4 |
-
|
| 5 |
-
Requires firebase-admin to be initialised first (done in main.py lifespan
|
| 6 |
-
via backend.app.db.firestore.init_firebase).
|
| 7 |
-
|
| 8 |
-
Env vars: FIREBASE_CREDENTIALS_JSON, FIREBASE_PROJECT_ID
|
| 9 |
-
"""
|
| 10 |
-
from fastapi import Depends, HTTPException, status
|
| 11 |
-
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 12 |
-
from firebase_admin import auth as firebase_auth
|
| 13 |
-
|
| 14 |
-
security_scheme = HTTPBearer()
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
async def get_current_user(
|
| 18 |
-
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
| 19 |
-
) -> str:
|
| 20 |
-
"""
|
| 21 |
-
Dependency: extracts and verifies the Firebase ID token from the
|
| 22 |
-
Authorization: Bearer <id_token> header.
|
| 23 |
-
Returns the Firebase UID of the authenticated user.
|
| 24 |
-
"""
|
| 25 |
-
id_token = credentials.credentials
|
| 26 |
-
try:
|
| 27 |
-
decoded = firebase_auth.verify_id_token(id_token)
|
| 28 |
-
except firebase_auth.ExpiredIdTokenError:
|
| 29 |
-
raise HTTPException(
|
| 30 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 31 |
-
detail="Firebase ID token has expired. Please re-authenticate.",
|
| 32 |
-
)
|
| 33 |
-
except firebase_auth.InvalidIdTokenError:
|
| 34 |
-
raise HTTPException(
|
| 35 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 36 |
-
detail="Invalid Firebase ID token.",
|
| 37 |
-
)
|
| 38 |
-
except Exception as e:
|
| 39 |
-
raise HTTPException(
|
| 40 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 41 |
-
detail=f"Token verification failed: {str(e)}",
|
| 42 |
-
)
|
| 43 |
-
return decoded["uid"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/core/config.py
CHANGED
|
@@ -14,12 +14,6 @@ class Settings(BaseSettings):
|
|
| 14 |
APP_NAME: str = "Zynera"
|
| 15 |
DEBUG: bool = False
|
| 16 |
|
| 17 |
-
# Firebase
|
| 18 |
-
FIREBASE_PROJECT_ID: str = ""
|
| 19 |
-
FIREBASE_CREDENTIALS_JSON: Optional[str] = None
|
| 20 |
-
# Set to False to skip Firestore init at startup (useful when credentials are absent)
|
| 21 |
-
FIRESTORE_AUTO_INIT: bool = True
|
| 22 |
-
|
| 23 |
# Redis
|
| 24 |
REDIS_URL: str = "redis://localhost:6379/0"
|
| 25 |
|
|
@@ -28,10 +22,10 @@ class Settings(BaseSettings):
|
|
| 28 |
|
| 29 |
# HuggingFace
|
| 30 |
HF_API_KEY: str = ""
|
| 31 |
-
HF_DETECTOR_PRIMARY: str = f"{_HF_ROUTER}/
|
| 32 |
-
HF_DETECTOR_FALLBACK: str = ""
|
| 33 |
-
HF_EMBEDDINGS_PRIMARY: str = ""
|
| 34 |
-
HF_EMBEDDINGS_FALLBACK: str = ""
|
| 35 |
HF_HARM_CLASSIFIER: str = f"{_HF_ROUTER}/facebook/roberta-hate-speech-dynabench-r4-target"
|
| 36 |
|
| 37 |
# Groq
|
|
@@ -58,6 +52,7 @@ class Settings(BaseSettings):
|
|
| 58 |
|
| 59 |
PERPLEXITY_THRESHOLD: float = 0.3
|
| 60 |
RATE_LIMIT_PER_MINUTE: int = 30
|
|
|
|
| 61 |
|
| 62 |
@property
|
| 63 |
def cors_origins_list(self) -> List[str]:
|
|
|
|
| 14 |
APP_NAME: str = "Zynera"
|
| 15 |
DEBUG: bool = False
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
# Redis
|
| 18 |
REDIS_URL: str = "redis://localhost:6379/0"
|
| 19 |
|
|
|
|
| 22 |
|
| 23 |
# HuggingFace
|
| 24 |
HF_API_KEY: str = ""
|
| 25 |
+
HF_DETECTOR_PRIMARY: str = f"{_HF_ROUTER}/desklib/ai-text-detector-v1.01"
|
| 26 |
+
HF_DETECTOR_FALLBACK: str = f"{_HF_ROUTER}/fakespot-ai/roberta-base-ai-text-detection-v1"
|
| 27 |
+
HF_EMBEDDINGS_PRIMARY: str = f"{_HF_ROUTER}/sentence-transformers/all-mpnet-base-v2"
|
| 28 |
+
HF_EMBEDDINGS_FALLBACK: str = f"{_HF_ROUTER}/sentence-transformers/all-MiniLM-L6-v2"
|
| 29 |
HF_HARM_CLASSIFIER: str = f"{_HF_ROUTER}/facebook/roberta-hate-speech-dynabench-r4-target"
|
| 30 |
|
| 31 |
# Groq
|
|
|
|
| 52 |
|
| 53 |
PERPLEXITY_THRESHOLD: float = 0.3
|
| 54 |
RATE_LIMIT_PER_MINUTE: int = 30
|
| 55 |
+
RESULT_STORE_LIMIT: int = 512
|
| 56 |
|
| 57 |
@property
|
| 58 |
def cors_origins_list(self) -> List[str]:
|
backend/app/db/firestore.py
CHANGED
|
@@ -1,117 +1,53 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
HF Spaces env-var storage. The firebase-admin SDK handles credential
|
| 9 |
-
refresh internally without surface-level JWT errors, and the
|
| 10 |
-
_fix_private_key helper below corrects the newline escaping before the
|
| 11 |
-
SDK ever touches the key.
|
| 12 |
-
|
| 13 |
-
Env vars required:
|
| 14 |
-
FIREBASE_CREDENTIALS_JSON β service account JSON string
|
| 15 |
-
FIREBASE_PROJECT_ID β e.g. "fir-config-d3c36"
|
| 16 |
"""
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
-
import
|
| 20 |
-
import threading
|
| 21 |
from typing import Any
|
| 22 |
|
| 23 |
-
import firebase_admin
|
| 24 |
-
from firebase_admin import credentials, firestore as fb_firestore
|
| 25 |
-
import google.auth.exceptions
|
| 26 |
-
|
| 27 |
from backend.app.core.config import settings
|
| 28 |
from backend.app.core.logging import get_logger
|
| 29 |
|
| 30 |
logger = get_logger(__name__)
|
| 31 |
|
| 32 |
-
_db: Any =
|
| 33 |
-
_enabled: bool =
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def _fix_private_key(d: dict) -> dict:
|
| 37 |
-
"""Unescape double-escaped newlines in private_key (common in HF Spaces env-var pastes)."""
|
| 38 |
-
if "private_key" in d:
|
| 39 |
-
d["private_key"] = d["private_key"].replace("\\\\n", "\\n").replace("\\n", "\n")
|
| 40 |
-
return d
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def _init_firebase_with_timeout():
|
| 44 |
-
"""Internal function to initialize Firebase with a timeout."""
|
| 45 |
-
global _db, _enabled
|
| 46 |
-
|
| 47 |
-
try:
|
| 48 |
-
cred_dict = json.loads(settings.FIREBASE_CREDENTIALS_JSON)
|
| 49 |
-
cred_dict = _fix_private_key(cred_dict)
|
| 50 |
-
|
| 51 |
-
# Avoid re-initialising if already done (e.g. hot reload in dev)
|
| 52 |
-
if not firebase_admin._apps:
|
| 53 |
-
cred = credentials.Certificate(cred_dict)
|
| 54 |
-
firebase_admin.initialize_app(cred)
|
| 55 |
-
|
| 56 |
-
_db = fb_firestore.client()
|
| 57 |
-
_enabled = True
|
| 58 |
-
project = settings.FIREBASE_PROJECT_ID or cred_dict.get("project_id", "")
|
| 59 |
-
logger.info("Firebase + Firestore initialised", project=project)
|
| 60 |
-
except Exception:
|
| 61 |
-
_db = None
|
| 62 |
-
_enabled = False
|
| 63 |
|
| 64 |
|
| 65 |
def init_firebase() -> None:
|
| 66 |
-
"""
|
| 67 |
-
|
| 68 |
-
Uses threading with a 5-second timeout to prevent hanging.
|
| 69 |
-
"""
|
| 70 |
-
global _db, _enabled
|
| 71 |
|
| 72 |
-
if not settings.FIREBASE_CREDENTIALS_JSON:
|
| 73 |
-
# Silently disable if not configured
|
| 74 |
-
_enabled = False
|
| 75 |
-
return
|
| 76 |
-
|
| 77 |
-
# Use threading for timeout instead of signal (more portable)
|
| 78 |
-
init_thread = threading.Thread(target=_init_firebase_with_timeout, daemon=True)
|
| 79 |
-
init_thread.start()
|
| 80 |
-
init_thread.join(timeout=5.0) # Wait max 5 seconds
|
| 81 |
-
|
| 82 |
-
if init_thread.is_alive():
|
| 83 |
-
# Timeout reached - thread still running
|
| 84 |
-
_db = None
|
| 85 |
-
_enabled = False
|
| 86 |
-
logger.info("Firebase init timed out - disabled (non-critical)")
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
# ---- Public helpers --------------------------------------------------------
|
| 90 |
|
| 91 |
async def save_document(collection: str, doc_id: str, data: dict) -> bool:
|
| 92 |
-
"""
|
| 93 |
-
if not _enabled
|
| 94 |
-
return False
|
| 95 |
-
try:
|
| 96 |
-
_db.collection(collection).document(doc_id).set(data)
|
| 97 |
-
return True
|
| 98 |
-
except (google.auth.exceptions.RefreshError, Exception):
|
| 99 |
-
# Silently fail - no logs, just return False
|
| 100 |
return False
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
async def get_document(collection: str, doc_id: str) -> dict | None:
|
| 104 |
-
"""Fetch a
|
| 105 |
-
if not _enabled
|
| 106 |
-
return None
|
| 107 |
-
try:
|
| 108 |
-
doc = _db.collection(collection).document(doc_id).get()
|
| 109 |
-
return doc.to_dict() if doc.exists else None
|
| 110 |
-
except (google.auth.exceptions.RefreshError, Exception):
|
| 111 |
-
# Silently fail - no logs, just return None
|
| 112 |
return None
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
def get_db():
|
| 116 |
-
"""Legacy shim. Returns the
|
| 117 |
return _db if _enabled else None
|
|
|
|
| 1 |
"""
|
| 2 |
+
Lightweight in-process storage helpers for analysis results.
|
| 3 |
|
| 4 |
+
Firebase/Firestore auth has been removed from the runtime because the app no
|
| 5 |
+
longer uses login/signup, and startup must not depend on external credentials.
|
| 6 |
+
The public helper API is preserved so the analysis routes can keep storing and
|
| 7 |
+
retrieving recent results without any deployment-time setup.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
from collections import OrderedDict
|
|
|
|
| 12 |
from typing import Any
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from backend.app.core.config import settings
|
| 15 |
from backend.app.core.logging import get_logger
|
| 16 |
|
| 17 |
logger = get_logger(__name__)
|
| 18 |
|
| 19 |
+
_db: "OrderedDict[str, dict[str, Any]]" = OrderedDict()
|
| 20 |
+
_enabled: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def init_firebase() -> None:
|
| 24 |
+
"""Legacy no-op kept for compatibility with existing imports/tests."""
|
| 25 |
+
logger.info("Using in-memory analysis result storage")
|
|
|
|
|
|
|
|
|
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
async def save_document(collection: str, doc_id: str, data: dict) -> bool:
|
| 29 |
+
"""Store a recent analysis result in memory."""
|
| 30 |
+
if not _enabled:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
return False
|
| 32 |
|
| 33 |
+
key = f"{collection}:{doc_id}"
|
| 34 |
+
_db[key] = dict(data)
|
| 35 |
+
_db.move_to_end(key)
|
| 36 |
+
if len(_db) > settings.RESULT_STORE_LIMIT:
|
| 37 |
+
_db.popitem(last=False)
|
| 38 |
+
return True
|
| 39 |
+
|
| 40 |
|
| 41 |
async def get_document(collection: str, doc_id: str) -> dict | None:
|
| 42 |
+
"""Fetch a previously stored analysis result from memory."""
|
| 43 |
+
if not _enabled:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
return None
|
| 45 |
|
| 46 |
+
key = f"{collection}:{doc_id}"
|
| 47 |
+
data = _db.get(key)
|
| 48 |
+
return dict(data) if data is not None else None
|
| 49 |
+
|
| 50 |
|
| 51 |
def get_db():
|
| 52 |
+
"""Legacy shim. Returns the current in-memory store."""
|
| 53 |
return _db if _enabled else None
|
backend/app/main.py
CHANGED
|
@@ -2,9 +2,6 @@
|
|
| 2 |
FastAPI main application entry point.
|
| 3 |
Configures CORS, secure headers, routes, and observability.
|
| 4 |
|
| 5 |
-
Auth: Firebase Auth (frontend issues ID tokens; backend verifies via firebase-admin)
|
| 6 |
-
DB: Firestore (via firebase-admin SDK)
|
| 7 |
-
|
| 8 |
Env vars: All from core/config.py
|
| 9 |
Run: uvicorn backend.app.main:app --host 0.0.0.0 --port 7860
|
| 10 |
"""
|
|
@@ -19,7 +16,6 @@ from backend.app.core.config import settings
|
|
| 19 |
from backend.app.core.logging import setup_logging, get_logger
|
| 20 |
from backend.app.api.routes import router as analysis_router
|
| 21 |
from backend.app.api.models import HealthResponse
|
| 22 |
-
from backend.app.db.firestore import init_firebase
|
| 23 |
|
| 24 |
# Sentry (optional)
|
| 25 |
if settings.SENTRY_DSN:
|
|
@@ -38,10 +34,6 @@ REQUEST_LATENCY = Histogram("http_request_duration_seconds", "Request latency",
|
|
| 38 |
@asynccontextmanager
|
| 39 |
async def lifespan(app: FastAPI):
|
| 40 |
logger.info("Starting Zynera API")
|
| 41 |
-
if settings.FIRESTORE_AUTO_INIT:
|
| 42 |
-
init_firebase()
|
| 43 |
-
else:
|
| 44 |
-
logger.info("Firestore auto-init disabled (FIRESTORE_AUTO_INIT=false) β skipping")
|
| 45 |
yield
|
| 46 |
logger.info("Shutting down")
|
| 47 |
|
|
|
|
| 2 |
FastAPI main application entry point.
|
| 3 |
Configures CORS, secure headers, routes, and observability.
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
Env vars: All from core/config.py
|
| 6 |
Run: uvicorn backend.app.main:app --host 0.0.0.0 --port 7860
|
| 7 |
"""
|
|
|
|
| 16 |
from backend.app.core.logging import setup_logging, get_logger
|
| 17 |
from backend.app.api.routes import router as analysis_router
|
| 18 |
from backend.app.api.models import HealthResponse
|
|
|
|
| 19 |
|
| 20 |
# Sentry (optional)
|
| 21 |
if settings.SENTRY_DSN:
|
|
|
|
| 34 |
@asynccontextmanager
|
| 35 |
async def lifespan(app: FastAPI):
|
| 36 |
logger.info("Starting Zynera API")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
yield
|
| 38 |
logger.info("Shutting down")
|
| 39 |
|
backend/requirements.txt
CHANGED
|
@@ -5,9 +5,6 @@ uvicorn[standard]==0.34.0
|
|
| 5 |
pydantic==2.10.4
|
| 6 |
pydantic-settings==2.7.1
|
| 7 |
|
| 8 |
-
# Firebase (Auth verification + Firestore)
|
| 9 |
-
firebase-admin==6.5.0
|
| 10 |
-
|
| 11 |
# Redis / Queue
|
| 12 |
redis==5.2.1
|
| 13 |
rq==2.1.0
|
|
|
|
| 5 |
pydantic==2.10.4
|
| 6 |
pydantic-settings==2.7.1
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
# Redis / Queue
|
| 9 |
redis==5.2.1
|
| 10 |
rq==2.1.0
|
backend/tests/test_api.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
Integration tests for the API endpoints with mocked external services.
|
| 3 |
-
Tests /api/analyze, /health, /metrics β no
|
| 4 |
"""
|
| 5 |
import pytest
|
| 6 |
from unittest.mock import AsyncMock, patch, MagicMock
|
|
@@ -8,10 +8,8 @@ from fastapi.testclient import TestClient
|
|
| 8 |
|
| 9 |
|
| 10 |
def _make_client():
|
| 11 |
-
"""Build a TestClient with
|
| 12 |
-
|
| 13 |
-
with patch("backend.app.db.firestore.init_firebase"), \
|
| 14 |
-
patch("backend.app.db.firestore._enabled", True), \
|
| 15 |
patch("backend.app.db.firestore.save_document", new_callable=AsyncMock, return_value=True), \
|
| 16 |
patch("backend.app.db.firestore.get_document", new_callable=AsyncMock, return_value=None):
|
| 17 |
from backend.app.main import app
|
|
@@ -27,40 +25,26 @@ def client():
|
|
| 27 |
return c
|
| 28 |
|
| 29 |
|
| 30 |
-
class
|
| 31 |
-
"""Verify the server starts cleanly
|
| 32 |
|
| 33 |
-
def
|
| 34 |
-
"""App must start and /health must return 200
|
| 35 |
-
# Use the same safe mock pattern as _make_client() β no real credentials needed
|
| 36 |
c, _ = _make_client()
|
| 37 |
response = c.get("/health")
|
| 38 |
assert response.status_code == 200
|
| 39 |
data = response.json()
|
| 40 |
assert data["status"] == "ok"
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
from backend.app.
|
| 46 |
-
|
| 47 |
-
original = config.settings.FIRESTORE_AUTO_INIT
|
| 48 |
-
try:
|
| 49 |
-
config.settings.FIRESTORE_AUTO_INIT = False
|
| 50 |
-
with patch("backend.app.db.firestore.init_firebase") as mock_init:
|
| 51 |
-
# Simulate a lifespan startup by calling the lifespan coroutine
|
| 52 |
-
import asyncio
|
| 53 |
-
from contextlib import asynccontextmanager
|
| 54 |
-
|
| 55 |
-
async def run_lifespan():
|
| 56 |
-
from backend.app.main import lifespan
|
| 57 |
-
async with lifespan(_app):
|
| 58 |
-
pass
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
|
| 65 |
|
| 66 |
class TestHealthEndpoint:
|
|
@@ -182,42 +166,17 @@ class TestAssistEndpoint:
|
|
| 182 |
config.settings.GROQ_API_KEY = original
|
| 183 |
|
| 184 |
|
| 185 |
-
class
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
async def test_save_document_refresh_error_returns_false(self):
|
| 190 |
-
"""RefreshError in save_document should return False without raising."""
|
| 191 |
-
import google.auth.exceptions
|
| 192 |
-
from unittest.mock import MagicMock, patch
|
| 193 |
-
|
| 194 |
-
mock_db = MagicMock()
|
| 195 |
-
mock_db.collection.return_value.document.return_value.set.side_effect = (
|
| 196 |
-
google.auth.exceptions.RefreshError("invalid_grant: Invalid JWT Signature")
|
| 197 |
-
)
|
| 198 |
-
|
| 199 |
-
with patch("backend.app.db.firestore._enabled", True), \
|
| 200 |
-
patch("backend.app.db.firestore._db", mock_db):
|
| 201 |
-
from backend.app.db.firestore import save_document
|
| 202 |
-
result = await save_document("test_col", "test_doc", {"key": "value"})
|
| 203 |
-
assert result is False
|
| 204 |
-
|
| 205 |
-
@pytest.mark.asyncio
|
| 206 |
-
async def test_get_document_refresh_error_returns_none(self):
|
| 207 |
-
"""RefreshError in get_document should return None without raising."""
|
| 208 |
-
import google.auth.exceptions
|
| 209 |
-
from unittest.mock import MagicMock, patch
|
| 210 |
-
|
| 211 |
-
mock_db = MagicMock()
|
| 212 |
-
mock_db.collection.return_value.document.return_value.get.side_effect = (
|
| 213 |
-
google.auth.exceptions.RefreshError("invalid_grant: Invalid JWT Signature")
|
| 214 |
-
)
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
class TestAttackSimulations:
|
|
|
|
| 1 |
"""
|
| 2 |
Integration tests for the API endpoints with mocked external services.
|
| 3 |
+
Tests /api/analyze, /health, /metrics β no external auth, persistence, or Redis needed.
|
| 4 |
"""
|
| 5 |
import pytest
|
| 6 |
from unittest.mock import AsyncMock, patch, MagicMock
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def _make_client():
|
| 11 |
+
"""Build a TestClient with persistence and external calls mocked out."""
|
| 12 |
+
with patch("backend.app.db.firestore._enabled", True), \
|
|
|
|
|
|
|
| 13 |
patch("backend.app.db.firestore.save_document", new_callable=AsyncMock, return_value=True), \
|
| 14 |
patch("backend.app.db.firestore.get_document", new_callable=AsyncMock, return_value=None):
|
| 15 |
from backend.app.main import app
|
|
|
|
| 25 |
return c
|
| 26 |
|
| 27 |
|
| 28 |
+
class TestStartup:
|
| 29 |
+
"""Verify the server starts cleanly without auth or external storage."""
|
| 30 |
|
| 31 |
+
def test_health_returns_200_on_startup(self):
|
| 32 |
+
"""App must start and /health must return 200 with default settings."""
|
|
|
|
| 33 |
c, _ = _make_client()
|
| 34 |
response = c.get("/health")
|
| 35 |
assert response.status_code == 200
|
| 36 |
data = response.json()
|
| 37 |
assert data["status"] == "ok"
|
| 38 |
|
| 39 |
+
@pytest.mark.asyncio
|
| 40 |
+
async def test_in_memory_storage_round_trip(self):
|
| 41 |
+
"""Recent analysis results should still be retrievable without Firestore."""
|
| 42 |
+
from backend.app.db.firestore import save_document, get_document, _db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
_db.clear()
|
| 45 |
+
payload = {"id": "abc123", "status": "done", "threat_score": 0.42}
|
| 46 |
+
assert await save_document("analysis_results", "abc123", payload) is True
|
| 47 |
+
assert await get_document("analysis_results", "abc123") == payload
|
| 48 |
|
| 49 |
|
| 50 |
class TestHealthEndpoint:
|
|
|
|
| 166 |
config.settings.GROQ_API_KEY = original
|
| 167 |
|
| 168 |
|
| 169 |
+
class TestModelConfiguration:
|
| 170 |
+
def test_default_models_match_documented_endpoints(self):
|
| 171 |
+
"""Default backend model settings should match the documented README models."""
|
| 172 |
+
from backend.app.core.config import settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
+
router_base = "https://router.huggingface.co/hf-inference/models"
|
| 175 |
+
assert settings.HF_DETECTOR_PRIMARY == f"{router_base}/desklib/ai-text-detector-v1.01"
|
| 176 |
+
assert settings.HF_DETECTOR_FALLBACK == f"{router_base}/fakespot-ai/roberta-base-ai-text-detection-v1"
|
| 177 |
+
assert settings.HF_EMBEDDINGS_PRIMARY == f"{router_base}/sentence-transformers/all-mpnet-base-v2"
|
| 178 |
+
assert settings.HF_EMBEDDINGS_FALLBACK == f"{router_base}/sentence-transformers/all-MiniLM-L6-v2"
|
| 179 |
+
assert settings.HF_HARM_CLASSIFIER == f"{router_base}/facebook/roberta-hate-speech-dynabench-r4-target"
|
| 180 |
|
| 181 |
|
| 182 |
class TestAttackSimulations:
|
backend/tests/test_auth.py
DELETED
|
@@ -1,60 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Unit tests for Firebase token verification (auth.py).
|
| 3 |
-
Mocks firebase_admin.auth so no real Firebase project is needed in CI.
|
| 4 |
-
"""
|
| 5 |
-
import pytest
|
| 6 |
-
from unittest.mock import patch, MagicMock
|
| 7 |
-
from fastapi import HTTPException
|
| 8 |
-
from fastapi.security import HTTPAuthorizationCredentials
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class TestFirebaseTokenVerification:
|
| 12 |
-
|
| 13 |
-
@patch("backend.app.core.auth.firebase_auth")
|
| 14 |
-
async def test_valid_token_returns_uid(self, mock_fb_auth):
|
| 15 |
-
"""A valid Firebase ID token should return the uid."""
|
| 16 |
-
from backend.app.core.auth import get_current_user
|
| 17 |
-
|
| 18 |
-
mock_fb_auth.verify_id_token.return_value = {"uid": "user-abc-123"}
|
| 19 |
-
mock_fb_auth.ExpiredIdTokenError = Exception
|
| 20 |
-
mock_fb_auth.InvalidIdTokenError = Exception
|
| 21 |
-
|
| 22 |
-
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="fake-valid-token")
|
| 23 |
-
uid = await get_current_user(credentials=creds)
|
| 24 |
-
assert uid == "user-abc-123"
|
| 25 |
-
mock_fb_auth.verify_id_token.assert_called_once_with("fake-valid-token")
|
| 26 |
-
|
| 27 |
-
@patch("backend.app.core.auth.firebase_auth")
|
| 28 |
-
async def test_expired_token_raises_401(self, mock_fb_auth):
|
| 29 |
-
"""An expired Firebase token should raise HTTP 401."""
|
| 30 |
-
from backend.app.core.auth import get_current_user
|
| 31 |
-
|
| 32 |
-
class FakeExpiredError(Exception):
|
| 33 |
-
pass
|
| 34 |
-
|
| 35 |
-
mock_fb_auth.ExpiredIdTokenError = FakeExpiredError
|
| 36 |
-
mock_fb_auth.InvalidIdTokenError = Exception
|
| 37 |
-
mock_fb_auth.verify_id_token.side_effect = FakeExpiredError("expired")
|
| 38 |
-
|
| 39 |
-
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="expired-token")
|
| 40 |
-
with pytest.raises(HTTPException) as exc_info:
|
| 41 |
-
await get_current_user(credentials=creds)
|
| 42 |
-
assert exc_info.value.status_code == 401
|
| 43 |
-
assert "expired" in exc_info.value.detail.lower()
|
| 44 |
-
|
| 45 |
-
@patch("backend.app.core.auth.firebase_auth")
|
| 46 |
-
async def test_invalid_token_raises_401(self, mock_fb_auth):
|
| 47 |
-
"""A tampered / invalid Firebase token should raise HTTP 401."""
|
| 48 |
-
from backend.app.core.auth import get_current_user
|
| 49 |
-
|
| 50 |
-
class FakeInvalidError(Exception):
|
| 51 |
-
pass
|
| 52 |
-
|
| 53 |
-
mock_fb_auth.ExpiredIdTokenError = Exception
|
| 54 |
-
mock_fb_auth.InvalidIdTokenError = FakeInvalidError
|
| 55 |
-
mock_fb_auth.verify_id_token.side_effect = FakeInvalidError("invalid")
|
| 56 |
-
|
| 57 |
-
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="bad-token")
|
| 58 |
-
with pytest.raises(HTTPException) as exc_info:
|
| 59 |
-
await get_current_user(credentials=creds)
|
| 60 |
-
assert exc_info.value.status_code == 401
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|