diff --git a/backend/app/api/session_to_agent.py b/backend/app/api/session_to_agent.py new file mode 100644 index 0000000..75090d2 --- /dev/null +++ b/backend/app/api/session_to_agent.py @@ -0,0 +1,293 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from uuid import UUID + +from app.repositories.session_to_agent import SessionToAgentRepository +from app.repositories.chat import ChatRepository +from app.repositories.user import UserRepository +from app.core.socketio import sio +from fastapi import APIRouter, HTTPException, Depends, status +from sqlalchemy.orm import Session +from app.core.logger import get_logger +from app.models.user import User +from app.models.session_to_agent import SessionStatus +from app.core.auth import CHAT_MANAGE_PERMISSIONS, get_current_user, has_any_permission +from app.database import get_db +from app.services.chat_notifications import notify_chat_assigned +from app.services.human_routing import notify_customer, route_session_to_human +from app.models.schemas.chat import ChatDetailResponse, TransferReasonType + + +logger = get_logger(__name__) + +router = APIRouter() + +# Shown to the customer when a human takes over an external-channel chat. The +# widget surfaces the handover in its own UI, so only channels need a message. +HANDOVER_NOTICE = "You're now connected with a member of our team." + + +def _is_uuid(value: str) -> bool: + """Guard the repository lookup: a malformed id is a 404, not a 500.""" + try: + UUID(str(value)) + return True + except (ValueError, TypeError, AttributeError): + return False + + +async def _notify_customer_of_handover(db: Session, session, user: User) -> None: + """Tell a channel customer a human joined, and record it in the thread.""" + await notify_customer(db, session, HANDOVER_NOTICE, user_id=str(user.id)) + + +async def _load_manageable_session(db: Session, session_id: str, current_user: User): + """The session, if this user may act on it — or the right HTTPException. + + Shared by every action that changes who handles a chat, so they cannot + drift apart: the permission grant, the organization scope and the + visibility check are one sequence, checked in one place. + """ + # manage_all_chats, not "manage_chats" — the latter is not a real + # permission and never matched. has_any_permission also honours the + # super_admin bypass the previous hand-rolled set check ignored. + if not has_any_permission(current_user, CHAT_MANAGE_PERMISSIONS): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + session = SessionToAgentRepository(db).get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Chat session not found") + + # A session id is guessable and was never scoped here: without this an + # agent could act on a conversation belonging to another organization. + if session.organization_id != current_user.organization_id: + raise HTTPException(status_code=404, detail="Chat session not found") + + # Acting is limited to what the caller may actually see, so widening the + # inbox to the unclaimed queue can't be used to reach past it. + user_permissions = {p.name for p in current_user.role.permissions} + is_super_admin = "super_admin" in user_permissions + # manage_all_chats implies seeing them all — a role that may manage every + # chat should not need a separate view grant to act on one. + can_view_all = is_super_admin or bool( + {"view_all_chats", "manage_all_chats"} & user_permissions + ) + + if not can_view_all: + can_view_assigned = is_super_admin or "view_assigned_chats" in user_permissions + has_access = await ChatRepository(db).check_session_access( + session_id=session_id, + user_id=current_user.id if can_view_assigned else None, + user_groups=[str(group.id) for group in current_user.groups] if can_view_assigned else [], + include_unassigned=is_super_admin or "view_unassigned_chats" in user_permissions + ) + if not has_access: + raise HTTPException(status_code=404, detail="Chat session not found") + + return session + + +@router.post("/{session_id}/takeover", response_model=ChatDetailResponse) +async def takeover_chat( + session_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Take over a chat session""" + try: + session = await _load_manageable_session(db, session_id, current_user) + session_repo = SessionToAgentRepository(db) + chat_repo = ChatRepository(db) + + # Update session + success = session_repo.takeover_session( + session_id=session_id, + user_id=str(current_user.id) + ) + + if not success: + raise HTTPException( + status_code=400, + detail="Failed to take over chat" + ) + + # Let the customer know a human joined (external channels only), before + # the detail is read back so the notice is part of the returned thread. + await _notify_customer_of_handover(db, session, current_user) + + # Get updated chat details + chat = await chat_repo.get_chat_detail( + session_id=session_id, + org_id=current_user.organization_id + ) + + if not chat: + raise HTTPException( + status_code=500, + detail="Failed to get chat details after takeover" + ) + + return ChatDetailResponse(**chat) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error taking over chat: {str(e)}") + raise HTTPException( + status_code=500, + detail="Failed to take over chat" + ) + + +@router.post("/{session_id}/route-to-human", response_model=ChatDetailResponse) +async def route_chat_to_human( + session_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Stop the AI answering this chat and queue it for the team. + + Taking a chat over was the only way to silence the AI, which forces whoever + spots the problem to handle it personally. This hands it to the queue + instead, leaving it claimable by anyone. + """ + try: + session = await _load_manageable_session(db, session_id, current_user) + + if session.status == SessionStatus.CLOSED: + raise HTTPException( + status_code=400, detail="This chat is closed") + if session.user_id is not None: + raise HTTPException( + status_code=400, detail="A human is already handling this chat") + + routed = await route_session_to_human( + db, + session, + reason=TransferReasonType.DIRECT_REQUEST.value, + description=f"Routed to the team by {current_user.full_name}", + ) + if not routed: + raise HTTPException( + status_code=400, detail="This chat is already waiting for a human") + + chat = await ChatRepository(db).get_chat_detail( + session_id=session_id, + org_id=current_user.organization_id + ) + if not chat: + raise HTTPException( + status_code=500, + detail="Failed to get chat details after routing" + ) + + return ChatDetailResponse(**chat) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error routing chat to human: {str(e)}") + raise HTTPException( + status_code=500, + detail="Failed to route chat to a human" + ) + + +@router.post("/{session_id}/reassign", response_model=ChatDetailResponse) +async def reassign_chat( + session_id: str, + to_user_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Reassign a chat session to a different user""" + try: + session = await _load_manageable_session(db, session_id, current_user) + session_repo = SessionToAgentRepository(db) + + # The new owner must be a real, active member of the same org — + # otherwise a chat (and its assignment notification) could be pushed + # onto someone outside it. A 404 here doesn't confirm whether an id + # exists elsewhere. + target_user = UserRepository(db).get_user(to_user_id) if _is_uuid(to_user_id) else None + if (target_user is None + or not target_user.is_active + or target_user.organization_id != current_user.organization_id): + raise HTTPException(status_code=404, detail="User not found") + + # Only allow reassignment for open sessions handled by a user (not AI) + if str(session.status.name).lower() != 'open': + raise HTTPException(status_code=400, detail="Only open sessions can be reassigned") + if session.user_id is None: + raise HTTPException(status_code=400, detail="Chat must be handled by a user to reassign") + + # Capture the previous assignee BEFORE reassigning. reassign_session + # re-fetches this same row, so within one DB session the identity map + # hands back the very object `session` points at — it mutates + # session.user_id to the new owner in place. Reading it afterward would + # target the new assignee's room, not the previous one's. + previous_user_id = session.user_id + + # Update session owner + success = session_repo.reassign_session(session_id=session_id, to_user_id=to_user_id) + if not success: + raise HTTPException(status_code=400, detail="Failed to reassign chat") + + # Fetch updated chat detail + chat_repo = ChatRepository(db) + chat = await chat_repo.get_chat_detail( + session_id=session_id, + org_id=current_user.organization_id + ) + if not chat: + raise HTTPException(status_code=500, detail="Failed to get chat details after reassignment") + + # Notify widget room (customer) + await sio.emit('room_event', { + 'type': 'reassigned', + 'session_id': session_id, + 'message': 'Your conversation has been reassigned to another agent.' + }, room=session_id, namespace='/widget') + + # Notify agent rooms (convention: user_{id}) + await sio.emit('room_event', { + 'type': 'reassigned', + 'session_id': session_id, + 'assigned_to': to_user_id + }, room=f"user_{to_user_id}") + + # Also notify the PREVIOUS assignee that the chat left their queue — + # unless it was reassigned to themselves (a no-op). + if previous_user_id and str(previous_user_id) != str(to_user_id): + await sio.emit('room_event', { + 'type': 'reassigned_from_you', + 'session_id': session_id + }, room=f"user_{previous_user_id}") + + # Push to the new assignee — a socket event only reaches them if the + # dashboard is already open. + await notify_chat_assigned(db, session, to_user_id, assigned_by=current_user.id) + + return ChatDetailResponse(**chat) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error reassigning chat: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to reassign chat") diff --git a/backend/tests/api/conftest.py b/backend/tests/api/conftest.py new file mode 100644 index 0000000..40b47e0 --- /dev/null +++ b/backend/tests/api/conftest.py @@ -0,0 +1,23 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import os +import sys +from pathlib import Path + +# Add the backend directory to Python path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) \ No newline at end of file diff --git a/backend/tests/api/test_agent.py b/backend/tests/api/test_agent.py new file mode 100644 index 0000000..8ec2f58 --- /dev/null +++ b/backend/tests/api/test_agent.py @@ -0,0 +1,1882 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest +from unittest.mock import AsyncMock, patch +from fastapi.testclient import TestClient +from fastapi import FastAPI +from app.models.user import User, UserGroup +from app.models.agent import Agent, AgentCustomization, AgentType +from app.models.role import Role +from app.models.permission import Permission, role_permissions +from uuid import UUID, uuid4 +from datetime import datetime +import os +from typing import Dict, Generator +from app.api import agent as agent_router +from app.core.auth import get_current_user, require_permissions, get_unified_auth +from app.models.schemas.agent_customization import CustomizationCreate +from app.database import get_db +from app.models.organization import Organization + +# Create a test FastAPI app +app = FastAPI() +app.include_router( + agent_router.router, + prefix="/api/agents", + tags=["agent"] +) + +@pytest.fixture +def test_permissions(db) -> list[Permission]: + """Create test permissions""" + permissions = [] + for name in ["manage_agents", "view_all"]: + perm = Permission( + name=name, + description=f"Test permission for {name}" + ) + db.add(perm) + permissions.append(perm) + db.commit() + for p in permissions: + db.refresh(p) + return permissions + +@pytest.fixture +def test_role(db, test_organization_id, test_permissions) -> Role: + """Create a test role with required permissions""" + role = Role( + name="Test Role", + description="Test Role Description", + organization_id=test_organization_id, + is_default=True + ) + db.add(role) + db.commit() + + # Associate permissions with role using the role_permissions table + for perm in test_permissions: + db.execute( + role_permissions.insert().values( + role_id=role.id, + permission_id=perm.id + ) + ) + db.commit() + db.refresh(role) + + return role + +@pytest.fixture +def test_user(db, test_organization_id, test_role) -> User: + """Create a test user with required permissions""" + user = User( + id=uuid4(), + email="test@example.com", + hashed_password="hashed_password", + is_active=True, + organization_id=test_organization_id, + full_name="Test User", + role_id=test_role.id + ) + db.add(user) + db.commit() + db.refresh(user) + return user + +@pytest.fixture +def test_group(db, test_user) -> UserGroup: + """Create a test user group""" + group = UserGroup( + id=uuid4(), + name="Test Group", + organization_id=test_user.organization_id + ) + db.add(group) + db.commit() + db.refresh(group) + return group + +@pytest.fixture +def test_agent(db, test_user) -> Agent: + """Create a test agent""" + agent = Agent( + id=uuid4(), + name="Test Agent", + display_name="Test Display Name", + description="Test Description", + agent_type=AgentType.CUSTOMER_SUPPORT, + instructions=["Test instruction"], + is_active=True, + organization_id=test_user.organization_id, + transfer_to_human=True + ) + db.add(agent) + db.commit() + db.refresh(agent) + return agent + +@pytest.fixture +def test_customization(db, test_agent) -> AgentCustomization: + """Create a test agent customization""" + customization = AgentCustomization( + agent_id=test_agent.id, + photo_url="/test/photo.jpg", + chat_background_color="#F8F9FA", + chat_bubble_color="#E9ECEF", + chat_text_color="#212529", + icon_color="#6C757D", + accent_color="#f34611" + ) + db.add(customization) + db.commit() + db.refresh(customization) + return customization + +@pytest.fixture +def client(db, test_user) -> TestClient: + """Create test client with mocked dependencies""" + async def override_get_current_user(): + return test_user + + async def override_require_permissions(*args, **kwargs): + return test_user + + async def override_get_unified_auth(): + # Authentication only — the permission check belongs to + # require_unified_permissions, which wraps this. Re-implementing it here + # made these tests pass whether or not the routes were actually gated. + db.refresh(test_user) + db.refresh(test_user.role) + + return { + "auth_type": "jwt", + "organization_id": test_user.organization_id, # Keep as UUID, not string + "user_id": test_user.id, + "current_user": test_user + } + + def override_get_db(): + try: + yield db + finally: + pass + + app.dependency_overrides[get_current_user] = override_get_current_user + app.dependency_overrides[require_permissions] = override_require_permissions + app.dependency_overrides[get_unified_auth] = override_get_unified_auth + app.dependency_overrides[get_db] = override_get_db + + return TestClient(app) + +# Test cases +def test_get_organization_agents( + client, + db, + test_user, + test_agent, + test_customization +): + """Test getting all agents for an organization""" + response = client.get("/api/agents/list") + assert response.status_code == 200 + agents = response.json() + assert len(agents) == 1 + assert agents[0]["id"] == str(test_agent.id) + assert agents[0]["name"] == test_agent.name + +def test_get_agent_by_id( + client, + db, + test_user, + test_agent, + test_customization +): + """Test getting a specific agent by ID""" + response = client.get(f"/api/agents/{test_agent.id}") + assert response.status_code == 200 + agent_data = response.json() + assert agent_data["id"] == str(test_agent.id) + assert agent_data["name"] == test_agent.name + +def test_update_agent( + client, + db, + test_user, + test_agent +): + """Test updating an agent""" + update_data = { + "display_name": "Updated Display Name", + "description": "Updated Description", + "instructions": ["Updated instruction"], + "is_active": True, + "transfer_to_human": True + } + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + assert response.status_code == 200 + updated_agent = response.json() + assert updated_agent["display_name"] == update_data["display_name"] + assert updated_agent["instructions"] == update_data["instructions"] + +def test_reply_buttons_are_off_by_default_and_turning_them_on_reregisters_telegram( + client, db, test_user, test_agent +): + assert client.get(f"/api/agents/{test_agent.id}").json()["channel_quick_replies"] is False + with patch("app.api.agent.telegram_webhooks.reregister_for_agent", AsyncMock()) as reregister: + response = client.put(f"/api/agents/{test_agent.id}", json={"channel_quick_replies": True}) + assert response.status_code == 200 and response.json()["channel_quick_replies"] is True + reregister.assert_awaited_once() + # Already on: nothing to re-register + client.put(f"/api/agents/{test_agent.id}", json={"channel_quick_replies": True}) + client.put(f"/api/agents/{test_agent.id}", json={"display_name": "Other"}) + assert reregister.await_count == 1 + + +def test_update_agent_guardrail_round_trips( + client, + db, + test_user, + test_agent +): + """Switching the guardrail off must come back off in the same response. + + Regression: the update endpoint builds AgentWithCustomizationResponse from + an explicit field list, so a new column is silently dropped and the schema + default is returned instead. That made the dashboard toggle spring back to + "on" after saving until the page was reloaded — the row was correct, the + response was not. topic_scope had the same problem. + """ + response = client.put( + f"/api/agents/{test_agent.id}", + json={ + "guardrail_enabled": False, + "guardrail_prompt": "Only answer maths tutoring questions.", + "topic_scope": "maths tutoring", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["guardrail_enabled"] is False, "toggle did not survive the response" + assert body["guardrail_prompt"] == "Only answer maths tutoring questions." + assert body["topic_scope"] == "maths tutoring" + + # And it really persisted, not just echoed back. + db.refresh(test_agent) + assert test_agent.guardrail_enabled is False + assert test_agent.guardrail_prompt == "Only answer maths tutoring questions." + + +def test_update_response_carries_every_agent_column( + client, + db, + test_user, + test_agent +): + """No column may be silently dropped from the update response. + + The endpoint builds AgentWithCustomizationResponse from an explicit field + list in four places, so adding a column to the model without touching all + four returns the schema default instead of the stored value. That has + happened twice — topic_scope, then guardrail_enabled — and the second time + it reached the dashboard as a toggle that sprang back to "on" after saving. + This asserts it generically so the third time fails here instead. + """ + from app.models.agent import Agent + from app.models.schemas.agent import AgentResponse + + # Give every scalar column a value distinguishable from its schema default. + columns = {c.name: c for c in Agent.__table__.columns} + probes = {} + for name in AgentResponse.model_fields: + column = columns.get(name) + if column is None or name in {"id", "organization_id", "agent_type", + "instructions", "created_at", "updated_at"}: + continue + python_type = getattr(column.type, "python_type", None) + try: + kind = python_type + except Exception: + continue + if kind is bool: + probes[name] = not bool(getattr(test_agent, name)) + elif kind is str: + probes[name] = f"probe-{name}" + for name, value in probes.items(): + setattr(test_agent, name, value) + db.commit() + + response = client.put(f"/api/agents/{test_agent.id}", json={"is_active": True}) + assert response.status_code == 200 + body = response.json() + + dropped = { + name: (value, body.get(name)) + for name, value in probes.items() + if name != "is_active" and body.get(name) != value + } + assert not dropped, ( + "these columns are missing from the response builder in " + f"app/api/agent.py (stored -> returned): {dropped}" + ) + + +def test_update_agent_groups( + client, + db, + test_user, + test_agent, + test_group +): + """Test updating agent's assigned groups""" + group_ids = [str(test_group.id)] + response = client.put( + f"/api/agents/{test_agent.id}/groups", + json=group_ids + ) + assert response.status_code == 200 + updated_agent = response.json() + assert len(updated_agent["groups"]) == 1 + assert updated_agent["groups"][0]["id"] == str(test_group.id) + +def test_create_agent_customization( + client, + db, + test_user, + test_agent +): + """Test creating agent customization""" + customization_data = CustomizationCreate( + chat_background_color="#F8F9FA", + chat_bubble_color="#E9ECEF", + chat_text_color="#212529", + icon_color="#6C757D", + accent_color="#f34611" + ).model_dump() + + response = client.post( + f"/api/agents/{test_agent.id}/customization", + json=customization_data + ) + assert response.status_code == 200 + customization = response.json() + assert customization["chat_background_color"] == customization_data["chat_background_color"] + assert customization["chat_bubble_color"] == customization_data["chat_bubble_color"] + # Disclosure is on unless the operator turns it off + assert customization["show_ai_disclaimer"] is True + + +def test_guardrail_default_is_served_with_the_org_filled_in(client, db, test_user): + """The dashboard shows this as editable starting text. + + Served from the backend rather than copied into the frontend: the previous + UI described the rule in prose and went stale the first time the wording + changed. {org} must already be substituted, so the operator reads exactly + what the model is sent. + """ + response = client.get("/api/agents/guardrail-default") + assert response.status_code == 200 + + prompt = response.json()["prompt"] + assert "{org}" not in prompt + assert "general-purpose AI" in prompt + + +def test_guardrail_default_route_is_not_shadowed_by_agent_id(client, db, test_agent): + """It sits next to GET /{agent_id}; a literal path declared after the + parameterised one would be swallowed by it and return 404/422.""" + assert client.get("/api/agents/guardrail-default").status_code == 200 + + +def test_ai_disclaimer_toggle_round_trips(client, db, test_agent): + """Turning the footer disclaimer off must survive the round trip. + + The widget reads this from the customization payload, so a value that is + accepted but not returned would leave the dashboard toggle springing back on. + """ + payload = CustomizationCreate(show_ai_disclaimer=False).model_dump() + + response = client.post(f"/api/agents/{test_agent.id}/customization", json=payload) + assert response.status_code == 200 + assert response.json()["show_ai_disclaimer"] is False + + db.expire_all() + stored = db.query(AgentCustomization).filter( + AgentCustomization.agent_id == test_agent.id + ).first() + assert stored.show_ai_disclaimer is False + + +def test_allow_new_chat_round_trips(client, db, test_agent): + """The widget reads this from the customization payload, so a value that is + accepted but not returned would leave the dashboard toggle springing back off.""" + payload = CustomizationCreate(allow_new_chat=True).model_dump() + + response = client.post(f"/api/agents/{test_agent.id}/customization", json=payload) + assert response.status_code == 200 + assert response.json()["allow_new_chat"] is True + + db.expire_all() + stored = db.query(AgentCustomization).filter( + AgentCustomization.agent_id == test_agent.id + ).first() + assert stored.allow_new_chat is True + +# Tests for create_agent endpoint +def test_create_agent_success( + client, + db, + test_user +): + """Test successful agent creation""" + agent_data = { + "name": "New Test Agent", + "display_name": "New Test Display", + "description": "New Test Description", + "agent_type": "customer_support", # Use lowercase with underscores + "instructions": ["Be helpful"], + "is_active": True, + "transfer_to_human": True, + "ask_for_rating": False, + "enable_rate_limiting": False, + "overall_limit_per_ip": 100, + "requests_per_sec": 1.0, + "use_workflow": False + } + + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 201 + created_agent = response.json() + assert created_agent["name"] == agent_data["name"] + assert created_agent["display_name"] == agent_data["display_name"] + assert created_agent["organization_id"] == str(test_user.organization_id) + +def test_create_agent_duplicate_name( + client, + db, + test_user, + test_agent +): + """Test creating agent with duplicate name""" + agent_data = { + "name": test_agent.name, # Same name as existing agent + "display_name": "Duplicate Test", + "description": "Test duplicate", + "agent_type": "customer_support", + "instructions": ["Be helpful"], + "is_active": True + } + + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 400 + assert "already exists" in response.json()["detail"] + +def test_create_agent_auto_display_name( + client, + db, + test_user +): + """Test agent creation with auto-generated display name""" + agent_data = { + "name": "Auto Display Agent", + "description": "Test auto display name", + "agent_type": "customer_support", + "instructions": ["Be helpful"], + "is_active": True + } + + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 201 + created_agent = response.json() + assert created_agent["display_name"] == agent_data["name"] + +def test_create_agent_minimal_data( + client, + db, + test_user +): + """Test agent creation with minimal required data""" + agent_data = { + "name": "Minimal Agent", + "agent_type": "customer_support", + "instructions": ["Be helpful"] # This is required + } + + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 201 + created_agent = response.json() + assert created_agent["name"] == agent_data["name"] + assert created_agent["display_name"] == agent_data["name"] + + + +def test_generate_instructions_empty_prompt( + client, + db, + test_user +): + """Test instruction generation with empty prompt""" + prompt_data = { + "prompt": "", + "existing_instructions": [] + } + + response = client.post("/api/agents/generate-instructions", json=prompt_data) + assert response.status_code == 400 + assert "cannot be empty" in response.json()["detail"] + +def test_generate_instructions_long_prompt( + client, + db, + test_user +): + """Test instruction generation with overly long prompt""" + prompt_data = { + "prompt": "x" * 1001, # Exceed 1000 character limit + "existing_instructions": [] + } + + response = client.post("/api/agents/generate-instructions", json=prompt_data) + assert response.status_code == 400 + assert "exceeds maximum length" in response.json()["detail"] + +def test_update_agent_workflow_settings( + client, + db, + test_user, + test_agent +): + """Test updating agent workflow settings""" + workflow_id = uuid4() + update_data = { + "use_workflow": True, + "active_workflow_id": str(workflow_id) + } + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + assert response.status_code == 200 + updated_agent = response.json() + assert updated_agent["use_workflow"] is True + assert updated_agent["active_workflow_id"] == str(workflow_id) + +def test_update_agent_rate_limiting( + client, + db, + test_user, + test_agent +): + """Test updating agent rate limiting settings""" + update_data = { + "enable_rate_limiting": True, + "overall_limit_per_ip": 50, + "requests_per_sec": 2.5 + } + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + assert response.status_code == 200 + updated_agent = response.json() + assert updated_agent["enable_rate_limiting"] is True + assert updated_agent["overall_limit_per_ip"] == 50 + assert updated_agent["requests_per_sec"] == 2.5 + +# Tests for group assignment edge cases +def test_update_agent_groups_invalid_group( + client, + db, + test_user, + test_agent +): + """Test updating agent groups with invalid group ID""" + invalid_group_id = uuid4() + + response = client.put( + f"/api/agents/{test_agent.id}/groups", + json=[str(invalid_group_id)] + ) + assert response.status_code == 400 + assert "Invalid group IDs" in response.json()["detail"] + +def test_update_agent_groups_empty_list( + client, + db, + test_user, + test_agent +): + """Test updating agent groups with empty list""" + response = client.put( + f"/api/agents/{test_agent.id}/groups", + json=[] + ) + assert response.status_code == 200 + updated_agent = response.json() + assert len(updated_agent["groups"]) == 0 + +def test_update_agent_groups_wrong_org_group( + client, + db, + test_user, + test_agent +): + """Test updating agent groups with group from wrong organization""" + # Create group in different organization + other_org_id = uuid4() + other_org = Organization( + id=other_org_id, + name="Other Org", + domain="other.org", + timezone="UTC" + ) + db.add(other_org) + db.commit() + + other_group = UserGroup( + id=uuid4(), + name="Other Group", + organization_id=other_org_id + ) + db.add(other_group) + db.commit() + + response = client.put( + f"/api/agents/{test_agent.id}/groups", + json=[str(other_group.id)] + ) + assert response.status_code == 400 + +# Tests for customization edge cases +def test_create_customization_with_existing( + client, + db, + test_user, + test_agent, + test_customization +): + """Test creating customization when one already exists (should update)""" + new_customization_data = { + "chat_background_color": "#FFFFFF", + "chat_bubble_color": "#000000", + "chat_text_color": "#FFFFFF", + "icon_color": "#000000", + "accent_color": "#FF0000" + } + + response = client.post( + f"/api/agents/{test_agent.id}/customization", + json=new_customization_data + ) + assert response.status_code == 200 + customization = response.json() + assert customization["chat_background_color"] == new_customization_data["chat_background_color"] + +def test_customization_response_signs_photo_url_without_persisting_it( + client, + db, + test_user, + test_agent, + test_customization +): + """The response carries a signed photo_url; the column keeps the bare URL. + + Regression: the endpoints used to assign the signed value onto the ORM + instance, which SQLAlchemy then flushed, so stored URLs carried a signature + that eventually expired. + """ + from unittest.mock import AsyncMock, patch, MagicMock + + stored_url = "https://bucket.s3.us-east-1.amazonaws.com/agents/photo.png" + signed_url = f"{stored_url}?AWSAccessKeyId=A&Signature=S&Expires=1" + test_customization.photo_url = stored_url + db.commit() + + signing_client = MagicMock() + signing_client.generate_presigned_url.return_value = signed_url + + with patch('app.core.s3.settings.S3_FILE_STORAGE', True), \ + patch('app.core.s3.get_s3_client', return_value=signing_client): + + response = client.get(f"/api/agents/{test_agent.id}") + assert response.status_code == 200 + assert response.json()["customization"]["photo_url"] == signed_url + + # The request must not leave the instance dirty. Asserting on the committed + # row alone would not catch this: a GET never flushes, so the signature only + # reaches the column on some later write in the same session. + assert test_customization not in db.dirty + + db.refresh(test_customization) + assert test_customization.photo_url == stored_url + + +def test_customization_update_rejects_a_signed_photo_url( + client, + db, + test_agent, + test_user, + test_customization +): + """A client echoing a signed photo_url back into an update must not store it. + + The agent customization form seeds itself from the GET response and POSTs + that value straight back, which is how signatures reached the database. + """ + stored_url = "https://bucket.s3.us-east-1.amazonaws.com/agents/photo.png" + signed_url = f"{stored_url}?AWSAccessKeyId=A&Signature=S&Expires=1" + + response = client.post( + f"/api/agents/{test_agent.id}/customization", + json={"photo_url": signed_url} + ) + assert response.status_code == 200 + + db.expire_all() + saved = db.query(AgentCustomization).filter_by(agent_id=test_agent.id).first() + assert saved.photo_url == stored_url + assert "Signature=" not in saved.photo_url + + +def test_create_customization_invalid_agent( + client, + db, + test_user +): + """Test creating customization for non-existent agent""" + customization_data = { + "chat_background_color": "#F8F9FA", + "chat_bubble_color": "#E9ECEF" + } + + response = client.post( + f"/api/agents/{uuid4()}/customization", + json=customization_data + ) + assert response.status_code == 404 + +def test_create_customization_wrong_org_agent( + client, + db, + test_user, + test_agent +): + """Test creating customization for agent from wrong organization""" + # Move agent to different organization + new_org_id = uuid4() + new_org = Organization( + id=new_org_id, + name="Wrong Org", + domain="wrong.org", + timezone="UTC" + ) + db.add(new_org) + db.commit() + + test_agent.organization_id = new_org_id + db.commit() + + customization_data = { + "chat_background_color": "#F8F9FA" + } + + response = client.post( + f"/api/agents/{test_agent.id}/customization", + json=customization_data + ) + assert response.status_code == 404 # Returns 404 for security - don't reveal resource existence + +# Error handling tests +def test_get_organization_agents_error_handling( + client, + db, + test_user +): + """Test error handling in get_organization_agents""" + from unittest.mock import patch + + with patch('app.repositories.agent.AgentRepository.get_all_agents') as mock_get_all: + mock_get_all.side_effect = Exception("Database error") + + response = client.get("/api/agents/list") + assert response.status_code == 500 + +# Negative test cases +def test_get_nonexistent_agent( + client, + db, + test_user +): + """Test getting a non-existent agent""" + response = client.get(f"/api/agents/{uuid4()}") + assert response.status_code == 404 + +def test_update_agent_wrong_org( + client, + db, + test_user, + test_agent +): + """Test updating an agent from wrong organization""" + # Create a new organization + new_org_id = uuid4() + new_org = Organization( + id=new_org_id, + name="Wrong Org", + domain="wrong.org", + timezone="UTC" + ) + db.add(new_org) + db.commit() + + # Change agent's organization + test_agent.organization_id = new_org_id + db.commit() + + response = client.put( + f"/api/agents/{test_agent.id}", + json={"display_name": "Wrong Org Update"} + ) + assert response.status_code == 404 # Returns 404 for security - don't reveal resource existence + +def test_update_nonexistent_agent( + client, + db, + test_user +): + """Test updating a non-existent agent""" + response = client.put( + f"/api/agents/{uuid4()}", + json={"display_name": "Non-existent"} + ) + assert response.status_code == 404 + +def test_get_agent_by_id_wrong_org( + client, + db, + test_user, + test_agent +): + """Test getting agent by ID from wrong organization""" + # Move agent to different organization + new_org_id = uuid4() + new_org = Organization( + id=new_org_id, + name="Wrong Org", + domain="wrong.org", + timezone="UTC" + ) + db.add(new_org) + db.commit() + + test_agent.organization_id = new_org_id + db.commit() + + response = client.get(f"/api/agents/{test_agent.id}") + assert response.status_code == 404 + +def test_update_agent_groups_nonexistent_agent( + client, + db, + test_user +): + """Test updating groups for non-existent agent""" + response = client.put( + f"/api/agents/{uuid4()}/groups", + json=[] + ) + assert response.status_code == 404 + +# Additional photo upload tests + +# Tests for complex data validation +def test_create_agent_invalid_agent_type( + client, + db, + test_user +): + """Test creating agent with invalid agent type""" + agent_data = { + "name": "Invalid Type Agent", + "agent_type": "invalid_type", + "instructions": ["Be helpful"] + } + + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 422 # Validation error + +def test_create_agent_empty_name( + client, + db, + test_user +): + """Test creating agent with empty name""" + agent_data = { + "name": "", + "agent_type": "customer_support", + "instructions": ["Be helpful"] + } + + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 422 # Validation error + +def test_update_agent_invalid_uuid( + client, + db, + test_user +): + """Test updating agent with invalid UUID""" + response = client.put( + "/api/agents/invalid-uuid", + json={"display_name": "Test"} + ) + assert response.status_code == 422 # Validation error + +def test_update_agent_groups_invalid_uuid( + client, + db, + test_user, + test_agent +): + """Test updating agent groups with invalid UUID format""" + response = client.put( + f"/api/agents/{test_agent.id}/groups", + json=["invalid-uuid"] + ) + assert response.status_code == 422 # Validation error + +# Tests for rate limiting edge cases +def test_update_agent_negative_rate_limits( + client, + db, + test_user, + test_agent +): + """Test updating agent with negative rate limit values""" + update_data = { + "overall_limit_per_ip": -1, + "requests_per_sec": -0.5 + } + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + # Should accept negative values (they might have business logic meaning) + assert response.status_code == 200 + +def test_update_agent_zero_rate_limits( + client, + db, + test_user, + test_agent +): + """Test updating agent with zero rate limit values""" + update_data = { + "overall_limit_per_ip": 0, + "requests_per_sec": 0.0 + } + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + assert response.status_code == 200 + updated_agent = response.json() + assert updated_agent["overall_limit_per_ip"] == 0 + assert updated_agent["requests_per_sec"] == 0.0 + +# Tests for instructions array handling +def test_update_agent_empty_instructions( + client, + db, + test_user, + test_agent +): + """Test updating agent with empty instructions array""" + update_data = { + "instructions": [] + } + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + assert response.status_code == 200 + updated_agent = response.json() + assert updated_agent["instructions"] == [] + +def test_update_agent_long_instructions( + client, + db, + test_user, + test_agent +): + """Test updating agent with very long instructions""" + long_instruction = "x" * 1000 + update_data = { + "instructions": [long_instruction, "Short instruction"] + } + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + assert response.status_code == 200 + updated_agent = response.json() + assert len(updated_agent["instructions"]) == 2 + assert updated_agent["instructions"][0] == long_instruction + +def test_update_agent_many_instructions( + client, + db, + test_user, + test_agent +): + """Test updating agent with many instructions""" + many_instructions = [f"Instruction {i}" for i in range(20)] + update_data = { + "instructions": many_instructions + } + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + assert response.status_code == 200 + updated_agent = response.json() + assert len(updated_agent["instructions"]) == 20 + + +# Tests for workflow integration +def test_update_agent_clear_workflow( + client, + db, + test_user, + test_agent +): + """Test clearing workflow from agent""" + # First set workflow + test_agent.use_workflow = True + test_agent.active_workflow_id = uuid4() + db.commit() + + update_data = { + "use_workflow": False, + "active_workflow_id": None + } + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + assert response.status_code == 200 + updated_agent = response.json() + assert updated_agent["use_workflow"] is False + assert updated_agent["active_workflow_id"] is None + +# Error simulation tests +def test_create_agent_database_error( + client, + db, + test_user +): + """Test creating agent when database error occurs""" + from unittest.mock import patch + + agent_data = { + "name": "DB Error Agent", + "agent_type": "customer_support", + "instructions": ["Be helpful"] + } + + with patch('app.repositories.agent.AgentRepository.create_agent') as mock_create: + mock_create.side_effect = Exception("Database connection error") + + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 500 + +def test_update_agent_database_error( + client, + db, + test_user, + test_agent +): + """Test updating agent when database error occurs""" + from unittest.mock import patch + + update_data = {"description": "This will fail"} + + with patch('app.repositories.agent.AgentRepository.update_agent') as mock_update: + mock_update.side_effect = Exception("Database connection error") + + response = client.put( + f"/api/agents/{test_agent.id}", + json=update_data + ) + assert response.status_code == 500 + +def test_create_customization_database_error( + client, + db, + test_user, + test_agent +): + """Test creating customization when database error occurs""" + from unittest.mock import patch + + customization_data = { + "chat_background_color": "#F8F9FA" + } + + with patch.object(db, 'commit') as mock_commit: + mock_commit.side_effect = Exception("Database error") + + response = client.post( + f"/api/agents/{test_agent.id}/customization", + json=customization_data + ) + assert response.status_code == 500 + +def test_update_agent_groups_database_error( + client, + db, + test_user, + test_agent, + test_group +): + """Test updating agent groups when database error occurs""" + from unittest.mock import patch + + with patch.object(db, 'commit') as mock_commit: + mock_commit.side_effect = Exception("Database error") + + response = client.put( + f"/api/agents/{test_agent.id}/groups", + json=[str(test_group.id)] + ) + assert response.status_code == 500 + +# Advanced tests for generate_instructions with AI configuration +def test_generate_instructions_no_ai_config( + client, + db, + test_user +): + """Test instruction generation when no AI config exists""" + prompt_data = { + "prompt": "Create helpful instructions", + "existing_instructions": [] + } + + # Mock no AI config and make enterprise import fail + from unittest.mock import patch + import sys + + # Create a mock module to prevent the actual import + class MockModule: + def __init__(self, *args, **kwargs): + raise ImportError("No module named 'app.enterprise.repositories.subscription'") + + # Add the mock module to sys.modules + sys.modules['app.enterprise.repositories.subscription'] = MockModule + + with patch('app.repositories.ai_config.AIConfigRepository.get_active_config') as mock_get_config: + mock_get_config.return_value = None + + response = client.post("/api/agents/generate-instructions", json=prompt_data) + assert response.status_code == 404 + assert "No AI configuration found" in response.json()["detail"] + + # Clean up + del sys.modules['app.enterprise.repositories.subscription'] + +def test_generate_instructions_missing_api_key( + client, + db, + test_user +): + """Test instruction generation when AI config has no API key""" + prompt_data = { + "prompt": "Create helpful instructions", + "existing_instructions": [] + } + + from unittest.mock import patch, MagicMock + import sys + + # Create a mock module to prevent the actual import + class MockModule: + def __init__(self, *args, **kwargs): + raise ImportError("No module named 'app.enterprise.repositories.subscription'") + + # Create AI config mock without API key + mock_config = MagicMock() + mock_config.model_type = "OPENAI" + mock_config.model_name = "gpt-4" + mock_config.api_key = "" # Mock the decrypted api_key property + mock_config.is_active = True + + # Add the mock module to sys.modules + sys.modules['app.enterprise.repositories.subscription'] = MockModule + + with patch('app.repositories.ai_config.AIConfigRepository.get_active_config') as mock_get_config: + mock_get_config.return_value = mock_config + + response = client.post("/api/agents/generate-instructions", json=prompt_data) + assert response.status_code == 500 + assert "API configuration missing" in response.json()["detail"] + + # Clean up + del sys.modules['app.enterprise.repositories.subscription'] + +def test_generate_instructions_ai_error( + client, + db, + test_user +): + """Test instruction generation when AI service fails""" + prompt_data = { + "prompt": "Create helpful instructions", + "existing_instructions": [] + } + + from unittest.mock import patch, AsyncMock, MagicMock + + # Create valid AI config mock + mock_config = MagicMock() + mock_config.model_type = "OPENAI" + mock_config.model_name = "gpt-4" + mock_config.api_key = "test-key" + mock_config.is_active = True + + with patch('app.repositories.ai_config.AIConfigRepository.get_active_config') as mock_get_config, \ + patch('app.api.agent.AgnoAgent') as mock_agent: + + mock_get_config.return_value = mock_config + mock_agent.return_value.arun.side_effect = Exception("AI service error") + + response = client.post("/api/agents/generate-instructions", json=prompt_data) + assert response.status_code == 500 + assert "Failed to generate instructions" in response.json()["detail"] + + +def test_generate_instructions_rate_limiting( + client, + db, + test_user +): + """Test rate limiting on instruction generation endpoint""" + from unittest.mock import patch + + prompt_data = { + "prompt": "Create helpful instructions", + "existing_instructions": [] + } + + # Mock rate limiting decorator to raise exception + with patch('app.utils.rate_limit.limit_instruction_generation') as mock_limit: + def rate_limit_side_effect(func): + def wrapper(*args, **kwargs): + raise HTTPException(status_code=429, detail="Rate limit exceeded") + return wrapper + + mock_limit.side_effect = rate_limit_side_effect + + # This test would need the actual rate limiting implementation to work properly + # For now, we'll just test that the decorator is applied + assert hasattr(client.app.router.routes[3].endpoint, '__wrapped__') or True + + +def test_get_agent_s3_signed_url_error( + client, + db, + test_user, + test_agent, + test_customization +): + """Test getting agent when S3 signed URL generation fails""" + from unittest.mock import patch, MagicMock + + stored_url = "https://bucket.s3.us-east-1.amazonaws.com/path/photo.png" + test_customization.photo_url = stored_url + db.commit() + + # Signing happens during response serialization now, so fail the boto3 call + # itself rather than an endpoint-level helper. + failing_client = MagicMock() + failing_client.generate_presigned_url.side_effect = Exception("S3 error") + + with patch('app.core.s3.settings.S3_FILE_STORAGE', True), \ + patch('app.core.s3.get_s3_client', return_value=failing_client): + + # Should not fail the request, just log the error + response = client.get(f"/api/agents/{test_agent.id}") + assert response.status_code == 200 + agent = response.json() + # Should still have the original S3 URL since signing failed + assert agent["customization"]["photo_url"] == stored_url + +# Edge case and integration tests +def test_create_agent_with_all_optional_fields( + client, + db, + test_user +): + """Test creating agent with all possible optional fields""" + agent_data = { + "name": "Full Featured Agent", + "display_name": "Full Display Name", + "description": "Complete agent with all fields", + "agent_type": "sales", + "instructions": [ + "First instruction", + "Second instruction with detailed explanation", + "Third instruction for edge cases" + ], + "is_active": True, + "transfer_to_human": True, + "ask_for_rating": True, + "enable_rate_limiting": True, + "overall_limit_per_ip": 200, + "requests_per_sec": 5.0, + "use_workflow": True + } + + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 201 + created_agent = response.json() + + # Verify all fields are set correctly + assert created_agent["name"] == agent_data["name"] + assert created_agent["display_name"] == agent_data["display_name"] + assert created_agent["description"] == agent_data["description"] + assert created_agent["agent_type"] == agent_data["agent_type"] + assert created_agent["instructions"] == agent_data["instructions"] + assert created_agent["is_active"] == agent_data["is_active"] + assert created_agent["transfer_to_human"] == agent_data["transfer_to_human"] + assert created_agent["ask_for_rating"] == agent_data["ask_for_rating"] + assert created_agent["enable_rate_limiting"] == agent_data["enable_rate_limiting"] + assert created_agent["overall_limit_per_ip"] == agent_data["overall_limit_per_ip"] + assert created_agent["requests_per_sec"] == agent_data["requests_per_sec"] + assert created_agent["use_workflow"] == agent_data["use_workflow"] + +def test_multiple_agents_same_org( + client, + db, + test_user +): + """Test creating multiple agents in the same organization""" + agents_to_create = 5 + created_agents = [] + + for i in range(agents_to_create): + agent_data = { + "name": f"Multi Agent {i}", + "agent_type": "customer_support", + "description": f"Agent number {i}", + "instructions": ["Be helpful"] + } + + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 201 + created_agents.append(response.json()) + + # Verify all agents are returned in list + response = client.get("/api/agents/list") + assert response.status_code == 200 + agents_list = response.json() + + # Should have original test agent plus the new ones + assert len(agents_list) >= agents_to_create + + # Verify each created agent is in the list + agent_names = [agent["name"] for agent in agents_list] + for i in range(agents_to_create): + assert f"Multi Agent {i}" in agent_names + +def test_agent_groups_multiple_assignments( + client, + db, + test_user, + test_agent +): + """Test assigning agent to multiple groups""" + # Create multiple groups + groups = [] + for i in range(3): + group = UserGroup( + id=uuid4(), + name=f"Test Group {i}", + organization_id=test_user.organization_id + ) + db.add(group) + groups.append(group) + db.commit() + + # Assign agent to all groups + group_ids = [str(group.id) for group in groups] + response = client.put( + f"/api/agents/{test_agent.id}/groups", + json=group_ids + ) + assert response.status_code == 200 + updated_agent = response.json() + assert len(updated_agent["groups"]) == 3 + + # Verify group names + group_names = [group["name"] for group in updated_agent["groups"]] + for i in range(3): + assert f"Test Group {i}" in group_names + +def test_customization_with_all_fields( + client, + db, + test_user, + test_agent +): + """Test creating customization with all possible fields""" + full_customization_data = { + "chat_background_color": "#FFFFFF", + "chat_bubble_color": "#E0E0E0", + "chat_text_color": "#000000", + "icon_color": "#007BFF", + "accent_color": "#FF5722", + "photo_url": "/custom/photo.png" + } + + response = client.post( + f"/api/agents/{test_agent.id}/customization", + json=full_customization_data + ) + assert response.status_code == 200 + customization = response.json() + + # Verify all fields except photo_url (which is excluded from model_dump) + assert customization["chat_background_color"] == full_customization_data["chat_background_color"] + assert customization["chat_bubble_color"] == full_customization_data["chat_bubble_color"] + assert customization["chat_text_color"] == full_customization_data["chat_text_color"] + assert customization["icon_color"] == full_customization_data["icon_color"] + assert customization["accent_color"] == full_customization_data["accent_color"] + +def test_customization_partial_updates( + client, + db, + test_user, + test_agent, + test_customization +): + """Test partial updates to existing customization""" + # Update only one field + partial_update = { + "accent_color": "#FF0000" + } + + response = client.post( + f"/api/agents/{test_agent.id}/customization", + json=partial_update + ) + assert response.status_code == 200 + customization = response.json() + + # Should have new accent color + assert customization["accent_color"] == "#FF0000" + # Should preserve other existing fields + assert customization["chat_background_color"] == test_customization.chat_background_color + +def test_concurrent_agent_operations( + client, + db, + test_user, + test_agent +): + """Updates to one agent from callers at the same time each apply cleanly, + and the agent ends as one of them, intact. + + The test database is in-memory SQLite on a single shared connection + (StaticPool), shared with this test's session: two requests on it at once + collide in the driver, and which error comes out was luck (it failed CI + with "has been deleted"). So the callers start together and take turns, + as row locks would make them in Postgres. + """ + import threading + + turn = threading.Lock() + start = threading.Barrier(3) + results = {} + + def update_agent(suffix): + start.wait() # all three callers arrive together + with turn: + response = client.put( + f"/api/agents/{test_agent.id}", + json={"display_name": f"Concurrent update {suffix}"}, + ) + results[suffix] = response.status_code + + threads = [threading.Thread(target=update_agent, args=(i,)) for i in range(3)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + + assert results == {0: 200, 1: 200, 2: 200} + + db.expire_all() + agents = db.query(Agent).filter(Agent.id == test_agent.id).all() + assert len(agents) == 1 + assert agents[0].display_name in {f"Concurrent update {i}" for i in range(3)} + assert (agents[0].name, agents[0].description) == ("Test Agent", "Test Description") # the rest survives + +def test_agent_with_very_long_data( + client, + db, + test_user +): + """Test agent creation with maximum length data""" + # Create agent with very long but valid data + long_name = "A" * 255 # Assuming reasonable name limit + long_description = "D" * 2000 # Long description + long_instructions = [f"Instruction {i}: " + "X" * 500 for i in range(10)] + + agent_data = { + "name": long_name, + "description": long_description, + "agent_type": "customer_support", + "instructions": long_instructions + } + + response = client.post("/api/agents", json=agent_data) + # This might succeed or fail based on actual database constraints + assert response.status_code in [201, 422, 400] + + if response.status_code == 201: + created_agent = response.json() + assert len(created_agent["name"]) <= 255 + assert len(created_agent["instructions"]) == 10 + +def test_agent_list_with_large_dataset( + client, + db, + test_user +): + """Test getting agent list when organization has many agents""" + # Create many agents + for i in range(20): + agent_data = { + "name": f"Bulk Agent {i:03d}", + "agent_type": "customer_support", + "description": f"Bulk test agent {i}", + "instructions": ["Be helpful"] + } + response = client.post("/api/agents", json=agent_data) + assert response.status_code == 201 + + # Get all agents + response = client.get("/api/agents/list") + assert response.status_code == 200 + agents = response.json() + + # Should have all agents plus the original test agent + assert len(agents) >= 20 + + # Verify agents are properly formatted + for agent in agents: + assert "id" in agent + assert "name" in agent + assert "agent_type" in agent + assert "organization_id" in agent + assert "knowledge" in agent + assert "groups" in agent + + +def test_agent_knowledge_integration_empty( + client, + db, + test_user, + test_agent +): + """Test agent with no knowledge sources""" + from unittest.mock import patch + + with patch('app.repositories.knowledge.KnowledgeRepository.get_by_agent') as mock_get_knowledge: + mock_get_knowledge.return_value = [] + + response = client.get(f"/api/agents/{test_agent.id}") + assert response.status_code == 200 + agent = response.json() + assert agent["knowledge"] == [] + +def test_performance_with_complex_agent_data( + client, + db, + test_user +): + """Test performance with agent containing complex data structures""" + import time + + # Create agent with complex instructions and data + complex_agent_data = { + "name": "Performance Test Agent", + "description": "Agent for performance testing with complex data", + "agent_type": "tech_support", + "instructions": [ + "Handle complex technical queries with detailed step-by-step instructions", + "Provide comprehensive troubleshooting guides for multiple scenarios", + "Maintain context across multiple conversation turns and reference previous interactions", + "Generate detailed technical documentation and code examples when requested", + "Escalate to human agents when encountering issues beyond defined parameters" + ], + "transfer_to_human": True, + "ask_for_rating": True, + "enable_rate_limiting": True, + "overall_limit_per_ip": 1000, + "requests_per_sec": 10.0 + } + + # Measure creation time + start_time = time.time() + response = client.post("/api/agents", json=complex_agent_data) + creation_time = time.time() - start_time + + assert response.status_code == 201 + assert creation_time < 5.0 # Should complete within 5 seconds + + agent_id = response.json()["id"] + + # Measure retrieval time + start_time = time.time() + response = client.get(f"/api/agents/{agent_id}") + retrieval_time = time.time() - start_time + + assert response.status_code == 200 + assert retrieval_time < 2.0 # Should complete within 2 seconds + + +# ---------------------------------------------------------------- permissions + +@pytest.fixture +def unprivileged_client(db, test_organization_id) -> TestClient: + """A client whose role grants nothing. + + get_unified_auth authenticates only; require_unified_permissions is what + refuses. Before that split the check lived inside the auth helper, so every + route sharing it — including reading your own org's subscription — demanded + manage_agents. + """ + role = Role(name="No Permissions", organization_id=test_organization_id) + db.add(role) + db.commit() + user = User( + id=uuid4(), + email="nopermissions@example.com", + hashed_password="hashed_password", + is_active=True, + organization_id=test_organization_id, + full_name="No Permissions", + role_id=role.id, + ) + db.add(user) + db.commit() + db.refresh(user) + + async def override_get_unified_auth(): + return { + "auth_type": "jwt", + "organization_id": user.organization_id, + "user_id": user.id, + "current_user": user, + } + + def override_get_db(): + yield db + + app.dependency_overrides[get_unified_auth] = override_get_unified_auth + app.dependency_overrides[get_db] = override_get_db + + yield TestClient(app) + app.dependency_overrides.clear() + + +def test_agent_list_requires_manage_agents(unprivileged_client: TestClient): + """Reading the agent list still needs manage_agents after the authn/authz split""" + assert unprivileged_client.get("/api/agents/list").status_code == 403 + + +def test_agent_detail_requires_manage_agents(unprivileged_client: TestClient, test_agent: Agent): + """Agent config carries instructions and the guardrail prompt — not for everyone""" + assert unprivileged_client.get(f"/api/agents/{test_agent.id}").status_code == 403 + + +def test_agent_update_requires_manage_agents(unprivileged_client: TestClient, test_agent: Agent): + """A missed wrapper here would let any org member rewrite an agent""" + response = unprivileged_client.put( + f"/api/agents/{test_agent.id}", json={"name": "Hijacked"} + ) + assert response.status_code == 403 + + +def test_agent_customization_requires_manage_agents(unprivileged_client: TestClient, test_agent: Agent): + """Customization is customer-facing branding""" + response = unprivileged_client.post( + f"/api/agents/{test_agent.id}/customization", json={"chat_bubble_color": "#000000"} + ) + assert response.status_code == 403 + + +def test_guardrail_default_requires_manage_agents(unprivileged_client: TestClient): + """The default guardrail prompt tells a reader how to phrase a bypass""" + assert unprivileged_client.get("/api/agents/guardrail-default").status_code == 403 + + +# -------------------------------------------------------------------- roster + +@pytest.fixture +def inbox_agent_client(db, test_organization_id) -> TestClient: + """A seeded Human Agent: works the inbox, manages no AI agents.""" + role = Role(name="Human Agent", organization_id=test_organization_id) + db.add(role) + db.commit() + for name in ("view_assigned_chats", "manage_assigned_chats", "view_people"): + perm = Permission(name=name, description=name) + db.add(perm) + db.commit() + db.execute(role_permissions.insert().values(role_id=role.id, permission_id=perm.id)) + db.commit() + user = User( + id=uuid4(), + email="inboxagent@example.com", + hashed_password="hashed_password", + is_active=True, + organization_id=test_organization_id, + full_name="Inbox Agent", + role_id=role.id, + ) + db.add(user) + db.commit() + db.refresh(user) + + async def override_get_current_user(): + return user + + def override_get_db(): + yield db + + app.dependency_overrides[get_current_user] = override_get_current_user + app.dependency_overrides[get_db] = override_get_db + + yield TestClient(app) + app.dependency_overrides.clear() + + +def test_roster_readable_by_inbox_roles(inbox_agent_client: TestClient, test_agent: Agent): + """The inbox filter needs agent names; it does not need manage_agents""" + response = inbox_agent_client.get("/api/agents/roster") + + assert response.status_code == 200 + assert [a["name"] for a in response.json()] == [test_agent.name] + + +def test_roster_withholds_instructions_and_guardrail(inbox_agent_client: TestClient, test_agent: Agent): + """A guardrail prompt tells a reader how to phrase a bypass""" + response = inbox_agent_client.get("/api/agents/roster") + + assert response.status_code == 200 + for item in response.json(): + assert set(item) == {"id", "name", "display_name", "is_active"} + + +def test_roster_is_org_scoped(inbox_agent_client: TestClient, db, test_agent: Agent): + """Another tenant's agents are not on this roster""" + other_org = Organization(id=uuid4(), name="Other", domain="other-roster.example.com") + db.add(other_org) + db.commit() + db.add(Agent( + id=uuid4(), + name="Foreign Agent", + display_name="Foreign", + agent_type=AgentType.CUSTOMER_SUPPORT, + instructions=["secret"], + is_active=True, + organization_id=other_org.id, + )) + db.commit() + + response = inbox_agent_client.get("/api/agents/roster") + + assert response.status_code == 200 + assert "Foreign Agent" not in [a["name"] for a in response.json()] + + +def test_roster_route_is_not_shadowed_by_the_agent_id_route(inbox_agent_client: TestClient): + """Declared above GET /{agent_id}, which takes a UUID — below it, 422""" + assert inbox_agent_client.get("/api/agents/roster").status_code == 200 + + +def test_view_agents_grants_the_reads(db, test_organization_id, test_agent: Agent): + """view_agents was declared in the catalogue but enforced nowhere. + + An admin could tick it on a role and nothing happened: every /agent read + still demanded manage_agents, so the page rendered and then 403'd. + """ + role = Role(name="Agent Viewer", organization_id=test_organization_id) + db.add(role) + db.commit() + perm = Permission(name="view_agents", description="Can view chat agents") + db.add(perm) + db.commit() + db.execute(role_permissions.insert().values(role_id=role.id, permission_id=perm.id)) + db.commit() + viewer = User( + id=uuid4(), + email="viewer@example.com", + hashed_password="hashed_password", + is_active=True, + organization_id=test_organization_id, + full_name="Viewer", + role_id=role.id, + ) + db.add(viewer) + db.commit() + + async def override_get_unified_auth(): + return { + "auth_type": "jwt", + "organization_id": viewer.organization_id, + "user_id": viewer.id, + "current_user": viewer, + } + + def override_get_db(): + yield db + + app.dependency_overrides[get_unified_auth] = override_get_unified_auth + app.dependency_overrides[get_db] = override_get_db + try: + client = TestClient(app) + assert client.get("/api/agents/list").status_code == 200 + assert client.get(f"/api/agents/{test_agent.id}").status_code == 200 + # Reading is not managing + assert client.put(f"/api/agents/{test_agent.id}", json={"name": "X"}).status_code == 403 + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/api/test_agent_photo_upload.py b/backend/tests/api/test_agent_photo_upload.py new file mode 100644 index 0000000..9e66ffb --- /dev/null +++ b/backend/tests/api/test_agent_photo_upload.py @@ -0,0 +1,85 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import io +import os +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from fastapi import HTTPException, UploadFile +from PIL import Image + +from app.api.agent import save_file + +# A real 1x1 PNG, since save_file verifies the bytes with Pillow before storing. +def _png_bytes() -> bytes: + buffer = io.BytesIO() + Image.new("RGB", (1, 1)).save(buffer, format="PNG") + return buffer.getvalue() + + +def _upload(filename: str, content_type: str = "image/png") -> UploadFile: + return UploadFile( + filename=filename, + file=io.BytesIO(_png_bytes()), + headers={"content-type": content_type}, + ) + + +@pytest.mark.asyncio +class TestAgentPhotoStoredName: + """The stored name must not inherit anything from the caller's filename. + + It used to be `uuid4() + os.path.splitext(file.filename)[1]`, so the caller + chose the extension. The name now comes from the content type we already + validated, which keeps caller-supplied text out of the path entirely. + """ + + async def test_extension_comes_from_the_content_type(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("app.api.agent.settings.S3_FILE_STORAGE", False): + path = await save_file(_upload("holiday snap.png"), uuid4()) + + assert path.endswith(".png") + + async def test_a_lying_extension_is_ignored(self, tmp_path, monkeypatch): + # Caller says .php; the bytes and the content type say PNG. + monkeypatch.chdir(tmp_path) + with patch("app.api.agent.settings.S3_FILE_STORAGE", False): + path = await save_file(_upload("shell.php"), uuid4()) + + assert path.endswith(".png") + assert ".php" not in path + + async def test_traversal_in_the_filename_cannot_reach_the_path(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + organization_id = uuid4() + with patch("app.api.agent.settings.S3_FILE_STORAGE", False): + path = await save_file(_upload("../../../../etc/passwd.png"), organization_id) + + assert ".." not in path + assert path.endswith(".png") + # Everything written stays under the organization's own upload folder. + assert f"uploads/agents/{organization_id}" in path + written = os.path.join("uploads", "agents", str(organization_id)) + assert len(os.listdir(written)) == 1 + + async def test_a_disallowed_content_type_is_refused(self): + with pytest.raises(HTTPException) as excinfo: + await save_file(_upload("payload.svg", content_type="image/svg+xml"), uuid4()) + + assert excinfo.value.status_code == 400 diff --git a/backend/tests/api/test_agent_workflow.py b/backend/tests/api/test_agent_workflow.py new file mode 100644 index 0000000..bede4b7 --- /dev/null +++ b/backend/tests/api/test_agent_workflow.py @@ -0,0 +1,277 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest +from fastapi.testclient import TestClient +from fastapi import FastAPI +from app.models.user import User +from app.models.agent import Agent, AgentType +from app.models.role import Role +from app.models.permission import Permission, role_permissions +from app.models.organization import Organization +from app.models.workflow import Workflow, WorkflowStatus +from uuid import uuid4 +from app.api import agent as agent_router +from app.core.auth import get_current_user, require_permissions, get_unified_auth +from app.database import get_db + +# Create a test FastAPI app +app = FastAPI() +app.include_router( + agent_router.router, + prefix="/api/agents", + tags=["agent"] +) + + +@pytest.fixture +def test_permissions(db) -> list[Permission]: + """Create test permissions""" + permissions = [] + for name in ["manage_agents", "view_all"]: + perm = Permission( + name=name, + description=f"Test permission for {name}" + ) + db.add(perm) + permissions.append(perm) + db.commit() + for p in permissions: + db.refresh(p) + return permissions + + +@pytest.fixture +def test_role(db, test_organization_id, test_permissions) -> Role: + """Create a test role with required permissions""" + role = Role( + name="Test Role", + description="Test Role Description", + organization_id=test_organization_id, + is_default=True + ) + db.add(role) + db.commit() + + # Associate permissions with role using the role_permissions table + for perm in test_permissions: + db.execute( + role_permissions.insert().values( + role_id=role.id, + permission_id=perm.id + ) + ) + db.commit() + db.refresh(role) + + return role + + +@pytest.fixture +def test_user(db, test_organization_id, test_role) -> User: + """Create a test user with required permissions""" + user = User( + id=uuid4(), + email="test@example.com", + hashed_password="hashed_password", + is_active=True, + organization_id=test_organization_id, + full_name="Test User", + role_id=test_role.id + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def test_agent(db, test_user) -> Agent: + """Create a test agent""" + agent = Agent( + id=uuid4(), + name="Test Agent", + display_name="Test Display Name", + description="Test Description", + agent_type=AgentType.CUSTOMER_SUPPORT, + instructions=["Test instruction"], # Using the correct field name + is_active=True, + organization_id=test_user.organization_id, + transfer_to_human=True, + use_workflow=False + ) + db.add(agent) + db.commit() + db.refresh(agent) + return agent + + +@pytest.fixture +def test_workflow(db, test_user, test_agent) -> Workflow: + """Create a test workflow""" + workflow = Workflow( + id=uuid4(), + name="Test Workflow", + description="Test workflow description", + status=WorkflowStatus.PUBLISHED, + version=1, + is_template=False, + default_language="en", + canvas_data={}, + settings={}, + organization_id=test_user.organization_id, + agent_id=test_agent.id, + created_by=test_user.id + ) + db.add(workflow) + db.commit() + db.refresh(workflow) + return workflow + + +@pytest.fixture +def client(db, test_user) -> TestClient: + """Create test client with mocked dependencies""" + async def override_get_current_user(): + return test_user + + async def override_require_permissions(*args, **kwargs): + return test_user + + async def override_get_unified_auth(): + # Authentication only — see the note in tests/api/test_agent.py. + db.refresh(test_user) + db.refresh(test_user.role) + + return { + "auth_type": "jwt", + "organization_id": test_user.organization_id, # Keep as UUID + "user_id": test_user.id, + "current_user": test_user + } + + def override_get_db(): + try: + yield db + finally: + pass + + app.dependency_overrides[get_current_user] = override_get_current_user + app.dependency_overrides[require_permissions] = override_require_permissions + app.dependency_overrides[get_unified_auth] = override_get_unified_auth + app.dependency_overrides[get_db] = override_get_db + + return TestClient(app) + + +class TestAgentWorkflowAPI: + + def test_create_agent_with_workflow_disabled(self, client, test_user): + """Test creating an agent with workflow disabled""" + agent_data = { + "name": "New Agent", + "display_name": "New Agent Display", + "description": "New agent description", + "agent_type": "customer_support", + "instructions": ["New agent instruction"], + "is_active": True, + "transfer_to_human": True, + "use_workflow": False + } + + response = client.post("/api/agents", json=agent_data) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == agent_data["name"] + assert data["use_workflow"] is False + assert data["active_workflow_id"] is None + + def test_update_agent_enable_workflow(self, client, test_agent, test_workflow): + """Test updating an agent to enable workflow""" + update_data = { + "use_workflow": True, + "active_workflow_id": str(test_workflow.id) + } + + response = client.put(f"/api/agents/{test_agent.id}", json=update_data) + + assert response.status_code == 200 + data = response.json() + assert data["use_workflow"] is True + assert data["active_workflow_id"] == str(test_workflow.id) + + def test_update_agent_disable_workflow(self, client, test_agent, test_workflow): + """Test updating an agent to disable workflow""" + # First enable workflow + test_agent.use_workflow = True + test_agent.active_workflow_id = test_workflow.id + + update_data = { + "use_workflow": False, + "active_workflow_id": None + } + + response = client.put(f"/api/agents/{test_agent.id}", json=update_data) + + assert response.status_code == 200 + data = response.json() + assert data["use_workflow"] is False + assert data["active_workflow_id"] is None + + def test_update_agent_workflow_without_id(self, client, test_agent): + """Test updating an agent to use workflow without providing workflow ID""" + update_data = { + "use_workflow": True + # Missing active_workflow_id + } + + response = client.put(f"/api/agents/{test_agent.id}", json=update_data) + + # This should either fail validation or default to None + # The exact behavior depends on your API implementation + assert response.status_code in [200, 422] + + def test_get_agent_with_workflow(self, client, test_agent, test_workflow): + """Test getting an agent that has workflow enabled""" + # Set up agent with workflow + test_agent.use_workflow = True + test_agent.active_workflow_id = test_workflow.id + + response = client.get(f"/api/agents/{test_agent.id}") + + assert response.status_code == 200 + data = response.json() + assert data["use_workflow"] is True + assert data["active_workflow_id"] == str(test_workflow.id) + + def test_list_agents_with_workflow(self, client, test_agent, test_workflow): + """Test listing agents, including those with workflows""" + # Set up agent with workflow + test_agent.use_workflow = True + test_agent.active_workflow_id = test_workflow.id + + response = client.get("/api/agents/list") + + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + + # Find our test agent in the response + agent_data = next((agent for agent in data if agent["id"] == str(test_agent.id)), None) + assert agent_data is not None + assert agent_data["use_workflow"] is True + assert agent_data["active_workflow_id"] == str(test_workflow.id) \ No newline at end of file diff --git a/backend/tests/api/test_ai_setup.py b/backend/tests/api/test_ai_setup.py new file mode 100644 index 0000000..b95e589 --- /dev/null +++ b/backend/tests/api/test_ai_setup.py @@ -0,0 +1,774 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest +import os +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +from fastapi import FastAPI +from app.models.user import User +from app.models.ai_config import AIConfig, AIModelType +from app.models.role import Role +from app.models.permission import Permission, role_permissions +from uuid import uuid4 +from app.api import ai_setup as ai_setup_router +from app.core.auth import get_current_user, require_permissions +from app.models.schemas.ai_config import AIConfigCreate, AIConfigUpdate +from pydantic import SecretStr +from app.database import get_db + +# Create a test FastAPI app +app = FastAPI() +app.include_router( + ai_setup_router.router, + prefix="/api/ai", + tags=["ai"] +) + +@pytest.fixture +def test_permissions(db) -> list[Permission]: + """Create test permissions""" + permissions = [] + for name in ["manage_ai_config", "view_ai_config"]: + perm = Permission( + name=name, + description=f"Test permission for {name}" + ) + db.add(perm) + permissions.append(perm) + db.commit() + for p in permissions: + db.refresh(p) + return permissions + +@pytest.fixture +def test_role(db, test_organization_id, test_permissions) -> Role: + """Create a test role with required permissions""" + role = Role( + name="Test Role", + description="Test Role Description", + organization_id=test_organization_id, + is_default=True + ) + db.add(role) + db.commit() + + # Associate permissions with role + for perm in test_permissions: + db.execute( + role_permissions.insert().values( + role_id=role.id, + permission_id=perm.id + ) + ) + db.commit() + db.refresh(role) + return role + +@pytest.fixture +def test_user(db, test_organization_id, test_role) -> User: + """Create a test user with required permissions""" + user = User( + id=uuid4(), + email="test@example.com", + hashed_password="hashed_password", + is_active=True, + organization_id=test_organization_id, + full_name="Test User", + role_id=test_role.id + ) + db.add(user) + db.commit() + db.refresh(user) + return user + +@pytest.fixture +def test_ai_config(db, test_user) -> AIConfig: + """Create a test AI configuration""" + config = AIConfig( + organization_id=test_user.organization_id, + model_type="OPENAI", + model_name="gpt-4o-mini", + encrypted_api_key="test_api_key", + is_active=True + ) + db.add(config) + db.commit() + db.refresh(config) + return config + +@pytest.fixture +def client(db, test_user) -> TestClient: + """Create test client with mocked dependencies""" + async def override_get_current_user(): + return test_user + + async def override_require_permissions(*args, **kwargs): + return test_user + + def override_get_db(): + try: + yield db + finally: + pass + + app.dependency_overrides[get_current_user] = override_get_current_user + app.dependency_overrides[require_permissions] = override_require_permissions + app.dependency_overrides[get_db] = override_get_db + + return TestClient(app) + +# Mock for ChatAgent.test_api_key +async def mock_test_api_key(api_key: str, model_type: str, model_name: str, base_url: str = None) -> bool: + """Mock implementation of test_api_key""" + if api_key == "invalid_key": + raise Exception("Invalid API key") + elif api_key == "failed_validation": + return False + return True + +# Patch ChatAgent.test_api_key for tests +ai_setup_router.ChatAgent.test_api_key = mock_test_api_key + +# Also patch the validate_model_selection function +original_validate = ai_setup_router.validate_model_selection + +def mock_validate_model_selection(model_type: str, model_name: str): + if model_type.upper() == "INVALID_MODEL": + raise ai_setup_router.HTTPException( + status_code=400, + detail={ + "error": "Unsupported provider", + "type": "invalid_provider", + "details": "Currently only these providers are supported: GROQ, OPENAI, CHATTERMATE" + } + ) + return original_validate(model_type, model_name) + +ai_setup_router.validate_model_selection = mock_validate_model_selection + +# Test cases +def test_setup_ai_success(client, db, test_user): + """Test successful AI setup""" + config_data = { + "model_type": "OPENAI", + "model_name": "gpt-4o-mini", + "api_key": "test_valid_key" + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 200 + data = response.json() + assert data["message"] == "AI configuration completed successfully" + assert data["config"]["model_type"] == config_data["model_type"] + assert data["config"]["model_name"] == config_data["model_name"] + +def test_setup_ai_openai_compatible_requires_base_url(client, db, test_user): + """OPENAI_COMPATIBLE without a base_url is rejected before the API key is tested""" + config_data = { + "model_type": "OPENAI_COMPATIBLE", + "model_name": "local-model", + "api_key": "test_valid_key" + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 400 + data = response.json() + assert data["detail"]["type"] == "invalid_base_url" + +def test_setup_ai_openai_compatible_success(client, db, test_user): + """OPENAI_COMPATIBLE with a base_url is set up and the base_url is stored in settings""" + config_data = { + "model_type": "OPENAI_COMPATIBLE", + "model_name": "local-model", + "api_key": "test_valid_key", + "base_url": "http://localhost:11434/v1" + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 200 + data = response.json() + assert data["config"]["model_type"] == "OPENAI_COMPATIBLE" + assert data["config"]["settings"]["base_url"] == "http://localhost:11434/v1" + +def test_setup_ai_invalid_key(client, db, test_user): + """Test AI setup with invalid API key""" + config_data = { + "model_type": "OPENAI", + "model_name": "gpt-4o-mini", + "api_key": "invalid_key" + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 400 + data = response.json() + assert "error" in data["detail"] + assert data["detail"]["type"] == "api_key_validation_error" + +def test_setup_ai_failed_validation(client, db, test_user): + """Test AI setup with failed API key validation""" + config_data = { + "model_type": "OPENAI", + "model_name": "gpt-4o-mini", + "api_key": "failed_validation" + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 400 + data = response.json() + assert "error" in data["detail"] + assert data["detail"]["type"] == "invalid_api_key" + +def test_setup_ai_invalid_model(client, db, test_user): + """A model ID that fails the live API-key/model test is rejected. + + Model IDs are no longer statically allowlisted (orgs may enter custom ones), so + an unusable model is caught by the live test returning False, not by validation. + """ + config_data = { + "model_type": "OPENAI", + "model_name": "nonexistent-model", + "api_key": "failed_validation" # mock_test_api_key returns False for this key + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 400 + data = response.json() + assert "error" in data["detail"] + assert data["detail"]["type"] == "invalid_api_key" + +def test_setup_ai_custom_model_id_succeeds(client, db, test_user): + """A custom (non-catalog) model ID with a working key is accepted.""" + config_data = { + "model_type": "OPENAI", + "model_name": "gpt-some-future-model", + "api_key": "test_valid_key" + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 200 + +def test_setup_ai_invalid_provider(client, db, test_user): + """Test AI setup with invalid provider""" + config_data = { + "model_type": "INVALID_MODEL", + "model_name": "test-model", + "api_key": "test_valid_key" + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 422 # Pydantic validation error returns 422 + data = response.json() + assert "detail" in data + # Pydantic validation errors have a different format + assert any("model_type" in str(err).lower() for err in data["detail"]) + +def test_setup_ai_groq(client, db, test_user): + """Test AI setup with Groq""" + config_data = { + "model_type": "GROQ", + "model_name": "llama-3.3-70b-versatile", + "api_key": "test_valid_key" + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 200 + data = response.json() + assert data["config"]["model_type"] == "GROQ" + assert data["config"]["model_name"] == "llama-3.3-70b-versatile" + +@patch.dict(os.environ, { + 'CHATTERMATE_API_KEY': 'test_chattermate_key', + 'CHATTERMATE_MODEL_NAME': 'gpt-4o-mini' +}) +@patch('app.api.ai_setup.HAS_ENTERPRISE', True) +def test_setup_ai_chattermate_success(client, db, test_user): + """Test successful ChatterMate AI setup""" + config_data = { + "model_type": "CHATTERMATE", + "model_name": "chattermate", + "api_key": "any_key" # This will be ignored for ChatterMate + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 200 + data = response.json() + assert data["message"] == "AI configuration completed successfully" + assert data["config"]["model_type"] == "CHATTERMATE" + assert data["config"]["model_name"] == "gpt-4o-mini" + +@patch.dict(os.environ, {}, clear=True) # Clear environment variables +@patch('app.api.ai_setup.HAS_ENTERPRISE', True) +def test_setup_ai_chattermate_missing_key(client, db, test_user): + """Test ChatterMate AI setup with missing API key""" + config_data = { + "model_type": "CHATTERMATE", + "model_name": "chattermate", + "api_key": "any_key" + } + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 500 + assert "ChatterMate API configuration missing" in response.json()["detail"] + +def test_setup_ai_general_exception(client, db, test_user): + """Test setup AI with general exception""" + config_data = { + "model_type": "OPENAI", + "model_name": "gpt-4o-mini", + "api_key": "test_valid_key" + } + + # Mock the repository to raise an exception + with patch('app.repositories.ai_config.AIConfigRepository.create_config') as mock_create: + mock_create.side_effect = Exception("Database error") + + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 500 + assert response.json()["detail"] == "Failed to setup AI configuration" + +def test_requires_base_url_is_catalog_driven(): + """The base_url requirement is read from the catalog, not a hardcoded name.""" + from app.core.model_catalog import requires_base_url + + assert requires_base_url("OPENAI_COMPATIBLE") is True + assert requires_base_url("openai_compatible") is True + assert requires_base_url("OPENAI") is False + assert requires_base_url("NOT_A_PROVIDER") is False + assert requires_base_url("") is False + assert requires_base_url(None) is False + + +def test_setup_ai_base_url_enforced_for_any_catalog_provider(client, db, test_user): + """A new provider only has to set requires_base_url in the catalog - the API + enforces it without another hardcoded provider-name check.""" + from app.core import model_catalog + + # OLLAMA is a real AIModelType but is deliberately not in the catalog today, + # so it stands in for "a provider added later that needs a base URL". + fake_catalog = { + **model_catalog.MODEL_CATALOG, + "OLLAMA": { + "label": "Ollama (self-hosted)", + "requires_api_key": True, + "custom_allowed": True, + "requires_base_url": True, + "api_key_url": "", + "models": [], + }, + } + + config_data = { + "model_type": "OLLAMA", + "model_name": "some-model", + "api_key": "test_valid_key" + } + + with patch.object(model_catalog, 'MODEL_CATALOG', fake_catalog): + response = client.post( + "/api/ai/setup", + json=config_data + ) + assert response.status_code == 400 + data = response.json() + assert data["detail"]["type"] == "invalid_base_url" + + +def test_get_providers(client, db, test_user): + """The /providers endpoint returns the catalog of selectable providers.""" + response = client.get("/api/ai/providers") + assert response.status_code == 200 + data = response.json() + providers = {p["value"]: p for p in data["providers"]} + # Newly enabled providers are present alongside OpenAI/Groq + for expected in ("OPENAI", "GROQ", "ANTHROPIC", "GOOGLE", "MISTRAL", "XAI", "DEEPSEEK", "OPENAI_COMPATIBLE"): + assert expected in providers, f"{expected} missing from /providers" + # ChatterMate (managed) is not a user-selectable BYO-key provider + assert "CHATTERMATE" not in providers + # Each provider exposes suggested models and BYO-key metadata + openai = providers["OPENAI"] + assert openai["requires_api_key"] is True + assert openai["custom_allowed"] is True + assert len(openai["models"]) > 0 + assert all("value" in m and "label" in m for m in openai["models"]) + # OPENAI_COMPATIBLE points at a user-supplied endpoint rather than a hosted + # console, so it has no api_key_url, and it's the only provider that + # requires a base URL up front. + openai_compatible = providers["OPENAI_COMPATIBLE"] + assert openai_compatible["api_key_url"] == "" + assert openai_compatible["requires_base_url"] is True + assert openai_compatible["models"] == [] + # Every other provider exposes a console URL for obtaining an API key + for p in data["providers"]: + if p["value"] == "OPENAI_COMPATIBLE": + continue + assert p["api_key_url"].startswith("https://"), f"{p['value']} missing api_key_url" + assert p.get("requires_base_url", False) is False + +def test_get_ai_config_success(client, db, test_user, test_ai_config): + """Test getting AI configuration""" + response = client.get("/api/ai/config") + assert response.status_code == 200 + data = response.json() + assert data["model_type"] == test_ai_config.model_type + assert data["model_name"] == test_ai_config.model_name + +def test_get_ai_config_not_found(client, db, test_user): + """Test getting AI configuration when none exists""" + response = client.get("/api/ai/config") + assert response.status_code == 404 + assert response.json()["detail"] == "No active AI configuration found" + +def test_get_ai_config_exception(client, db, test_user): + """Test getting AI config with database exception""" + with patch('app.repositories.ai_config.AIConfigRepository.get_active_config') as mock_get: + mock_get.side_effect = Exception("Database error") + + response = client.get("/api/ai/config") + assert response.status_code == 500 + assert response.json()["detail"] == "Failed to get AI configuration" + +def test_update_ai_config_success(client, db, test_user, test_ai_config): + """Test successful AI config update""" + update_data = { + "model_type": "OPENAI", + "model_name": "o1-mini", + "api_key": "new_valid_key" + } + + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 200 + data = response.json() + assert data["message"] == "AI configuration updated successfully" + assert data["config"]["model_name"] == update_data["model_name"] + +def test_update_ai_config_openai_compatible_requires_base_url(client, db, test_user, test_ai_config): + """Switching an existing config to OPENAI_COMPATIBLE without a base_url is rejected""" + update_data = { + "model_type": "OPENAI_COMPATIBLE", + "model_name": "local-model", + "api_key": "new_valid_key" + } + + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 400 + data = response.json() + assert data["detail"]["type"] == "invalid_base_url" + +def test_update_ai_config_openai_compatible_requires_base_url_without_api_key(client, db, test_user, test_ai_config): + """The base_url-required check must not be skippable by omitting api_key.""" + update_data = { + "model_type": "OPENAI_COMPATIBLE", + "model_name": "local-model" + } + + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 400 + data = response.json() + assert data["detail"]["type"] == "invalid_base_url" + +def test_update_ai_config_openai_compatible_base_url_only_validates_existing_key(client, db, test_user, test_ai_config): + """A base_url-only change (no new api_key) is still live-validated, using the + existing stored key, instead of being persisted unchecked.""" + update_data = { + "model_type": "OPENAI_COMPATIBLE", + "model_name": "local-model", + "base_url": "http://localhost:11434/v1" + } + + with patch.object(ai_setup_router, 'decrypt_api_key', return_value='decrypted-existing-key'): + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 200 + data = response.json() + assert data["config"]["model_type"] == "OPENAI_COMPATIBLE" + +def test_update_ai_config_not_found(client, db, test_user): + """Test updating AI config when none exists""" + update_data = { + "model_type": "OPENAI", + "model_name": "gpt-4o-mini", + "api_key": "test_valid_key" + } + + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 404 + assert response.json()["detail"] == "No active AI configuration found to update" + +def test_update_ai_config_without_api_key(client, db, test_user, test_ai_config): + """Test updating AI config without providing new API key""" + update_data = { + "model_type": "OPENAI", + "model_name": "o1-mini" + # No api_key provided + } + + # Mock the repository to handle None API key properly + with patch('app.repositories.ai_config.AIConfigRepository.update_config') as mock_update: + mock_update.return_value = test_ai_config + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 200 + data = response.json() + assert data["message"] == "AI configuration updated successfully" + # Verify that update_config was called with None for api_key (no change) + mock_update.assert_called_once() + call_args = mock_update.call_args + assert call_args[1]['api_key'] is None # None indicates no change + +def test_update_ai_config_failed_validation(client, db, test_user, test_ai_config): + """Test update AI config with failed API key validation""" + update_data = { + "model_type": "OPENAI", + "model_name": "gpt-4o-mini", + "api_key": "failed_validation" + } + + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 400 + data = response.json() + # The mock returns False, which raises the invalid_api_key HTTPException + # directly - it's re-raised as-is, not wrapped into a generic validation error. + assert data["detail"]["type"] == "invalid_api_key" + +def test_update_ai_config_validation_exception(client, db, test_user, test_ai_config): + """Test update AI config with validation exception""" + update_data = { + "model_type": "OPENAI", + "model_name": "gpt-4o-mini", + "api_key": "invalid_key" + } + + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 400 + data = response.json() + assert data["detail"]["type"] == "api_key_validation_error" + +@patch.dict(os.environ, { + 'CHATTERMATE_API_KEY': 'test_chattermate_key', + 'CHATTERMATE_MODEL_NAME': 'gpt-4o-mini' +}) +@patch('app.api.ai_setup.HAS_ENTERPRISE', True) +def test_update_ai_config_chattermate_success(client, db, test_user, test_ai_config): + """Test successful ChatterMate AI config update""" + update_data = { + "model_type": "CHATTERMATE", + "model_name": "chattermate" + } + + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 200 + data = response.json() + assert data["message"] == "AI configuration updated successfully" + assert data["config"]["model_type"] == "CHATTERMATE" + +@patch.dict(os.environ, {}, clear=True) +@patch('app.api.ai_setup.HAS_ENTERPRISE', True) +def test_update_ai_config_chattermate_missing_key(client, db, test_user, test_ai_config): + """Test ChatterMate AI config update with missing API key""" + update_data = { + "model_type": "CHATTERMATE", + "model_name": "chattermate" + } + + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 500 + assert "ChatterMate API configuration missing" in response.json()["detail"] + +def test_update_ai_config_general_exception(client, db, test_user, test_ai_config): + """Test update AI config with general exception""" + update_data = { + "model_type": "OPENAI", + "model_name": "gpt-4o-mini", + "api_key": "test_valid_key" + } + + with patch('app.repositories.ai_config.AIConfigRepository.update_config') as mock_update: + mock_update.side_effect = Exception("Database error") + + response = client.put( + "/api/ai/config", + json=update_data + ) + assert response.status_code == 500 + assert response.json()["detail"] == "Failed to update AI configuration" + +def test_validate_model_selection_chattermate(): + """Test validate_model_selection with ChatterMate""" + # Should not raise any exception + ai_setup_router.validate_model_selection("CHATTERMATE", "chattermate") + ai_setup_router.validate_model_selection("chattermate", "CHATTERMATE") + +def test_validate_model_selection_groq_valid(): + """Test validate_model_selection with valid Groq model""" + # Should not raise any exception + ai_setup_router.validate_model_selection("GROQ", "llama-3.3-70b-versatile") + +def test_validate_model_selection_custom_model_allowed(): + """Custom (non-catalog) model IDs are allowed for any known provider. + + Model IDs are no longer a hard allowlist — orgs may type their own; the live + API-key test is what rejects a bad model ID. + """ + # Should not raise — custom model IDs are permitted for known providers + ai_setup_router.validate_model_selection("GROQ", "some-custom-groq-model") + ai_setup_router.validate_model_selection("OPENAI", "gpt-custom-preview") + +def test_validate_model_selection_openai_valid(): + """Test validate_model_selection with valid OpenAI model""" + # Should not raise any exception + ai_setup_router.validate_model_selection("OPENAI", "gpt-4o-mini") + ai_setup_router.validate_model_selection("OPENAI", "o1-mini") + +def test_validate_model_selection_empty_model_name(): + """A known provider still requires a non-empty model ID.""" + with pytest.raises(ai_setup_router.HTTPException) as exc_info: + ai_setup_router.validate_model_selection("OPENAI", " ") + + assert exc_info.value.status_code == 400 + assert "Invalid model selection" in exc_info.value.detail["error"] + assert "invalid_model" in exc_info.value.detail["type"] + +def test_validate_model_selection_newly_enabled_providers(): + """Providers added to the catalog (Anthropic, Google, etc.) now validate.""" + # Should not raise — these are known providers in the catalog now + ai_setup_router.validate_model_selection("ANTHROPIC", "claude-sonnet-5") + ai_setup_router.validate_model_selection("GOOGLE", "gemini-2.5-flash") + ai_setup_router.validate_model_selection("MISTRAL", "mistral-large-latest") + ai_setup_router.validate_model_selection("XAI", "grok-4") + +def test_validate_model_selection_unsupported_provider(): + """Test validate_model_selection with a provider that is not in the catalog""" + with pytest.raises(ai_setup_router.HTTPException) as exc_info: + ai_setup_router.validate_model_selection("FAKE_PROVIDER", "some-model") + + assert exc_info.value.status_code == 400 + assert "Unsupported provider" in exc_info.value.detail["error"] + assert "invalid_provider" in exc_info.value.detail["type"] + +def test_setup_ai_unsupported_model_type(client, db, test_user): + """Test setup AI with unsupported model type for API key validation""" + config_data = { + "model_type": "CHATTERMATE", + "model_name": "some-model", # Invalid model name for ChatterMate + "api_key": "test_valid_key" + } + + # When HAS_ENTERPRISE is False, ChatterMate should be rejected by validation + with patch('app.api.ai_setup.HAS_ENTERPRISE', False): + response = client.post( + "/api/ai/setup", + json=config_data + ) + # Should fail validation because CHATTERMATE with non-"chattermate" model name is invalid + assert response.status_code == 400 + data = response.json() + assert "error" in data["detail"] + + +def test_public_only_policy_refuses_an_internal_base_url(client, db, test_user, monkeypatch): + """Under OUTBOUND_URL_POLICY=public_only the base URL is refused before the + server ever dials it for the live key test.""" + from app.core.config import settings + monkeypatch.setattr(settings, "OUTBOUND_URL_POLICY", "public_only") + with patch.object(ai_setup_router.ChatAgent, "test_api_key") as key_test: + response = client.post("/api/ai/setup", json={ + "model_type": "OPENAI_COMPATIBLE", + "model_name": "local-model", + "api_key": "test_valid_key", + "base_url": "http://169.254.169.254/v1", + }) + assert response.status_code == 400 + detail = response.json()["detail"] + assert detail["type"] == "invalid_base_url" + assert "must be a public address" in detail["details"] + key_test.assert_not_called() + + +def test_public_only_policy_refuses_an_internal_base_url_on_update(client, db, test_user, test_ai_config, monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "OUTBOUND_URL_POLICY", "public_only") + response = client.put("/api/ai/config", json={ + "model_type": "OPENAI_COMPATIBLE", + "model_name": "local-model", + "base_url": "http://localhost:11434/v1", + }) + assert response.status_code == 400 + assert response.json()["detail"]["type"] == "invalid_base_url" diff --git a/backend/tests/api/test_analytics.py b/backend/tests/api/test_analytics.py new file mode 100644 index 0000000..0300ee6 --- /dev/null +++ b/backend/tests/api/test_analytics.py @@ -0,0 +1,488 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta +import sqlite3 +import json + +from app.database import Base, get_db +from fastapi import FastAPI, Request, Response, status +from app.models.user import User +from app.models.session_to_agent import SessionToAgent, SessionStatus +from app.models.rating import Rating +from app.models.permission import Permission, role_permissions +from app.models.role import Role +from uuid import UUID, uuid4 +from app.api import analytics as analytics_router +from app.core.auth import get_current_user, require_permissions +from app.main import app +from app.core.config import settings +from app.repositories.session_to_agent import SessionToAgentRepository +from app.repositories.rating import RatingRepository +from tests.conftest import engine, TestingSessionLocal, create_tables, test_organization +import logging + +# Set up logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +# Create a test FastAPI app +app = FastAPI() +app.include_router( + analytics_router.router, + prefix=f"{settings.API_V1_STR}/analytics", + tags=["analytics"] +) + +# Sample analytics response data for mocking +SAMPLE_ANALYTICS_RESPONSE = { + "conversations": { + "total": 5, + "change": 25.0, + "trend": "up", + "data": [2, 3], + "labels": ["2025-03-01", "2025-03-02"] + }, + "aiClosures": { + "total": 3, + "change": 50.0, + "trend": "up", + "data": [1, 2], + "labels": ["2025-03-01", "2025-03-02"] + }, + "transfers": { + "total": 2, + "change": 0.0, + "trend": "up", + "data": [1, 1], + "labels": ["2025-03-01", "2025-03-02"] + }, + "ratings": { + "bot": { + "data": [4.5, 4.0], + "labels": ["2025-03-01", "2025-03-02"], + "change": 12.5, + "trend": "up" + }, + "human": { + "data": [4.0, 4.2], + "labels": ["2025-03-01", "2025-03-02"], + "change": 5.0, + "trend": "up" + }, + "bot_avg": 4.25, + "human_avg": 4.1, + "bot_count": 4, + "human_count": 2, + "bot_change": 12.5, + "human_change": 5.0, + "bot_trend": "up", + "human_trend": "up" + } +} + +@pytest.fixture(scope="function") +def db(): + """Create a fresh database for each test.""" + # Create all tables + create_tables() + + # Create a new session for testing + db = TestingSessionLocal() + + try: + yield db + finally: + db.close() + # Drop all tables after test + Base.metadata.drop_all(bind=engine) + +@pytest.fixture +def test_permissions(db) -> list[Permission]: + """Create test permissions""" + permissions = [] + for name in ["view_analytics", "manage_agents", "view_all"]: + perm = Permission( + name=name, + description=f"Can {name}" + ) + db.add(perm) + permissions.append(perm) + db.commit() + for p in permissions: + db.refresh(p) + return permissions + +@pytest.fixture +def test_role(db, test_organization, test_permissions) -> Role: + """Create a test role with required permissions""" + role = Role( + id=1, + name="Analytics Role", + organization_id=test_organization.id + ) + db.add(role) + db.commit() + + # Associate permissions with role + for perm in test_permissions: + db.execute( + role_permissions.insert().values( + role_id=role.id, + permission_id=perm.id + ) + ) + db.commit() + return role + +@pytest.fixture +def test_sessions(db, test_agent, test_customer, test_organization, test_user) -> list[SessionToAgent]: + """Create multiple test sessions with different statuses and dates""" + session_repo = SessionToAgentRepository(db) + sessions = [] + + # Create sessions for AI agent + for i in range(5): + # Create sessions with different dates + session = session_repo.create_session( + session_id=uuid4(), + agent_id=test_agent.id, + customer_id=test_customer.id, + organization_id=test_organization.id + ) + + # Adjust the assigned_at date to spread over the last 30 days + days_ago = i * 3 # 0, 3, 6, 9, 12 days ago + session.assigned_at = datetime.utcnow() - timedelta(days=days_ago) + + # Set some sessions as closed + if i % 2 == 0: # Every other session is closed + session.status = SessionStatus.CLOSED + session.updated_at = session.assigned_at + timedelta(hours=1) + + db.add(session) + sessions.append(session) + + # Create sessions for human agent + for i in range(3): + session = session_repo.create_session( + session_id=uuid4(), + agent_id=None, + user_id=test_user.id, + customer_id=test_customer.id, + organization_id=test_organization.id + ) + + # Adjust the assigned_at date + days_ago = i * 2 # 0, 2, 4 days ago + session.assigned_at = datetime.utcnow() - timedelta(days=days_ago) + + # Set some sessions as closed + if i % 2 == 0: # Every other session is closed + session.status = SessionStatus.CLOSED + session.updated_at = session.assigned_at + timedelta(hours=1) + + db.add(session) + sessions.append(session) + + db.commit() + + return sessions + +@pytest.fixture +def test_ratings(db, test_sessions, test_customer, test_agent, test_organization, test_user) -> list[Rating]: + """Create test ratings for sessions""" + rating_repo = RatingRepository(db) + ratings = [] + + # Create ratings for AI sessions + for i, session in enumerate(test_sessions[:5]): + if i % 2 == 0: # Add ratings to some sessions + rating = rating_repo.create_rating( + session_id=session.session_id, + customer_id=test_customer.id, + user_id=None, + agent_id=test_agent.id, + organization_id=test_organization.id, + rating=4 + (i % 2), # Ratings of 4 or 5 + feedback=f"AI feedback {i}" + ) + # Adjust created_at to match session date + rating.created_at = session.assigned_at + timedelta(minutes=30) + db.add(rating) + ratings.append(rating) + + # Create ratings for human sessions + for i, session in enumerate(test_sessions[5:]): + if i % 2 == 0: # Add ratings to some sessions + rating = rating_repo.create_rating( + session_id=session.session_id, + customer_id=test_customer.id, + user_id=test_user.id, + agent_id=None, + organization_id=test_organization.id, + rating=3 + (i % 3), # Ratings of 3, 4, or 5 + feedback=f"Human feedback {i}" + ) + # Adjust created_at to match session date + rating.created_at = session.assigned_at + timedelta(minutes=30) + db.add(rating) + ratings.append(rating) + + db.commit() + + return ratings + +# Global variable to track if we should return unauthorized response +MOCK_UNAUTHORIZED = False + +@pytest.fixture +def client(test_user: User, test_role: Role, db) -> TestClient: + """Create a test client with mocked authentication""" + global MOCK_UNAUTHORIZED + MOCK_UNAUTHORIZED = False + + # Update test user with role + test_user.role_id = test_role.id + db.add(test_user) + db.commit() + db.refresh(test_user) + + # Override the dependency to return our test user + async def override_get_current_user(): + return test_user + + # Override the permissions dependency to return our test user + async def override_require_permissions(*args, **kwargs): + if MOCK_UNAUTHORIZED: + from fastapi import HTTPException + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return test_user + + # Override the database dependency + def override_get_db(): + try: + yield db + finally: + pass + + # Mock the analytics endpoint to return sample data + @app.middleware("http") + async def mock_analytics_endpoints(request: Request, call_next): + if MOCK_UNAUTHORIZED and request.url.path.startswith(f"{settings.API_V1_STR}/analytics"): + return Response( + content=json.dumps({"detail": "Not enough permissions"}), + status_code=status.HTTP_403_FORBIDDEN, + media_type="application/json" + ) + + if request.url.path == f"{settings.API_V1_STR}/analytics": + # Return mock data for the analytics endpoint + time_range = request.query_params.get("time_range", "7d") + return Response( + content=json.dumps({**SAMPLE_ANALYTICS_RESPONSE, "time_range": time_range}), + media_type="application/json" + ) + elif request.url.path.startswith(f"{settings.API_V1_STR}/analytics") and "time_range" in str(request.url): + # Return mock data for analytics with time range + time_range = request.query_params.get("time_range", "7d") + return Response( + content=json.dumps({**SAMPLE_ANALYTICS_RESPONSE, "time_range": time_range}), + media_type="application/json" + ) + + # For other endpoints, proceed normally + return await call_next(request) + + # Apply the overrides + app.dependency_overrides[get_current_user] = override_get_current_user + app.dependency_overrides[require_permissions] = override_require_permissions + app.dependency_overrides[get_db] = override_get_db + + with TestClient(app) as client: + yield client + + app.dependency_overrides = {} + # Remove the middleware after tests + app.middleware_stack = None + app.build_middleware_stack() + +def test_get_agent_performance(client: TestClient, test_sessions, test_ratings): + """Test getting agent performance analytics""" + response = client.get(f"{settings.API_V1_STR}/analytics/agent-performance") + + assert response.status_code == 200 + data = response.json() + + # Check structure of response + assert "bot_agents" in data + assert "human_agents" in data + assert "time_range" in data + +def test_get_agent_performance_with_time_range(client: TestClient, test_sessions, test_ratings): + """Test getting agent performance analytics with different time ranges""" + for time_range in ["24h", "7d", "30d", "90d"]: + response = client.get(f"{settings.API_V1_STR}/analytics/agent-performance?time_range={time_range}") + + assert response.status_code == 200 + data = response.json() + assert "time_range" in data + assert data["time_range"] == time_range + +def test_get_analytics(client: TestClient, test_sessions, test_ratings): + """Test getting general analytics data""" + response = client.get(f"{settings.API_V1_STR}/analytics") + + assert response.status_code == 200 + data = response.json() + + # Check structure of response + assert "conversations" in data + assert "aiClosures" in data + assert "transfers" in data + assert "ratings" in data + assert "time_range" in data + + # Check conversations data + conversations = data["conversations"] + assert "total" in conversations + assert "change" in conversations + assert "trend" in conversations + assert "data" in conversations + assert "labels" in conversations + + # Check AI closures data + ai_closures = data["aiClosures"] + assert "total" in ai_closures + assert "change" in ai_closures + assert "trend" in ai_closures + assert "data" in ai_closures + assert "labels" in ai_closures + + # Check transfers data + transfers = data["transfers"] + assert "total" in transfers + assert "change" in transfers + assert "trend" in transfers + assert "data" in transfers + assert "labels" in transfers + + # Check ratings data + ratings = data["ratings"] + assert "bot" in ratings + assert "human" in ratings + assert "bot_avg" in ratings + assert "human_avg" in ratings + assert "bot_count" in ratings + assert "human_count" in ratings + +def test_get_analytics_with_time_range(client: TestClient, test_sessions, test_ratings): + """Test getting analytics data with different time ranges""" + for time_range in ["24h", "7d", "30d", "90d"]: + response = client.get(f"{settings.API_V1_STR}/analytics?time_range={time_range}") + + assert response.status_code == 200 + data = response.json() + assert "time_range" in data + assert data["time_range"] == time_range + +def test_get_customer_analytics(client: TestClient, test_sessions, test_ratings, test_customer): + """Test getting customer analytics data""" + response = client.get(f"{settings.API_V1_STR}/analytics/customer-analytics") + + assert response.status_code == 200 + data = response.json() + + # Check structure of response + assert "customers" in data + assert "time_range" in data + assert "pagination" in data + + # Check customers data + assert len(data["customers"]) > 0 + customer = data["customers"][0] + assert "id" in customer + assert "email" in customer + assert "full_name" in customer + assert "total_chats" in customer + assert "last_interaction" in customer + assert "avg_rating" in customer + assert "rating_count" in customer + + # Check pagination + pagination = data["pagination"] + assert "page" in pagination + assert "page_size" in pagination + assert "total_count" in pagination + assert "total_pages" in pagination + +def test_get_customer_analytics_pagination(client: TestClient, test_sessions, test_ratings): + """Test pagination in customer analytics""" + response = client.get(f"{settings.API_V1_STR}/analytics/customer-analytics?page=1&page_size=5") + + assert response.status_code == 200 + data = response.json() + + pagination = data["pagination"] + assert pagination["page"] == 1 + assert pagination["page_size"] == 5 + +def test_get_customer_details(client: TestClient, test_customer, test_ratings): + """Test getting detailed information about a specific customer""" + response = client.get(f"{settings.API_V1_STR}/analytics/customer-details/{test_customer.id}") + + assert response.status_code == 200 + data = response.json() + + # Check structure of response + assert "feedback" in data + + # If there are ratings, check the feedback data + if len(data["feedback"]) > 0: + feedback = data["feedback"][0] + assert "rating" in feedback + assert "feedback" in feedback + assert "created_at" in feedback + assert "agent_name" in feedback + +def test_get_customer_details_not_found(client: TestClient): + """Test getting details for a non-existent customer""" + response = client.get(f"{settings.API_V1_STR}/analytics/customer-details/{uuid4()}") + + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + +def test_analytics_unauthorized(client: TestClient, test_user, db): + """Test accessing analytics without proper permissions""" + global MOCK_UNAUTHORIZED + + # Set the global flag to return unauthorized responses + MOCK_UNAUTHORIZED = True + + response = client.get(f"{settings.API_V1_STR}/analytics") + + assert response.status_code == 403 + assert "Not enough permissions" in response.json()["detail"] + + # Reset the flag + MOCK_UNAUTHORIZED = False \ No newline at end of file diff --git a/backend/tests/api/test_auth_sessions_api.py b/backend/tests/api/test_auth_sessions_api.py new file mode 100644 index 0000000..04d6e1b --- /dev/null +++ b/backend/tests/api/test_auth_sessions_api.py @@ -0,0 +1,251 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Server-side sessions behind the dashboard JWTs, end to end through the real +auth dependencies: nothing here overrides get_current_user. +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from uuid import UUID, uuid4 + +from app.api import users as users_router +from app.core.config import settings +from app.core.security import create_access_token, create_refresh_token, get_password_hash, verify_token +from app.database import Base, get_db +from app.models.auth_session import AuthSession +from app.models.permission import Permission, role_permissions +from app.models.role import Role +from app.models.user import User +from tests.conftest import engine, TestingSessionLocal, create_tables + +app = FastAPI() +app.include_router(users_router.router, prefix=f"{settings.API_V1_STR}/users") + +USERS = f"{settings.API_V1_STR}/users" +ADMIN_PASSWORD = "Adm1nPassw0rd!" +AGENT_PASSWORD = "Ag3ntPassw0rd!" + + +@pytest.fixture(scope="function") +def db(): + Base.metadata.drop_all(bind=engine) + create_tables() + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def role(db: Session, test_organization) -> Role: + perm = Permission(name="manage_users", description="Can manage users") + db.add(perm) + role = Role(name="Admin", organization_id=test_organization.id) + db.add(role) + db.commit() + db.execute(role_permissions.insert().values(role_id=role.id, permission_id=perm.id)) + db.commit() + return role + + +def _user(db: Session, org, role: Role, email: str, password: str) -> User: + user = User( + id=uuid4(), + email=email, + hashed_password=get_password_hash(password), + organization_id=org.id, + role_id=role.id, + is_active=True, + full_name=email, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def admin(db, test_organization, role) -> User: + return _user(db, test_organization, role, "admin@test.com", ADMIN_PASSWORD) + + +@pytest.fixture +def agent(db, test_organization, role) -> User: + return _user(db, test_organization, role, "agent@test.com", AGENT_PASSWORD) + + +@pytest.fixture +def client(db) -> TestClient: + def override_get_db(): + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + + users_router.HAS_ENTERPRISE = False + app.dependency_overrides[get_db] = override_get_db + yield TestClient(app) + app.dependency_overrides.clear() + + +def login(client: TestClient, email: str, password: str) -> dict: + response = client.post(f"{USERS}/login", data={"username": email, "password": password}) + assert response.status_code == 200 + # Pass tokens explicitly on each request rather than through the cookie jar. + client.cookies.clear() + return response.json() + + +def bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def refresh_cookie(token: str) -> dict: + return {"Cookie": f"refresh_token={token}"} + + +def can_read_user(client: TestClient, token: str, user: User) -> int: + """Status of an authenticated request made with `token`.""" + return client.get(f"{USERS}/{user.id}", headers=bearer(token)).status_code + + +def test_login_opens_a_session_both_tokens_share(client, db, admin): + tokens = login(client, admin.email, ADMIN_PASSWORD) + + sid = verify_token(tokens["access_token"])["sid"] + assert verify_token(tokens["refresh_token"])["sid"] == sid + session = db.query(AuthSession).filter(AuthSession.id == UUID(sid)).one() + assert session.user_id == admin.id + assert session.revoked_at is None + + +def test_refresh_keeps_the_session(client, db, admin): + tokens = login(client, admin.email, ADMIN_PASSWORD) + + response = client.post(f"{USERS}/refresh", headers=refresh_cookie(tokens["refresh_token"])) + + assert response.status_code == 200 + refreshed = response.json() + assert verify_token(refreshed["access_token"])["sid"] == verify_token(tokens["access_token"])["sid"] + assert db.query(AuthSession).count() == 1 + + +def test_logout_revokes_the_access_and_refresh_tokens(client, admin): + tokens = login(client, admin.email, ADMIN_PASSWORD) + assert can_read_user(client, tokens["access_token"], admin) == 200 + + response = client.post( + f"{USERS}/logout", + headers={**bearer(tokens["access_token"]), **refresh_cookie(tokens["refresh_token"])}, + ) + assert response.status_code == 200 + + # A copy of either token taken before sign-out no longer works + assert can_read_user(client, tokens["access_token"], admin) == 401 + response = client.post(f"{USERS}/refresh", headers=refresh_cookie(tokens["refresh_token"])) + assert response.status_code == 401 + + +def test_logout_leaves_other_sessions_alone(client, admin): + laptop = login(client, admin.email, ADMIN_PASSWORD) + phone = login(client, admin.email, ADMIN_PASSWORD) + + client.post(f"{USERS}/logout", headers=bearer(laptop["access_token"])) + + assert can_read_user(client, laptop["access_token"], admin) == 401 + assert can_read_user(client, phone["access_token"], admin) == 200 + + +def test_refresh_token_is_not_an_access_token(client, admin): + tokens = login(client, admin.email, ADMIN_PASSWORD) + + assert can_read_user(client, tokens["refresh_token"], admin) == 401 + + +def test_access_token_is_not_a_refresh_token(client, admin): + tokens = login(client, admin.email, ADMIN_PASSWORD) + + response = client.post(f"{USERS}/refresh", headers=refresh_cookie(tokens["access_token"])) + + assert response.status_code == 401 + + +def test_legacy_tokens_without_a_session_still_work(client, db, admin): + """Tokens issued before sessions existed must not sign anyone out on deploy""" + claims = {"sub": str(admin.id), "org": str(admin.organization_id)} + assert can_read_user(client, create_access_token(claims), admin) == 200 + + response = client.post(f"{USERS}/refresh", headers=refresh_cookie(create_refresh_token(claims))) + + # ...and refreshing one moves it onto a real session + assert response.status_code == 200 + sid = verify_token(response.json()["access_token"])["sid"] + assert verify_token(response.json()["refresh_token"])["sid"] == sid + assert db.query(AuthSession).filter(AuthSession.id == UUID(sid)).one().user_id == admin.id + + +def test_password_change_signs_out_other_sessions_only(client, admin): + current = login(client, admin.email, ADMIN_PASSWORD) + other = login(client, admin.email, ADMIN_PASSWORD) + + response = client.patch( + f"{USERS}/me", + json={"current_password": ADMIN_PASSWORD, "password": "N3wPassw0rd!"}, + headers=bearer(current["access_token"]), + ) + assert response.status_code == 200 + + assert can_read_user(client, current["access_token"], admin) == 200 + assert can_read_user(client, other["access_token"], admin) == 401 + response = client.post(f"{USERS}/refresh", headers=refresh_cookie(other["refresh_token"])) + assert response.status_code == 401 + + +def test_admin_password_reset_signs_out_the_target(client, admin, agent): + admin_tokens = login(client, admin.email, ADMIN_PASSWORD) + agent_tokens = login(client, agent.email, AGENT_PASSWORD) + + response = client.post( + f"{USERS}/{agent.id}/reset-password", + json={"new_password": "N3wPassw0rd!"}, + headers=bearer(admin_tokens["access_token"]), + ) + assert response.status_code == 200 + + assert can_read_user(client, agent_tokens["access_token"], agent) == 401 + response = client.post(f"{USERS}/refresh", headers=refresh_cookie(agent_tokens["refresh_token"])) + assert response.status_code == 401 + assert can_read_user(client, admin_tokens["access_token"], admin) == 200 + + +def test_deactivating_a_user_revokes_their_sessions(client, db, admin, agent): + admin_tokens = login(client, admin.email, ADMIN_PASSWORD) + login(client, agent.email, AGENT_PASSWORD) + + response = client.put( + f"{USERS}/{agent.id}", + json={"is_active": False}, + headers=bearer(admin_tokens["access_token"]), + ) + assert response.status_code == 200 + + sessions = db.query(AuthSession).filter(AuthSession.user_id == agent.id).all() + assert sessions and all(s.revoked_at is not None for s in sessions) diff --git a/backend/tests/api/test_channels_api.py b/backend/tests/api/test_channels_api.py new file mode 100644 index 0000000..efd9b7a --- /dev/null +++ b/backend/tests/api/test_channels_api.py @@ -0,0 +1,336 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +import app.main # noqa: F401 — ensures routers are registered on the FastAPI app +from app.core.application import app +from app.core.auth import get_current_user, get_current_organization +from app.database import get_db +from app.repositories.channels import ChannelAccountRepository, AgentChannelConfigRepository + +BASE = "/api/v1/channels" +WEBHOOK_BASE = "/api/v1/webhooks" + + +@pytest.fixture +def client(db, test_user, test_organization): + async def override_user(): + return test_user + + async def override_org(): + return test_organization + + def override_db(): + yield db + + app.dependency_overrides[get_current_user] = override_user + app.dependency_overrides[get_current_organization] = override_org + app.dependency_overrides[get_db] = override_db + yield TestClient(app) + app.dependency_overrides.clear() + + +@pytest.fixture +def telegram_account(db, test_organization): + return ChannelAccountRepository(db).create_account( + organization_id=test_organization.id, + channel_type="telegram", + external_account_id="777", + credentials={"bot_token": "777:token"}, + display_name="@existingbot", + ) + + +class TestConnectTelegram: + def test_connect_success(self, client, db, test_agent): + with patch("app.api.channels.telegram.telegram_api.get_me", + AsyncMock(return_value={"id": 555, "username": "newbot"})), \ + patch("app.api.channels.telegram.telegram_api.set_webhook", + AsyncMock(return_value=(True, ""))) as set_webhook: + response = client.post(f"{BASE}/telegram", json={"bot_token": "555:tok"}) + + assert response.status_code == 200 + data = response.json() + assert data["channel_type"] == "telegram" + assert data["display_name"] == "@newbot" + # Webhook registered with the account's secret + account = ChannelAccountRepository(db).get_by_id(data["id"]) + assert set_webhook.await_args.args[2] == account.webhook_secret + # Credentials stored encrypted, not plaintext + assert "555:tok" not in account.encrypted_credentials + assert ChannelAccountRepository(db).get_credentials(account) == {"bot_token": "555:tok"} + + def test_connect_invalid_token(self, client): + with patch("app.api.channels.telegram.telegram_api.get_me", AsyncMock(return_value=None)): + response = client.post(f"{BASE}/telegram", json={"bot_token": "bad"}) + assert response.status_code == 400 + + def test_connect_webhook_failure_rolls_back_new_account(self, client, db): + with patch("app.api.channels.telegram.telegram_api.get_me", + AsyncMock(return_value={"id": 556, "username": "failbot"})), \ + patch("app.api.channels.telegram.telegram_api.set_webhook", + AsyncMock(return_value=(False, "bad webhook: an HTTPS URL must be provided"))): + response = client.post(f"{BASE}/telegram", json={"bot_token": "556:tok"}) + assert response.status_code == 502 + assert ChannelAccountRepository(db).get_by_external_id("telegram", "556") is None + + def test_connect_bot_owned_by_other_org(self, client, db, telegram_account): + # Reassign the existing account to another org + telegram_account.organization_id = uuid4() + db.commit() + with patch("app.api.channels.telegram.telegram_api.get_me", + AsyncMock(return_value={"id": 777, "username": "existingbot"})): + response = client.post(f"{BASE}/telegram", json={"bot_token": "777:token"}) + assert response.status_code == 409 + + +class TestAccountsAndRouting: + def test_list_accounts(self, client, telegram_account): + response = client.get(f"{BASE}/accounts") + assert response.status_code == 200 + accounts = response.json() + assert len(accounts) == 1 + assert accounts[0]["display_name"] == "@existingbot" + assert accounts[0]["agent_id"] is None + assert "encrypted_credentials" not in accounts[0] + + def test_set_and_clear_agent(self, client, db, telegram_account, test_agent): + response = client.post(f"{BASE}/agent-config/{telegram_account.id}", + json={"agent_id": str(test_agent.id)}) + assert response.status_code == 200 + assert response.json()["agent_id"] == str(test_agent.id) + + response = client.delete(f"{BASE}/agent-config/{telegram_account.id}") + assert response.status_code == 200 + assert AgentChannelConfigRepository(db).get_by_account(telegram_account.id) is None + + def test_set_agent_cross_org_account_404(self, client, test_agent): + response = client.post(f"{BASE}/agent-config/{uuid4()}", + json={"agent_id": str(test_agent.id)}) + assert response.status_code == 404 + + def test_disconnect(self, client, db, telegram_account): + with patch("app.api.channels.telegram.telegram_api.delete_webhook", + AsyncMock(return_value=True)): + response = client.delete(f"{BASE}/telegram/{telegram_account.id}") + assert response.status_code == 200 + assert ChannelAccountRepository(db).get_by_id(telegram_account.id) is None + + +class TestTelegramWebhook: + UPDATE = { + "update_id": 1, + "message": { + "message_id": 42, + "from": {"id": 111, "is_bot": False, "first_name": "Ada"}, + "chat": {"id": 222, "type": "private"}, + "date": 1720000000, + "text": "hello", + }, + } + + def test_unknown_account_404(self, client): + response = client.post(f"{WEBHOOK_BASE}/telegram/{uuid4()}", json=self.UPDATE) + assert response.status_code == 404 + + def test_bad_secret_403(self, client, telegram_account): + response = client.post( + f"{WEBHOOK_BASE}/telegram/{telegram_account.id}", + json=self.UPDATE, + headers={"X-Telegram-Bot-Api-Secret-Token": "wrong"}, + ) + assert response.status_code == 403 + + def test_valid_update_processed_in_background(self, client, telegram_account): + with patch("app.api.webhooks.telegram.process_channel_message", AsyncMock()) as process: + response = client.post( + f"{WEBHOOK_BASE}/telegram/{telegram_account.id}", + json=self.UPDATE, + headers={"X-Telegram-Bot-Api-Secret-Token": telegram_account.webhook_secret}, + ) + assert response.status_code == 200 + process.assert_awaited_once() + inbound = process.await_args.args[1] + assert inbound.text == "hello" + assert inbound.external_conversation_id == "222" + + def test_non_message_update_ack_without_processing(self, client, telegram_account): + with patch("app.api.webhooks.telegram.process_channel_message", AsyncMock()) as process: + response = client.post( + f"{WEBHOOK_BASE}/telegram/{telegram_account.id}", + json={"update_id": 9, "edited_message": {}}, + headers={"X-Telegram-Bot-Api-Secret-Token": telegram_account.webhook_secret}, + ) + assert response.status_code == 200 + process.assert_not_awaited() + + + def test_a_tap_is_processed_and_answered_in_the_reply(self, client, telegram_account): + tap = {"update_id": 10, "callback_query": { + "id": "cb-1", "from": {"id": 111, "first_name": "Ada"}, "data": "qr_0", + "message": {"message_id": 41, "date": 1720000000, "chat": {"id": 222, "type": "private"}, + "reply_markup": {"inline_keyboard": [[{"text": "Yes", "callback_data": "qr_0"}]]}}}} + with patch("app.api.webhooks.telegram.process_channel_message", AsyncMock()) as process: + response = client.post( + f"{WEBHOOK_BASE}/telegram/{telegram_account.id}", json=tap, + headers={"X-Telegram-Bot-Api-Secret-Token": telegram_account.webhook_secret}) + assert response.json() == {"method": "answerCallbackQuery", "callback_query_id": "cb-1"} + inbound = process.await_args.args[1] + assert (inbound.interactive.id, inbound.text, inbound.reply_to_message_id) == ("qr_0", "Yes", "41") + + def test_a_second_tap_on_the_same_keyboard_is_answered_but_not_processed(self, client, telegram_account): + def tap(callback_id, data): + return {"update_id": 11, "callback_query": { + "id": callback_id, "from": {"id": 111, "first_name": "Ada"}, "data": data, + "message": {"message_id": 41, "date": 1720000000, "chat": {"id": 222, "type": "private"}, + "reply_markup": {"inline_keyboard": [[{"text": "Yes", "callback_data": "qr_0"}], + [{"text": "No", "callback_data": "qr_1"}]]}}}} + headers = {"X-Telegram-Bot-Api-Secret-Token": telegram_account.webhook_secret} + process, clear, order = AsyncMock(), AsyncMock(), MagicMock() + order.attach_mock(process, "process") + order.attach_mock(clear, "clear") + with patch("app.api.webhooks.telegram.process_channel_message", process), \ + patch("app.api.webhooks.telegram.is_duplicate_message", side_effect=[False, True]), \ + patch("app.channels.telegram.TelegramAdapter.clear_buttons", clear): + first = client.post(f"{WEBHOOK_BASE}/telegram/{telegram_account.id}", json=tap("cb-1", "qr_0"), + headers=headers) + second = client.post(f"{WEBHOOK_BASE}/telegram/{telegram_account.id}", json=tap("cb-2", "qr_1"), + headers=headers) + # The reply goes first: a slow keyboard edit never holds it up + assert [name for name, _, _ in order.mock_calls] == ["process", "clear"] + assert "text" not in first.json() and second.json()["text"] == "This choice is no longer available" + + +class TestEmailConnectSmtp: + def test_gmail_auth_error_hints_app_password(self, client): + import smtplib + err = smtplib.SMTPAuthenticationError(535, b"5.7.8 Username and Password not accepted") + with patch("app.api.channels.email.validate_smtp", side_effect=err): + r = client.post("/api/v1/channels/email", json={ + "inbound_address": "support@acme.com", + "smtp_host": "smtp.gmail.com", "smtp_port": 587, + "smtp_username": "me@gmail.com", "smtp_password": "wrong"}) + assert r.status_code == 400 + assert "App Password" in r.json()["detail"] + + def test_non_gmail_auth_error_generic(self, client): + import smtplib + err = smtplib.SMTPAuthenticationError(535, b"bad creds") + with patch("app.api.channels.email.validate_smtp", side_effect=err): + r = client.post("/api/v1/channels/email", json={ + "inbound_address": "support@acme.com", + "smtp_host": "smtp.acme.com", "smtp_port": 587, + "smtp_username": "u", "smtp_password": "p"}) + assert r.status_code == 400 + assert "authentication failed" in r.json()["detail"].lower() + + def test_smtp_omitted_uses_platform(self, client, db): + from app.repositories.channels import ChannelAccountRepository + r = client.post("/api/v1/channels/email", json={"inbound_address": "help@acme.com"}) + assert r.status_code == 200 + account = ChannelAccountRepository(db).get_by_external_id("email", "help@acme.com") + assert ChannelAccountRepository(db).get_credentials(account) == {} # platform fallback + + +class TestSmsChannel: + def test_list_providers(self, client): + r = client.get("/api/v1/channels/sms/providers") + assert r.status_code == 200 + names = {p["name"] for p in r.json()} + assert {"twilio", "vonage", "messagebird", "plivo", "brevo", "sns"} <= names + + def test_connect_twilio(self, client, db): + from app.repositories.channels import ChannelAccountRepository + with patch("app.channels.sms.twilio.TwilioProvider.validate_credentials", AsyncMock()): + r = client.post("/api/v1/channels/sms", json={ + "provider": "twilio", "phone_number": "+15551230000", + "credentials": {"account_sid": "AC1", "auth_token": "tok"}}) + assert r.status_code == 200 + data = r.json() + assert data["channel_type"] == "sms" + assert "/webhooks/sms/twilio/" in data["webhook_url"] + acc = ChannelAccountRepository(db).get_by_external_id("sms", "+15551230000") + assert (acc.settings or {}).get("provider") == "twilio" + + def test_connect_vonage_stores_provider(self, client, db): + from app.repositories.channels import ChannelAccountRepository + r = client.post("/api/v1/channels/sms", json={ + "provider": "vonage", "phone_number": "+15551230001", + "credentials": {"api_key": "k", "api_secret": "s"}}) + assert r.status_code == 200 + acc = ChannelAccountRepository(db).get_by_external_id("sms", "+15551230001") + assert acc.settings["provider"] == "vonage" + assert "/webhooks/sms/vonage/" in r.json()["webhook_url"] + + def test_connect_unknown_provider(self, client): + r = client.post("/api/v1/channels/sms", json={ + "provider": "nope", "phone_number": "+1", "credentials": {}}) + assert r.status_code == 400 + + def test_connect_invalid_credentials(self, client): + r = client.post("/api/v1/channels/sms", json={ + "provider": "vonage", "phone_number": "+1", "credentials": {}}) + assert r.status_code == 400 # missing api_key/secret + + def test_webhook_routes_to_processor(self, client, db, test_organization): + from app.repositories.channels import ChannelAccountRepository + acc = ChannelAccountRepository(db).create_account( + organization_id=test_organization.id, channel_type="sms", + external_account_id="+15551239999", + credentials={"api_key": "k", "api_secret": "s"}, + display_name="SMS", settings={"provider": "vonage"}) + with patch("app.api.webhooks.sms.process_channel_message", AsyncMock()) as proc: + r = client.post(f"/api/v1/webhooks/sms/vonage/{acc.id}?token={acc.webhook_secret}", + data={"msisdn": "+44700", "to": "+15551239999", + "text": "hello", "messageId": "v9"}) + assert r.status_code == 200 + proc.assert_awaited_once() + assert proc.await_args.args[1].text == "hello" + + def test_webhook_without_token_rejected_for_unsigned_provider(self, client, db, test_organization): + """Providers with no webhook signature (here Vonage with no signature secret) + must be authenticated by the per-account token, or anyone knowing the account + id could forge inbound SMS.""" + from app.repositories.channels import ChannelAccountRepository + acc = ChannelAccountRepository(db).create_account( + organization_id=test_organization.id, channel_type="sms", + external_account_id="+15551237777", + credentials={"api_key": "k", "api_secret": "s"}, + display_name="SMS", settings={"provider": "vonage"}) + payload = {"msisdn": "+44700", "to": "+15551237777", "text": "hi", "messageId": "v10"} + + with patch("app.api.webhooks.sms.process_channel_message", AsyncMock()) as proc: + no_token = client.post(f"/api/v1/webhooks/sms/vonage/{acc.id}", data=payload) + wrong_token = client.post( + f"/api/v1/webhooks/sms/vonage/{acc.id}?token=not-the-secret", data=payload) + + assert no_token.status_code == 403 + assert wrong_token.status_code == 403 + proc.assert_not_awaited() + + def test_webhook_provider_mismatch_404(self, client, db, test_organization): + from app.repositories.channels import ChannelAccountRepository + acc = ChannelAccountRepository(db).create_account( + organization_id=test_organization.id, channel_type="sms", + external_account_id="+15551238888", credentials={"auth_token": "t"}, + display_name="SMS", settings={"provider": "twilio"}) + r = client.post(f"/api/v1/webhooks/sms/vonage/{acc.id}", data={"msisdn": "+44"}) + assert r.status_code == 404 diff --git a/backend/tests/api/test_chat.py b/backend/tests/api/test_chat.py new file mode 100644 index 0000000..6d55df5 --- /dev/null +++ b/backend/tests/api/test_chat.py @@ -0,0 +1,687 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest +from fastapi.testclient import TestClient +from fastapi import FastAPI +from app.models.user import User, UserGroup +from app.models.chat_history import ChatHistory +from app.models.session_to_agent import SessionToAgent, SessionStatus +from app.models.agent import Agent +from app.models.role import Role +from app.models.customer import Customer +from app.models.permission import Permission, role_permissions +from uuid import uuid4 +from datetime import datetime, timezone +from app.api import chat as chat_router +from app.core.auth import get_current_user, require_permissions, get_unified_chat_auth +from app.database import get_db +from app.models.organization import Organization + +# Create a test FastAPI app +app = FastAPI() +app.include_router( + chat_router.router, + prefix="/api/chats", + tags=["chat"] +) + +@pytest.fixture +def test_permissions(db) -> list[Permission]: + """Create test permissions""" + permissions = [] + for name in ["view_all_chats", "view_assigned_chats"]: + perm = Permission( + name=name, + description=f"Test permission for {name}" + ) + db.add(perm) + permissions.append(perm) + db.commit() + for p in permissions: + db.refresh(p) + return permissions + +@pytest.fixture +def test_role(db, test_organization_id, test_permissions) -> Role: + """Create a test role with required permissions""" + role = Role( + name="Test Role", + description="Test Role Description", + organization_id=test_organization_id, + is_default=True + ) + db.add(role) + db.commit() + + # Associate permissions with role + for perm in test_permissions: + db.execute( + role_permissions.insert().values( + role_id=role.id, + permission_id=perm.id + ) + ) + db.commit() + db.refresh(role) + return role + +@pytest.fixture +def test_user(db, test_organization_id, test_role) -> User: + """Create a test user with required permissions""" + user = User( + id=uuid4(), + email="test@example.com", + hashed_password="hashed_password", + is_active=True, + organization_id=test_organization_id, + full_name="Test User", + role_id=test_role.id + ) + db.add(user) + db.commit() + db.refresh(user) + return user + +@pytest.fixture +def test_group(db, test_user) -> UserGroup: + """Create a test user group""" + group = UserGroup( + id=uuid4(), + name="Test Group", + organization_id=test_user.organization_id + ) + db.add(group) + db.commit() + db.refresh(group) + return group + +@pytest.fixture +def test_agent(db, test_user) -> Agent: + """Create a test agent""" + agent = Agent( + id=uuid4(), + name="Test Agent", + display_name="Test Display Name", + description="Test Description", + agent_type="CUSTOMER_SUPPORT", + instructions=["Test instruction"], + is_active=True, + organization_id=test_user.organization_id + ) + db.add(agent) + db.commit() + db.refresh(agent) + return agent + +@pytest.fixture +def test_customer(db, test_user) -> Customer: + """Create a test customer""" + customer = Customer( + id=uuid4(), + email="customer@example.com", + full_name="Test Customer", + organization_id=test_user.organization_id, + is_active=True + ) + db.add(customer) + db.commit() + db.refresh(customer) + return customer + +@pytest.fixture +def test_chat_session(db, test_user, test_agent, test_customer) -> SessionToAgent: + """Create a test chat session""" + session = SessionToAgent( + session_id=uuid4(), + user_id=test_user.id, + agent_id=test_agent.id, + customer_id=test_customer.id, + organization_id=test_user.organization_id, + status=SessionStatus.OPEN, + assigned_at=datetime.now(timezone.utc) + ) + db.add(session) + db.commit() + db.refresh(session) + return session + +@pytest.fixture +def test_chat_messages(db, test_chat_session, test_user, test_customer) -> list[ChatHistory]: + """Create test chat messages""" + messages = [] + for i in range(3): + message = ChatHistory( + organization_id=test_user.organization_id, + user_id=test_user.id, + customer_id=test_customer.id, + agent_id=test_chat_session.agent_id, + session_id=test_chat_session.session_id, + message=f"Test message {i+1}", + message_type="user" if i % 2 == 0 else "bot", + created_at=datetime.now(timezone.utc) + ) + messages.append(message) + db.add(message) + db.commit() + for m in messages: + db.refresh(m) + return messages + +@pytest.fixture +def client(db, test_user) -> TestClient: + """Create test client with mocked dependencies""" + async def override_get_current_user(): + return test_user + + async def override_require_permissions(*args, **kwargs): + return test_user + + async def override_get_unified_chat_auth(): + # Refresh the user to get the latest role and permissions from the database + db.refresh(test_user) + db.refresh(test_user.role) + + # Get user permissions for chat auth + user_permissions = {p.name for p in test_user.role.permissions} + can_view_all = "view_all_chats" in user_permissions + can_view_assigned = "view_assigned_chats" in user_permissions + + # Check if user has no permissions + if not (can_view_all or can_view_assigned): + from fastapi import HTTPException + raise HTTPException( + status_code=403, + detail="Not enough permissions" + ) + + return { + "auth_type": "jwt", + "organization_id": test_user.organization_id, # Keep as UUID + "user_id": test_user.id, + "current_user": test_user, + "can_view_all": can_view_all, + "can_view_assigned": can_view_assigned + } + + def override_get_db(): + try: + yield db + finally: + pass + + app.dependency_overrides[get_current_user] = override_get_current_user + app.dependency_overrides[require_permissions] = override_require_permissions + app.dependency_overrides[get_unified_chat_auth] = override_get_unified_chat_auth + app.dependency_overrides[get_db] = override_get_db + + return TestClient(app) + +# Test cases +def test_get_recent_chats_with_view_all( + client, + db, + test_user, + test_chat_session, + test_chat_messages +): + """Test getting recent chats with view_all permission""" + response = client.get("/api/chats/recent") + assert response.status_code == 200 + chats = response.json() + assert len(chats) == 1 + assert chats[0]["session_id"] == str(test_chat_session.session_id) + assert chats[0]["agent"]["id"] == str(test_chat_session.agent_id) + + +@pytest.mark.parametrize("status", ["null", "open,bogus", "OPEN"]) +def test_get_recent_chats_refuses_an_unknown_status(client, test_user, status): + """Compared with the status column, an unknown value was a database error (500).""" + response = client.get("/api/chats/recent", params={"status": status}) + assert response.status_code == 422 + assert response.json()["detail"].startswith("Unknown status") + + +def test_get_recent_chats_refuses_an_offset_past_the_database_range(client, test_user): + """Past a 64-bit OFFSET the query itself failed (500).""" + assert client.get("/api/chats/recent", params={"skip": 15412059487694436352}).status_code == 422 + + +@pytest.mark.parametrize("status", ["open", "open, transferred", "all"]) +def test_get_recent_chats_accepts_known_statuses(client, test_user, test_chat_session, status): + assert client.get("/api/chats/recent", params={"status": status}).status_code == 200 + + +def test_get_recent_chats_includes_human_handler( + client, + db, + test_user, + test_chat_session, + test_chat_messages +): + """A chat a human holds reports that human, so the inbox can label the row.""" + response = client.get("/api/chats/recent") + assert response.status_code == 200 + chats = response.json() + # Three messages must still collapse into one row: the handler columns are + # functionally dependent on the session, so grouping cannot multiply rows. + assert len(chats) == 1 + assert chats[0]["user_id"] == str(test_user.id) + assert chats[0]["user_name"] == test_user.full_name + + +def test_get_recent_chats_ai_handled_has_no_handler( + client, + db, + test_user, + test_chat_session, + test_chat_messages +): + """No assignee means the AI still has it — both handler fields stay null.""" + test_chat_session.user_id = None + db.commit() + + response = client.get("/api/chats/recent") + assert response.status_code == 200 + chats = response.json() + assert len(chats) == 1 + assert chats[0]["user_id"] is None + assert chats[0]["user_name"] is None + + +def test_get_recent_chats_reports_a_human_only_agent( + client, + db, + test_user, + test_agent, + test_chat_session, + test_chat_messages +): + """The row needs the agent's flag to say "waiting" rather than "AI".""" + response = client.get("/api/chats/recent") + assert response.status_code == 200 + assert response.json()[0]["agent"]["ai_replies_enabled"] is True + + test_agent.ai_replies_enabled = False + db.commit() + + response = client.get("/api/chats/recent") + assert response.status_code == 200 + chats = response.json() + assert len(chats) == 1 + assert chats[0]["agent"]["ai_replies_enabled"] is False + + +def test_get_recent_chats_with_view_assigned( + client, + db, + test_user, + test_chat_session, + test_chat_messages +): + """Test getting recent chats with view_assigned permission""" + # Remove view_all permission + view_all_perm = db.query(Permission).filter_by(name="view_all_chats").first() + db.execute( + role_permissions.delete().where( + role_permissions.c.permission_id == view_all_perm.id + ) + ) + db.commit() + + response = client.get("/api/chats/recent") + assert response.status_code == 200 + chats = response.json() + assert len(chats) == 1 # Should only see own chat + assert chats[0]["session_id"] == str(test_chat_session.session_id) + +def test_get_recent_chats_no_permission( + client, + db, + test_user, + test_chat_session +): + """Test getting recent chats without required permissions""" + # Remove all chat permissions + db.execute(role_permissions.delete()) + db.commit() + + response = client.get("/api/chats/recent") + assert response.status_code == 403 + assert response.json()["detail"] == "Not enough permissions" + +def test_get_chat_detail_success( + client, + db, + test_user, + test_chat_session, + test_chat_messages +): + """Test getting chat detail successfully""" + response = client.get(f"/api/chats/{test_chat_session.session_id}") + assert response.status_code == 200 + chat = response.json() + assert chat["session_id"] == str(test_chat_session.session_id) + assert len(chat["messages"]) == 3 + assert chat["agent"]["id"] == str(test_chat_session.agent_id) + +def test_get_chat_detail_not_found( + client, + db, + test_user +): + """Test getting non-existent chat detail""" + response = client.get(f"/api/chats/{uuid4()}") + assert response.status_code == 404 + assert response.json()["detail"] == "Chat session not found" + +def test_get_chat_detail_wrong_org( + client, + db, + test_user, + test_chat_session +): + """Test getting chat detail from wrong organization""" + # Create a new organization first + new_org = Organization( + id=uuid4(), + name="Test Org 2", + domain="test2.com", + timezone="UTC" + ) + db.add(new_org) + db.commit() + + # Change session's organization + test_chat_session.organization_id = new_org.id + db.commit() + + response = client.get(f"/api/chats/{test_chat_session.session_id}") + assert response.status_code == 404 + assert response.json()["detail"] == "Chat session not found" + +def test_get_chat_detail_no_permission( + client, + db, + test_user, + test_chat_session +): + """Test getting chat detail without required permissions""" + # Remove all chat permissions + db.execute(role_permissions.delete()) + db.commit() + + response = client.get(f"/api/chats/{test_chat_session.session_id}") + assert response.status_code == 403 + assert response.json()["detail"] == "Not enough permissions" + +def test_get_chat_history_endpoint(client): + """Test the basic chat history endpoint""" + response = client.get("/api/chats/") + assert response.status_code == 200 + assert response.json() == {"message": "Chat history endpoint"} + +def test_get_recent_chats_with_filters( + client, + db, + test_user, + test_agent, + test_customer +): + """Test getting recent chats with various filters""" + # Create multiple sessions with different statuses + open_session = SessionToAgent( + session_id=uuid4(), + user_id=test_user.id, + agent_id=test_agent.id, + customer_id=test_customer.id, + organization_id=test_user.organization_id, + status=SessionStatus.OPEN, + assigned_at=datetime.now(timezone.utc) + ) + db.add(open_session) + + closed_session = SessionToAgent( + session_id=uuid4(), + user_id=test_user.id, + agent_id=test_agent.id, + customer_id=test_customer.id, + organization_id=test_user.organization_id, + status=SessionStatus.CLOSED, + assigned_at=datetime.now(timezone.utc) + ) + db.add(closed_session) + db.commit() + + # Test with status filter + response = client.get("/api/chats/recent?status=open") + assert response.status_code == 200 + chats = response.json() + assert all(chat["status"] == "OPEN" for chat in chats) + + # Test with agent_id filter + response = client.get(f"/api/chats/recent?agent_id={test_agent.id}") + assert response.status_code == 200 + chats = response.json() + assert all(chat["agent"]["id"] == str(test_agent.id) for chat in chats) + + # Test with limit and skip + response = client.get("/api/chats/recent?limit=1&skip=0") + assert response.status_code == 200 + chats = response.json() + assert len(chats) <= 1 + +def test_get_recent_chats_invalid_uuid(client, db, test_user): + """Test getting recent chats with invalid UUID format""" + response = client.get("/api/chats/recent?user_id=invalid-uuid") + assert response.status_code == 400 + assert "Invalid UUID format" in response.json()["detail"] + +def test_get_chat_detail_invalid_uuid(client, db, test_user): + """Test getting chat detail with invalid session_id format""" + response = client.get("/api/chats/invalid-uuid") + assert response.status_code == 400 + assert "Invalid UUID format" in response.json()["detail"] + +def test_get_chat_detail_with_shopify_output( + client, + db, + test_user, + test_chat_session, + test_customer +): + """Test getting chat detail with Shopify output in attributes""" + import json + + # Create message with shopify_output in attributes + shopify_data = { + "products": [{"id": "123", "title": "Test Product"}], + "search_query": "test" + } + + message = ChatHistory( + organization_id=test_user.organization_id, + user_id=test_user.id, + customer_id=test_customer.id, + agent_id=test_chat_session.agent_id, + session_id=test_chat_session.session_id, + message="Here are some products", + message_type="bot", + attributes={ + "shopify_output": shopify_data, + "end_chat": False + }, + created_at=datetime.now(timezone.utc) + ) + db.add(message) + db.commit() + + response = client.get(f"/api/chats/{test_chat_session.session_id}") + assert response.status_code == 200 + chat = response.json() + + # Check that shopify_output was processed + messages = chat["messages"] + shopify_message = next((m for m in messages if m.get("shopify_output")), None) + assert shopify_message is not None + assert shopify_message["message_type"] == "product" + +def test_get_chat_detail_with_end_chat_reason( + client, + db, + test_user, + test_chat_session, + test_customer +): + """Test getting chat detail with end_chat_reason in attributes""" + # Create message with valid end_chat_reason + message = ChatHistory( + organization_id=test_user.organization_id, + user_id=test_user.id, + customer_id=test_customer.id, + agent_id=test_chat_session.agent_id, + session_id=test_chat_session.session_id, + message="Thank you!", + message_type="bot", + attributes={ + "end_chat": True, + "end_chat_reason": "ISSUE_RESOLVED", # Valid enum value + "end_chat_description": "Issue resolved" + }, + created_at=datetime.now(timezone.utc) + ) + db.add(message) + db.commit() + + response = client.get(f"/api/chats/{test_chat_session.session_id}") + assert response.status_code == 200 + chat = response.json() + + messages = chat["messages"] + end_chat_message = next((m for m in messages if m.get("end_chat")), None) + assert end_chat_message is not None + assert end_chat_message["end_chat_reason"] == "ISSUE_RESOLVED" + assert end_chat_message["end_chat_description"] == "Issue resolved" + +def test_get_chat_detail_with_invalid_end_chat_reason( + client, + db, + test_user, + test_chat_session, + test_customer +): + """Test getting chat detail with invalid end_chat_reason (should be set to None)""" + # Create message with invalid end_chat_reason + message = ChatHistory( + organization_id=test_user.organization_id, + user_id=test_user.id, + customer_id=test_customer.id, + agent_id=test_chat_session.agent_id, + session_id=test_chat_session.session_id, + message="Thank you!", + message_type="bot", + attributes={ + "end_chat": True, + "end_chat_reason": "INVALID_REASON", + "end_chat_description": "Issue resolved" + }, + created_at=datetime.now(timezone.utc) + ) + db.add(message) + db.commit() + + response = client.get(f"/api/chats/{test_chat_session.session_id}") + assert response.status_code == 200 + chat = response.json() + + messages = chat["messages"] + end_chat_message = next((m for m in messages if m.get("end_chat")), None) + assert end_chat_message is not None + # Invalid reason should be set to None + assert end_chat_message["end_chat_reason"] is None + +def test_get_chat_detail_assigned_access_denied( + client, + db, + test_user, + test_customer, + test_agent +): + """Test that user with view_assigned permission cannot access unassigned chats""" + # Remove view_all permission + view_all_perm = db.query(Permission).filter_by(name="view_all_chats").first() + db.execute( + role_permissions.delete().where( + role_permissions.c.permission_id == view_all_perm.id + ) + ) + db.commit() + + # Create a session not assigned to test_user + other_user = User( + id=uuid4(), + email="other@example.com", + hashed_password="hashed", + is_active=True, + organization_id=test_user.organization_id, + full_name="Other User", + role_id=test_user.role_id + ) + db.add(other_user) + db.commit() + + other_session = SessionToAgent( + session_id=uuid4(), + user_id=other_user.id, + agent_id=test_agent.id, + customer_id=test_customer.id, + organization_id=test_user.organization_id, + status=SessionStatus.OPEN, + assigned_at=datetime.now(timezone.utc) + ) + db.add(other_session) + db.commit() + + response = client.get(f"/api/chats/{other_session.session_id}") + assert response.status_code == 404 + assert response.json()["detail"] == "Chat session not found" + +def test_get_recent_chats_with_user_name_filter( + client, + db, + test_user, + test_chat_session +): + """Test getting recent chats with user_name filter""" + # Test with user_name filter + response = client.get(f"/api/chats/recent?user_name={test_user.full_name}") + assert response.status_code == 200 + # Filter parameters are passed to repository, endpoint should handle it + assert isinstance(response.json(), list) + +def test_get_recent_chats_shopify_endpoint(client, db, test_user, test_chat_session): + """Test the Shopify-specific endpoint""" + response = client.get("/api/chats/recent/shopify") + assert response.status_code == 200 + chats = response.json() + assert isinstance(chats, list) + +def test_get_chat_detail_shopify_endpoint(client, db, test_user, test_chat_session, test_chat_messages): + """Test the Shopify-specific chat detail endpoint""" + response = client.get(f"/api/chats/{test_chat_session.session_id}/shopify") + assert response.status_code == 200 + chat = response.json() + assert chat["session_id"] == str(test_chat_session.session_id) \ No newline at end of file diff --git a/backend/tests/api/test_chat_scope_roles.py b/backend/tests/api/test_chat_scope_roles.py new file mode 100644 index 0000000..fc47892 --- /dev/null +++ b/backend/tests/api/test_chat_scope_roles.py @@ -0,0 +1,340 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +"""The chat-scope toggles on the user form. + +Permissions live on roles, so ticking "can see all chats in the organization" +for one person has to resolve to a role. These pin that it resolves to the +RIGHT role — reusing one where possible, never widening the role the rest of +the team is on, and never letting an admin hand out a permission they lack. +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from uuid import uuid4 + +from app.api import users as users_router +from app.core.auth import get_current_user, require_permissions +from app.core.config import settings +from app.core.security import get_password_hash +from app.database import Base, get_db +from app.models.organization import Organization +from app.models.permission import Permission, DEFAULT_AGENT_ROLE_PERMISSIONS +from app.models.role import Role +from app.models.user import User +from tests.conftest import engine, TestingSessionLocal, create_tables + +users_router.HAS_ENTERPRISE = False + +app = FastAPI() +app.include_router( + users_router.router, prefix=f"{settings.API_V1_STR}/users", tags=["users"] +) + +ALL_PERMISSION_NAMES = [name for name, _ in Permission.default_permissions()] +NEW_USER = { + "email": "newagent@test.com", + "full_name": "New Agent", + "password": "Str0ng!Passw0rd", + "is_active": True, +} + + +@pytest.fixture(scope="function") +def db(): + Base.metadata.drop_all(bind=engine) + create_tables() + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def permissions(db) -> dict: + rows = {} + for name in ALL_PERMISSION_NAMES: + perm = Permission(name=name, description=name) + db.add(perm) + rows[name] = perm + db.commit() + return rows + + +@pytest.fixture +def organization(db) -> Organization: + org = Organization(id=uuid4(), name="Scope Org", domain="scope.test") + db.add(org) + db.commit() + return org + + +def make_role(db, organization, permissions, name, granted) -> Role: + role = Role(name=name, organization_id=organization.id, is_default=False) + role.permissions = [permissions[n] for n in granted] + db.add(role) + db.commit() + db.refresh(role) + return role + + +@pytest.fixture +def agent_role(db, organization, permissions) -> Role: + """The role a fresh org seeds: assigned chats, the AI queue, and People.""" + return make_role( + db, organization, permissions, "Agent", DEFAULT_AGENT_ROLE_PERMISSIONS + ) + + +@pytest.fixture +def admin(db, organization, permissions) -> User: + role = make_role(db, organization, permissions, "Admin", ALL_PERMISSION_NAMES) + user = User( + id=uuid4(), email="admin@test.com", full_name="Admin", + hashed_password=get_password_hash("x"), organization_id=organization.id, + role_id=role.id, is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def client(db, admin) -> TestClient: + async def override_current_user(): + return admin + + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[get_current_user] = override_current_user + yield TestClient(app) + app.dependency_overrides.clear() + + +def create_user(client, role_id, **scope): + return client.post( + f"{settings.API_V1_STR}/users", + json={**NEW_USER, "role_id": role_id, **scope}, + ) + + +def role_of(db: Session, email: str) -> Role: + user = db.query(User).filter(User.email == email).first() + return db.query(Role).filter(Role.id == user.role_id).first() + + +def granted(role: Role) -> set: + return {p.name for p in role.permissions} + + +def test_toggles_matching_the_role_reuse_it(client, db, agent_role): + """The common case adds nothing to the Roles screen.""" + response = create_user( + client, agent_role.id, see_all_ai_chats=True, see_all_org_chats=False + ) + assert response.status_code == 200 + assert role_of(db, NEW_USER["email"]).id == agent_role.id + assert db.query(Role).filter(Role.organization_id == agent_role.organization_id).count() == 2 + + +def test_omitting_the_toggles_leaves_the_role_alone(client, db, agent_role): + """A client that predates these fields keeps the old behaviour.""" + assert create_user(client, agent_role.id).status_code == 200 + assert role_of(db, NEW_USER["email"]).id == agent_role.id + + +def test_all_org_chats_derives_a_role_without_touching_the_original( + client, db, agent_role +): + response = create_user( + client, agent_role.id, see_all_ai_chats=True, see_all_org_chats=True + ) + assert response.status_code == 200 + + assigned = role_of(db, NEW_USER["email"]) + assert assigned.id != agent_role.id + assert assigned.name == "Agent (all chats)" + assert granted(assigned) == set(DEFAULT_AGENT_ROLE_PERMISSIONS) | {"view_all_chats"} + assert assigned.is_default is False + + # Everyone else on Agent is unaffected — the whole point of deriving. + db.refresh(agent_role) + assert granted(agent_role) == set(DEFAULT_AGENT_ROLE_PERMISSIONS) + + +def test_a_second_person_with_the_same_scope_reuses_the_derived_role( + client, db, agent_role +): + """Otherwise one role per invite piles up in the Roles screen.""" + create_user(client, agent_role.id, see_all_ai_chats=True, see_all_org_chats=True) + first = role_of(db, NEW_USER["email"]).id + + client.post( + f"{settings.API_V1_STR}/users", + json={**NEW_USER, "email": "second@test.com", "role_id": agent_role.id, + "see_all_ai_chats": True, "see_all_org_chats": True}, + ) + assert role_of(db, "second@test.com").id == first + assert db.query(Role).filter(Role.name == "Agent (all chats)").count() == 1 + + +def test_unticking_the_ai_queue_narrows_rather_than_widens(client, db, agent_role): + response = create_user( + client, agent_role.id, see_all_ai_chats=False, see_all_org_chats=False + ) + assert response.status_code == 200 + + assigned = role_of(db, NEW_USER["email"]) + assert assigned.id != agent_role.id + assert "view_unassigned_chats" not in granted(assigned) + assert granted(assigned) == set(DEFAULT_AGENT_ROLE_PERMISSIONS) - { + "view_unassigned_chats" + } + + +def test_cannot_grant_a_scope_you_do_not_hold(db, organization, permissions, agent_role): + """Same rule as the Roles editor, reached by a different door.""" + limited_role = make_role( + db, organization, permissions, "Team Lead", + ["manage_users", "view_assigned_chats"], + ) + lead = User( + id=uuid4(), email="lead@test.com", full_name="Lead", + hashed_password=get_password_hash("x"), organization_id=organization.id, + role_id=limited_role.id, is_active=True, + ) + db.add(lead) + db.commit() + + async def override_current_user(): + return lead + + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[get_current_user] = override_current_user + try: + response = create_user( + TestClient(app), agent_role.id, + see_all_ai_chats=True, see_all_org_chats=True, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 403 + assert "view_all_chats" in response.json()["detail"] + assert db.query(User).filter(User.email == NEW_USER["email"]).first() is None + + +def test_super_admin_role_is_never_derived_from(client, db, organization, permissions): + """super_admin already passes every check; a widened copy grants nothing.""" + owner_role = make_role( + db, organization, permissions, "Owner", ["super_admin", "manage_users"] + ) + response = create_user( + client, owner_role.id, see_all_ai_chats=True, see_all_org_chats=True + ) + assert response.status_code == 200 + assert role_of(db, NEW_USER["email"]).id == owner_role.id + + +def test_editing_a_person_moves_them_to_the_scoped_role(client, db, agent_role): + """The leave-cover case: widen one person mid-employment, not the team.""" + create_user(client, agent_role.id, see_all_ai_chats=True, see_all_org_chats=False) + user = db.query(User).filter(User.email == NEW_USER["email"]).first() + assert user.role_id == agent_role.id + + response = client.put( + f"{settings.API_V1_STR}/users/{user.id}", + json={"see_all_org_chats": True}, + ) + assert response.status_code == 200 + + db.expire_all() + assigned = role_of(db, NEW_USER["email"]) + assert assigned.id != agent_role.id + assert "view_all_chats" in granted(assigned) + db.refresh(agent_role) + assert "view_all_chats" not in granted(agent_role) + + +def test_rescoping_does_not_stack_suffixes_on_the_name(client, db, agent_role): + """Widen someone, then narrow them: "Agent (assigned only)", not + "Agent (all chats) (assigned only)".""" + create_user(client, agent_role.id, see_all_ai_chats=True, see_all_org_chats=True) + user = db.query(User).filter(User.email == NEW_USER["email"]).first() + assert role_of(db, NEW_USER["email"]).name == "Agent (all chats)" + + response = client.put( + f"{settings.API_V1_STR}/users/{user.id}", + json={"see_all_ai_chats": False, "see_all_org_chats": False}, + ) + assert response.status_code == 200 + + db.expire_all() + assert role_of(db, NEW_USER["email"]).name == "Agent (assigned only)" + + +def test_toggles_win_over_the_dropdown_when_they_resolve_elsewhere( + client, db, agent_role +): + """Re-picking the base role while leaving the scope ticked must not narrow + someone who is already on the derived role.""" + create_user(client, agent_role.id, see_all_ai_chats=True, see_all_org_chats=True) + user = db.query(User).filter(User.email == NEW_USER["email"]).first() + derived_id = role_of(db, NEW_USER["email"]).id + assert derived_id != agent_role.id + + response = client.put( + f"{settings.API_V1_STR}/users/{user.id}", + json={ + "role_id": agent_role.id, + "see_all_ai_chats": True, + "see_all_org_chats": True, + }, + ) + assert response.status_code == 200 + + db.expire_all() + assert role_of(db, NEW_USER["email"]).id == derived_id + + +def test_editing_without_the_toggles_keeps_the_role(client, db, agent_role): + create_user(client, agent_role.id) + user = db.query(User).filter(User.email == NEW_USER["email"]).first() + + response = client.put( + f"{settings.API_V1_STR}/users/{user.id}", json={"full_name": "Renamed"} + ) + assert response.status_code == 200 + db.expire_all() + assert role_of(db, NEW_USER["email"]).id == agent_role.id + + +def test_a_foreign_orgs_role_is_still_refused(client, db, permissions): + """The toggles must not become a way around the tenant check.""" + other_org = Organization(id=uuid4(), name="Other", domain="other.test") + db.add(other_org) + db.commit() + foreign = Role(name="Agent", organization_id=other_org.id) + foreign.permissions = [permissions["view_assigned_chats"]] + db.add(foreign) + db.commit() + + response = create_user(client, foreign.id, see_all_org_chats=True) + assert response.status_code == 404 diff --git a/backend/tests/api/test_crm_api.py b/backend/tests/api/test_crm_api.py new file mode 100644 index 0000000..12867eb --- /dev/null +++ b/backend/tests/api/test_crm_api.py @@ -0,0 +1,297 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import base64 +import hashlib +import hmac +import time +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +import app.main # noqa: F401 — ensures routers are registered on the FastAPI app +import app.api.crm as crm_api +import app.core.oauth_state as oauth_state +from app.core.application import app +from app.core.auth import get_current_user +from app.core.config import settings +from app.crm.base import OAuthTokens +from app.crm.hubspot import HubSpotAdapter +from app.crm.pipedrive import PipedriveAdapter +from app.database import get_db +from app.models.crm import CrmConnectionStatus, CrmSyncJobStatus +from app.models.lead_capture import LeadCaptureResponse +from app.repositories.crm import CrmConnectionRepository, CrmSyncJobRepository + +BASE = "/api/v1/crm" + + +@pytest.fixture +def client(db, test_user): + async def override_user(): + return test_user + + def override_db(): + yield db + + app.dependency_overrides[get_current_user] = override_user + app.dependency_overrides[get_db] = override_db + yield TestClient(app) + app.dependency_overrides.clear() + + +@pytest.fixture +def crm_settings(monkeypatch): + monkeypatch.setattr(settings, "HUBSPOT_CLIENT_ID", "hs-cid") + monkeypatch.setattr(settings, "HUBSPOT_CLIENT_SECRET", "hs-sek") + monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "pd-cid") + monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_SECRET", "pd-sek") + + +@pytest.fixture +def connection(db, test_organization): + return CrmConnectionRepository(db).create_or_update( + organization_id=test_organization.id, + provider="hubspot", + external_account_id="12345", + credentials={"access_token": "at", "refresh_token": "rt", "api_domain": None}, + display_name="acme.hubspot.com", + access_token_expires_at=datetime.now(timezone.utc) + timedelta(hours=1), + ) + + +def _lead_response(db, org, agent, customer): + response = LeadCaptureResponse( + organization_id=org.id, agent_id=agent.id, customer_id=customer.id, + field_values={"email": "lead@example.com"}, consent=True) + db.add(response) + db.commit() + db.refresh(response) + return response + + +@pytest.fixture(autouse=True) +def _inprocess_oauth_state(monkeypatch): + """Force the in-process OAuth-state fallback so the callback tests are + deterministic whether or not a Redis happens to be reachable in the env, + and open the plan gate so these tests exercise the endpoints, not the + enterprise plan machinery (present or absent depending on the checkout).""" + monkeypatch.setattr(oauth_state, "get_redis", lambda: None) + monkeypatch.setattr(crm_api, "check_feature_access", lambda *a, **k: None) + oauth_state._fallback.clear() + + +class TestInstall: + + def test_redirects_to_provider_consent(self, client, crm_settings): + r = client.get(f"{BASE}/hubspot/install", follow_redirects=False) + assert r.status_code == 307 + location = r.headers["location"] + assert location.startswith("https://app.hubspot.com/oauth/authorize?") + assert "state=" in location + + def test_unknown_provider_404(self, client, crm_settings): + assert client.get(f"{BASE}/zoho/install", + follow_redirects=False).status_code == 404 + + def test_unconfigured_credentials_400(self, client, monkeypatch): + monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "") + assert client.get(f"{BASE}/pipedrive/install", + follow_redirects=False).status_code == 400 + + +class TestCallback: + + def test_happy_path_creates_encrypted_connection( + self, client, crm_settings, db, test_organization, monkeypatch): + crm_api._store_state("st-1", str(test_organization.id), "hubspot") + monkeypatch.setattr(HubSpotAdapter, "exchange_code", AsyncMock( + return_value=OAuthTokens( + access_token="new-at", refresh_token="new-rt", + expires_at=datetime.now(timezone.utc) + timedelta(minutes=30), + external_account_id="999", display_name="acme.hubspot.com"))) + + r = client.get(f"{BASE}/hubspot/callback?code=c1&state=st-1", + follow_redirects=False) + + assert r.status_code == 307 and "status=success" in r.headers["location"] + repo = CrmConnectionRepository(db) + connection = repo.get_by_org_provider(test_organization.id, "hubspot") + assert connection.status == CrmConnectionStatus.ACTIVE.value + assert "new-at" not in connection.encrypted_credentials # encrypted at rest + assert repo.get_credentials(connection)["access_token"] == "new-at" + + def test_invalid_state_rejected(self, client, crm_settings): + r = client.get(f"{BASE}/hubspot/callback?code=c1&state=bogus", + follow_redirects=False) + assert "status=failure" in r.headers["location"] + assert "invalid_state" in r.headers["location"] + + def test_state_provider_mismatch_rejected(self, client, crm_settings, + test_organization): + crm_api._store_state("st-2", str(test_organization.id), "pipedrive") + r = client.get(f"{BASE}/hubspot/callback?code=c1&state=st-2", + follow_redirects=False) + assert "invalid_state" in r.headers["location"] + + def test_portal_connected_to_another_org_rejected( + self, client, crm_settings, db, test_organization, monkeypatch): + other_org_connection = CrmConnectionRepository(db).create_or_update( + organization_id=_make_org(db).id, provider="hubspot", + external_account_id="999", credentials={"access_token": "x"}) + assert other_org_connection is not None + + crm_api._store_state("st-3", str(test_organization.id), "hubspot") + monkeypatch.setattr(HubSpotAdapter, "exchange_code", AsyncMock( + return_value=OAuthTokens(access_token="a", refresh_token="r", + external_account_id="999"))) + r = client.get(f"{BASE}/hubspot/callback?code=c1&state=st-3", + follow_redirects=False) + assert "account_connected_elsewhere" in r.headers["location"] + + def test_user_cancelled(self, client, crm_settings): + r = client.get(f"{BASE}/hubspot/callback?error=access_denied", + follow_redirects=False) + assert "reason=cancelled" in r.headers["location"] + + +class TestConnectionsList: + + def test_lists_with_recent_failures(self, client, crm_settings, db, connection, + test_organization, test_agent, test_customer): + lead = _lead_response(db, test_organization, test_agent, test_customer) + job_repo = CrmSyncJobRepository(db) + job = job_repo.enqueue(test_organization.id, lead.id, "hubspot") + job_repo.fail(job, "boom") + + r = client.get(f"{BASE}/connections") + assert r.status_code == 200 + [item] = r.json() + assert item["provider"] == "hubspot" + assert item["status"] == "active" + assert item["recent_failures"] == 1 + assert "encrypted_credentials" not in item + assert "access_token" not in str(item) + + +class TestTestEndpoint: + + def test_read_only_check(self, client, crm_settings, connection, monkeypatch): + monkeypatch.setattr(HubSpotAdapter, "test_connection", AsyncMock( + return_value={"ok": True, "account_name": "acme.hubspot.com", + "error": None})) + r = client.post(f"{BASE}/hubspot/test") + assert r.status_code == 200 + assert r.json() == {"ok": True, "account_name": "acme.hubspot.com", + "error": None} + + def test_not_connected_404(self, client, crm_settings): + assert client.post(f"{BASE}/pipedrive/test").status_code == 404 + + +class TestDisconnect: + + def test_revokes_deletes_and_skips_pending( + self, client, crm_settings, db, connection, + test_organization, test_agent, test_customer, monkeypatch): + revoke = AsyncMock() + monkeypatch.setattr(HubSpotAdapter, "revoke", revoke) + lead = _lead_response(db, test_organization, test_agent, test_customer) + job = CrmSyncJobRepository(db).enqueue(test_organization.id, lead.id, "hubspot") + + r = client.delete(f"{BASE}/hubspot") + + assert r.status_code == 200 + revoke.assert_awaited_once() + assert CrmConnectionRepository(db).get_by_org_provider( + test_organization.id, "hubspot") is None + db.refresh(job) + assert job.status == CrmSyncJobStatus.SKIPPED.value + + +class TestUninstallWebhooks: + + def test_pipedrive_uninstall_revokes_connection( + self, client, crm_settings, db, test_organization): + CrmConnectionRepository(db).create_or_update( + organization_id=test_organization.id, provider="pipedrive", + external_account_id="777", credentials={"access_token": "x"}) + auth = base64.b64encode(b"pd-cid:pd-sek").decode() + + r = client.post(f"{BASE}/pipedrive/uninstall", + json={"company_id": 777}, + headers={"Authorization": f"Basic {auth}"}) + + assert r.status_code == 200 + connection = CrmConnectionRepository(db).get_by_external_id("pipedrive", "777") + assert connection.status == CrmConnectionStatus.REVOKED.value + + def test_pipedrive_bad_auth_is_uniform_404(self, client, crm_settings): + bad = base64.b64encode(b"wrong:creds").decode() + r = client.post(f"{BASE}/pipedrive/uninstall", json={"company_id": 1}, + headers={"Authorization": f"Basic {bad}"}) + assert r.status_code == 404 + + def test_hubspot_uninstall_with_valid_v3_signature( + self, client, crm_settings, db, test_organization): + CrmConnectionRepository(db).create_or_update( + organization_id=test_organization.id, provider="hubspot", + external_account_id="999", credentials={"access_token": "x"}) + body = b'[{"portalId": 999, "subscriptionType": "app.uninstalled"}]' + timestamp = str(int(time.time() * 1000)) + url = f"http://testserver{BASE}/hubspot/uninstall" + message = b"POST" + url.encode() + body + timestamp.encode() + signature = base64.b64encode( + hmac.new(b"hs-sek", message, hashlib.sha256).digest()).decode() + + r = client.post(f"{BASE}/hubspot/uninstall", content=body, headers={ + "Content-Type": "application/json", + "X-HubSpot-Signature-v3": signature, + "X-HubSpot-Request-Timestamp": timestamp, + }) + + assert r.status_code == 200 + connection = CrmConnectionRepository(db).get_by_external_id("hubspot", "999") + assert connection.status == CrmConnectionStatus.REVOKED.value + + def test_hubspot_bad_signature_is_uniform_404(self, client, crm_settings): + r = client.post(f"{BASE}/hubspot/uninstall", content=b"[]", headers={ + "X-HubSpot-Signature-v3": "bogus", + "X-HubSpot-Request-Timestamp": str(int(time.time() * 1000)), + }) + assert r.status_code == 404 + + def test_hubspot_stale_timestamp_rejected(self, client, crm_settings): + stale = str(int((time.time() - 3600) * 1000)) + r = client.post(f"{BASE}/hubspot/uninstall", content=b"[]", headers={ + "X-HubSpot-Signature-v3": "whatever", + "X-HubSpot-Request-Timestamp": stale, + }) + assert r.status_code == 404 + + +def _make_org(db): + from app.models.organization import Organization + org = Organization(id=uuid.uuid4(), name=f"Other {uuid.uuid4().hex[:6]}", + domain=f"other-{uuid.uuid4().hex[:6]}.example.com", + timezone="UTC", is_active=True) + db.add(org) + db.commit() + db.refresh(org) + return org diff --git a/backend/tests/api/test_file_upload.py b/backend/tests/api/test_file_upload.py new file mode 100644 index 0000000..c8ec303 --- /dev/null +++ b/backend/tests/api/test_file_upload.py @@ -0,0 +1,546 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest +import os +from unittest.mock import patch, Mock +from fastapi.testclient import TestClient +from fastapi import FastAPI, status +from sqlalchemy.orm import Session + +from app.api.file_upload import router, get_current_user_or_widget +from app.models.user import User +from app.models.organization import Organization +from app.core.config import settings +from tests.conftest import TestingSessionLocal, create_tables, engine +from app.database import Base + + +# Downloads are only ever served for chat attachments, which live at +# chat_attachments// — the path's org segment is what authorizes +# the read, so every download test needs a real org id on both sides. +ORG_ID = "bab82aab-d095-46f8-bf16-da638671bcf4" +OTHER_ORG_ID = "11111111-2222-3333-4444-555555555555" +ATTACHMENT_KEY = f"chat_attachments/{ORG_ID}/file.txt" + + +def auth_user(org_id=ORG_ID): + return {"type": "user", "user_id": "123", "org_id": org_id} + + +# Create test FastAPI app +test_app = FastAPI() +test_app.include_router(router, prefix="/api/v1/files") + + +@pytest.fixture +def uploads(tmp_path, monkeypatch): + """Local storage lives under ./uploads; give each test its own.""" + monkeypatch.chdir(tmp_path) + folder = tmp_path / "uploads" / "chat_attachments" / ORG_ID + folder.mkdir(parents=True) + return folder + + +@pytest.fixture +def client(): + """Create test client""" + return TestClient(test_app) + + +@pytest.fixture +def db_session(): + """Create database session for testing""" + create_tables() + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def test_organization(db_session): + """Create test organization""" + org = Organization( + name="Test Organization", + domain="test.com", + timezone="UTC" + ) + db_session.add(org) + db_session.commit() + db_session.refresh(org) + return org + + +@pytest.fixture +def test_user(db_session, test_organization): + """Create test user""" + user = User( + email="test@example.com", + full_name="Test User", + hashed_password="dummy_hash", + is_active=True, + organization_id=test_organization.id + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture +def mock_request(): + """Create mock request object""" + request = Mock() + request.headers = {"origin": "https://example.com"} + return request + + +class TestGetCurrentUserOrWidget: + """Test authentication helper function""" + + @pytest.mark.asyncio + async def test_get_current_user_success(self, db_session, test_user): + """Test successful user authentication""" + request = Mock() + + with patch('app.api.file_upload.get_current_user') as mock_get_user: + mock_get_user.return_value = test_user + + result = await get_current_user_or_widget(request, db_session) + + assert result["type"] == "user" + assert result["user_id"] == str(test_user.id) + assert result["org_id"] == str(test_user.organization_id) + + @pytest.mark.asyncio + async def test_get_current_user_widget_token(self, db_session): + """Test widget authentication with conversation token""" + request = Mock() + + with patch('app.api.file_upload.get_current_user') as mock_get_user, \ + patch('app.api.file_upload.verify_conversation_token') as mock_verify_token: + + # Mock user auth failure + from fastapi import HTTPException + mock_get_user.side_effect = HTTPException(status_code=401) + + # Mock successful token verification + mock_verify_token.return_value = { + "widget_id": "widget_123", + "org_id": ORG_ID, + "customer_id": "customer_789" + } + + result = await get_current_user_or_widget( + request, db_session, x_conversation_token="valid_token" + ) + + assert result["type"] == "widget" + assert result["widget_id"] == "widget_123" + assert result["org_id"] == ORG_ID + assert result["customer_id"] == "customer_789" + + @pytest.mark.asyncio + async def test_get_current_user_no_auth(self, db_session): + """Test authentication failure""" + request = Mock() + + with patch('app.api.file_upload.get_current_user') as mock_get_user: + from fastapi import HTTPException + mock_get_user.side_effect = HTTPException(status_code=401) + + with pytest.raises(HTTPException) as exc_info: + await get_current_user_or_widget(request, db_session) + + assert exc_info.value.status_code == 401 + assert "Authentication required" in str(exc_info.value.detail) + + +class TestDownloadFileLocal: + """Test file download from local storage""" + + def test_download_file_local_success(self, client, uploads): + (uploads / "file.txt").write_text("Test file content") + with patch.object(settings, 'S3_FILE_STORAGE', False), \ + patch('app.api.file_upload.get_current_user_or_widget', return_value=auth_user()): + response = client.get(f"/api/v1/files/download/{ATTACHMENT_KEY}") + + assert response.status_code == 200 + assert response.text == "Test file content" + assert response.headers["content-type"] == "text/plain; charset=utf-8" + + def test_download_file_local_not_found(self, client, uploads): + with patch.object(settings, 'S3_FILE_STORAGE', False), \ + patch('app.api.file_upload.get_current_user_or_widget', return_value=auth_user()): + response = client.get(f"/api/v1/files/download/chat_attachments/{ORG_ID}/nonexistent.txt") + + assert response.status_code == 404 + assert "File not found" in response.json()["detail"] + + def test_an_svg_opened_directly_cannot_run_script(self, client, uploads): + """Visitors upload SVGs; served from the API origin they must stay inert.""" + (uploads / "logo.svg").write_text('') + with patch.object(settings, 'S3_FILE_STORAGE', False), \ + patch('app.api.file_upload.get_current_user_or_widget', return_value=auth_user()): + response = client.get(f"/api/v1/files/download/chat_attachments/{ORG_ID}/logo.svg") + + assert response.status_code == 200 + assert response.headers["content-security-policy"] == "script-src 'none'" + assert response.headers["x-content-type-options"] == "nosniff" + + def test_download_file_local_without_auth(self, client): + """Unauthenticated downloads are refused, not served anyway""" + with patch.object(settings, 'S3_FILE_STORAGE', False), \ + patch('app.api.file_upload.get_current_user_or_widget') as mock_auth, \ + patch('os.path.exists') as mock_exists, \ + patch('builtins.open', create=True) as mock_open: + + from fastapi import HTTPException + mock_auth.side_effect = HTTPException(status_code=401) + mock_exists.return_value = True + mock_open.return_value.__enter__.return_value.read.return_value = b"Test file content" + + response = client.get(f"/api/v1/files/download/{ATTACHMENT_KEY}") + + assert response.status_code == 401 + + +class TestDownloadFileS3: + """Test file download from S3 storage""" + + def test_download_file_s3_success(self, client): + """Test successful file download from S3""" + with patch.object(settings, 'S3_FILE_STORAGE', True), \ + patch.object(settings, 'S3_BUCKET', 'test-bucket'), \ + patch('app.api.file_upload.get_current_user_or_widget') as mock_auth, \ + patch('app.core.s3.get_s3_client') as mock_s3_client: + + mock_auth.return_value = auth_user() + + # Mock S3 client + mock_client = Mock() + mock_s3_client.return_value = mock_client + + # Mock successful head_object (file exists) + mock_client.head_object.return_value = {} + + # Mock successful get_object + mock_response = { + 'Body': Mock(), + 'ContentType': 'text/plain' + } + mock_response['Body'].read.side_effect = [b"Test content", b""] # First chunk, then empty + mock_client.get_object.return_value = mock_response + + response = client.get(f"/api/v1/files/download/{ATTACHMENT_KEY}") + + assert response.status_code == 200 + mock_client.head_object.assert_called_once_with(Bucket='test-bucket', Key=ATTACHMENT_KEY) + mock_client.get_object.assert_called_once_with(Bucket='test-bucket', Key=ATTACHMENT_KEY) + assert response.headers["content-security-policy"] == "script-src 'none'" + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["content-disposition"] == "inline; filename*=utf-8''file.txt" + + def test_download_file_s3_not_found(self, client): + """Test S3 file download when file doesn't exist""" + with patch.object(settings, 'S3_FILE_STORAGE', True), \ + patch.object(settings, 'S3_BUCKET', 'test-bucket'), \ + patch('app.api.file_upload.get_current_user_or_widget') as mock_auth, \ + patch('app.core.s3.get_s3_client') as mock_s3_client: + + mock_auth.return_value = auth_user() + + # Mock S3 client + mock_client = Mock() + mock_s3_client.return_value = mock_client + + # Mock NoSuchKey exception + from botocore.exceptions import ClientError + no_such_key_error = ClientError( + error_response={'Error': {'Code': 'NoSuchKey'}}, + operation_name='HeadObject' + ) + mock_client.exceptions = Mock() + mock_client.exceptions.NoSuchKey = ClientError + mock_client.head_object.side_effect = no_such_key_error + + response = client.get(f"/api/v1/files/download/chat_attachments/{ORG_ID}/nonexistent.txt") + + assert response.status_code == 404 + assert "File not found" in response.json()["detail"] + + def test_download_file_s3_client_error(self, client): + """Test S3 file download with client creation error""" + with patch.object(settings, 'S3_FILE_STORAGE', True), \ + patch('app.api.file_upload.get_current_user_or_widget') as mock_auth, \ + patch('app.core.s3.get_s3_client') as mock_s3_client: + + mock_auth.return_value = auth_user() + mock_s3_client.side_effect = Exception("S3 client creation failed") + + response = client.get(f"/api/v1/files/download/{ATTACHMENT_KEY}") + + assert response.status_code == 500 + assert "Failed to create S3 client" in response.json()["detail"] + + def test_download_file_s3_get_object_error(self, client): + """Test S3 file download with get_object error""" + with patch.object(settings, 'S3_FILE_STORAGE', True), \ + patch.object(settings, 'S3_BUCKET', 'test-bucket'), \ + patch('app.api.file_upload.get_current_user_or_widget') as mock_auth, \ + patch('app.core.s3.get_s3_client') as mock_s3_client: + + mock_auth.return_value = auth_user() + + # Mock S3 client + mock_client = Mock() + mock_s3_client.return_value = mock_client + + # Mock successful head_object but failed get_object + mock_client.head_object.return_value = {} + + # Mock exceptions properly + from botocore.exceptions import ClientError + mock_client.exceptions = Mock() + mock_client.exceptions.NoSuchKey = ClientError + mock_client.get_object.side_effect = Exception("S3 get_object failed") + + response = client.get(f"/api/v1/files/download/{ATTACHMENT_KEY}") + + assert response.status_code == 500 + assert "Failed to retrieve file from S3" in response.json()["detail"] + + +class TestDownloadFilePathHandling: + """Test file path handling in download endpoint""" + + def test_download_file_path_with_uploads_prefix(self, client): + """Test file path handling with /uploads/ prefix""" + with patch.object(settings, 'S3_FILE_STORAGE', True), \ + patch.object(settings, 'S3_BUCKET', 'test-bucket'), \ + patch('app.api.file_upload.get_current_user_or_widget') as mock_auth, \ + patch('app.core.s3.get_s3_client') as mock_s3_client: + + mock_auth.return_value = auth_user() + + # Mock S3 client + mock_client = Mock() + mock_s3_client.return_value = mock_client + mock_client.head_object.return_value = {} + + # Mock successful get_object + mock_response = { + 'Body': Mock(), + 'ContentType': 'image/jpeg' + } + mock_response['Body'].read.side_effect = [b"image data", b""] + mock_client.get_object.return_value = mock_response + + response = client.get(f"/api/v1/files/download//uploads/chat_attachments/{ORG_ID}/image.jpg") + + assert response.status_code == 200 + # The "/uploads/" prefix is stripped to get the storage key + mock_client.head_object.assert_called_once_with( + Bucket='test-bucket', + Key=f'chat_attachments/{ORG_ID}/image.jpg' + ) + + def test_download_file_path_without_uploads_prefix(self, client): + """Test file path handling without /uploads/ prefix""" + with patch.object(settings, 'S3_FILE_STORAGE', True), \ + patch.object(settings, 'S3_BUCKET', 'test-bucket'), \ + patch('app.api.file_upload.get_current_user_or_widget') as mock_auth, \ + patch('app.core.s3.get_s3_client') as mock_s3_client: + + mock_auth.return_value = auth_user() + + # Mock S3 client + mock_client = Mock() + mock_s3_client.return_value = mock_client + mock_client.head_object.return_value = {} + + # Mock successful get_object + mock_response = { + 'Body': Mock(), + 'ContentType': 'application/pdf' + } + mock_response['Body'].read.side_effect = [b"pdf data", b""] + mock_client.get_object.return_value = mock_response + + response = client.get(f"/api/v1/files/download/chat_attachments/{ORG_ID}/document.pdf") + + assert response.status_code == 200 + # Should use path as-is for S3 key + mock_client.head_object.assert_called_once_with( + Bucket='test-bucket', + Key=f'chat_attachments/{ORG_ID}/document.pdf' + ) + + +class TestDownloadFileAuthentication: + """Test authentication scenarios for file download""" + + def test_download_file_with_jwt_auth(self, client, uploads): + (uploads / "file.txt").write_text("Test content") + with patch.object(settings, 'S3_FILE_STORAGE', False), \ + patch('app.api.file_upload.get_current_user_or_widget', return_value=auth_user()): + response = client.get(f"/api/v1/files/download/{ATTACHMENT_KEY}", + headers={"Authorization": "Bearer valid_jwt_token"}) + + assert response.status_code == 200 + + def test_download_file_with_conversation_token(self, client, uploads): + (uploads / "file.txt").write_text("Test content") + widget_auth = {"type": "widget", "widget_id": "widget_123", "org_id": ORG_ID, "customer_id": "customer_789"} + with patch.object(settings, 'S3_FILE_STORAGE', False), \ + patch('app.api.file_upload.get_current_user_or_widget', return_value=widget_auth): + response = client.get(f"/api/v1/files/download/{ATTACHMENT_KEY}", + headers={"X-Conversation-Token": "valid_conversation_token"}) + + assert response.status_code == 200 + + +class TestDownloadFileContentTypes: + """Test content type handling in file download""" + + @pytest.mark.parametrize("name, content_type", [ + ("image.jpg", "image/jpeg"), + ("unknown.zzqq", "application/octet-stream"), + ]) + def test_content_type_follows_the_file_name(self, client, uploads, name, content_type): + (uploads / name).write_bytes(b"data") + with patch.object(settings, 'S3_FILE_STORAGE', False), \ + patch('app.api.file_upload.get_current_user_or_widget', return_value=auth_user()): + response = client.get(f"/api/v1/files/download/chat_attachments/{ORG_ID}/{name}") + + assert response.status_code == 200 + assert response.headers["content-type"] == content_type + + +class TestDownloadFileErrorHandling: + """Test error handling in file download""" + + def test_download_file_general_exception(self, client): + """An unexpected failure is a generic 500 that leaks no internals.""" + with patch.object(settings, 'S3_FILE_STORAGE', False), \ + patch('app.api.file_upload.get_current_user_or_widget', return_value=auth_user()), \ + patch('os.path.isfile', side_effect=Exception("Unexpected error at /srv/secret")): + response = client.get(f"/api/v1/files/download/{ATTACHMENT_KEY}") + + assert response.status_code == 500 + assert response.json()["detail"] == "Failed to download file" + + def test_download_file_s3_head_object_general_error(self, client): + """Test S3 head_object with general error (not NoSuchKey)""" + with patch.object(settings, 'S3_FILE_STORAGE', True), \ + patch.object(settings, 'S3_BUCKET', 'test-bucket'), \ + patch('app.api.file_upload.get_current_user_or_widget') as mock_auth, \ + patch('app.core.s3.get_s3_client') as mock_s3_client: + + mock_auth.return_value = auth_user() + + # Mock S3 client + mock_client = Mock() + mock_s3_client.return_value = mock_client + + # Mock exceptions properly + from botocore.exceptions import ClientError + mock_client.exceptions = Mock() + mock_client.exceptions.NoSuchKey = ClientError + + # Mock general exception in head_object (should continue to get_object) + mock_client.head_object.side_effect = Exception("General S3 error") + + # Mock successful get_object + mock_response = { + 'Body': Mock(), + 'ContentType': 'text/plain' + } + mock_response['Body'].read.side_effect = [b"Test content", b""] + mock_client.get_object.return_value = mock_response + + response = client.get(f"/api/v1/files/download/{ATTACHMENT_KEY}") + + # Should still succeed because get_object works + assert response.status_code == 200 + + +class TestDownloadFileAuthorization: + """The download path is authorization data, not just a lookup key""" + + def _serve(self, client, path, auth): + with patch.object(settings, 'S3_FILE_STORAGE', False), \ + patch('app.api.file_upload.get_current_user_or_widget') as mock_auth, \ + patch('os.path.exists') as mock_exists, \ + patch('builtins.open', create=True) as mock_open: + + mock_auth.return_value = auth + mock_exists.return_value = True + mock_open.return_value.__enter__.return_value.read.return_value = b"secret" + + return client.get(f"/api/v1/files/download/{path}") + + def test_another_orgs_attachment_is_not_served(self, client): + response = self._serve( + client, + f"chat_attachments/{OTHER_ORG_ID}/file.txt", + auth_user(), + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "File not found" + + def test_widget_token_is_scoped_to_its_own_org(self, client): + response = self._serve( + client, + f"chat_attachments/{OTHER_ORG_ID}/file.txt", + {"type": "widget", "widget_id": "w1", "org_id": ORG_ID, "customer_id": "c1"}, + ) + + assert response.status_code == 404 + + @pytest.mark.parametrize("path", [ + "chat_attachments/../../etc/passwd", + f"chat_attachments/{ORG_ID}/../../../etc/passwd", + "uploads/../../etc/passwd", + "etc/passwd", + # Not an attachment: only chat_attachments is servable here. + f"profile_pics/{ORG_ID}/avatar.png", + # Missing the filename segment. + f"chat_attachments/{ORG_ID}", + # Org segment must be a real uuid. + "chat_attachments/not-an-org/file.txt", + ]) + def test_paths_outside_the_attachment_layout_are_refused(self, client, path): + response = self._serve(client, path, auth_user()) + + # Traversal attempts are normalized by the client and never match the + # route at all, so assert the property that matters for every case: + # nothing is served. + assert response.status_code == 404 + assert b"secret" not in response.content + + def test_auth_without_an_org_gets_nothing(self, client): + response = self._serve( + client, + ATTACHMENT_KEY, + {"type": "user", "user_id": "123", "org_id": None}, + ) + + assert response.status_code == 404 diff --git a/backend/tests/api/test_help_center_api.py b/backend/tests/api/test_help_center_api.py new file mode 100644 index 0000000..c0359ef --- /dev/null +++ b/backend/tests/api/test_help_center_api.py @@ -0,0 +1,429 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Help-center admin API tests: settings get-or-create + slug, FAQ CRUD and +publish flow, generation/import job enqueueing (409/400 guards, SSRF), +and plan gating in simulated cloud mode. +""" + +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +import app.main # noqa: F401 — ensures routers are registered on the FastAPI app +from app.core.application import app +from app.core.auth import get_current_user, require_permissions +from app.database import get_db +from app.models.faq import FAQ, FAQStatus +from app.repositories.faq import FAQRepository + +BASE = "/api/v1/help-center" + + +@pytest.fixture(autouse=True) +def _open_plan_gate(request): + """Env-independent plan gating: local dev has the enterprise module (test + org has no subscription → everything 403s), CI/OSS doesn't. These tests + target the help-center endpoints, not the gate — the plan-gating tests + below opt out via the `real_plan_gate` marker to exercise it for real.""" + if request.node.get_closest_marker("real_plan_gate"): + yield + return + # Patch the names bound inside help_center_access (import-time binding — + # patching feature_gate itself would not reach them). + with patch("app.services.help_center_access.feature_allowed", return_value=True), \ + patch("app.services.help_center_access.check_feature_access", return_value=None): + yield + + +@pytest.fixture +def client(db, test_user): + # The shared test_role lacks manage_knowledge, which every help-center + # endpoint requires. + from app.models.permission import Permission + perm = Permission(name="manage_knowledge") + db.add(perm) + test_user.role.permissions.append(perm) + db.commit() + + async def override_user(): + return test_user + + def override_db(): + yield db + + # NOTE: overriding require_permissions itself would be a no-op (FastAPI + # keys overrides on the closure it returns) — the real permission check + # runs against test_user's role, which now has manage_knowledge. + app.dependency_overrides[get_current_user] = override_user + app.dependency_overrides[get_db] = override_db + yield TestClient(app) + app.dependency_overrides.clear() + + +def _create_faq(db, org_id, **kwargs): + defaults = dict(question="How do I sign up?", answer="With your email.", category="Getting started") + defaults.update(kwargs) + return FAQRepository(db).create(FAQ(organization_id=org_id, **defaults)) + + +# ---------- settings ---------- + +def test_settings_get_or_create_assigns_slug_and_defaults(client, test_agent): + r = client.get(f"{BASE}/settings") + assert r.status_code == 200 + body = r.json() + assert body["slug"] == "test-organization" + # Default (path) mode advertises {BACKEND_URL}/help/{slug}. + assert body["live_url"].endswith("/help/test-organization") + assert body["plan_allowed"] is True # OSS mode: never locked + assert body["auto_generate"] is True + assert body["ai_search_enabled"] is True + assert [a["name"] for a in body["agents"]] == ["Test Agent"] + # Second call returns the same row (no duplicate creation). + assert client.get(f"{BASE}/settings").json()["slug"] == "test-organization" + + +def test_settings_update_branding(client): + client.get(f"{BASE}/settings") + r = client.put(f"{BASE}/settings", json={ + "brand_color": "#0E8C8C", + "header_links": [{"label": "Product", "url": "example.com"}], + "cta_text": "Sign in", + "cta_url": "app.example.com", + "enabled": True, + }) + assert r.status_code == 200 + body = r.json() + assert body["brand_color"] == "#0E8C8C" + assert body["header_links"] == [{"label": "Product", "url": "https://example.com"}] + assert body["cta_url"] == "https://app.example.com" + assert body["enabled"] is True + + +def test_settings_update_rejects_bad_color_and_cross_org_agent(client): + assert client.put(f"{BASE}/settings", json={"brand_color": "red"}).status_code == 422 + assert client.put(f"{BASE}/settings", json={"agent_id": str(uuid4())}).status_code == 404 + + +# ---------- FAQ CRUD ---------- + +def test_faq_crud_flow(client, db, test_organization): + created = client.post(f"{BASE}/faqs", json={ + "question": "What does it cost?", "answer": "From $19.", "category": "Billing", + }) + assert created.status_code == 201 + faq_id = created.json()["id"] + assert created.json()["status"] == "draft" + assert created.json()["source_label"] == "Added manually" + + # Partial update must not reset omitted fields (category keeps Billing). + updated = client.put(f"{BASE}/faqs/{faq_id}", json={"status": "published"}) + assert updated.status_code == 200 + assert updated.json()["status"] == "published" + assert updated.json()["category"] == "Billing" + + listing = client.get(f"{BASE}/faqs", params={"status": "published"}) + assert listing.status_code == 200 + assert listing.json()["pagination"]["total"] == 1 + + assert client.get(f"{BASE}/faqs/categories").json() == ["Billing"] + + assert client.delete(f"{BASE}/faqs/{faq_id}").status_code == 204 + assert client.get(f"{BASE}/faqs").json()["pagination"]["total"] == 0 + + +def test_faq_seo_fields_round_trip(client): + """Slug and meta overrides are settable at create and edit time.""" + created = client.post(f"{BASE}/faqs", json={ + "question": "What does it cost?", "answer": "From $19.", + "slug": "Pricing & Plans!", "meta_title": "Pricing", + "meta_description": "What ChatterMate costs.", + }) + assert created.status_code == 201 + # A hand-typed slug is normalized the same way a generated one is. + assert created.json()["slug"] == "pricing-plans" + assert created.json()["meta_title"] == "Pricing" + + faq_id = created.json()["id"] + updated = client.put(f"{BASE}/faqs/{faq_id}", json={"slug": "our-pricing"}) + assert updated.json()["slug"] == "our-pricing" + # Untouched overrides survive a partial update. + assert updated.json()["meta_description"] == "What ChatterMate costs." + + +def test_faq_slug_is_deduped_but_stable_for_its_own_row(client): + """A taken slug gets a numeric suffix; re-saving a row's own slug is a + no-op rather than a self-collision that renames the article.""" + first = client.post(f"{BASE}/faqs", json={ + "question": "A", "answer": "a", "slug": "pricing", + }).json() + second = client.post(f"{BASE}/faqs", json={ + "question": "B", "answer": "b", "slug": "pricing", + }).json() + assert first["slug"] == "pricing" + assert second["slug"] == "pricing-2" + + resaved = client.put(f"{BASE}/faqs/{first['id']}", json={"slug": "pricing"}) + assert resaved.json()["slug"] == "pricing" + + +def test_blank_seo_values_clear_overrides_but_keep_the_slug(client): + """Emptying a field in the UI restores the derived default. The slug has no + derived-at-render fallback, so a blank one keeps the assigned value instead + of leaving the article unreachable.""" + faq = client.post(f"{BASE}/faqs", json={ + "question": "What does it cost?", "answer": "From $19.", "meta_title": "Pricing", + }).json() + cleared = client.put(f"{BASE}/faqs/{faq['id']}", json={"meta_title": "", "slug": ""}) + assert cleared.json()["meta_title"] is None + assert cleared.json()["slug"] == faq["slug"] + + +def test_faq_seo_fields_reject_oversized_values(client): + """Lengths are bounded at the API so a value can never exceed its column.""" + over_title = client.post(f"{BASE}/faqs", json={ + "question": "Q", "answer": "a", "meta_title": "x" * 121, + }) + over_description = client.post(f"{BASE}/faqs", json={ + "question": "Q", "answer": "a", "meta_description": "x" * 301, + }) + assert over_title.status_code == 422 + assert over_description.status_code == 422 + + +def test_faq_cross_org_is_404(client, db): + from app.models.organization import Organization + other_org = Organization(name="Other", domain="other.com", timezone="UTC") + db.add(other_org) + db.commit() + foreign = _create_faq(db, other_org.id) + assert client.put(f"{BASE}/faqs/{foreign.id}", json={"status": "published"}).status_code == 404 + assert client.delete(f"{BASE}/faqs/{foreign.id}").status_code == 404 + + +def test_bulk_status_publish(client, db, test_organization): + ids = [str(_create_faq(db, test_organization.id, question=f"Q{i}?").id) for i in range(3)] + r = client.post(f"{BASE}/faqs/bulk-status", json={"faq_ids": ids, "status": "published"}) + assert r.status_code == 200 + assert r.json()["updated"] == 3 + + +def test_bulk_delete_is_org_scoped(client, db, test_organization): + from app.models.organization import Organization + mine = [str(_create_faq(db, test_organization.id, question=f"D{i}?").id) for i in range(2)] + other_org = Organization(name="Other2", domain="other2.com", timezone="UTC") + db.add(other_org) + db.commit() + foreign = _create_faq(db, other_org.id) + + r = client.post(f"{BASE}/faqs/bulk-delete", json={"faq_ids": mine + [str(foreign.id)]}) + assert r.status_code == 200 + assert r.json()["deleted"] == 2 # foreign id silently ignored + assert client.get(f"{BASE}/faqs").json()["pagination"]["total"] == 0 + db.expire_all() + assert db.get(FAQ, foreign.id) is not None # other org untouched + + +# ---------- generation / import ---------- + +def test_generate_requires_ai_config(client): + assert client.post(f"{BASE}/generate").status_code == 400 + + +def _create_knowledge(db, org_id, source="docs.example.com"): + from app.models.knowledge import Knowledge, SourceType + knowledge = Knowledge( + source=source, source_type=SourceType.WEBSITE, + schema="ai", table_name="d_test", organization_id=org_id, + ) + db.add(knowledge) + db.commit() + return knowledge + + +def test_generate_requires_knowledge_sources(client, test_ai_config): + assert client.post(f"{BASE}/generate").status_code == 400 + + +def test_generate_enqueues_and_409s_on_duplicate(client, db, test_organization, test_ai_config): + _create_knowledge(db, test_organization.id) + first = client.post(f"{BASE}/generate") + assert first.status_code == 202 + assert first.json()["status"] == "pending" + assert client.post(f"{BASE}/generate").status_code == 409 + # Polling returns the active job. + active = client.get(f"{BASE}/jobs") + assert active.json()["id"] == first.json()["id"] + + +def test_generate_409s_when_all_sources_generated(client, db, test_organization, test_ai_config): + knowledge = _create_knowledge(db, test_organization.id) + _create_faq(db, test_organization.id, knowledge_id=knowledge.id) + r = client.post(f"{BASE}/generate") + assert r.status_code == 409 + assert "already have FAQs" in r.json()["detail"] + + +def test_generate_estimate_reports_new_sources(client, db, test_organization, test_ai_config): + generated = _create_knowledge(db, test_organization.id, "done.example.com") + _create_faq(db, test_organization.id, knowledge_id=generated.id) + _create_knowledge(db, test_organization.id, "new.example.com") + r = client.get(f"{BASE}/generate/estimate") + assert r.status_code == 200 + body = r.json() + assert body["total_sources"] == 2 + assert body["new_sources"] == 1 + assert body["estimated_calls"] >= 1 + # Per-source list for the picker: both sources, with has_faqs per source. + by_name = {s["name"]: s for s in body["sources"]} + assert set(by_name) == {"done.example.com", "new.example.com"} + assert by_name["done.example.com"]["has_faqs"] is True + assert by_name["new.example.com"]["has_faqs"] is False + + +def test_import_rejects_internal_hosts(client, test_ai_config): + with patch("app.api.help_center.generation.resolves_to_blocked_host", return_value=True): + assert client.post(f"{BASE}/import", json={"url": "https://internal.host/faq"}).status_code == 400 + + +def test_import_enqueues_with_source_url(client, test_ai_config): + with patch("app.api.help_center.generation.resolves_to_blocked_host", return_value=False): + r = client.post(f"{BASE}/import", json={"url": "support.example.com/faq"}) + assert r.status_code == 202 + assert r.json()["source_url"] == "https://support.example.com/faq" + assert r.json()["job_type"] == "import_url" + + +def test_import_articles_carries_the_preserve_urls_opt_in(client, db, test_ai_config): + """The migration toggle has to reach the worker, or imported articles quietly + lose the URLs the org already ranks for.""" + from app.models.faq_generation_job import FAQGenerationJob + + with patch("app.api.help_center.generation.resolves_to_blocked_host", return_value=False): + r = client.post( + f"{BASE}/import", + json={"url": "support.example.com/help", "mode": "articles", "preserve_urls": True}, + ) + + assert r.status_code == 202 + assert r.json()["job_type"] == "import_articles" + job = db.query(FAQGenerationJob).filter(FAQGenerationJob.id == r.json()["id"]).one() + assert job.preserve_source_urls is True + + +def test_import_defaults_to_not_preserving_urls(client, db, test_ai_config): + from app.models.faq_generation_job import FAQGenerationJob + + with patch("app.api.help_center.generation.resolves_to_blocked_host", return_value=False): + r = client.post(f"{BASE}/import", json={"url": "support.example.com/help", "mode": "articles"}) + + job = db.query(FAQGenerationJob).filter(FAQGenerationJob.id == r.json()["id"]).one() + assert job.preserve_source_urls is False + + +def test_job_by_id_cross_org_404(client): + assert client.get(f"{BASE}/jobs/999999").status_code == 404 + + +# ---------- plan gating (simulated cloud) ---------- + +def _cloud_gate(feature_available: bool): + """Simulate the enterprise module being present with a plan whose + help_center flag is `feature_available`.""" + subscription = MagicMock(plan_id=uuid4()) + plan_repo = MagicMock() + plan_repo.check_feature_availability.return_value = feature_available + return ( + patch("app.services.feature_gate.HAS_ENTERPRISE", True), + patch("app.services.feature_gate.require_accessible_subscription", + return_value=subscription, create=True), + patch("app.services.feature_gate.PlanRepository", + return_value=plan_repo, create=True), + ) + + +@pytest.mark.real_plan_gate +def test_cloud_free_plan_gets_403_on_writes(client, test_ai_config): + p1, p2, p3 = _cloud_gate(feature_available=False) + with p1, p2, p3: + assert client.post(f"{BASE}/faqs", json={"question": "Q?", "answer": "A."}).status_code == 403 + assert client.post(f"{BASE}/generate").status_code == 403 + # Ungated read still works and reports the lock. + settings_response = client.get(f"{BASE}/settings") + assert settings_response.status_code == 200 + assert settings_response.json()["plan_allowed"] is False + + +@pytest.mark.real_plan_gate +def test_cloud_pro_plan_allowed(client, test_ai_config): + p1, p2, p3 = _cloud_gate(feature_available=True) + with p1, p2, p3: + assert client.post(f"{BASE}/faqs", json={"question": "Q?", "answer": "A."}).status_code == 201 + assert client.get(f"{BASE}/settings").json()["plan_allowed"] is True + + +# ---------- per-article preserved URL path ---------- + +def test_create_faq_with_a_preserved_url_path(client, db, test_organization): + r = client.post(f"{BASE}/faqs", json={ + "question": "How do I reset my password?", "answer": "Use the link.", + "url_path": "https://help.acme.com/hc/en-us/articles/360012-reset?utm=x", + }) + assert r.status_code == 201 + assert r.json()["url_path"] == "/hc/en-us/articles/360012-reset" + + +def test_update_faq_sets_then_clears_the_url_path(client, db, test_organization): + faq = _create_faq(db, test_organization.id) + + r = client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": "/hc/articles/1"}) + assert r.status_code == 200 + assert r.json()["url_path"] == "/hc/articles/1" + + # Blank CLEARS it (unlike the slug, where blank is a no-op) — back to /a/{slug}. + r = client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": ""}) + assert r.status_code == 200 + assert r.json()["url_path"] is None + assert r.json()["slug"] + + +def test_update_faq_keeps_its_own_url_path_on_resave(client, db, test_organization): + faq = _create_faq(db, test_organization.id, url_path="/hc/articles/1") + + r = client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": "/hc/articles/1"}) + + assert r.status_code == 200 + assert r.json()["url_path"] == "/hc/articles/1" + + +def test_update_faq_rejects_a_url_path_another_article_owns(client, db, test_organization): + _create_faq(db, test_organization.id, question="Taken", url_path="/hc/articles/1") + faq = _create_faq(db, test_organization.id, question="Other") + + r = client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": "/hc/articles/1"}) + + assert r.status_code == 409 + + +@pytest.mark.parametrize("bad", ["/a/x", "/ask", "/api/v1/uploads/x", "/sitemap.xml", "/"]) +def test_update_faq_rejects_url_paths_that_would_shadow_a_route( + client, db, test_organization, bad +): + faq = _create_faq(db, test_organization.id) + + assert client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": bad}).status_code == 400 diff --git a/backend/tests/api/test_help_center_images.py b/backend/tests/api/test_help_center_images.py new file mode 100644 index 0000000..449f049 --- /dev/null +++ b/backend/tests/api/test_help_center_images.py @@ -0,0 +1,128 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Delivery of help-center article images on both storage backends. +""" +import os +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.help_center_images import router +from app.core.config import settings +from app.services.help_center_images import UPLOAD_FOLDER, store_article_image + +VALID_NAME = "7b6fe1aa-1234-4c5d-9e0f-0123456789ab.png" + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + app.include_router(router, prefix=f"{settings.API_V1_STR}/help-center") + # follow_redirects=False so the S3 redirect is asserted, not chased to AWS. + return TestClient(app, follow_redirects=False) + + +def _url(name: str) -> str: + return f"{settings.API_V1_STR}/help-center/images/{name}" + + +def test_s3_mode_redirects_to_a_freshly_signed_url(client): + signed = f"https://s3.us-east-1.amazonaws.com/b/{UPLOAD_FOLDER}/{VALID_NAME}?Signature=S" + signing_client = MagicMock() + signing_client.generate_presigned_url.return_value = signed + + # settings is a single shared object, so one patch covers both modules. + with patch.object(settings, 'S3_FILE_STORAGE', True), \ + patch('app.core.s3.get_s3_client', return_value=signing_client): + response = client.get(_url(VALID_NAME)) + + assert response.status_code == 307 + assert response.headers["location"] == signed + # Signed from the key, not from a stored URL. + assert signing_client.generate_presigned_url.call_args.kwargs['Params']['Key'] == ( + f"{UPLOAD_FOLDER}/{VALID_NAME}" + ) + + +def test_local_mode_redirects_to_the_uploads_mount(client): + """The local flow must keep working with S3_FILE_STORAGE=false. + + Delivery is delegated to the /api/v1/uploads static mount, which both the + main app and public_app already serve this directory from. + """ + with patch.object(settings, 'S3_FILE_STORAGE', False): + response = client.get(_url(VALID_NAME)) + + assert response.status_code == 307 + assert response.headers["location"] == ( + f"{settings.API_V1_STR}/uploads/{UPLOAD_FOLDER}/{VALID_NAME}" + ) + + +def test_never_redirects_off_s3(client): + """Defence in depth: only ever bounce a visitor to an S3 host.""" + with patch.object(settings, 'S3_FILE_STORAGE', True), \ + patch('app.api.help_center_images.sign_s3_key', return_value="https://evil.example.com/x"): + response = client.get(_url(VALID_NAME)) + + assert response.status_code == 404 + assert "location" not in response.headers + + +@pytest.mark.asyncio +async def test_local_upload_then_serve_round_trip(client, tmp_path, monkeypatch): + """End-to-end with S3_FILE_STORAGE=false: the path store_article_image bakes + into the answer Markdown is the one this route can serve.""" + monkeypatch.chdir(tmp_path) + + with patch.object(settings, 'S3_FILE_STORAGE', False): + baked = await store_article_image(b"\x89PNG round trip", "image/png") + assert baked.startswith(f"{settings.API_V1_STR}/help-center/images/") + + name = baked.rsplit("/", 1)[-1] + response = client.get(baked) + + # The route resolves the baked path to the mount, and the bytes really are + # on disk where that mount serves from. + assert response.status_code == 307 + assert response.headers["location"] == ( + f"{settings.API_V1_STR}/uploads/{UPLOAD_FOLDER}/{name}" + ) + served_from = os.path.join("uploads", UPLOAD_FOLDER, name) + assert os.path.isfile(served_from) + with open(served_from, "rb") as f: + assert f.read() == b"\x89PNG round trip" + + +@pytest.mark.parametrize("name", [ + "../../etc/passwd", + "..%2f..%2fagents%2fphoto.png", + "not-a-uuid.png", + f"{VALID_NAME[:-4]}.svg", # extension outside FAQ_IMAGE_TYPES + f"{VALID_NAME}.exe", +]) +def test_rejects_names_that_are_not_ours(client, name): + """The name is interpolated into an S3 key and a filesystem path, so a + non-matching name must 404 before either happens.""" + signing_client = MagicMock() + with patch('app.api.help_center_images.settings.S3_FILE_STORAGE', True), \ + patch('app.core.s3.get_s3_client', return_value=signing_client): + response = client.get(_url(name)) + + assert response.status_code == 404 + signing_client.generate_presigned_url.assert_not_called() diff --git a/backend/tests/api/test_help_center_public.py b/backend/tests/api/test_help_center_public.py new file mode 100644 index 0000000..e8f7f4d --- /dev/null +++ b/backend/tests/api/test_help_center_public.py @@ -0,0 +1,697 @@ +""" +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Public help-center tests: host resolution, published-only rendering with +escaping and JSON-LD, plan/enabled gating (404s), Ask AI guards, host +dispatch routing. +""" + +import json +import re +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.api.help_center_public import public_app +from app.core.config import settings +from app.core.help_center_host import HelpCenterHostMiddleware, is_help_center_host +from app.database import get_db +from app.models.faq import FAQ, FAQStatus +from app.models.help_center import HelpCenterSettings +from app.repositories.faq import FAQRepository +from app.services.help_center_public import contrast_ink, resolve_help_center, slug_for_host + +HOST = "test-org.chattermate.help" + + +@pytest.fixture(autouse=True) +def _open_plan_gate(): + """Env-independent plan gating: local dev has the enterprise module (test + org has no subscription → the public site 404s), CI/OSS doesn't. These + tests target the public renderer, not the gate.""" + with patch("app.services.help_center_public.help_center_allowed", return_value=True): + yield + + +@pytest.fixture +def client(db): + def override_db(): + yield db + + public_app.dependency_overrides[get_db] = override_db + yield TestClient(public_app) + public_app.dependency_overrides.clear() + + +@pytest.fixture +def help_center(db, test_organization): + row = HelpCenterSettings( + organization_id=test_organization.id, + slug="test-org", + enabled=True, + brand_color="#4338CA", + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def _publish_faq(db, org_id, question="How do I sign up?", answer="Use your email.", + category="Getting started", slug=None, url_path=None): + return FAQRepository(db).create(FAQ( + organization_id=org_id, question=question, answer=answer, + category=category, status=FAQStatus.PUBLISHED, slug=slug, url_path=url_path, + )) + + +@pytest.fixture +def subdomain_mode(monkeypatch): + """Advertise {slug}.{base} URLs (cloud config) for tests that assert them.""" + monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "subdomain") + + +async def _not_found_app(scope, receive, send): + """Main-app stand-in: anything not dispatched to public_app 404s as 'main app'.""" + await send({"type": "http.response.start", "status": 404, + "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"main app"}) + + +@pytest.fixture +def path_client(db, monkeypatch): + """Client through the real dispatch middleware in PATH mode, so /help/{slug} + requests exercise path dispatch (public_app is bypassed by the plain client).""" + monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "path") + + def override_db(): + yield db + + public_app.dependency_overrides[get_db] = override_db + yield TestClient(HelpCenterHostMiddleware(_not_found_app, public_app)) + public_app.dependency_overrides.clear() + + +# ---------- helpers ---------- + +def test_slug_for_host(): + assert slug_for_host("acme.chattermate.help") == "acme" + assert slug_for_host("a.b.chattermate.help") is None + assert slug_for_host("chattermate.help") is None + assert slug_for_host("help.acme.com") is None + + +def test_contrast_ink(): + assert contrast_ink("#FFFFFF") == "#12131A" + assert contrast_ink("#0B0C10") == "#FFFFFF" + assert contrast_ink("not-a-color") == "#FFFFFF" + + +# ---------- rendering ---------- + +def test_page_renders_published_only_and_escapes(client, db, test_organization, help_center, subdomain_mode): + _publish_faq(db, test_organization.id) + FAQRepository(db).create(FAQ( + organization_id=test_organization.id, question="Secret draft?", + answer="Hidden.", category="Getting started", status=FAQStatus.DRAFT, + )) + r = client.get("/", headers={"host": HOST}) + assert r.status_code == 200 + assert "How do I sign up?" in r.text + assert "Secret draft?" not in r.text + # The index shows a plain-text preview (Markdown stripped) that links to the + # article page — the answer's raw HTML never reaches the page. + assert "email" not in r.text + assert "Use your email" in r.text + # SEO artifacts. + assert 'application/ld+json' in r.text and "FAQPage" in r.text + assert f'' in r.text + assert r.headers["cache-control"] == "public, max-age=60" + + +def test_article_page_renders_sanitized_markdown(client, db, test_organization, help_center): + faq = _publish_faq( + db, test_organization.id, + answer="Click **Settings**.\n\n", + ) + faq.slug = "how-do-i-sign-up" + db.commit() + r = client.get(f"/a/{faq.slug}", headers={"host": HOST}) + assert r.status_code == 200 + # Markdown rendered, but the injected ', response.text, re.DOTALL + ) + assert blocks, "page emitted no JSON-LD" + return json.loads(blocks[0]) + + +def _nodes_by_type(graph: dict) -> dict: + """Graph nodes keyed by @type (multi-typed nodes land under each type).""" + nodes: dict = {} + for node in graph["@graph"]: + types = node["@type"] + for node_type in [types] if isinstance(types, str) else types: + nodes[node_type] = node + return nodes + + +def test_index_json_ld_graph_keeps_faqpage(client, db, test_organization, help_center, subdomain_mode): + """The landing page stays a FAQPage (it really is a list of Q&A), wired into + an @graph with the Organization and WebSite nodes.""" + _publish_faq(db, test_organization.id, answer="Click **Settings**.") + r = client.get("/", headers={"host": HOST}) + assert r.status_code == 200 + + nodes = _nodes_by_type(_json_ld(r)) + assert set(nodes) >= {"Organization", "WebSite", "CollectionPage", "FAQPage"} + page = nodes["FAQPage"] + # Same node carries both types, and it is wired to the WebSite by @id. + assert page["@id"] == nodes["CollectionPage"]["@id"] + assert page["isPartOf"]["@id"] == nodes["WebSite"]["@id"] + assert nodes["WebSite"]["publisher"]["@id"] == nodes["Organization"]["@id"] + question = page["mainEntity"][0] + assert question["@type"] == "Question" + assert question["name"] == "How do I sign up?" + # Answer text is plain text, with the Markdown stripped. + assert question["acceptedAnswer"]["text"] == "Click Settings ." + assert "QAPage" not in r.text + + +def test_article_json_ld_is_techarticle_with_breadcrumbs( + client, db, test_organization, help_center, subdomain_mode +): + """A single help article is a TechArticle, not a FAQPage: Google retired FAQ + rich results, while BreadcrumbList still renders in search.""" + faq = _publish_faq(db, test_organization.id, answer="Click **Settings**.") + faq.slug = "how-do-i-sign-up" + db.commit() + r = client.get(f"/a/{faq.slug}", headers={"host": HOST}) + assert r.status_code == 200 + + nodes = _nodes_by_type(_json_ld(r)) + assert set(nodes) >= {"Organization", "WebSite", "WebPage", "BreadcrumbList", "TechArticle"} + assert "FAQPage" not in nodes and "QAPage" not in r.text + + article, page, crumbs = nodes["TechArticle"], nodes["WebPage"], nodes["BreadcrumbList"] + assert article["headline"] == "How do I sign up?" + assert article["articleSection"] == "Getting started" + # Every node is wired by @id, not repeated inline. + assert article["mainEntityOfPage"]["@id"] == page["@id"] + assert article["publisher"]["@id"] == nodes["Organization"]["@id"] + assert page["breadcrumb"]["@id"] == crumbs["@id"] + # datePublished comes from the row; dateModified falls back to it when the + # article has never been edited (so it can't predate publication). + assert article["dateModified"] == article["datePublished"] + + items = crumbs["itemListElement"] + assert [c["position"] for c in items] == [1, 2, 3] + assert [c["name"] for c in items] == ["Home", "Getting started", "How do I sign up?"] + assert items[-1]["item"] == f"https://{HOST}/a/how-do-i-sign-up" + + +def test_article_renders_visible_breadcrumb_matching_markup( + client, db, test_organization, help_center +): + """Google requires the BreadcrumbList to match what the page shows, so the + trail must actually be rendered — the current page as text, not a link.""" + faq = _publish_faq(db, test_organization.id) + faq.slug = "how-do-i-sign-up" + db.commit() + r = client.get(f"/a/{faq.slug}", headers={"host": HOST}) + + assert '