Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ FROM python:3.13-slim

WORKDIR /app

COPY backend/pyproject.toml backend/requirements.txt ./
RUN pip install --no-cache-dir -e ".[dev]"

COPY backend/src ./src
COPY backend/pyproject.toml ./

RUN pip install --no-cache-dir .

EXPOSE 8000

Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies = [
dev = [
"mypy>=1.16.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.24",
"ruff>=0.11.2",
]

Expand Down
10 changes: 8 additions & 2 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,12 @@ def _release_session_lock(session_id: str) -> None:
_session_locks.pop(session_id, None)

logger.info("Initializing SupportAgent...")
agent: Any = SupportAgent()
logger.info("SupportAgent initialized successfully.")
try:
agent: Any = SupportAgent()
logger.info("SupportAgent initialized successfully.")
except Exception as e:
logger.warning("SupportAgent init failed (external services unavailable): %s", e)
agent = None

# Startup security check
env = os.getenv("ENV", "development").lower()
Expand Down Expand Up @@ -200,6 +204,8 @@ async def solve_ticket(
user: dict = Depends(get_current_user),
x_session_id: str | None = Header(default=None, alias="X-Session-Id"),
):
if agent is None:
raise HTTPException(status_code=503, detail="Agent not initialized — external services unavailable")
try:
# Running our LangGraph State Machine
logger.info("Solving ticket", extra={"query": request.user_query})
Expand Down
27 changes: 15 additions & 12 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from unittest.mock import patch
from middleware.auth import get_current_user


Expand All @@ -8,13 +9,14 @@ def test_solve_ticket_off_topic():
app.dependency_overrides[get_current_user] = lambda: {"sub": "anonymous", "permissions": []}
client = TestClient(app)

response = client.post("/v1/solve-ticket", json={
"user_query": "How to bake a cake?",
"session_id": "test-1"
})
assert response.status_code == 200
body = response.json()
assert body["status"] == "needs_ingestion"
with patch("main.check_rate_limit", return_value=True):
response = client.post("/v1/solve-ticket", json={
"user_query": "How to bake a cake?",
"session_id": "test-1"
})
assert response.status_code == 200
body = response.json()
assert body["status"] == "needs_ingestion"


def test_session_id_presence():
Expand All @@ -24,8 +26,9 @@ def test_session_id_presence():
app.dependency_overrides[get_current_user] = lambda: {"sub": "anonymous", "permissions": []}
client = TestClient(app)

response = client.post("/v1/solve-ticket", json={
"user_query": "What is this repository about?",
"session_id": "unique-id-999"
})
assert response.json()["metadata"]["session_id"] == "unique-id-999"
with patch("main.check_rate_limit", return_value=True):
response = client.post("/v1/solve-ticket", json={
"user_query": "What is this repository about?",
"session_id": "unique-id-999"
})
assert response.json()["metadata"]["session_id"] == "unique-id-999"
40 changes: 24 additions & 16 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,31 @@ def test_auth_error_exception():

def test_jwks_network_error_returns_401():
from jwt import PyJWKClientError
with patch("middleware.auth.PyJWKClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.get_signing_key_from_jwt.side_effect = PyJWKClientError("Connection refused")
from middleware.auth import decode_jwt
with pytest.raises(AuthError) as excinfo:
decode_jwt("some.token.here")
assert excinfo.value.status_code == 401
with patch("middleware.auth.settings") as mock_settings:
mock_settings.AUTH_ENABLED = True
mock_settings.AUTH0_DOMAIN = "test.auth0.com"
mock_settings.AUTH0_AUDIENCE = "https://api.test.com"
with patch("middleware.auth.PyJWKClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.get_signing_key_from_jwt.side_effect = PyJWKClientError("Connection refused")
from middleware.auth import decode_jwt
with pytest.raises(AuthError) as excinfo:
decode_jwt("some.token.here")
assert excinfo.value.status_code == 401


def test_jwks_timeout_error_returns_401():
from jwt import PyJWKClientError
with patch("middleware.auth.PyJWKClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.get_signing_key_from_jwt.side_effect = PyJWKClientError("Timeout")
from middleware.auth import decode_jwt
with pytest.raises(AuthError) as excinfo:
decode_jwt("some.token.here")
assert excinfo.value.status_code == 401
with patch("middleware.auth.settings") as mock_settings:
mock_settings.AUTH_ENABLED = True
mock_settings.AUTH0_DOMAIN = "test.auth0.com"
mock_settings.AUTH0_AUDIENCE = "https://api.test.com"
with patch("middleware.auth.PyJWKClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.get_signing_key_from_jwt.side_effect = PyJWKClientError("Timeout")
from middleware.auth import decode_jwt
with pytest.raises(AuthError) as excinfo:
decode_jwt("some.token.here")
assert excinfo.value.status_code == 401
20 changes: 10 additions & 10 deletions backend/tests/test_docs_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

class TestFileLimitEnforcement:
def test_file_limit_respected(self):
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader
DocsLoader()
files = [(f"path/to/doc{i}.md", f"doc{i}.md") for i in range(settings.REPO_FILE_LIMIT)]
assert len(files) <= settings.REPO_FILE_LIMIT

def test_file_limit_exceeded_raises(self):
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader

class TestLoader(DocsLoader):
def load_and_split(self):
Expand All @@ -32,7 +32,7 @@ def load_and_split(self):
class TestLocalModeBranching:
def test_local_mode_true_calls_clone_path(self, monkeypatch):
monkeypatch.setattr(settings, "LOCAL_MODE", True)
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader

loader = DocsLoader(repo_url="owner/repo")
# Mock prepare_local_repo to avoid network calls
Expand All @@ -45,7 +45,7 @@ def test_local_mode_true_calls_clone_path(self, monkeypatch):

def test_local_mode_false_uses_api(self, monkeypatch):
monkeypatch.setattr(settings, "LOCAL_MODE", False)
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader

loader = DocsLoader(repo_url="owner/repo")
# Mock fetch_via_api to avoid network
Expand All @@ -56,37 +56,37 @@ def test_local_mode_false_uses_api(self, monkeypatch):

class TestFetchViaApi:
def test_repo_owner_name_from_url(self):
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader
loader = DocsLoader(repo_url="https://github.com/owner/repo.git")
assert loader._repo_owner_name() == "owner/repo"

def test_repo_owner_name_from_short(self):
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader
loader = DocsLoader(repo_url="owner/repo")
assert loader._repo_owner_name() == "owner/repo"

def test_repo_owner_name_empty_fallback(self):
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader
loader = DocsLoader()
# Falls back to settings.TARGET_REPO which might be None
result = loader._repo_owner_name()
assert result is not None or result == ""

def test_repo_owner_name_from_nested_url(self):
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader
loader = DocsLoader(repo_url="https://github.com/org/team/repo.git")
assert loader._repo_owner_name() == "team/repo"


class TestDocsLoaderInit:
def test_github_token_from_settings(self, monkeypatch):
monkeypatch.setattr(settings, "GITHUB_TOKEN", "ghp_test_token")
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader
loader = DocsLoader(repo_url="owner/repo")
assert loader.github_token == "ghp_test_token"

def test_github_token_from_param_overrides_settings(self, monkeypatch):
monkeypatch.setattr(settings, "GITHUB_TOKEN", "ghp_default")
from src.ingestion.docs_loader import DocsLoader
from ingestion.docs_loader import DocsLoader
loader = DocsLoader(repo_url="owner/repo", github_token="ghp_explicit")
assert loader.github_token == "ghp_explicit"
8 changes: 4 additions & 4 deletions backend/tests/test_rate_limit_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@

def test_solve_ticket_uses_user_sub_for_rate_limit():
"""Verify solve_ticket passes user sub to check_rate_limit."""
from src.main import app
from main import app
from middleware.auth import get_current_user

app.dependency_overrides[get_current_user] = lambda: {"sub": "auth0|user123", "permissions": []}

with patch("src.main.check_rate_limit") as mock_check:
with patch("main.check_rate_limit") as mock_check:
mock_check.return_value = True
from fastapi.testclient import TestClient
client = TestClient(app)
Expand All @@ -24,12 +24,12 @@ def test_solve_ticket_uses_user_sub_for_rate_limit():

def test_solve_ticket_uses_anonymous_when_no_sub():
"""When auth returns no 'sub', fallback to 'anonymous'."""
from src.main import app
from main import app
from middleware.auth import get_current_user

app.dependency_overrides[get_current_user] = lambda: {"permissions": []}

with patch("src.main.check_rate_limit") as mock_check:
with patch("main.check_rate_limit") as mock_check:
mock_check.return_value = True
from fastapi.testclient import TestClient
client = TestClient(app)
Expand Down
18 changes: 9 additions & 9 deletions backend/tests/test_webhook_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@

def test_webhook_refuses_when_secret_not_configured_in_production():
"""When WEBHOOK_SECRET is not set and ENV=production, return 503."""
from src.main import app
from main import app

client = TestClient(app)

# Mock settings to have no WEBHOOK_SECRET
with patch('src.main.settings') as mock_settings:
with patch('main.settings') as mock_settings:
mock_settings.WEBHOOK_SECRET = ""
mock_settings.WEBHOOK_SESSION_ID = ""

Expand All @@ -27,11 +27,11 @@ def test_webhook_refuses_when_secret_not_configured_in_production():

def test_webhook_allows_when_auth_disabled_in_development():
"""When WEBHOOK_SECRET is not set, ENV=development, and WEBHOOK_AUTH_DISABLED=true, allow."""
from src.main import app
from main import app

client = TestClient(app)

with patch('src.main.settings') as mock_settings:
with patch('main.settings') as mock_settings:
mock_settings.WEBHOOK_SECRET = ""
mock_settings.WEBHOOK_SESSION_ID = ""

Expand All @@ -48,11 +48,11 @@ def test_webhook_allows_when_auth_disabled_in_development():

def test_webhook_refuses_when_auth_disabled_not_true():
"""When WEBHOOK_SECRET is not set, ENV=development, and WEBHOOK_AUTH_DISABLED not 'true', return 503."""
from src.main import app
from main import app

client = TestClient(app)

with patch('src.main.settings') as mock_settings:
with patch('main.settings') as mock_settings:
mock_settings.WEBHOOK_SECRET = ""
mock_settings.WEBHOOK_SESSION_ID = ""

Expand All @@ -69,11 +69,11 @@ def test_webhook_refuses_when_auth_disabled_not_true():

def test_webhook_with_secret_proceeds_to_signature_verification():
"""When WEBHOOK_SECRET is set, should proceed to signature verification."""
from src.main import app
from main import app

client = TestClient(app)

with patch('src.main.settings') as mock_settings:
with patch('main.settings') as mock_settings:
mock_settings.WEBHOOK_SECRET = "test_secret"
mock_settings.WEBHOOK_SESSION_ID = ""

Expand All @@ -84,4 +84,4 @@ def test_webhook_with_secret_proceeds_to_signature_verification():
content=b"test"
)
assert response.status_code == 403
assert response.json()["detail"] == "Missing signature"
assert response.json()["detail"] == "Missing signature"
Loading