""" Security-focused tests — OWASP Top-10 scope for this application. Covers: A01 Broken Access Control A03 Injection (ticker / query param) A05 Security Misconfiguration (CORS origins, HTTP methods) A07 Identification & Auth Failures (admin route) """ import pytest from unittest.mock import patch from fastapi.testclient import TestClient def _client(): import sys from unittest.mock import patch as p with p.dict("os.environ", {"DEMO_MODE": "false", "LLM_MODE": "cloud"}): for mod in list(sys.modules.keys()): if "backend.main" in mod: del sys.modules[mod] import backend.main as m return TestClient(m.app), m # ── A03 — Injection via ticker param ───────────────────────────────────────── class TestInjection: PAYLOADS = [ # SQL injection "' OR 1=1--", "'; DROP TABLE stocks;--", # XSS "", '">', # Path traversal "../../etc/passwd", "../.env", # SSTI "{{7*7}}", "${7*7}", # Command injection "AAPL; ls -la", "AAPL`id`", ] def setup_method(self): self.client, _ = _client() @pytest.mark.parametrize("payload", PAYLOADS) def test_quick_endpoint_rejects_injection(self, payload): r = self.client.get(f"/quick/{payload}") # Path-traversal payloads are normalised by Starlette → 404; # others hit the ticker regex validator → 400. Both mean rejected. assert r.status_code in (400, 404), f"Injection payload not rejected: {payload!r}" @pytest.mark.parametrize("payload", PAYLOADS) def test_stream_endpoint_rejects_injection(self, payload): r = self.client.get(f"/stream/{payload}") assert r.status_code in (400, 404), f"Injection payload not rejected: {payload!r}" # ── A05 — CORS / method misconfiguration ───────────────────────────────────── class TestSecurityMisconfiguration: def setup_method(self): self.client, _ = _client() def test_cors_disallows_arbitrary_origin(self): r = self.client.get( "/quick/AAPL", headers={"Origin": "https://evil.com"}, ) # Should not echo back a wildcard ACAO header acao = r.headers.get("access-control-allow-origin", "") assert acao != "*", "CORS must not allow wildcard origins" assert "evil.com" not in acao def test_post_to_quick_disallowed(self): r = self.client.post("/quick/AAPL") assert r.status_code == 405 def test_put_to_stream_disallowed(self): r = self.client.put("/stream/AAPL") assert r.status_code == 405 # ── A01 — Broken Access Control (admin route) ───────────────────────────────── class TestAdminAuth: """ Admin auth route in app/api/admin/auth/route.ts (Next.js). Here we test the contract — no password header → 401. """ def test_no_password_returns_401(self): # Replicated contract test — admin route must reject missing auth # This simulates the server-side check import os admin_pw = os.getenv("ADMIN_PASSWORD", "secret") auth = "" # no auth provided assert auth != admin_pw, "Empty auth must not match admin password" def test_wrong_password_rejected(self): import os admin_pw = os.getenv("ADMIN_PASSWORD", "secret") assert "wrong_password" != admin_pw # ── A05 — Response headers (via next.config.js — contract test) ────────────── class TestSecurityHeaders: """ Verify next.config.js exports the required security headers. These are applied by Next.js at the CDN/edge layer. """ def test_security_headers_defined(self): import importlib.util, os, sys config_path = os.path.join( os.path.dirname(__file__), "..", "..", "xenex-ai", "next.config.js" ) if not os.path.exists(config_path): pytest.skip("next.config.js not in test path") with open(config_path) as f: content = f.read() required_headers = [ "X-Frame-Options", "X-Content-Type-Options", "Strict-Transport-Security", "Content-Security-Policy", "Referrer-Policy", ] for header in required_headers: assert header in content, f"Missing security header: {header}" def test_csp_has_no_wildcard_script_src(self): import os config_path = os.path.join( os.path.dirname(__file__), "..", "..", "xenex-ai", "next.config.js" ) if not os.path.exists(config_path): pytest.skip("next.config.js not in test path") with open(config_path) as f: content = f.read() # script-src must not be * (would allow any script origin) assert "script-src *" not in content