from __future__ import annotations import asyncio from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone import jwt import pytest from starlette.requests import Request def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} def _signup(client, email: str = "launch-security@example.test") -> str: response = client.post( "/auth/signup", json={"name": "Launch Security", "email": email, "password": "Pass123!beta"}, ) assert response.status_code == 201, response.text return response.json()["access_token"] def _request_with_headers(headers: dict[str, str]) -> Request: return Request( { "type": "http", "method": "POST", "path": "/ask", "headers": [(name.lower().encode(), value.encode()) for name, value in headers.items()], "client": ("198.51.100.25", 12345), "server": ("testserver", 80), "scheme": "http", "query_string": b"", }, ) def test_production_auth_disabled_fails_before_init_db(monkeypatch, tmp_path): from app.core.config import get_settings from app import main monkeypatch.setenv("ENVIRONMENT", "production") monkeypatch.setenv("AUTH_ENABLED", "false") monkeypatch.setenv("AUTH_PROVIDER", "jwt") monkeypatch.setenv("JWT_SECRET_KEY", "production-safe-test-secret-32-chars") monkeypatch.setenv("FRONTEND_BASE_URL", "https://docdoe.ai") monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'prod.db'}") get_settings.cache_clear() def fail_if_called() -> None: raise AssertionError("init_db should not run when production auth is disabled") async def enter_lifespan() -> None: async with main.lifespan(main.app): pass monkeypatch.setattr(main, "init_db", fail_if_called) with pytest.raises(RuntimeError, match="AUTH_ENABLED must be true"): asyncio.run(enter_lifespan()) def test_production_sqlite_database_url_is_blocked(monkeypatch, tmp_path): from app.core.config import get_settings from app.main import _startup_safety_checks monkeypatch.setenv("ENVIRONMENT", "production") monkeypatch.setenv("AUTH_ENABLED", "true") monkeypatch.setenv("AUTH_PROVIDER", "jwt") monkeypatch.setenv("JWT_SECRET_KEY", "production-safe-test-secret-32-chars") monkeypatch.setenv("FRONTEND_BASE_URL", "https://docdoe.ai") monkeypatch.setenv("CORS_ORIGINS", "https://docdoe.ai") monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'prod.db'}") get_settings.cache_clear() with pytest.raises(RuntimeError, match="DATABASE_URL must be PostgreSQL"): _startup_safety_checks() def test_compute_video_routes_are_rate_limited_without_polling_gets() -> None: from app.main import _RATE_LIMIT_PREFIXES, _should_rate_limit_request assert "/video/study-video-jobs" in _RATE_LIMIT_PREFIXES assert "/video/render-jobs" in _RATE_LIMIT_PREFIXES assert _should_rate_limit_request("POST", "/video/study-video-jobs/job_1/render-preview") assert not _should_rate_limit_request("GET", "/video/study-video-jobs/job_1") def test_invalid_forged_jwt_not_trusted_for_rate_limit_key(monkeypatch): from app.core.config import get_settings from app.main import _rate_limit_key monkeypatch.setenv("AUTH_PROVIDER", "jwt") monkeypatch.setenv("JWT_SECRET_KEY", "correct-rate-limit-secret-32-chars") monkeypatch.setenv("JWT_ALGORITHM", "HS256") get_settings.cache_clear() expires_at = datetime.now(timezone.utc) + timedelta(minutes=10) forged = jwt.encode( {"sub": "forged-user", "exp": expires_at}, "wrong-rate-limit-secret-at-least-32", algorithm="HS256", ) request = _request_with_headers( { "Authorization": f"Bearer {forged}", "X-Forwarded-For": "203.0.113.44, 10.0.0.1", }, ) assert _rate_limit_key(request) == "ip:198.51.100.25" def test_rate_limit_ignores_client_supplied_forwarded_for() -> None: from app.main import _rate_limit_key request = _request_with_headers({"X-Forwarded-For": "203.0.113.99"}) assert _rate_limit_key(request) == "ip:198.51.100.25" def test_valid_jwt_is_trusted_for_rate_limit_key(monkeypatch): from app.core.config import get_settings from app.main import _rate_limit_key secret = "correct-rate-limit-secret-32-chars" monkeypatch.setenv("AUTH_PROVIDER", "jwt") monkeypatch.setenv("JWT_SECRET_KEY", secret) monkeypatch.setenv("JWT_ALGORITHM", "HS256") get_settings.cache_clear() token = jwt.encode( {"sub": "real-user", "exp": datetime.now(timezone.utc) + timedelta(minutes=10)}, secret, algorithm="HS256", ) request = _request_with_headers( { "Authorization": f"Bearer {token}", "X-Forwarded-For": "203.0.113.45", }, ) assert _rate_limit_key(request) == "user:real-user" def test_billing_paid_plan_direct_upgrade_blocked_in_production(auth_client, monkeypatch): from app.core.config import get_settings token = _signup(auth_client, "billing-prod@example.test") monkeypatch.setenv("ENVIRONMENT", "production") monkeypatch.setenv("AUTH_ENABLED", "true") monkeypatch.setenv("AUTH_PROVIDER", "jwt") monkeypatch.setenv("JWT_SECRET_KEY", "test-only-secret-for-auth-tests-32chars!") monkeypatch.setenv("FRONTEND_BASE_URL", "https://docdoe.ai") get_settings.cache_clear() paid = auth_client.post( "/billing/select-plan", headers=_auth(token), json={"plan": "popular_299"}, ) assert paid.status_code == 402 assert "checkout" in paid.json()["detail"].lower() current = auth_client.get("/billing/me", headers=_auth(token)) assert current.status_code == 200 assert current.json()["selected_plan"] == "free_trial" free = auth_client.post( "/billing/select-plan", headers=_auth(token), json={"plan": "free_trial"}, ) assert free.status_code == 200 assert free.json()["selected_plan"] == "free_trial" def test_usage_recording_uses_atomic_database_increments(client): """Atomic conditional UPDATE prevents over-limit and races simultaneously. Production hardening switched ``record_generation``/``record_video_plan`` from unconditional increments to ``UPDATE ... WHERE used + N <= limit`` so we can't exceed the quota. To exercise concurrency we widen the limits so every request fits inside the cap. """ from app.core.database import SessionLocal from app.models.user import User from app.models.user_plan import UserPlan from app.services.usage_service import ( get_or_create_user_plan, record_generation, record_video_plan, ) from sqlalchemy import select user_id = "usr_atomic_launch" with SessionLocal() as db: db.add(User(id=user_id, name="Atomic User", email="atomic@example.test")) db.commit() plan = get_or_create_user_plan(db, user_id) plan.monthly_generation_used = 0 plan.monthly_video_used = 0 # Widen limits so the test isolates atomicity, not quota enforcement. plan.monthly_generation_limit = 100 plan.monthly_video_limit = 50 db.add(plan) db.commit() def increment_generation() -> None: with SessionLocal() as db: record_generation(db, user_id) def increment_video() -> None: with SessionLocal() as db: record_video_plan(db, user_id) with ThreadPoolExecutor(max_workers=4) as executor: list(executor.map(lambda _: increment_generation(), range(20))) list(executor.map(lambda _: increment_video(), range(12))) with SessionLocal() as db: plan = db.scalar(select(UserPlan).where(UserPlan.user_id == user_id)) assert plan is not None assert plan.monthly_generation_used == 20 assert plan.monthly_video_used == 12 def test_real_user_api_does_not_return_bundled_demo_data(auth_client): token = _signup(auth_client, "no-demo-data@example.test") headers = _auth(token) sources = auth_client.get("/sources", headers=headers) assert sources.status_code == 200 assert sources.json()["sources"] == [] dashboard = auth_client.get("/dashboard/student", headers=headers) assert dashboard.status_code == 200 assert dashboard.json()["materials"]["total"] == 0 assert dashboard.json()["recent_results"] == [] papers = auth_client.get("/previous-papers", headers=headers) assert papers.status_code == 200 assert papers.json() == [] pyq = auth_client.post("/pyq/analyze", headers=headers, json={"subject": "Physics"}) assert pyq.status_code == 200 pyq_body = pyq.json() assert pyq_body["available"] is False assert pyq_body["predicted_questions"] == [] combined = f"{sources.text}\n{dashboard.text}\n{papers.text}\n{pyq.text}" assert "Electromagnetic Induction" not in combined assert "JEE Main Physics PYQ Set" not in combined def test_empty_previous_paper_account_never_runtime_seeds( auth_client, monkeypatch, ): """Production behavior must match tests: an empty user owns zero papers.""" monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) token = _signup(auth_client, "empty-pyq-account@example.test") response = auth_client.get("/previous-papers", headers=_auth(token)) assert response.status_code == 200 assert response.json() == [] def test_weak_topic_boost_does_not_dominate_irrelevant_chunks(client): from app.core.database import SessionLocal from app.models.document import Document from app.models.document_chunk import DocumentChunk from app.models.user import User from app.services.retrieval import retrieve_relevant_chunks from app.services.weak_topic_service import record_weak_topic user_id = "usr_weak_boost" document_id = "doc_weak_boost" relevant_chunk_id = "chunk_relevant_photosynthesis" with SessionLocal() as db: db.add(User(id=user_id, name="Weak Topic User", email="weak@example.test")) db.add( Document( id=document_id, user_id=user_id, title="Biology Notes", file_name="biology.txt", file_type="text/plain", file_path="/tmp/biology.txt", subject="Biology", status="ready", extracted_text="Photosynthesis converts carbon dioxide and water into glucose.", chunk_count=2, ), ) db.add_all( [ DocumentChunk( id=relevant_chunk_id, document_id=document_id, chunk_index=0, chunk_text=( "Photosynthesis uses light energy to convert carbon dioxide " "and water into glucose and oxygen." ), token_estimate=20, heading="Photosynthesis", ), DocumentChunk( id="chunk_irrelevant_weak_topic", document_id=document_id, chunk_index=1, chunk_text="Quantum tunneling is a weak area but it is unrelated to plant nutrition.", token_estimate=16, heading="Unrelated physics note", ), ], ) db.commit() record_weak_topic(db, user_id, "quantum tunneling", subject="Biology") results = retrieve_relevant_chunks( db, document_id=document_id, query="photosynthesis glucose oxygen", limit=2, user_id=user_id, ) assert results assert results[0].chunk.id == relevant_chunk_id