GitHub Actions commited on
Commit
76964f5
Β·
1 Parent(s): 1521258

Deploy backend from GitHub c7b5c288e89c3d1884e6d556b62378976a19fef4

Browse files
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
- Auth is handled entirely by Firebase on the frontend β€” no auth models here.
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: Firestore via REST helpers.
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 Firestore - it's optional
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 Firestore document ID."""
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}/Hello-SimpleAI/chatgpt-detector-roberta"
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
- Firestore client using the official firebase-admin SDK.
3
 
4
- Why firebase-admin (not REST):
5
- The REST approach required google.auth to sign JWTs directly, which
6
- failed with 'invalid_grant: Invalid JWT Signature' when the private_key
7
- in FIREBASE_CREDENTIALS_JSON had double-escaped newlines (\\n) from
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 json
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 = None
33
- _enabled: bool = False
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
- """Initialise firebase-admin app and Firestore client. Non-fatal if misconfigured.
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
- """Create or overwrite a Firestore document. Returns True on success."""
93
- if not _enabled or _db is None:
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 single Firestore document. Returns None if not found or disabled."""
105
- if not _enabled or _db is None:
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 Firestore client or None if disabled."""
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 real Firebase, Firestore, or Redis needed.
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 Firebase init and Firestore writes mocked out."""
12
- # Patch init_firebase and the _enabled flag so the app starts without real credentials
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 TestStartupWithoutCredentials:
31
- """Verify the server starts cleanly when no GCP/Firebase credentials are present."""
32
 
33
- def test_health_returns_200_without_firebase_credentials(self):
34
- """App must start and /health must return 200 even without Firebase credentials."""
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
- def test_firestore_auto_init_false_skips_init(self):
43
- """When FIRESTORE_AUTO_INIT is False, init_firebase must not be invoked at startup."""
44
- from backend.app.main import app as _app
45
- from backend.app.core import config
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
- asyncio.run(run_lifespan())
61
- mock_init.assert_not_called()
62
- finally:
63
- config.settings.FIRESTORE_AUTO_INIT = original
64
 
65
 
66
  class TestHealthEndpoint:
@@ -182,42 +166,17 @@ class TestAssistEndpoint:
182
  config.settings.GROQ_API_KEY = original
183
 
184
 
185
- class TestFirestoreRefreshError:
186
- """Verify RefreshError is logged at DEBUG, not WARNING."""
187
-
188
- @pytest.mark.asyncio
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
- with patch("backend.app.db.firestore._enabled", True), \
217
- patch("backend.app.db.firestore._db", mock_db):
218
- from backend.app.db.firestore import get_document
219
- result = await get_document("test_col", "test_doc")
220
- assert result is None
 
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