test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
8e3a4d95da
commit
164f5f9615
97 files changed
+37281
No files matched your search
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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))
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,774 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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, REPLYPIER"
|
||||
}
|
||||
)
|
||||
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, {
|
||||
'REPLYPIER_API_KEY': 'test_replypier_key',
|
||||
'REPLYPIER_MODEL_NAME': 'gpt-4o-mini'
|
||||
})
|
||||
@patch('app.api.ai_setup.HAS_ENTERPRISE', True)
|
||||
def test_setup_ai_replypier_success(client, db, test_user):
|
||||
"""Test successful ReplyPier AI setup"""
|
||||
config_data = {
|
||||
"model_type": "REPLYPIER",
|
||||
"model_name": "replypier",
|
||||
"api_key": "any_key" # This will be ignored for ReplyPier
|
||||
}
|
||||
|
||||
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"] == "REPLYPIER"
|
||||
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_replypier_missing_key(client, db, test_user):
|
||||
"""Test ReplyPier AI setup with missing API key"""
|
||||
config_data = {
|
||||
"model_type": "REPLYPIER",
|
||||
"model_name": "replypier",
|
||||
"api_key": "any_key"
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/ai/setup",
|
||||
json=config_data
|
||||
)
|
||||
assert response.status_code == 500
|
||||
assert "ReplyPier 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"
|
||||
# ReplyPier (managed) is not a user-selectable BYO-key provider
|
||||
assert "REPLYPIER" 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, {
|
||||
'REPLYPIER_API_KEY': 'test_replypier_key',
|
||||
'REPLYPIER_MODEL_NAME': 'gpt-4o-mini'
|
||||
})
|
||||
@patch('app.api.ai_setup.HAS_ENTERPRISE', True)
|
||||
def test_update_ai_config_replypier_success(client, db, test_user, test_ai_config):
|
||||
"""Test successful ReplyPier AI config update"""
|
||||
update_data = {
|
||||
"model_type": "REPLYPIER",
|
||||
"model_name": "replypier"
|
||||
}
|
||||
|
||||
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"] == "REPLYPIER"
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
@patch('app.api.ai_setup.HAS_ENTERPRISE', True)
|
||||
def test_update_ai_config_replypier_missing_key(client, db, test_user, test_ai_config):
|
||||
"""Test ReplyPier AI config update with missing API key"""
|
||||
update_data = {
|
||||
"model_type": "REPLYPIER",
|
||||
"model_name": "replypier"
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
"/api/ai/config",
|
||||
json=update_data
|
||||
)
|
||||
assert response.status_code == 500
|
||||
assert "ReplyPier 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_replypier():
|
||||
"""Test validate_model_selection with ReplyPier"""
|
||||
# Should not raise any exception
|
||||
ai_setup_router.validate_model_selection("REPLYPIER", "replypier")
|
||||
ai_setup_router.validate_model_selection("replypier", "REPLYPIER")
|
||||
|
||||
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": "REPLYPIER",
|
||||
"model_name": "some-model", # Invalid model name for ReplyPier
|
||||
"api_key": "test_valid_key"
|
||||
}
|
||||
|
||||
# When HAS_ENTERPRISE is False, ReplyPier 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 REPLYPIER with non-"replypier" 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"
|
||||
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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
|
||||
@@ -0,0 +1,687 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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
|
||||
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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/<org_id>/<file> — 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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
|
||||
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
|
||||
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 ReplyPier 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 ReplyPier 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
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,697 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.help.replypier.site"
|
||||
|
||||
|
||||
@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 <b>email</b>.",
|
||||
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.help.replypier.site") == "acme"
|
||||
assert slug_for_host("a.b.help.replypier.site") is None
|
||||
assert slug_for_host("help.replypier.site") 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 "<b>email</b>" 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'<link rel="canonical" href="https://{HOST}/">' 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<script>alert(1)</script>",
|
||||
)
|
||||
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 <script> is sanitized away (the body
|
||||
# is rendered | safe). The page has its own widget/enhancement scripts, so
|
||||
# the guard is on the injected payload, not the <script> tag in general.
|
||||
assert "<strong>Settings</strong>" in r.text
|
||||
assert "alert(1)" not in r.text
|
||||
|
||||
|
||||
def _json_ld(response) -> dict:
|
||||
blocks = re.findall(
|
||||
r'<script type="application/ld\+json">(.*?)</script>', 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 '<nav class="hc-crumbs" aria-label="Breadcrumb">' in r.text
|
||||
assert '<a href="/">Home</a>' in r.text
|
||||
assert '<a href="/?topic=Getting%20started">Getting started</a>' in r.text
|
||||
assert 'aria-current="page"' in r.text
|
||||
|
||||
|
||||
def test_meta_overrides_win_over_derived_values(client, db, test_organization, help_center):
|
||||
"""meta_title/meta_description replace the derived title and excerpt; the
|
||||
canonical URL is unaffected."""
|
||||
faq = _publish_faq(db, test_organization.id, answer="Click **Settings**.")
|
||||
faq.slug = "how-do-i-sign-up"
|
||||
faq.meta_title = "Signing up, step by step"
|
||||
faq.meta_description = "A short custom description."
|
||||
db.commit()
|
||||
r = client.get(f"/a/{faq.slug}", headers={"host": HOST})
|
||||
|
||||
assert "<title>Signing up, step by step</title>" in r.text
|
||||
assert '<meta name="description" content="A short custom description.">' in r.text
|
||||
assert '<meta property="og:title" content="Signing up, step by step">' in r.text
|
||||
# The visible <h1> still shows the real question — meta is search-only.
|
||||
assert "How do I sign up?" in r.text
|
||||
|
||||
|
||||
def test_og_image_is_absolute(client, db, test_organization, help_center, subdomain_mode):
|
||||
"""Scrapers don't resolve relative og:image URLs, so a stored relative logo
|
||||
path has to be anchored to the serving origin."""
|
||||
help_center.logo_url = "/api/v1/uploads/help_center/logo.png"
|
||||
db.commit()
|
||||
r = client.get("/", headers={"host": HOST})
|
||||
|
||||
expected = f"https://{HOST}/api/v1/uploads/help_center/logo.png"
|
||||
assert f'<meta property="og:image" content="{expected}">' in r.text
|
||||
assert '<meta name="twitter:card" content="summary_large_image">' in r.text
|
||||
|
||||
|
||||
def test_widget_embed_points_at_this_install(
|
||||
client, db, test_organization, help_center, test_widget, monkeypatch
|
||||
):
|
||||
"""The loader falls back to the API URL baked in at build time (the vendor
|
||||
cloud) unless the page sets window.replypierBaseUrl. Without it, a
|
||||
self-hosted help center asks the wrong backend for its widget and the
|
||||
visitor gets "Chat Unavailable"."""
|
||||
monkeypatch.setattr(settings, "BACKEND_URL", "https://api.selfhosted.example")
|
||||
help_center.agent_id = test_widget.agent_id
|
||||
db.commit()
|
||||
|
||||
r = client.get("/", headers={"host": HOST})
|
||||
assert f'window.replypierId = "{test_widget.id}"' in r.text
|
||||
assert 'window.replypierBaseUrl = "https://api.selfhosted.example/api/v1"' in r.text
|
||||
|
||||
|
||||
def test_custom_domain_page_is_self_contained(
|
||||
client, db, test_organization, help_center, test_widget, monkeypatch
|
||||
):
|
||||
"""On a verified custom domain the page must advertise ITS OWN origin for
|
||||
canonical/og/assets (public_app serves the uploads mount there), while the
|
||||
widget still calls back to the install's API origin cross-origin — which is
|
||||
exactly what domain verification adds to CORS."""
|
||||
monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "path")
|
||||
monkeypatch.setattr(settings, "BACKEND_URL", "https://api.selfhosted.example")
|
||||
help_center.custom_domain = "help.customer.com"
|
||||
help_center.txt_record_verified = True
|
||||
help_center.cname_record_verified = True
|
||||
help_center.logo_url = "/api/v1/uploads/help_center/logo.png"
|
||||
help_center.agent_id = test_widget.agent_id
|
||||
db.commit()
|
||||
|
||||
r = client.get("/", headers={"host": "help.customer.com"})
|
||||
assert r.status_code == 200
|
||||
# The custom domain wins over path mode — no /help/{slug} anywhere.
|
||||
assert '<link rel="canonical" href="https://help.customer.com/">' in r.text
|
||||
assert "/help/test-org" not in r.text
|
||||
assert (
|
||||
'<meta property="og:image" content="https://help.customer.com/api/v1/uploads/help_center/logo.png">'
|
||||
in r.text
|
||||
)
|
||||
assert 'window.replypierBaseUrl = "https://api.selfhosted.example/api/v1"' in r.text
|
||||
|
||||
|
||||
def test_og_card_degrades_without_a_logo(client, db, test_organization, help_center):
|
||||
"""No logo means no og:image at all — a broken thumbnail is worse than a
|
||||
text-only card."""
|
||||
r = client.get("/", headers={"host": HOST})
|
||||
assert "og:image" not in r.text
|
||||
assert '<meta name="twitter:card" content="summary">' in r.text
|
||||
|
||||
|
||||
def test_search_filters_results(client, db, test_organization, help_center):
|
||||
_publish_faq(db, test_organization.id, question="How does billing work?", answer="Per seat.")
|
||||
_publish_faq(db, test_organization.id, question="Is data encrypted?", answer="Yes.")
|
||||
r = client.get("/", params={"q": "billing"}, headers={"host": HOST})
|
||||
assert "How does billing work?" in r.text
|
||||
assert "Is data encrypted?" not in r.text
|
||||
|
||||
|
||||
def test_disabled_or_unknown_hosts_404(client, db, test_organization, help_center):
|
||||
assert client.get("/", headers={"host": "nope.help.replypier.site"}).status_code == 404
|
||||
help_center.enabled = False
|
||||
db.commit()
|
||||
assert client.get("/", headers={"host": HOST}).status_code == 404
|
||||
|
||||
|
||||
def test_lapsed_plan_hides_site(client, db, test_organization, help_center):
|
||||
with patch("app.services.help_center_public.help_center_allowed", return_value=False):
|
||||
assert client.get("/", headers={"host": HOST}).status_code == 404
|
||||
|
||||
|
||||
def test_sitemap_and_robots(client, db, test_organization, help_center, subdomain_mode):
|
||||
sitemap = client.get("/sitemap.xml", headers={"host": HOST})
|
||||
assert sitemap.status_code == 200 and f"https://{HOST}/" in sitemap.text
|
||||
robots = client.get("/robots.txt", headers={"host": HOST})
|
||||
assert "Sitemap:" in robots.text
|
||||
|
||||
|
||||
def test_robots_closes_non_content_paths_but_keeps_articles(
|
||||
client, db, test_organization, help_center, subdomain_mode
|
||||
):
|
||||
"""The articles stay crawlable — only the paths that are not pages are
|
||||
closed: /feedback and /search (the catch-all 404s both as article slugs)
|
||||
and the per-category ?topic= variants of the landing page."""
|
||||
lines = client.get("/robots.txt", headers={"host": HOST}).text.splitlines()
|
||||
|
||||
assert "Allow: /" in lines
|
||||
assert "Disallow: /feedback" in lines
|
||||
assert "Disallow: /search" in lines
|
||||
assert "Disallow: /*?topic=" in lines
|
||||
# The landing page re-renders itself filtered for ?q= too — an unbounded
|
||||
# set of thin near-duplicates, and the bigger surface of the two.
|
||||
assert "Disallow: /*?q=" in lines
|
||||
assert not any(line.startswith("Disallow: /a/") for line in lines)
|
||||
|
||||
|
||||
def test_robots_disallows_carry_the_base_path_in_path_mode(
|
||||
path_client, db, test_organization, help_center
|
||||
):
|
||||
"""A bare "Disallow: /feedback" would name the origin root under path
|
||||
dispatch, not this site — so the rules carry the base path, same as every
|
||||
other link the site builds. (Crawlers only read the origin-root
|
||||
robots.txt, which is the API one; this keeps the two consistent.)"""
|
||||
lines = path_client.get("/help/test-org/robots.txt").text.splitlines()
|
||||
|
||||
assert "Disallow: /help/test-org/feedback" in lines
|
||||
|
||||
|
||||
# ---------- path dispatch (self-host default) ----------
|
||||
|
||||
def test_path_dispatch_serves_index(path_client, db, test_organization, help_center):
|
||||
_publish_faq(db, test_organization.id)
|
||||
r = path_client.get("/help/test-org")
|
||||
assert r.status_code == 200
|
||||
assert "How do I sign up?" in r.text
|
||||
# Internal links carry the /help/{slug} base_path prefix.
|
||||
assert 'href="/help/test-org/a/' in r.text
|
||||
# Canonical follows path mode (BACKEND_URL/help/{slug}).
|
||||
assert '<link rel="canonical" href="http' in r.text and "/help/test-org/" in r.text
|
||||
|
||||
|
||||
def test_path_dispatch_nested_article(path_client, db, test_organization, help_center):
|
||||
faq = _publish_faq(db, test_organization.id, answer="Click **Settings**.")
|
||||
faq.slug = "how-do-i-sign-up"
|
||||
db.commit()
|
||||
r = path_client.get("/help/test-org/a/how-do-i-sign-up")
|
||||
assert r.status_code == 200
|
||||
assert "<strong>Settings</strong>" in r.text
|
||||
|
||||
|
||||
def test_path_dispatch_unknown_slug_404(path_client, db):
|
||||
assert path_client.get("/help/nope").status_code == 404
|
||||
|
||||
|
||||
def test_public_app_only_serves_help_center_uploads():
|
||||
"""The help-center app must expose ONLY help_center images — never the whole
|
||||
uploads/ tree (chat_attachments, knowledge, avatars, …) or /assets."""
|
||||
from starlette.routing import Mount
|
||||
mount_paths = {r.path for r in public_app.routes if isinstance(r, Mount)}
|
||||
assert "/api/v1/uploads/help_center" in mount_paths
|
||||
assert "/api/v1/uploads" not in mount_paths # not the whole tree
|
||||
assert "/assets" not in mount_paths
|
||||
|
||||
|
||||
def test_path_dispatch_trailing_slash_served_not_redirected(path_client, db, test_organization, help_center):
|
||||
"""A trailing slash must serve the article, not 307 to the origin root without
|
||||
the /help/{slug} prefix (which would 404)."""
|
||||
faq = _publish_faq(db, test_organization.id)
|
||||
faq.slug = "how-do-i-sign-up"
|
||||
db.commit()
|
||||
r = path_client.get("/help/test-org/a/how-do-i-sign-up/")
|
||||
assert r.status_code == 200
|
||||
assert "How do I sign up?" in r.text
|
||||
|
||||
|
||||
def test_path_dispatch_feedback_post(path_client, db, test_organization, help_center):
|
||||
faq = _publish_faq(db, test_organization.id)
|
||||
faq.slug = "how-do-i-sign-up"
|
||||
db.commit()
|
||||
r = path_client.post("/help/test-org/a/how-do-i-sign-up/feedback", json={"helpful": True})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_path_dispatch_inert_in_subdomain_mode(db, monkeypatch):
|
||||
"""In subdomain mode, /help/{slug} is NOT claimed — it falls through to the
|
||||
main app (cloud safety: no path serving, no duplicate content)."""
|
||||
monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "subdomain")
|
||||
client = TestClient(HelpCenterHostMiddleware(_not_found_app, public_app))
|
||||
r = client.get("/help/test-org")
|
||||
assert r.status_code == 404 and r.text == "main app"
|
||||
|
||||
|
||||
# ---------- ask ----------
|
||||
|
||||
def test_ask_disabled_404(client, db, test_organization, help_center):
|
||||
help_center.ai_search_enabled = False
|
||||
db.commit()
|
||||
r = client.post("/ask", json={"question": "How do I sign up?"}, headers={"host": HOST})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_ask_answers_and_rate_limits(client, db, test_organization, help_center, test_agent):
|
||||
help_center.agent_id = test_agent.id
|
||||
db.commit()
|
||||
with patch("app.api.help_center_public.answer_question", new=AsyncMock(return_value="From your dashboard.")):
|
||||
ok = client.post("/ask", json={"question": "Where do I log in?"}, headers={"host": HOST})
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["answer"] == "From your dashboard."
|
||||
with patch("app.api.help_center_public.allow_request", return_value=False):
|
||||
limited = client.post("/ask", json={"question": "again?"}, headers={"host": HOST})
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
def test_ask_unanswerable_503(client, db, test_organization, help_center, test_agent):
|
||||
help_center.agent_id = test_agent.id
|
||||
db.commit()
|
||||
with patch("app.api.help_center_public.answer_question", new=AsyncMock(return_value=None)):
|
||||
r = client.post("/ask", json={"question": "Where do I log in?"}, headers={"host": HOST})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
# ---------- host dispatch ----------
|
||||
|
||||
def test_is_help_center_host_matches_subdomains_only():
|
||||
assert is_help_center_host("acme.help.replypier.site") is True
|
||||
assert is_help_center_host("help.replypier.site") is False
|
||||
assert is_help_center_host("api.replypier.site") is False
|
||||
assert is_help_center_host("") is False
|
||||
|
||||
|
||||
def test_is_help_center_host_checks_verified_domain_cache():
|
||||
with patch("app.core.help_center_host._domain_cache") as cache:
|
||||
cache.contains.return_value = True
|
||||
assert is_help_center_host("help.customer.com") is True
|
||||
cache.contains.assert_called_once_with("help.customer.com")
|
||||
|
||||
|
||||
def test_resolve_help_center_via_verified_domain(db, test_organization, help_center):
|
||||
help_center.custom_domain = "help.customer.com"
|
||||
help_center.txt_record_verified = True
|
||||
help_center.cname_record_verified = True
|
||||
db.commit()
|
||||
assert resolve_help_center(db, "help.customer.com").id == help_center.id
|
||||
# Unverified custom domains never resolve.
|
||||
help_center.txt_record_verified = False
|
||||
db.commit()
|
||||
assert resolve_help_center(db, "help.customer.com") is None
|
||||
|
||||
|
||||
def test_article_feedback_records_and_dedupes(client, db, test_organization, help_center):
|
||||
faq = _publish_faq(db, test_organization.id)
|
||||
faq.slug = "how-do-i-sign-up"
|
||||
db.commit()
|
||||
r = client.post(f"/a/{faq.slug}/feedback", json={"helpful": True}, headers={"host": HOST})
|
||||
assert r.status_code == 200 and r.json() == {"ok": True}
|
||||
db.refresh(faq)
|
||||
assert (faq.helpful_yes, faq.helpful_no) == (1, 0)
|
||||
# Same client (IP) can't vote again — deduped, tallies unchanged.
|
||||
client.post(f"/a/{faq.slug}/feedback", json={"helpful": False}, headers={"host": HOST})
|
||||
db.refresh(faq)
|
||||
assert (faq.helpful_yes, faq.helpful_no) == (1, 0)
|
||||
|
||||
|
||||
def test_article_feedback_unknown_slug_404(client, db, test_organization, help_center):
|
||||
r = client.post("/a/does-not-exist/feedback", json={"helpful": True}, headers={"host": HOST})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ---------- preserved original article URLs (help-center migration) ----------
|
||||
|
||||
# The kind of URL an org arrives with from Zendesk/Intercom.
|
||||
MIGRATED_PATH = "/hc/en-us/articles/360012-reset-password"
|
||||
|
||||
|
||||
def _migrated_faq(db, org_id, **kw):
|
||||
kw.setdefault("question", "How do I reset my password?")
|
||||
kw.setdefault("slug", "how-do-i-reset-my-password")
|
||||
kw.setdefault("url_path", MIGRATED_PATH)
|
||||
return _publish_faq(db, org_id, **kw)
|
||||
|
||||
|
||||
def test_preserved_path_serves_the_article(client, db, test_organization, help_center):
|
||||
"""The whole point: the URL the org already ranks for keeps working."""
|
||||
faq = _migrated_faq(db, test_organization.id)
|
||||
|
||||
r = client.get(MIGRATED_PATH, headers={"host": HOST})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert faq.question in r.text
|
||||
|
||||
|
||||
def test_slug_url_redirects_to_the_preserved_path(client, db, test_organization, help_center):
|
||||
"""One canonical URL per article — /a/{slug} is only an alias."""
|
||||
faq = _migrated_faq(db, test_organization.id)
|
||||
|
||||
r = client.get(f"/a/{faq.slug}", headers={"host": HOST}, follow_redirects=False)
|
||||
|
||||
assert r.status_code == 301
|
||||
assert r.headers["location"] == MIGRATED_PATH
|
||||
# Bounded, so clearing the path later doesn't strand browsers on a dead 301.
|
||||
assert r.headers["cache-control"] == "public, max-age=60"
|
||||
|
||||
|
||||
def test_preserved_path_drives_canonical_and_json_ld(
|
||||
client, db, test_organization, help_center, subdomain_mode
|
||||
):
|
||||
_migrated_faq(db, test_organization.id)
|
||||
|
||||
r = client.get(MIGRATED_PATH, headers={"host": HOST})
|
||||
|
||||
expected = f"https://test-org.help.replypier.site{MIGRATED_PATH}"
|
||||
assert f'<link rel="canonical" href="{expected}">' in r.text
|
||||
assert f'<meta property="og:url" content="{expected}">' in r.text
|
||||
graph = json.loads(re.search(
|
||||
r'<script type="application/ld\+json">(.*?)</script>', r.text, re.S
|
||||
).group(1))["@graph"]
|
||||
page = next(n for n in graph if n["@type"] == "WebPage")
|
||||
assert page["url"] == expected
|
||||
crumbs = next(n for n in graph if n["@type"] == "BreadcrumbList")
|
||||
assert crumbs["itemListElement"][-1]["item"] == expected
|
||||
|
||||
|
||||
def test_sitemap_lists_the_preserved_path(client, db, test_organization, help_center, subdomain_mode):
|
||||
_migrated_faq(db, test_organization.id)
|
||||
|
||||
r = client.get("/sitemap.xml", headers={"host": HOST})
|
||||
|
||||
assert f"<loc>https://test-org.help.replypier.site{MIGRATED_PATH}</loc>" in r.text
|
||||
assert "/a/how-do-i-reset-my-password" not in r.text
|
||||
|
||||
|
||||
def test_index_and_related_cards_link_to_the_preserved_path(
|
||||
client, db, test_organization, help_center
|
||||
):
|
||||
_migrated_faq(db, test_organization.id)
|
||||
other = _publish_faq(
|
||||
db, test_organization.id, question="How do I sign up?", slug="how-do-i-sign-up",
|
||||
)
|
||||
|
||||
index = client.get("/", headers={"host": HOST})
|
||||
assert f'href="{MIGRATED_PATH}"' in index.text
|
||||
|
||||
# Same-category related list on the other article points at it too.
|
||||
article = client.get(f"/a/{other.slug}", headers={"host": HOST})
|
||||
assert f'href="{MIGRATED_PATH}"' in article.text
|
||||
|
||||
|
||||
def test_cross_article_body_links_resolve_to_the_preserved_path(
|
||||
client, db, test_organization, help_center
|
||||
):
|
||||
"""Bodies STORE /a/{slug}; the reader should still land on the preserved URL
|
||||
directly rather than bouncing through the 301."""
|
||||
_migrated_faq(db, test_organization.id)
|
||||
_publish_faq(
|
||||
db, test_organization.id, question="Billing", slug="billing",
|
||||
answer="See [resetting](/a/how-do-i-reset-my-password) and [home](/).",
|
||||
)
|
||||
|
||||
r = client.get("/a/billing", headers={"host": HOST})
|
||||
|
||||
assert f'href="{MIGRATED_PATH}"' in r.text
|
||||
assert 'href="/a/how-do-i-reset-my-password"' not in r.text
|
||||
|
||||
|
||||
def test_feedback_posts_to_the_slug_route_from_a_preserved_path_page(
|
||||
client, db, test_organization, help_center
|
||||
):
|
||||
"""location.pathname would POST to a path with no route (a 405, not a 404)."""
|
||||
faq = _migrated_faq(db, test_organization.id)
|
||||
|
||||
page = client.get(MIGRATED_PATH, headers={"host": HOST})
|
||||
assert f'data-feedback-url="/a/{faq.slug}/feedback"' in page.text
|
||||
|
||||
r = client.post(f"/a/{faq.slug}/feedback", json={"helpful": True}, headers={"host": HOST})
|
||||
assert r.status_code == 200
|
||||
assert faq.helpful_yes == 1
|
||||
|
||||
|
||||
def test_preserved_path_trailing_slash_redirects(client, db, test_organization, help_center):
|
||||
_migrated_faq(db, test_organization.id)
|
||||
|
||||
r = client.get(f"{MIGRATED_PATH}/", headers={"host": HOST}, follow_redirects=False)
|
||||
|
||||
assert r.status_code == 301
|
||||
assert r.headers["location"] == MIGRATED_PATH
|
||||
|
||||
|
||||
def test_slug_trailing_slash_still_redirects(client, db, test_organization, help_center):
|
||||
"""Regression guard: a catch-all matches everything, so Starlette's own
|
||||
redirect_slashes fallback never fires again and we must do it ourselves."""
|
||||
faq = _publish_faq(db, test_organization.id, slug="how-do-i-sign-up")
|
||||
|
||||
r = client.get(f"/a/{faq.slug}/", headers={"host": HOST}, follow_redirects=False)
|
||||
|
||||
assert r.status_code == 301
|
||||
assert r.headers["location"] == f"/a/{faq.slug}"
|
||||
|
||||
|
||||
def test_unknown_path_404s(client, db, test_organization, help_center):
|
||||
_migrated_faq(db, test_organization.id)
|
||||
|
||||
assert client.get("/hc/en-us/articles/nope", headers={"host": HOST}).status_code == 404
|
||||
|
||||
|
||||
def test_reserved_routes_win_over_a_preserved_path(client, db, test_organization, help_center):
|
||||
"""Belt and braces on route ordering: even a path forced past the normaliser
|
||||
straight into the DB must not shadow a real route."""
|
||||
_publish_faq(
|
||||
db, test_organization.id, slug="sneaky", url_path="/sitemap.xml",
|
||||
)
|
||||
|
||||
r = client.get("/sitemap.xml", headers={"host": HOST})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert "<urlset" in r.text
|
||||
|
||||
|
||||
def test_path_dispatch_serves_the_preserved_path(path_client, db, test_organization, help_center):
|
||||
"""Under /help/{slug} the preserved path hangs off the base path, and every
|
||||
rendered link keeps the prefix."""
|
||||
_migrated_faq(db, test_organization.id)
|
||||
_publish_faq(db, test_organization.id, question="Billing", slug="billing")
|
||||
|
||||
r = path_client.get(f"/help/test-org{MIGRATED_PATH}")
|
||||
assert r.status_code == 200
|
||||
|
||||
index = path_client.get("/help/test-org/")
|
||||
assert f'href="/help/test-org{MIGRATED_PATH}"' in index.text
|
||||
|
||||
|
||||
def test_path_dispatch_slug_url_redirects_with_the_base_path(
|
||||
path_client, db, test_organization, help_center
|
||||
):
|
||||
faq = _migrated_faq(db, test_organization.id)
|
||||
|
||||
r = path_client.get(f"/help/test-org/a/{faq.slug}", follow_redirects=False)
|
||||
|
||||
assert r.status_code == 301
|
||||
assert r.headers["location"] == f"/help/test-org{MIGRATED_PATH}"
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
|
||||
Tests for the on-demand-TLS "ask" gate (GET /health/help-center-domain) that an
|
||||
edge proxy (e.g. Caddy) calls before issuing a certificate for a hostname.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main # noqa: F401 — registers routes on the FastAPI app
|
||||
from app.core.application import app
|
||||
|
||||
client = TestClient(app)
|
||||
ASK = "/health/help-center-domain"
|
||||
|
||||
|
||||
def test_allows_slug_subdomain():
|
||||
# {slug}.HELP_CENTER_BASE_DOMAIN is a help-center host without any DB lookup.
|
||||
assert client.get(ASK, params={"domain": "acme.help.replypier.site"}).status_code == 200
|
||||
|
||||
|
||||
def test_allows_verified_custom_domain():
|
||||
with patch("app.core.help_center_host._domain_cache") as cache:
|
||||
cache.contains.return_value = True
|
||||
assert client.get(ASK, params={"domain": "help.customer.com"}).status_code == 200
|
||||
|
||||
|
||||
def test_rejects_unknown_domain():
|
||||
with patch("app.core.help_center_host._domain_cache") as cache:
|
||||
cache.contains.return_value = False
|
||||
assert client.get(ASK, params={"domain": "evil.example.com"}).status_code == 404
|
||||
|
||||
|
||||
def test_rejects_missing_or_empty_domain():
|
||||
assert client.get(ASK).status_code == 404
|
||||
assert client.get(ASK, params={"domain": ""}).status_code == 404
|
||||
|
||||
|
||||
def test_normalizes_host_with_port_and_uppercase():
|
||||
# SNI can arrive with a port / mixed case; normalize_host must canonicalize
|
||||
# it so the slug match still succeeds.
|
||||
assert client.get(ASK, params={"domain": "ACME.help.replypier.site:443"}).status_code == 200
|
||||
@@ -0,0 +1,625 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 unittest.mock import patch, MagicMock, AsyncMock
|
||||
from app.database import get_db
|
||||
from fastapi import FastAPI
|
||||
from app.models.user import User
|
||||
from app.models.organization import Organization
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from app.models.jira import JiraToken, AgentJiraConfig
|
||||
from app.models.agent import Agent, AgentType
|
||||
from uuid import UUID, uuid4
|
||||
from datetime import datetime, timedelta
|
||||
from app.api import jira as jira_router
|
||||
import app.core.oauth_state as oauth_state
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from app.core.auth import get_current_user, get_current_organization, require_permissions
|
||||
from app.core.exceptions import JiraAuthError
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, Base
|
||||
from app.core.config import settings
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
jira_router.router,
|
||||
prefix="/api/v1/jira", # Match the prefix in main.py
|
||||
tags=["jira"]
|
||||
)
|
||||
|
||||
# Override the dependencies
|
||||
def override_get_db():
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db():
|
||||
"""Create a fresh database for each test."""
|
||||
# Drop all tables first
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
# Create tables except enterprise ones
|
||||
create_tables()
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture
|
||||
def test_permissions(db) -> list[Permission]:
|
||||
"""Create test permissions"""
|
||||
permissions = []
|
||||
for name in ["manage_organization", "view_organization"]:
|
||||
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_permissions) -> Role:
|
||||
"""Create a test role with required permissions"""
|
||||
role = Role(
|
||||
id=1,
|
||||
name="Test Role",
|
||||
description="Test Role Description",
|
||||
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_organization(db) -> Organization:
|
||||
"""Create a test organization"""
|
||||
org = Organization(
|
||||
id=uuid4(),
|
||||
name="Test Organization",
|
||||
domain="test.com",
|
||||
timezone="UTC",
|
||||
business_hours={"monday": {"start": "09:00", "end": "17:00"}}
|
||||
)
|
||||
db.add(org)
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
return org
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db, test_organization, test_role) -> User:
|
||||
"""Create a test user"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="test@test.com",
|
||||
hashed_password="hashed_password",
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
role_id=test_role.id
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent(db, test_organization) -> Agent:
|
||||
"""Create a test agent"""
|
||||
agent = Agent(
|
||||
id=uuid4(),
|
||||
name="Test Agent",
|
||||
organization_id=test_organization.id,
|
||||
instructions="Test instructions",
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
agent_type=AgentType.GENERAL
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
@pytest.fixture
|
||||
def test_jira_token(db, test_organization) -> JiraToken:
|
||||
"""Create a test Jira token"""
|
||||
token = JiraToken(
|
||||
organization_id=test_organization.id,
|
||||
access_token="test_access_token",
|
||||
refresh_token="test_refresh_token",
|
||||
token_type="Bearer",
|
||||
expires_at=datetime.utcnow() + timedelta(hours=1),
|
||||
cloud_id="test_cloud_id",
|
||||
site_url="https://test.atlassian.net"
|
||||
)
|
||||
db.add(token)
|
||||
db.commit()
|
||||
db.refresh(token)
|
||||
return token
|
||||
|
||||
@pytest.fixture
|
||||
def test_expired_jira_token(db, test_organization) -> JiraToken:
|
||||
"""Create a test expired Jira token"""
|
||||
token = JiraToken(
|
||||
organization_id=test_organization.id,
|
||||
access_token="test_access_token",
|
||||
refresh_token="test_refresh_token",
|
||||
token_type="Bearer",
|
||||
expires_at=datetime.utcnow() - timedelta(hours=1),
|
||||
cloud_id="test_cloud_id",
|
||||
site_url="https://test.atlassian.net"
|
||||
)
|
||||
db.add(token)
|
||||
db.commit()
|
||||
db.refresh(token)
|
||||
return token
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent_jira_config(db, test_agent) -> AgentJiraConfig:
|
||||
"""Create a test agent Jira config"""
|
||||
config = AgentJiraConfig(
|
||||
agent_id=str(test_agent.id),
|
||||
enabled=True,
|
||||
project_key="TEST",
|
||||
issue_type_id="10001"
|
||||
)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return config
|
||||
|
||||
@pytest.fixture
|
||||
def client(db, test_user, test_organization):
|
||||
"""Create a test client with dependencies overridden"""
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# Override auth dependencies
|
||||
def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
def override_get_current_organization():
|
||||
return test_organization
|
||||
|
||||
def override_require_permissions(*permissions):
|
||||
def _require_permissions():
|
||||
return test_user
|
||||
return _require_permissions
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_current_organization] = override_get_current_organization
|
||||
app.dependency_overrides[require_permissions] = override_require_permissions
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
def test_jira_status_connected(client, test_jira_token):
|
||||
"""Test getting Jira status when connected"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=True):
|
||||
response = client.get("/api/v1/jira/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"connected": True,
|
||||
"site_url": "https://test.atlassian.net"
|
||||
}
|
||||
|
||||
def test_jira_status_not_connected(client, db):
|
||||
"""Test getting Jira status when not connected"""
|
||||
response = client.get("/api/v1/jira/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"connected": False
|
||||
}
|
||||
|
||||
def test_jira_status_expired_token_refresh_success(client, test_expired_jira_token):
|
||||
"""Test getting Jira status with expired token that gets refreshed successfully"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=False):
|
||||
with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh:
|
||||
mock_refresh.return_value = {
|
||||
"access_token": "new_access_token",
|
||||
"refresh_token": "new_refresh_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": datetime.utcnow() + timedelta(hours=1)
|
||||
}
|
||||
|
||||
response = client.get("/api/v1/jira/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"connected": True,
|
||||
"site_url": "https://test.atlassian.net"
|
||||
}
|
||||
mock_refresh.assert_called_once_with("test_refresh_token")
|
||||
|
||||
def test_jira_status_expired_token_refresh_failure(client, test_expired_jira_token):
|
||||
"""Test getting Jira status with expired token that fails to refresh"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=False):
|
||||
with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh:
|
||||
mock_refresh.side_effect = Exception("Failed to refresh token")
|
||||
|
||||
response = client.get("/api/v1/jira/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"connected": False,
|
||||
"site_url": None
|
||||
}
|
||||
mock_refresh.assert_called_once_with("test_refresh_token")
|
||||
|
||||
def test_disconnect_jira_success(client, test_jira_token, test_agent_jira_config):
|
||||
"""Test disconnecting Jira successfully"""
|
||||
response = client.delete("/api/v1/jira/disconnect")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Jira disconnected successfully"}
|
||||
|
||||
def test_disconnect_jira_not_connected(client):
|
||||
"""Test disconnecting Jira when not connected"""
|
||||
response = client.delete("/api/v1/jira/disconnect")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Jira connection found"}
|
||||
|
||||
@pytest.fixture
|
||||
def inprocess_oauth_state(monkeypatch):
|
||||
"""The in-process state store, whether or not a Redis is reachable here."""
|
||||
monkeypatch.setattr(oauth_state, "get_redis", lambda: None)
|
||||
oauth_state._fallback.clear()
|
||||
|
||||
|
||||
def test_authorize_jira(client, inprocess_oauth_state):
|
||||
"""The redirect carries a state the callback can redeem once."""
|
||||
response = client.get("/api/v1/jira/authorize", follow_redirects=False)
|
||||
assert response.status_code == 307
|
||||
location = response.headers["location"]
|
||||
assert location.startswith("https://auth.atlassian.com/authorize?")
|
||||
state = parse_qs(urlparse(location).query)["state"][0]
|
||||
assert oauth_state.pop_state("jira", state) is not None
|
||||
assert oauth_state.pop_state("jira", state) is None
|
||||
|
||||
|
||||
def test_jira_oauth_callback_success(client, db, test_organization, inprocess_oauth_state):
|
||||
"""Test successful Jira OAuth callback"""
|
||||
state = "test_state"
|
||||
oauth_state.store_state("jira", state, str(test_organization.id))
|
||||
|
||||
with patch('app.api.jira.jira_service.exchange_code_for_token', new_callable=AsyncMock) as mock_exchange:
|
||||
mock_exchange.return_value = {
|
||||
"access_token": "new_access_token",
|
||||
"refresh_token": "new_refresh_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": datetime.utcnow() + timedelta(hours=1)
|
||||
}
|
||||
|
||||
with patch('app.api.jira.jira_service.get_cloud_id', new_callable=AsyncMock) as mock_cloud_id:
|
||||
mock_cloud_id.return_value = {
|
||||
"cloud_id": "test_cloud_id",
|
||||
"site_url": "https://test.atlassian.net"
|
||||
}
|
||||
|
||||
response = client.get(f"/api/v1/jira/callback?code=test_code&state={state}", follow_redirects=False)
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == f"{settings.FRONTEND_URL}/settings/integrations?status=success"
|
||||
|
||||
# The state is single-use.
|
||||
assert oauth_state.pop_state("jira", state) is None
|
||||
|
||||
token = db.query(JiraToken).filter(JiraToken.organization_id == test_organization.id).first()
|
||||
assert token is not None
|
||||
assert token.access_token == "new_access_token"
|
||||
assert token.refresh_token == "new_refresh_token"
|
||||
|
||||
|
||||
def test_jira_oauth_callback_error(client, inprocess_oauth_state):
|
||||
"""Test Jira OAuth callback with error"""
|
||||
response = client.get("/api/v1/jira/callback?error=access_denied&error_description=User%20denied%20access", follow_redirects=False)
|
||||
assert response.status_code == 307
|
||||
assert "status=failure" in response.headers["location"]
|
||||
assert "reason=access_denied" in response.headers["location"]
|
||||
|
||||
|
||||
def test_jira_oauth_callback_invalid_state(client, inprocess_oauth_state):
|
||||
"""Test Jira OAuth callback with invalid state"""
|
||||
response = client.get("/api/v1/jira/callback?code=test_code&state=invalid_state", follow_redirects=False)
|
||||
assert response.status_code == 307
|
||||
assert "status=failure" in response.headers["location"]
|
||||
assert "reason=invalid_state" in response.headers["location"]
|
||||
|
||||
|
||||
def test_jira_oauth_callback_exception(client, test_organization, inprocess_oauth_state):
|
||||
"""Test Jira OAuth callback with exception"""
|
||||
state = "test_state"
|
||||
oauth_state.store_state("jira", state, str(test_organization.id))
|
||||
|
||||
with patch('app.api.jira.jira_service.exchange_code_for_token', new_callable=AsyncMock) as mock_exchange:
|
||||
mock_exchange.side_effect = Exception("Test exception")
|
||||
|
||||
response = client.get(f"/api/v1/jira/callback?code=test_code&state={state}", follow_redirects=False)
|
||||
assert response.status_code == 307
|
||||
assert "status=failure" in response.headers["location"]
|
||||
assert "reason=Test_exception" in response.headers["location"]
|
||||
|
||||
assert oauth_state.pop_state("jira", state) is None
|
||||
|
||||
def test_refresh_jira_token_success(client, test_jira_token):
|
||||
"""Test refreshing Jira token successfully"""
|
||||
with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh:
|
||||
mock_refresh.return_value = {
|
||||
"access_token": "new_access_token",
|
||||
"refresh_token": "new_refresh_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": datetime.utcnow() + timedelta(hours=1)
|
||||
}
|
||||
|
||||
response = client.get("/api/v1/jira/refresh")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Token refreshed successfully"}
|
||||
mock_refresh.assert_called_once_with("test_refresh_token")
|
||||
|
||||
def test_refresh_jira_token_not_found(client):
|
||||
"""Test refreshing Jira token when not found"""
|
||||
response = client.get("/api/v1/jira/refresh")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Jira token found"}
|
||||
|
||||
def test_refresh_jira_token_error(client, test_jira_token):
|
||||
"""Test refreshing Jira token with error"""
|
||||
with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh:
|
||||
mock_refresh.side_effect = Exception("Failed to refresh token")
|
||||
|
||||
response = client.get("/api/v1/jira/refresh")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Failed to refresh token"}
|
||||
|
||||
def test_get_jira_projects_success(client, test_jira_token):
|
||||
"""Test getting Jira projects successfully"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=True):
|
||||
with patch('app.api.jira.jira_service.get_projects', new_callable=AsyncMock) as mock_projects:
|
||||
mock_projects.return_value = [
|
||||
{"id": "10000", "key": "TEST", "name": "Test Project"},
|
||||
{"id": "10001", "key": "DEMO", "name": "Demo Project"}
|
||||
]
|
||||
|
||||
response = client.get("/api/v1/jira/projects")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{"id": "10000", "key": "TEST", "name": "Test Project"},
|
||||
{"id": "10001", "key": "DEMO", "name": "Demo Project"}
|
||||
]
|
||||
|
||||
def test_get_jira_projects_not_connected(client):
|
||||
"""Test getting Jira projects when not connected"""
|
||||
response = client.get("/api/v1/jira/projects")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Jira connection found"}
|
||||
|
||||
def test_get_jira_projects_expired_token_refresh_success(client, test_expired_jira_token):
|
||||
"""Test getting Jira projects with expired token that gets refreshed successfully"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=False):
|
||||
with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh:
|
||||
mock_refresh.return_value = {
|
||||
"access_token": "new_access_token",
|
||||
"refresh_token": "new_refresh_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": datetime.utcnow() + timedelta(hours=1)
|
||||
}
|
||||
|
||||
with patch('app.api.jira.jira_service.get_projects', new_callable=AsyncMock) as mock_projects:
|
||||
mock_projects.return_value = [
|
||||
{"id": "10000", "key": "TEST", "name": "Test Project"}
|
||||
]
|
||||
|
||||
response = client.get("/api/v1/jira/projects")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{"id": "10000", "key": "TEST", "name": "Test Project"}
|
||||
]
|
||||
|
||||
def test_get_jira_projects_expired_token_refresh_failure(client, test_expired_jira_token):
|
||||
"""Test getting Jira projects with expired token that fails to refresh"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=False):
|
||||
with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh:
|
||||
mock_refresh.side_effect = Exception("Failed to refresh token")
|
||||
|
||||
response = client.get("/api/v1/jira/projects")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Jira token expired and could not be refreshed"}
|
||||
|
||||
def test_get_jira_projects_error(client, test_jira_token):
|
||||
"""Test getting Jira projects with error"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=True):
|
||||
with patch('app.api.jira.jira_service.get_projects', new_callable=AsyncMock) as mock_projects:
|
||||
mock_projects.side_effect = Exception("Failed to get projects")
|
||||
|
||||
response = client.get("/api/v1/jira/projects")
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Failed to get Jira projects"}
|
||||
|
||||
def test_get_jira_issue_types_success(client, test_jira_token):
|
||||
"""Test getting Jira issue types successfully"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=True):
|
||||
with patch('app.api.jira.jira_service.get_issue_types', new_callable=AsyncMock) as mock_issue_types:
|
||||
mock_issue_types.return_value = [
|
||||
{"id": "10001", "name": "Bug", "description": "A bug"},
|
||||
{"id": "10002", "name": "Task", "description": "A task"}
|
||||
]
|
||||
|
||||
response = client.get("/api/v1/jira/projects/TEST/issue-types")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{"id": "10001", "name": "Bug", "description": "A bug"},
|
||||
{"id": "10002", "name": "Task", "description": "A task"}
|
||||
]
|
||||
|
||||
def test_get_jira_priorities_success(client, test_jira_token):
|
||||
"""Test getting Jira priorities successfully"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=True):
|
||||
with patch('app.api.jira.jira_service.get_priorities', new_callable=AsyncMock) as mock_priorities:
|
||||
mock_priorities.return_value = [
|
||||
{"id": "1", "name": "Highest", "description": "Highest priority", "iconUrl": "https://example.com/highest.png"},
|
||||
{"id": "2", "name": "High", "description": "High priority", "iconUrl": "https://example.com/high.png"}
|
||||
]
|
||||
|
||||
response = client.get("/api/v1/jira/priorities")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{"id": "1", "name": "Highest", "description": "Highest priority", "iconUrl": "https://example.com/highest.png"},
|
||||
{"id": "2", "name": "High", "description": "High priority", "iconUrl": "https://example.com/high.png"}
|
||||
]
|
||||
|
||||
def test_check_priority_availability_success(client, test_jira_token):
|
||||
"""Test checking priority availability successfully"""
|
||||
with patch('app.api.jira.jira_service.validate_token', return_value=True):
|
||||
with patch('app.api.jira.jira_service.is_priority_available', new_callable=AsyncMock) as mock_priority:
|
||||
mock_priority.return_value = True
|
||||
|
||||
response = client.get("/api/v1/jira/projects/TEST/issue-types/10001/has-priority")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"hasPriority": True}
|
||||
|
||||
def test_create_jira_issue_success(client, test_jira_token):
|
||||
"""Test creating a Jira issue successfully"""
|
||||
with patch('app.api.jira.jira_service.create_issue', new_callable=AsyncMock) as mock_create:
|
||||
mock_create.return_value = {
|
||||
"id": "10000",
|
||||
"key": "TEST-123",
|
||||
"self": "https://test.atlassian.net/rest/api/3/issue/TEST-123"
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/jira/issues",
|
||||
json={
|
||||
"projectKey": "TEST",
|
||||
"issueTypeId": "10001",
|
||||
"summary": "Test Issue",
|
||||
"description": "This is a test issue",
|
||||
"priority": "High"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": "10000",
|
||||
"key": "TEST-123",
|
||||
"self": "https://test.atlassian.net/rest/api/3/issue/TEST-123"
|
||||
}
|
||||
|
||||
def test_create_jira_issue_auth_error(client, test_jira_token):
|
||||
"""Test creating a Jira issue with authentication error"""
|
||||
with patch('app.api.jira.jira_service.create_issue', new_callable=AsyncMock) as mock_create:
|
||||
mock_create.side_effect = JiraAuthError("Jira authentication failed")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/jira/issues",
|
||||
json={
|
||||
"projectKey": "TEST",
|
||||
"issueTypeId": "10001",
|
||||
"summary": "Test Issue",
|
||||
"description": "This is a test issue"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Jira authentication failed"}
|
||||
|
||||
def test_create_jira_issue_error(client, test_jira_token):
|
||||
"""Test creating a Jira issue with error"""
|
||||
with patch('app.api.jira.jira_service.create_issue', new_callable=AsyncMock) as mock_create:
|
||||
mock_create.side_effect = Exception("Failed to create issue")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/jira/issues",
|
||||
json={
|
||||
"projectKey": "TEST",
|
||||
"issueTypeId": "10001",
|
||||
"summary": "Test Issue",
|
||||
"description": "This is a test issue"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Failed to create Jira issue"}
|
||||
|
||||
def test_save_agent_jira_config_success(client, test_agent, test_jira_token):
|
||||
"""Test saving agent Jira config successfully"""
|
||||
response = client.post(
|
||||
f"/api/v1/jira/agent-config/{test_agent.id}",
|
||||
json={
|
||||
"enabled": True,
|
||||
"projectKey": "TEST",
|
||||
"issueTypeId": "10001"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Agent Jira configuration saved successfully"}
|
||||
|
||||
def test_save_agent_jira_config_update_existing(client, test_agent, test_agent_jira_config, test_jira_token):
|
||||
"""Test updating existing agent Jira config"""
|
||||
response = client.post(
|
||||
f"/api/v1/jira/agent-config/{test_agent.id}",
|
||||
json={
|
||||
"enabled": False,
|
||||
"projectKey": "DEMO",
|
||||
"issueTypeId": "10002"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Agent Jira configuration saved successfully"}
|
||||
|
||||
def test_save_agent_jira_config_no_jira_connection(client, test_agent):
|
||||
"""Test saving agent Jira config with no Jira connection"""
|
||||
response = client.post(
|
||||
f"/api/v1/jira/agent-config/{test_agent.id}",
|
||||
json={
|
||||
"enabled": True,
|
||||
"projectKey": "TEST",
|
||||
"issueTypeId": "10001"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Cannot enable Jira integration: Jira is not connected"}
|
||||
|
||||
def test_get_agent_jira_config_exists(client, test_agent, test_agent_jira_config):
|
||||
"""Test getting agent Jira config when it exists"""
|
||||
response = client.get(f"/api/v1/jira/agent-config/{test_agent.id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"enabled": True,
|
||||
"projectKey": "TEST",
|
||||
"issueTypeId": "10001"
|
||||
}
|
||||
|
||||
def test_get_agent_jira_config_not_exists(client, test_agent):
|
||||
"""Test getting agent Jira config when it doesn't exist"""
|
||||
response = client.get(f"/api/v1/jira/agent-config/{test_agent.id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"enabled": False,
|
||||
"projectKey": None,
|
||||
"issueTypeId": None
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 patch
|
||||
from uuid import uuid4
|
||||
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
|
||||
|
||||
BASE = "/api/v1/agent"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db, test_user):
|
||||
async def override_user():
|
||||
return test_user
|
||||
|
||||
async def override_perms(*args, **kwargs):
|
||||
return test_user
|
||||
|
||||
def override_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_user
|
||||
app.dependency_overrides[require_permissions] = override_perms
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_lazily_creates_off_config(client, test_agent):
|
||||
r = client.get(f"{BASE}/{test_agent.id}/lead-capture")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["enabled"] is False
|
||||
assert body["agent_id"] == str(test_agent.id)
|
||||
|
||||
|
||||
def test_get_cross_org_agent_404(client):
|
||||
r = client.get(f"{BASE}/{uuid4()}/lead-capture")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_put_updates_config_oss_mode(client, test_agent):
|
||||
# OSS mode (no enterprise gate) so any plan can save.
|
||||
with patch("app.api.lead_capture.HAS_ENTERPRISE", False):
|
||||
payload = {
|
||||
"enabled": True,
|
||||
"require_consent": True,
|
||||
"guidance": "Ask after pricing",
|
||||
"fields": [
|
||||
{"key": "email", "standard": True, "enabled": True, "required": True},
|
||||
{"key": "company", "standard": True, "enabled": True, "required": False},
|
||||
],
|
||||
"assignment_mode": "none",
|
||||
"assignment_target_user_id": None,
|
||||
"crm_sync_target": "none",
|
||||
"slack_notify_enabled": False,
|
||||
}
|
||||
r = client.put(f"{BASE}/{test_agent.id}/lead-capture", json=payload)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["enabled"] is True
|
||||
assert {f["key"] for f in body["fields"]} == {"email", "company"}
|
||||
|
||||
|
||||
def test_put_rejects_cross_org_assignment_target(client, test_agent):
|
||||
with patch("app.api.lead_capture.HAS_ENTERPRISE", False):
|
||||
payload = {
|
||||
"enabled": True,
|
||||
"require_consent": True,
|
||||
"guidance": None,
|
||||
"fields": [{"key": "email", "standard": True, "enabled": True, "required": True}],
|
||||
"assignment_mode": "specific_person",
|
||||
"assignment_target_user_id": str(uuid4()), # not in this org
|
||||
"crm_sync_target": "none",
|
||||
"slack_notify_enabled": False,
|
||||
}
|
||||
r = client.put(f"{BASE}/{test_agent.id}/lead-capture", json=payload)
|
||||
assert r.status_code == 400
|
||||
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
"""
|
||||
ReplyPier - MCP Tool API Tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api import mcp_tool as mcp_api
|
||||
from app.core.config import settings
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.mcp_tool import MCPTransportType
|
||||
from app.repositories.mcp_tool import MCPToolRepository
|
||||
from app.models.schemas.mcp_tool import (
|
||||
MCPToolCreate, MCPToolUpdate, MCPToolToAgentCreate,
|
||||
MAX_USAGE_GUIDANCE_CHARS, SECRET_MASK,
|
||||
)
|
||||
from app.models.ticket_settings import OrganizationTicketSettings
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mcp_api.router, prefix="/api/mcp", tags=["mcp"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db) -> TestClient:
|
||||
# Minimal user with permissions
|
||||
test_user = User(
|
||||
id=uuid4(),
|
||||
email="test@example.com",
|
||||
hashed_password="x",
|
||||
is_active=True,
|
||||
organization_id=uuid4(),
|
||||
)
|
||||
db.add(test_user)
|
||||
# Create a role with super_admin permission to bypass permission checks
|
||||
super_admin_perm = Permission(name="super_admin", description="All access")
|
||||
db.add(super_admin_perm)
|
||||
db.commit()
|
||||
db.refresh(super_admin_perm)
|
||||
|
||||
role = Role(name="Admin", organization_id=test_user.organization_id)
|
||||
role.permissions = [super_admin_perm]
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
test_user.role_id = role.id
|
||||
db.add(test_user)
|
||||
db.commit()
|
||||
db.refresh(test_user)
|
||||
|
||||
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[get_db] = override_get_db
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _create_agent(db, org_id):
|
||||
agent = Agent(
|
||||
id=uuid4(),
|
||||
name="A",
|
||||
agent_type=AgentType.CUSTOMER_SUPPORT,
|
||||
_instructions="[]",
|
||||
is_active=True,
|
||||
organization_id=org_id,
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
def test_create_and_get_mcp_tool(client: TestClient, db):
|
||||
# Disable enterprise gating for tests
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
# Fetch the user created in fixture
|
||||
user = db.query(User).first()
|
||||
|
||||
# Create an MCP tool
|
||||
payload = {
|
||||
"name": "Tool1",
|
||||
"description": "desc",
|
||||
"transport_type": MCPTransportType.STDIO.value,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"env_vars": {"FOO": "bar"},
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
resp = client.post("/api/mcp", json=payload)
|
||||
assert resp.status_code == 200
|
||||
tool = resp.json()
|
||||
assert tool["name"] == "Tool1"
|
||||
tool_id = tool["id"]
|
||||
|
||||
# Get tool by id
|
||||
resp2 = client.get(f"/api/mcp/{tool_id}")
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json()["id"] == tool_id
|
||||
|
||||
|
||||
def test_list_update_delete_mcp_tool(client: TestClient, db):
|
||||
# Ensure enterprise check passes by forcing HAS_ENTERPRISE False
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
|
||||
# Create another tool
|
||||
payload = {
|
||||
"name": "Tool2",
|
||||
"description": "",
|
||||
"transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {"Authorization": "Bearer"},
|
||||
"timeout": 15,
|
||||
"enabled": False,
|
||||
}
|
||||
resp = client.post("/api/mcp", json=payload)
|
||||
assert resp.status_code == 200
|
||||
tool = resp.json()
|
||||
tool_id = tool["id"]
|
||||
|
||||
# List tools
|
||||
resp2 = client.get("/api/mcp?enabled_only=false")
|
||||
assert resp2.status_code == 200
|
||||
tools = resp2.json()
|
||||
assert any(t["id"] == tool_id for t in tools)
|
||||
|
||||
# Update tool
|
||||
upd = {"description": "updated", "enabled": True}
|
||||
resp3 = client.put(f"/api/mcp/{tool_id}", json=upd)
|
||||
assert resp3.status_code == 200
|
||||
assert resp3.json()["description"] == "updated"
|
||||
assert resp3.json()["enabled"] is True
|
||||
|
||||
# Delete tool
|
||||
resp4 = client.delete(f"/api/mcp/{tool_id}")
|
||||
assert resp4.status_code == 200
|
||||
assert resp4.json()["message"] == "MCP tool deleted successfully"
|
||||
|
||||
|
||||
def test_agent_association_endpoints(client: TestClient, db):
|
||||
# Ensure enterprise check passes by forcing HAS_ENTERPRISE False
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
# Create a tool
|
||||
payload = {
|
||||
"name": "Tool3",
|
||||
"description": "",
|
||||
"transport_type": MCPTransportType.STDIO.value,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"enabled": True,
|
||||
}
|
||||
tool_resp = client.post("/api/mcp", json=payload)
|
||||
assert tool_resp.status_code == 200
|
||||
tool_id = tool_resp.json()["id"]
|
||||
|
||||
# Create agent in same org
|
||||
user = db.query(User).first()
|
||||
agent = _create_agent(db, user.organization_id)
|
||||
|
||||
# Add association
|
||||
assoc_payload = {"mcp_tool_id": tool_id, "agent_id": str(agent.id)}
|
||||
resp = client.post("/api/mcp/agent-association", json=assoc_payload)
|
||||
assert resp.status_code == 200
|
||||
assoc = resp.json()
|
||||
assert assoc["agent_id"] == str(agent.id)
|
||||
assert assoc["mcp_tool_id"] == tool_id
|
||||
|
||||
# Get agent mcp tools
|
||||
resp2 = client.get(f"/api/mcp/agent/{agent.id}")
|
||||
assert resp2.status_code == 200
|
||||
data = resp2.json()
|
||||
assert data["id"] == str(agent.id)
|
||||
assert any(t["id"] == tool_id for t in data["mcp_tools"])
|
||||
|
||||
# Remove association
|
||||
resp3 = client.delete(f"/api/mcp/agent-association/{tool_id}/{agent.id}")
|
||||
assert resp3.status_code == 200
|
||||
assert resp3.json()["message"] == "MCP tool removed from agent successfully"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_test_mcp_tool_endpoint(client: TestClient, db):
|
||||
"""POST /{id}/test proxies the manager's connection probe."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
payload = {
|
||||
"name": "Probe",
|
||||
"transport_type": MCPTransportType.STDIO.value,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@elastic/mcp-server-elasticsearch"],
|
||||
"enabled": True,
|
||||
}
|
||||
tool_id = client.post("/api/mcp", json=payload).json()["id"]
|
||||
|
||||
with patch.object(
|
||||
mcp_api.MCPToolsManager, "test_tool_config",
|
||||
new=AsyncMock(return_value={"success": False, "functions": [], "error": "[Errno 2] No such file or directory: 'npx'"}),
|
||||
):
|
||||
resp = client.post(f"/api/mcp/{tool_id}/test")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert "npx" in body["error"]
|
||||
|
||||
# Unknown id → 404
|
||||
resp2 = client.post("/api/mcp/999999/test")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
|
||||
def test_test_mcp_tool_endpoint_other_org_forbidden(client: TestClient, db):
|
||||
"""Testing a tool owned by another organization is rejected."""
|
||||
from app.models.mcp_tool import MCPTool
|
||||
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
other_org_tool = MCPTool(
|
||||
name="Other org tool",
|
||||
transport_type=MCPTransportType.STDIO,
|
||||
command="npx",
|
||||
args=["-y", "some-server"],
|
||||
enabled=True,
|
||||
organization_id=uuid4(),
|
||||
)
|
||||
db.add(other_org_tool)
|
||||
db.commit()
|
||||
db.refresh(other_org_tool)
|
||||
|
||||
resp = client.post(f"/api/mcp/{other_org_tool.id}/test")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_secrets_are_masked_on_the_way_out(client: TestClient, db):
|
||||
"""Credentials must never reach the browser — the edit form only ever
|
||||
sees the mask."""
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "MaskedTool",
|
||||
"transport_type": MCPTransportType.STDIO.value,
|
||||
"command": "uvx",
|
||||
"env_vars": {"AWS_SECRET_ACCESS_KEY": "super-secret", "AWS_REGION": "us-east-1"},
|
||||
"enabled": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
tool_id = resp.json()["id"]
|
||||
|
||||
# Keys stay visible so the operator knows what is set; values do not.
|
||||
assert resp.json()["env_vars"] == {
|
||||
"AWS_SECRET_ACCESS_KEY": SECRET_MASK,
|
||||
"AWS_REGION": SECRET_MASK,
|
||||
}
|
||||
assert client.get(f"/api/mcp/{tool_id}").json()["env_vars"] == {
|
||||
"AWS_SECRET_ACCESS_KEY": SECRET_MASK,
|
||||
"AWS_REGION": SECRET_MASK,
|
||||
}
|
||||
|
||||
|
||||
def test_update_keeps_secrets_the_form_sent_back_masked(client: TestClient, db):
|
||||
"""Fixing a typo in an unrelated field must not cost a credential rotation."""
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "KeepSecret",
|
||||
"transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "https://example.com/mpc",
|
||||
"headers": {"Authorization": "ApiKey real-key"},
|
||||
"enabled": True,
|
||||
})
|
||||
tool_id = resp.json()["id"]
|
||||
|
||||
# The form round-trips the mask and corrects only the URL typo.
|
||||
upd = client.put(f"/api/mcp/{tool_id}", json={
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {"Authorization": SECRET_MASK},
|
||||
})
|
||||
assert upd.status_code == 200
|
||||
assert upd.json()["url"] == "https://example.com/mcp"
|
||||
|
||||
stored = MCPToolRepository(db).get_mcp_tool(tool_id)
|
||||
assert stored.headers == {"Authorization": "ApiKey real-key"}
|
||||
|
||||
|
||||
def test_update_replaces_a_secret_the_operator_retyped(client: TestClient, db):
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "RotateSecret",
|
||||
"transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {"Authorization": "ApiKey old-key"},
|
||||
"enabled": True,
|
||||
})
|
||||
tool_id = resp.json()["id"]
|
||||
|
||||
client.put(f"/api/mcp/{tool_id}", json={"headers": {"Authorization": "ApiKey new-key"}})
|
||||
|
||||
stored = MCPToolRepository(db).get_mcp_tool(tool_id)
|
||||
assert stored.headers == {"Authorization": "ApiKey new-key"}
|
||||
|
||||
|
||||
def test_delete_clears_the_investigation_selection(client: TestClient, db):
|
||||
"""investigation_mcp_tool_ids has no foreign key, so a deleted connector
|
||||
would otherwise stay 'configured' and every later run would report fewer
|
||||
connectors loaded than configured."""
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
user = db.query(User).first()
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "Doomed",
|
||||
"transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "https://example.com/mcp",
|
||||
"enabled": True,
|
||||
})
|
||||
tool_id = resp.json()["id"]
|
||||
|
||||
settings = OrganizationTicketSettings(
|
||||
organization_id=user.organization_id,
|
||||
investigation_mcp_tool_ids=[tool_id, 999],
|
||||
)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
|
||||
assert client.get(f"/api/mcp/{tool_id}/references").json()["used_in_investigations"] is True
|
||||
|
||||
assert client.delete(f"/api/mcp/{tool_id}").status_code == 200
|
||||
db.refresh(settings)
|
||||
assert settings.investigation_mcp_tool_ids == [999]
|
||||
|
||||
|
||||
def test_references_names_the_agents_using_the_tool(client: TestClient, db):
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
user = db.query(User).first()
|
||||
agent = _create_agent(db, user.organization_id)
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "Referenced",
|
||||
"transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "https://example.com/mcp",
|
||||
"enabled": True,
|
||||
})
|
||||
tool_id = resp.json()["id"]
|
||||
client.post("/api/mcp/agent-association", json={
|
||||
"mcp_tool_id": tool_id, "agent_id": str(agent.id)
|
||||
})
|
||||
|
||||
refs = client.get(f"/api/mcp/{tool_id}/references")
|
||||
assert refs.status_code == 200
|
||||
assert refs.json() == {"agents": [agent.name], "used_in_investigations": False}
|
||||
|
||||
|
||||
def test_update_rejects_a_transport_switch_with_nothing_to_connect_to(client: TestClient, db):
|
||||
"""The update path must not accept a state the create path rejects."""
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "SwitchMe",
|
||||
"transport_type": MCPTransportType.STDIO.value,
|
||||
"command": "uvx",
|
||||
"enabled": True,
|
||||
})
|
||||
tool_id = resp.json()["id"]
|
||||
|
||||
bad = client.put(f"/api/mcp/{tool_id}", json={
|
||||
"transport_type": MCPTransportType.HTTP.value, "url": ""
|
||||
})
|
||||
assert bad.status_code == 400
|
||||
assert "URL is required" in bad.json()["detail"]
|
||||
|
||||
good = client.put(f"/api/mcp/{tool_id}", json={
|
||||
"transport_type": MCPTransportType.HTTP.value, "url": "https://example.com/mcp"
|
||||
})
|
||||
assert good.status_code == 200
|
||||
assert good.json()["transport_type"] == MCPTransportType.HTTP.value
|
||||
|
||||
|
||||
def test_update_rejects_clearing_the_stdio_command(client: TestClient, db):
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "NeedsCommand",
|
||||
"transport_type": MCPTransportType.STDIO.value,
|
||||
"command": "uvx",
|
||||
"enabled": True,
|
||||
})
|
||||
tool_id = resp.json()["id"]
|
||||
|
||||
bad = client.put(f"/api/mcp/{tool_id}", json={"command": ""})
|
||||
assert bad.status_code == 400
|
||||
assert "Command is required" in bad.json()["detail"]
|
||||
|
||||
|
||||
GUIDANCE = "Indices: app-logs-*. Order id is fields.order_ref, not order_id."
|
||||
|
||||
|
||||
def test_usage_guidance_round_trips_and_is_not_masked(client: TestClient, db):
|
||||
"""Guidance is prompt text, not a credential — masking it would make the
|
||||
edit form unable to show the operator what they wrote."""
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "GuidedTool",
|
||||
"transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "https://example.com/mcp",
|
||||
"usage_guidance": GUIDANCE,
|
||||
"enabled": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["usage_guidance"] == GUIDANCE
|
||||
tool_id = resp.json()["id"]
|
||||
|
||||
assert client.get(f"/api/mcp/{tool_id}").json()["usage_guidance"] == GUIDANCE
|
||||
|
||||
updated = client.put(f"/api/mcp/{tool_id}", json={"usage_guidance": "Replaced."})
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["usage_guidance"] == "Replaced."
|
||||
|
||||
|
||||
def test_usage_guidance_defaults_to_none(client: TestClient, db):
|
||||
"""A connector nobody has documented must stay untouched."""
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "UndocumentedTool",
|
||||
"transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "https://example.com/mcp",
|
||||
"enabled": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["usage_guidance"] is None
|
||||
|
||||
|
||||
def test_overlong_usage_guidance_is_rejected(client: TestClient, db):
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "TooMuchGuidance",
|
||||
"transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "https://example.com/mcp",
|
||||
"usage_guidance": "x" * (MAX_USAGE_GUIDANCE_CHARS + 1),
|
||||
"enabled": True,
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_public_only_policy_refuses_an_internal_server_url(client: TestClient, db, monkeypatch):
|
||||
"""OUTBOUND_URL_POLICY=public_only keeps a tenant's connector off the
|
||||
server's own network, on create and on update."""
|
||||
from app.core.config import settings
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
monkeypatch.setattr(settings, "OUTBOUND_URL_POLICY", "public_only")
|
||||
|
||||
created = client.post("/api/mcp", json={
|
||||
"name": "Metadata", "transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "http://169.254.169.254/latest", "enabled": True,
|
||||
})
|
||||
assert created.status_code == 400
|
||||
assert "MCP server URL must be a public address" in created.json()["detail"]
|
||||
|
||||
# STDIO has no URL to check.
|
||||
stdio = client.post("/api/mcp", json={
|
||||
"name": "Local", "transport_type": MCPTransportType.STDIO.value,
|
||||
"command": "uvx", "enabled": True,
|
||||
})
|
||||
assert stdio.status_code == 200
|
||||
switched = client.put(f"/api/mcp/{stdio.json()['id']}", json={
|
||||
"transport_type": MCPTransportType.SSE.value, "url": "http://localhost:3001/sse",
|
||||
})
|
||||
assert switched.status_code == 400
|
||||
|
||||
|
||||
def test_any_policy_allows_an_internal_server_url(client: TestClient, db, monkeypatch):
|
||||
from app.core.config import settings
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
monkeypatch.setattr(settings, "OUTBOUND_URL_POLICY", "any")
|
||||
|
||||
resp = client.post("/api/mcp", json={
|
||||
"name": "Internal", "transport_type": MCPTransportType.HTTP.value,
|
||||
"url": "http://10.0.0.8:8080/mcp", "enabled": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
STDIO_PAYLOAD = {
|
||||
"name": "Shell", "transport_type": MCPTransportType.STDIO.value,
|
||||
"command": "npx", "args": ["-y", "some-server"], "enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_stdio_connectors_are_refused_when_disabled(client: TestClient, db, monkeypatch):
|
||||
"""A shared deployment turns STDIO off: no admin-typed command may be saved…"""
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
monkeypatch.setattr(settings, "MCP_STDIO_ENABLED", False)
|
||||
|
||||
resp = client.post("/api/mcp", json=STDIO_PAYLOAD)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "STDIO" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_stdio_connectors_saved_earlier_never_run_when_disabled(monkeypatch):
|
||||
"""…and one saved before the switch is never started."""
|
||||
from types import SimpleNamespace
|
||||
from app.tools.mcp_manager import MCPToolsManager
|
||||
|
||||
monkeypatch.setattr(settings, "MCP_STDIO_ENABLED", False)
|
||||
config = SimpleNamespace(name="Shell", transport_type=MCPTransportType.STDIO, command="npx", args=["-y", "x"], env_vars={})
|
||||
|
||||
assert MCPToolsManager._build_tool(config) is None
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,460 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
|
||||
WhatsApp numbers and Messenger Pages connected from the customer's own Meta
|
||||
app: their own webhook address, checked with their app's secret, and how
|
||||
connecting, reconnecting and disconnecting set that up.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main # noqa: F401 — registers the routers
|
||||
from app.api.channels.accounts import to_account_out
|
||||
from app.api.channels.meta import _upsert_account
|
||||
from app.services.meta_own_app import APP_ID_SETTING
|
||||
from app.core.application import app
|
||||
from app.core.auth import get_current_organization, get_current_user
|
||||
from app.core.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.organization import Organization
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
|
||||
BASE = "/api/v1/channels/meta"
|
||||
WEBHOOK = "/api/v1/webhooks/meta"
|
||||
OUR_SECRET = "replypier-app-secret"
|
||||
OUR_APP_ID = "APP_REPLYPIER"
|
||||
THEIR_SECRET = "customer-app-secret"
|
||||
THEIR_APP_ID = "APP_THEIRS"
|
||||
OVERRIDE_RELEASED = {"webhook_configuration": {"override_callback_uri": ""}}
|
||||
|
||||
|
||||
def _wa_message(phone_number_id="PN1"):
|
||||
return {"object": "whatsapp_business_account", "entry": [{"changes": [{"value": {
|
||||
"metadata": {"phone_number_id": phone_number_id},
|
||||
"contacts": [{"profile": {"name": "Ada"}, "wa_id": "447"}],
|
||||
"messages": [{"from": "447", "id": f"wamid.{uuid4().hex[:8]}", "type": "text",
|
||||
"text": {"body": "hi"}, "timestamp": "1720000000"}],
|
||||
}}]}]}
|
||||
|
||||
|
||||
WA_RECEIPT = {"object": "whatsapp_business_account", "entry": [{"changes": [{"value": {
|
||||
"metadata": {"phone_number_id": "PN1"},
|
||||
"statuses": [{"id": "wamid.out", "status": "delivered", "timestamp": "1720000001", "recipient_id": "447"}],
|
||||
}}]}]}
|
||||
|
||||
|
||||
def _page_entry(page_id):
|
||||
return {"id": page_id, "time": 1720000000, "messaging": [{
|
||||
"sender": {"id": "PSID1"}, "recipient": {"id": page_id}, "timestamp": 1720000000,
|
||||
"message": {"mid": f"m.{uuid4().hex[:8]}", "text": "hello"}}]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db, test_user, test_organization, monkeypatch):
|
||||
monkeypatch.setattr(settings, "META_APP_SECRET", OUR_SECRET)
|
||||
monkeypatch.setattr(settings, "META_APP_ID", OUR_APP_ID)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def _account(db, organization_id, channel_type="whatsapp", external_id="PN1", app_id=THEIR_APP_ID):
|
||||
"""An account as a connect leaves it: from the customer's app when app_id is set."""
|
||||
credentials = {"access_token": "EAAG-theirs"}
|
||||
if app_id:
|
||||
credentials["app_secret"] = THEIR_SECRET
|
||||
return ChannelAccountRepository(db).create_account(
|
||||
organization_id=organization_id, channel_type=channel_type, external_account_id=external_id,
|
||||
credentials=credentials, display_name=external_id,
|
||||
settings={APP_ID_SETTING: app_id} if app_id else None)
|
||||
|
||||
|
||||
def _signed(payload: dict, secret: str = THEIR_SECRET):
|
||||
body = json.dumps(payload).encode()
|
||||
signature = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return body, {"X-Hub-Signature-256": signature, "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _post(client, account_id, payload, secret=THEIR_SECRET):
|
||||
body, headers = _signed(payload, secret)
|
||||
with patch("app.api.webhooks.meta.process_channel_message", AsyncMock()) as process, \
|
||||
patch("app.api.webhooks.meta.process_status_event", AsyncMock()) as status:
|
||||
response = client.post(f"{WEBHOOK}/{account_id}", content=body, headers=headers)
|
||||
return response, process, status
|
||||
|
||||
|
||||
def _processed_for(process):
|
||||
return [call.args[0] for call in process.await_args_list]
|
||||
|
||||
|
||||
class TestOwnAppWebhook:
|
||||
def test_a_message_signed_with_the_apps_secret_is_processed(self, client, db, test_organization):
|
||||
account = _account(db, test_organization.id)
|
||||
response, process, _ = _post(client, account.id, _wa_message())
|
||||
assert response.status_code == 200
|
||||
account_id, inbound = process.await_args.args
|
||||
assert (account_id, inbound.text) == (account.id, "hi")
|
||||
|
||||
def test_receipts_reach_the_status_processor(self, client, db, test_organization):
|
||||
account = _account(db, test_organization.id)
|
||||
_, _, status = _post(client, account.id, WA_RECEIPT)
|
||||
assert status.await_args.args[0] == account.id
|
||||
|
||||
@pytest.mark.parametrize("secret", [OUR_SECRET, "guess"])
|
||||
def test_any_other_secret_is_refused(self, client, db, test_organization, secret):
|
||||
"""ReplyPier's own secret included: this address answers to one app only."""
|
||||
account = _account(db, test_organization.id)
|
||||
response, process, _ = _post(client, account.id, _wa_message(), secret)
|
||||
assert response.status_code == 403
|
||||
process.assert_not_awaited()
|
||||
|
||||
def test_it_takes_the_orgs_other_numbers_from_the_same_app(self, client, db, test_organization):
|
||||
"""One app can hold several numbers; Meta may send any of them here."""
|
||||
first = _account(db, test_organization.id, external_id="PN1")
|
||||
second = _account(db, test_organization.id, external_id="PN2")
|
||||
_, process, _ = _post(client, first.id, _wa_message("PN2"))
|
||||
assert _processed_for(process) == [second.id]
|
||||
|
||||
def test_one_messenger_callback_serves_every_page_of_the_app(self, client, db, test_organization):
|
||||
"""Messenger has one callback per app, and Meta batches Pages."""
|
||||
page_a = _account(db, test_organization.id, "messenger", "PAGE_A")
|
||||
page_b = _account(db, test_organization.id, "messenger", "PAGE_B")
|
||||
payload = {"object": "page", "entry": [_page_entry("PAGE_A"), _page_entry("PAGE_B")]}
|
||||
_, process, _ = _post(client, page_a.id, payload)
|
||||
assert sorted(map(str, _processed_for(process))) == sorted([str(page_a.id), str(page_b.id)])
|
||||
|
||||
def test_it_never_reaches_another_organization_or_app(self, client, db, test_organization):
|
||||
"""The secret is one the customer chose: a payload naming anyone
|
||||
else's number goes nowhere, even though it is signed with it."""
|
||||
ours = _account(db, test_organization.id, external_id="PN-MINE")
|
||||
other_org = Organization(name="Other", domain="other.example", timezone="UTC")
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
_account(db, other_org.id, external_id="PN-THEIRS") # same app id, other org
|
||||
_account(db, test_organization.id, external_id="PN-OTHER-APP", app_id="APP_ELSE")
|
||||
_account(db, test_organization.id, external_id="PN-SHARED", app_id=None) # via ReplyPier's app
|
||||
for number in ("PN-THEIRS", "PN-OTHER-APP", "PN-SHARED"):
|
||||
response, process, status = _post(client, ours.id, _wa_message(number))
|
||||
assert response.status_code == 200
|
||||
process.assert_not_awaited()
|
||||
_, _, status = _post(client, ours.id, {**WA_RECEIPT, "entry": [{"changes": [{"value": {
|
||||
"metadata": {"phone_number_id": "PN-THEIRS"}, "statuses": WA_RECEIPT["entry"][0]["changes"][0]["value"]["statuses"]}}]}]})
|
||||
status.assert_not_awaited()
|
||||
|
||||
def test_another_products_payload_is_ignored(self, client, db, test_organization):
|
||||
account = _account(db, test_organization.id)
|
||||
response, process, _ = _post(client, account.id, {"object": "page", "entry": [_page_entry("PN1")]})
|
||||
assert response.json() == {"status": "ignored"}
|
||||
process.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize("state", ["unknown", "disconnected", "shared_app"])
|
||||
def test_an_address_with_no_own_app_account_is_acked_and_ignored(self, client, db, test_organization, state):
|
||||
"""A 403 would make Meta retry and in time disable the customer's webhook."""
|
||||
account = _account(db, test_organization.id, app_id=None if state == "shared_app" else THEIR_APP_ID)
|
||||
if state == "disconnected":
|
||||
ChannelAccountRepository(db).set_active(account, False)
|
||||
account_id = uuid4() if state == "unknown" else account.id
|
||||
response, process, _ = _post(client, account_id, _wa_message())
|
||||
assert (response.status_code, response.json()) == (200, {"status": "ignored"})
|
||||
process.assert_not_awaited()
|
||||
|
||||
def test_an_unreadable_secret_is_acked_not_a_500(self, client, db, test_organization):
|
||||
"""A 500 would make Meta retry for hours and disable the customer's webhook."""
|
||||
account = _account(db, test_organization.id)
|
||||
account.encrypted_credentials = "not-decryptable"
|
||||
db.commit()
|
||||
response, process, _ = _post(client, account.id, _wa_message())
|
||||
assert (response.status_code, response.json()) == (200, {"status": "ignored"})
|
||||
process.assert_not_awaited()
|
||||
|
||||
def test_the_handshake_needs_the_accounts_verify_token(self, client, db, test_organization):
|
||||
account = _account(db, test_organization.id)
|
||||
ok = client.get(f"{WEBHOOK}/{account.id}", params={
|
||||
"hub.mode": "subscribe", "hub.verify_token": account.webhook_secret, "hub.challenge": "42"})
|
||||
assert (ok.status_code, ok.text) == (200, "42")
|
||||
for token in ("wrong", "ünïcode", ""):
|
||||
bad = client.get(f"{WEBHOOK}/{account.id}", params={
|
||||
"hub.mode": "subscribe", "hub.verify_token": token, "hub.challenge": "42"})
|
||||
assert bad.status_code == 403
|
||||
|
||||
def test_no_handshake_for_an_account_from_replypiers_app(self, client, db, test_organization):
|
||||
account = _account(db, test_organization.id, app_id=None)
|
||||
response = client.get(f"{WEBHOOK}/{account.id}", params={
|
||||
"hub.mode": "subscribe", "hub.verify_token": account.webhook_secret, "hub.challenge": "42"})
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_the_shared_address_still_refuses_the_customers_secret(self, client, db, test_organization):
|
||||
_account(db, test_organization.id)
|
||||
body, headers = _signed(_wa_message())
|
||||
assert client.post(WEBHOOK, content=body, headers=headers).status_code == 403
|
||||
|
||||
|
||||
PROOF_ERROR = (False, {"error": {"message": "Invalid appsecret_proof provided in the API argument", "code": 100}})
|
||||
|
||||
|
||||
def _connect_whatsapp(client, app_secret=THEIR_SECRET, app=(True, {"id": THEIR_APP_ID}),
|
||||
override_ok=True, token="EAAG-x", waba_id="WABA9"):
|
||||
lookup = AsyncMock(return_value=(True, {"display_phone_number": "1555", "verified_name": "Acme"}))
|
||||
check = AsyncMock(return_value=app)
|
||||
graph_post = AsyncMock(return_value=(override_ok, {} if override_ok else {"error": {"message": "nope"}}))
|
||||
with patch("app.api.channels.meta.graph_get", lookup), \
|
||||
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)), \
|
||||
patch("app.services.meta_own_app.graph_get", check), \
|
||||
patch("app.services.meta_own_app.graph_post_json", graph_post):
|
||||
response = client.post(f"{BASE}/whatsapp", json={
|
||||
"phone_number_id": "PN9", "access_token": token, "waba_id": waba_id, "app_secret": app_secret})
|
||||
return response, check, graph_post
|
||||
|
||||
|
||||
def _stored(db, channel="whatsapp", external_id="PN9"):
|
||||
repo = ChannelAccountRepository(db)
|
||||
account = repo.get_by_external_id(channel, external_id)
|
||||
return account, repo.get_credentials(account)
|
||||
|
||||
|
||||
class TestConnect:
|
||||
def test_whatsapp_with_a_secret_reports_to_its_own_address(self, client, db):
|
||||
response, check, graph_post = _connect_whatsapp(client)
|
||||
assert response.status_code == 200
|
||||
account, credentials = _stored(db)
|
||||
assert credentials["app_secret"] == THEIR_SECRET
|
||||
assert account.settings[APP_ID_SETTING] == THEIR_APP_ID
|
||||
# The secret is proven against the token's own app
|
||||
proof = check.await_args.kwargs["params"]["appsecret_proof"]
|
||||
assert proof == hmac.new(THEIR_SECRET.encode(), b"EAAG-x", hashlib.sha256).hexdigest()
|
||||
# Meta is asked to send this number's webhooks to its own address
|
||||
node, token, body = graph_post.await_args.args
|
||||
assert (node, token) == ("PN9", "EAAG-x")
|
||||
assert body["webhook_configuration"] == {
|
||||
"override_callback_uri": f"{settings.BACKEND_URL.rstrip('/')}/api/v1/webhooks/meta/{account.id}",
|
||||
"verify_token": account.webhook_secret}
|
||||
data = response.json()
|
||||
assert data["webhook_url"].endswith(f"/webhooks/meta/{account.id}")
|
||||
assert (data["webhook_verify_token"], data["webhook_auto_configured"]) == (account.webhook_secret, True)
|
||||
assert THEIR_SECRET not in response.text
|
||||
|
||||
def test_the_address_answers_meta_during_the_override(self, client, db):
|
||||
"""Meta checks the address inside the override call, so the account
|
||||
must already be marked as from its own app by then."""
|
||||
seen = {}
|
||||
|
||||
async def override(node, token, body):
|
||||
account = ChannelAccountRepository(db).get_by_external_id("whatsapp", node)
|
||||
seen["status"] = client.get(f"{WEBHOOK}/{account.id}", params={
|
||||
"hub.mode": "subscribe", "hub.verify_token": body["webhook_configuration"]["verify_token"],
|
||||
"hub.challenge": "7"}).text
|
||||
return True, {}
|
||||
with patch("app.api.channels.meta.graph_get", AsyncMock(return_value=(True, {"verified_name": "Acme"}))), \
|
||||
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)), \
|
||||
patch("app.services.meta_own_app.graph_get", AsyncMock(return_value=(True, {"id": THEIR_APP_ID}))), \
|
||||
patch("app.services.meta_own_app.graph_post_json", override):
|
||||
client.post(f"{BASE}/whatsapp", json={
|
||||
"phone_number_id": "PN9", "access_token": "EAAG-x", "waba_id": "W", "app_secret": THEIR_SECRET})
|
||||
assert seen["status"] == "7"
|
||||
|
||||
def test_a_wrong_secret_is_refused_and_nothing_is_saved(self, client, db):
|
||||
response, _, graph_post = _connect_whatsapp(client, app=PROOF_ERROR)
|
||||
assert response.status_code == 400
|
||||
assert "App settings" in response.json()["detail"]
|
||||
assert ChannelAccountRepository(db).get_by_external_id("whatsapp", "PN9") is None
|
||||
graph_post.assert_not_awaited()
|
||||
|
||||
def test_when_meta_cant_be_asked_it_says_so(self, client, db):
|
||||
"""A Meta error is not a wrong secret, and Meta's own words are shown."""
|
||||
response, _, _ = _connect_whatsapp(client, app=(False, {"error": {
|
||||
"message": "Service temporarily unavailable", "code": 2}}))
|
||||
assert response.status_code == 502
|
||||
assert "try again (Service temporarily unavailable)" in response.json()["detail"]
|
||||
assert ChannelAccountRepository(db).get_by_external_id("whatsapp", "PN9") is None
|
||||
|
||||
def test_our_own_network_errors_stay_in_the_log(self, client):
|
||||
response, _, _ = _connect_whatsapp(client, app=(False, {"error": {"message": "[Errno 8] nodename nor servname"}}))
|
||||
assert response.status_code == 502
|
||||
assert "Errno" not in response.json()["detail"]
|
||||
|
||||
def test_without_a_secret_nothing_changes(self, client, db):
|
||||
response, check, graph_post = _connect_whatsapp(client, app_secret=" ")
|
||||
assert response.status_code == 200
|
||||
check.assert_not_awaited()
|
||||
graph_post.assert_not_awaited()
|
||||
assert response.json()["webhook_url"] is None
|
||||
assert APP_ID_SETTING not in (_stored(db)[0].settings or {})
|
||||
|
||||
def test_replypiers_own_app_needs_no_address(self, client, db):
|
||||
"""A self-hoster's number in the app ReplyPier runs on already
|
||||
reaches the shared address: nothing is stored or re-pointed."""
|
||||
response, _, graph_post = _connect_whatsapp(client, app=(True, {"id": OUR_APP_ID}))
|
||||
assert response.status_code == 200
|
||||
assert "app_secret" not in _stored(db)[1]
|
||||
graph_post.assert_not_awaited()
|
||||
assert response.json()["webhook_url"] is None
|
||||
|
||||
def test_a_failed_override_is_reported_on_the_card(self, client):
|
||||
response, _, _ = _connect_whatsapp(client, override_ok=False)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["webhook_auto_configured"] is False
|
||||
assert response.json()["webhook_verify_token"] # shown to paste by hand
|
||||
|
||||
def test_a_reconnect_without_the_secret_keeps_it_for_the_same_app(self, client, db):
|
||||
_connect_whatsapp(client)
|
||||
response, check, _ = _connect_whatsapp(client, app_secret=None, token="EAAG-rotated")
|
||||
assert response.status_code == 200
|
||||
assert _stored(db)[1]["app_secret"] == THEIR_SECRET
|
||||
# Checked again against the new token
|
||||
assert check.await_args.kwargs["params"]["appsecret_proof"] == hmac.new(
|
||||
THEIR_SECRET.encode(), b"EAAG-rotated", hashlib.sha256).hexdigest()
|
||||
|
||||
def test_a_reconnect_with_another_apps_token_asks_for_its_secret(self, client, db):
|
||||
_connect_whatsapp(client)
|
||||
response, _, _ = _connect_whatsapp(client, app_secret=None, app=PROOF_ERROR, token="EAAG-other-app")
|
||||
assert response.status_code == 400
|
||||
assert "Enter the app secret again" in response.json()["detail"]
|
||||
assert _stored(db)[1]["access_token"] == "EAAG-x" # untouched
|
||||
|
||||
def test_a_reconnect_from_another_app_moves_the_callback(self, client, db):
|
||||
"""The old app's override is the old app's: released with its token
|
||||
before the new app points the number at us."""
|
||||
_connect_whatsapp(client)
|
||||
_, _, graph_post = _connect_whatsapp(client, app_secret="new-secret", app=(True, {"id": "APP_NEW"}),
|
||||
token="EAAG-new-app")
|
||||
account, credentials = _stored(db)
|
||||
assert (credentials["app_secret"], account.settings[APP_ID_SETTING]) == ("new-secret", "APP_NEW")
|
||||
released, pointed = graph_post.await_args_list
|
||||
assert released.args == ("PN9", "EAAG-x", OVERRIDE_RELEASED)
|
||||
assert pointed.args[1] == "EAAG-new-app" and pointed.args[2]["webhook_configuration"]["override_callback_uri"]
|
||||
|
||||
def test_a_reconnect_with_a_rotated_secret_keeps_the_callback(self, client, db):
|
||||
_connect_whatsapp(client)
|
||||
_, _, graph_post = _connect_whatsapp(client, app_secret="rotated-secret")
|
||||
assert _stored(db)[1]["app_secret"] == "rotated-secret"
|
||||
assert graph_post.await_count == 1 # pointed again, nothing released
|
||||
|
||||
def test_another_orgs_number_is_a_409_before_meta_is_asked(self, client, db):
|
||||
other_org = Organization(name="Other", domain="other.example", timezone="UTC")
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
_account(db, other_org.id, external_id="PN9")
|
||||
response, check, _ = _connect_whatsapp(client, app=PROOF_ERROR)
|
||||
assert response.status_code == 409
|
||||
check.assert_not_awaited()
|
||||
|
||||
def test_messenger_with_a_secret_shows_the_address_to_paste(self, client, db):
|
||||
inspect = AsyncMock(return_value=(True, {"type": "PAGE", "profile_id": "PAGE9", "is_valid": True}))
|
||||
graph_post = AsyncMock(return_value=(True, {}))
|
||||
with patch("app.api.channels.meta.debug_token", inspect), \
|
||||
patch("app.api.channels.meta.graph_get", AsyncMock(return_value=(False, {}))), \
|
||||
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)), \
|
||||
patch("app.services.meta_own_app.graph_get", AsyncMock(return_value=(True, {"id": THEIR_APP_ID}))), \
|
||||
patch("app.services.meta_own_app.graph_post_json", graph_post):
|
||||
response = client.post(f"{BASE}/messenger", json={
|
||||
"page_id": "PAGE9", "page_access_token": "EAAG-p", "app_secret": THEIR_SECRET})
|
||||
assert response.status_code == 200
|
||||
account, _ = _stored(db, "messenger", "PAGE9")
|
||||
data = response.json()
|
||||
assert data["webhook_url"].endswith(f"/webhooks/meta/{account.id}")
|
||||
assert data["webhook_verify_token"] == account.webhook_secret
|
||||
assert data["webhook_auto_configured"] is None
|
||||
graph_post.assert_not_awaited() # Meta has no per-Page override
|
||||
|
||||
def test_messenger_with_a_wrong_secret_is_refused(self, client, db):
|
||||
with patch("app.api.channels.meta.debug_token", AsyncMock(return_value=(True, {
|
||||
"type": "PAGE", "profile_id": "PAGE9", "is_valid": True}))), \
|
||||
patch("app.services.meta_own_app.graph_get", AsyncMock(return_value=PROOF_ERROR)):
|
||||
response = client.post(f"{BASE}/messenger", json={
|
||||
"page_id": "PAGE9", "page_access_token": "EAAG-p", "app_secret": "wrong"})
|
||||
assert response.status_code == 400
|
||||
assert ChannelAccountRepository(db).get_by_external_id("messenger", "PAGE9") is None
|
||||
|
||||
def _embedded_signup(self, client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
||||
numbers = (True, {"data": [{"id": "PN9", "display_phone_number": "1555", "verified_name": "Acme"}]})
|
||||
graph_post = AsyncMock(return_value=(True, {}))
|
||||
with patch("app.api.channels.meta.exchange_signup_code",
|
||||
AsyncMock(return_value=(True, {"access_token": "EAAG-business"}))), \
|
||||
patch("app.api.channels.meta.register_phone_number", AsyncMock(return_value=(True, {}))), \
|
||||
patch("app.api.channels.meta.graph_get", AsyncMock(return_value=numbers)), \
|
||||
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)), \
|
||||
patch("app.services.meta_own_app.graph_post_json", graph_post):
|
||||
response = client.post(f"{BASE}/whatsapp/embedded-signup", json={
|
||||
"code": "AQD", "waba_id": "WABA9", "phone_number_id": "PN9"})
|
||||
return response, graph_post
|
||||
|
||||
def test_embedded_signup_hands_the_number_back_with_the_customers_token(self, client, db, monkeypatch):
|
||||
"""The override belongs to the customer's app, so only their token
|
||||
can take it back."""
|
||||
_connect_whatsapp(client)
|
||||
response, graph_post = self._embedded_signup(client, monkeypatch)
|
||||
assert response.status_code == 200
|
||||
account, credentials = _stored(db)
|
||||
assert "app_secret" not in credentials and APP_ID_SETTING not in (account.settings or {})
|
||||
assert response.json()["webhook_url"] is None
|
||||
assert graph_post.await_args.args == ("PN9", "EAAG-x", OVERRIDE_RELEASED)
|
||||
|
||||
def test_embedded_signup_of_a_fresh_number_touches_no_callback(self, client, monkeypatch):
|
||||
_, graph_post = self._embedded_signup(client, monkeypatch)
|
||||
graph_post.assert_not_awaited()
|
||||
|
||||
def test_a_signup_reconnect_forgets_the_secret(self, db, test_organization):
|
||||
"""Messenger signup passes the same `forget`: the Page is now in our app."""
|
||||
_account(db, test_organization.id, "messenger", "PAGE1")
|
||||
account = _upsert_account(db, test_organization, "messenger", "PAGE1",
|
||||
{"access_token": "EAAG-ours"}, "Acme", forget=("app_secret",))
|
||||
assert ChannelAccountRepository(db).get_credentials(account) == {"access_token": "EAAG-ours"}
|
||||
|
||||
def test_disconnect_hands_the_number_back(self, client, db):
|
||||
_connect_whatsapp(client)
|
||||
account, _ = _stored(db)
|
||||
graph_post = AsyncMock(return_value=(True, {}))
|
||||
with patch("app.services.meta_own_app.graph_post_json", graph_post):
|
||||
assert client.delete(f"{BASE}/{account.id}").status_code == 200
|
||||
assert graph_post.await_args.args == ("PN9", "EAAG-x", OVERRIDE_RELEASED)
|
||||
|
||||
def test_disconnect_never_fails_over_the_callback(self, client, db, test_organization):
|
||||
account = _account(db, test_organization.id)
|
||||
account.encrypted_credentials = "not-decryptable"
|
||||
db.commit()
|
||||
assert client.delete(f"{BASE}/{account.id}").status_code == 200
|
||||
assert ChannelAccountRepository(db).get_by_id(account.id) is None
|
||||
|
||||
def test_a_messenger_disconnect_has_no_callback_to_release(self, client, db, test_organization):
|
||||
account = _account(db, test_organization.id, "messenger", "PAGE1")
|
||||
graph_post = AsyncMock(return_value=(True, {}))
|
||||
with patch("app.services.meta_own_app.graph_post_json", graph_post):
|
||||
assert client.delete(f"{BASE}/{account.id}").status_code == 200
|
||||
graph_post.assert_not_awaited()
|
||||
|
||||
def test_people_who_cant_manage_the_org_never_see_the_token(self, db, test_organization):
|
||||
account = _account(db, test_organization.id)
|
||||
out = to_account_out(db, account, None, include_webhook_url=False)
|
||||
assert (out.webhook_url, out.webhook_verify_token, out.webhook_auto_configured) == (None, None, None)
|
||||
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 app.database import get_db
|
||||
from fastapi import FastAPI
|
||||
from app.models.user import User
|
||||
from app.models.notification import Notification, NotificationType
|
||||
from app.models.notification_settings import UserNotificationSettings
|
||||
from app.models.role import Role
|
||||
from app.models.organization import Organization
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
from app.api import notification as notification_router
|
||||
from app.core.auth import get_current_user
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, Base
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
notification_router.router,
|
||||
prefix="/api/notifications",
|
||||
tags=["notifications"]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db():
|
||||
"""Create a fresh database for each test."""
|
||||
# Drop all tables first
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
# Create tables except enterprise ones
|
||||
create_tables()
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture
|
||||
def test_role(db) -> Role:
|
||||
"""Create a test role"""
|
||||
role = Role(
|
||||
id=1,
|
||||
name="Test Role",
|
||||
description="Test Role Description",
|
||||
is_default=True
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
@pytest.fixture
|
||||
def test_organization(db) -> Organization:
|
||||
"""Create a test organization"""
|
||||
org = Organization(
|
||||
name="Test Organization",
|
||||
domain="test.com",
|
||||
timezone="UTC"
|
||||
)
|
||||
db.add(org)
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
return org
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db, test_role, test_organization) -> User:
|
||||
"""Create a test user"""
|
||||
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_notifications(db, test_user) -> list[Notification]:
|
||||
"""Create test notifications"""
|
||||
notifications = []
|
||||
for i in range(3):
|
||||
notification = Notification(
|
||||
user_id=test_user.id,
|
||||
type=NotificationType.CHAT,
|
||||
title=f"Test Notification {i+1}",
|
||||
message=f"Test message {i+1}",
|
||||
is_read=i == 0, # First notification is read
|
||||
notification_metadata={"test": True},
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
notifications.append(notification)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
for n in notifications:
|
||||
db.refresh(n)
|
||||
return notifications
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_user) -> TestClient:
|
||||
"""Create test client with mocked dependencies"""
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_db] = lambda: TestingSessionLocal()
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
def test_list_notifications(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""Test listing user's notifications"""
|
||||
response = client.get("/api/notifications")
|
||||
assert response.status_code == 200
|
||||
notifications = response.json()
|
||||
assert len(notifications) == 3
|
||||
assert notifications[0]["user_id"] == str(test_user.id)
|
||||
assert notifications[0]["type"] == NotificationType.CHAT.value
|
||||
|
||||
def test_list_notifications_pagination(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""Test notification listing with pagination"""
|
||||
response = client.get("/api/notifications?skip=1&limit=1")
|
||||
assert response.status_code == 200
|
||||
notifications = response.json()
|
||||
assert len(notifications) == 1
|
||||
|
||||
def test_mark_as_read(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""Test marking a notification as read"""
|
||||
unread_notification = next(n for n in test_notifications if not n.is_read)
|
||||
response = client.patch(f"/api/notifications/{unread_notification.id}/read")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Notification marked as read"
|
||||
|
||||
# Verify notification is marked as read in database
|
||||
db.refresh(unread_notification) # Refresh from database
|
||||
assert unread_notification.is_read == True
|
||||
|
||||
def test_mark_as_read_not_found(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Test marking non-existent notification as read"""
|
||||
response = client.patch("/api/notifications/999/read")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Notification not found"
|
||||
|
||||
def test_mark_as_read_wrong_user(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Test marking another user's notification as read"""
|
||||
# Create another user
|
||||
other_user = User(
|
||||
email="other@example.com",
|
||||
full_name="Other User",
|
||||
hashed_password="hashed_password",
|
||||
organization_id=test_user.organization_id # Use same org as test_user
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
|
||||
# Create notification for different user
|
||||
other_notification = Notification(
|
||||
user_id=other_user.id, # Use the other user's ID
|
||||
type="CHAT",
|
||||
title="Other Notification",
|
||||
message="Test message",
|
||||
notification_metadata={"test": True}
|
||||
)
|
||||
db.add(other_notification)
|
||||
db.commit()
|
||||
|
||||
response = client.patch(f"/api/notifications/{other_notification.id}/read")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Notification not found"
|
||||
|
||||
def test_get_unread_count(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""Test getting unread notification count"""
|
||||
response = client.get("/api/notifications/unread-count")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 2 # Two unread notifications from fixture
|
||||
|
||||
def test_get_or_create_survives_insert_race(db, test_user):
|
||||
"""A concurrent first-insert (IntegrityError) re-reads instead of 500ing"""
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.repositories.notification_settings import UserNotificationSettingsRepository
|
||||
|
||||
repo = UserNotificationSettingsRepository(db)
|
||||
|
||||
# The row the winning racer inserted; our repo's first get() is forced to
|
||||
# miss it (as if the winner hadn't committed yet when we looked).
|
||||
winner_row = UserNotificationSettings(user_id=test_user.id)
|
||||
db.add(winner_row)
|
||||
db.commit()
|
||||
|
||||
real_get = repo.get
|
||||
calls = {"n": 0}
|
||||
|
||||
def get_missing_first(user_id):
|
||||
calls["n"] += 1
|
||||
return None if calls["n"] == 1 else real_get(user_id)
|
||||
|
||||
with patch.object(repo, "get", side_effect=get_missing_first), \
|
||||
patch.object(db, "commit", side_effect=IntegrityError("dup", {}, Exception())):
|
||||
result = repo.get_or_create(test_user.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.user_id == test_user.id
|
||||
|
||||
|
||||
def test_get_settings_creates_defaults(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""First read lazily creates the row with the shipped defaults"""
|
||||
response = client.get("/api/notifications/settings")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"notify_new_chat": False,
|
||||
"notify_chat_transfer": True,
|
||||
"notify_chat_assigned": True,
|
||||
}
|
||||
|
||||
settings = db.query(UserNotificationSettings)\
|
||||
.filter_by(user_id=test_user.id).first()
|
||||
assert settings is not None
|
||||
|
||||
|
||||
def test_update_settings_is_partial(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Only the toggles sent are changed; the rest keep their values"""
|
||||
response = client.put(
|
||||
"/api/notifications/settings",
|
||||
json={"notify_new_chat": True}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["notify_new_chat"] is True
|
||||
assert body["notify_chat_transfer"] is True
|
||||
assert body["notify_chat_assigned"] is True
|
||||
|
||||
response = client.put(
|
||||
"/api/notifications/settings",
|
||||
json={"notify_chat_transfer": False}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["notify_new_chat"] is True
|
||||
assert body["notify_chat_transfer"] is False
|
||||
|
||||
|
||||
def test_settings_are_per_user(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Updating the caller's settings never touches another user's row"""
|
||||
other_user = User(
|
||||
id=uuid4(),
|
||||
email="other-settings@example.com",
|
||||
full_name="Other User",
|
||||
hashed_password="hashed_password",
|
||||
organization_id=test_user.organization_id
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
|
||||
other_settings = UserNotificationSettings(user_id=other_user.id)
|
||||
db.add(other_settings)
|
||||
db.commit()
|
||||
|
||||
response = client.put(
|
||||
"/api/notifications/settings",
|
||||
json={"notify_new_chat": True, "notify_chat_assigned": False}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
db.refresh(other_settings)
|
||||
assert other_settings.notify_new_chat is False
|
||||
assert other_settings.notify_chat_assigned is True
|
||||
|
||||
|
||||
def test_send_test_notification(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Test sending a test notification"""
|
||||
response = client.post("/api/notifications/test")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Test notification sent successfully"
|
||||
|
||||
# Verify notification was created
|
||||
notification = db.query(Notification)\
|
||||
.filter_by(user_id=test_user.id)\
|
||||
.order_by(Notification.created_at.desc())\
|
||||
.first()
|
||||
assert notification is not None
|
||||
assert notification.title == "Test Notification"
|
||||
assert notification.type == NotificationType.CHAT
|
||||
|
||||
def test_mark_all_as_read_clears_every_unread(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""One request must clear the whole backlog, not just the fetched page.
|
||||
|
||||
The client used to loop the 50 rows it had loaded, so anyone with more unread
|
||||
than that could never clear the badge — it sat at 99+ no matter how often
|
||||
they tapped "Mark all read".
|
||||
"""
|
||||
response = client.post("/api/notifications/read-all")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["updated"] == 2 # the fixture leaves two unread
|
||||
|
||||
for n in test_notifications:
|
||||
db.refresh(n)
|
||||
assert n.is_read is True
|
||||
|
||||
|
||||
def test_mark_all_as_read_leaves_other_users_alone(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""A bulk UPDATE is exactly where a missing user filter goes unnoticed."""
|
||||
other_user = User(
|
||||
email="bulk-other@example.com",
|
||||
hashed_password="x",
|
||||
full_name="Other",
|
||||
organization_id=test_user.organization_id,
|
||||
role_id=test_user.role_id,
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
theirs = Notification(
|
||||
user_id=other_user.id,
|
||||
type=NotificationType.CHAT,
|
||||
title="Theirs",
|
||||
message="Theirs",
|
||||
is_read=False,
|
||||
)
|
||||
db.add(theirs)
|
||||
db.commit()
|
||||
|
||||
client.post("/api/notifications/read-all")
|
||||
|
||||
db.refresh(theirs)
|
||||
assert theirs.is_read is False
|
||||
|
||||
|
||||
def test_delete_notification(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
target = test_notifications[0]
|
||||
response = client.delete(f"/api/notifications/{target.id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
assert db.query(Notification).filter(Notification.id == target.id).first() is None
|
||||
# The others survive
|
||||
assert db.query(Notification).filter(
|
||||
Notification.user_id == test_user.id
|
||||
).count() == len(test_notifications) - 1
|
||||
|
||||
|
||||
def test_delete_notification_not_found(client, db, test_user):
|
||||
response = client.delete("/api/notifications/999999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_another_users_notification_is_not_found(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Must 404 rather than delete someone else's row."""
|
||||
other_user = User(
|
||||
email="delete-other@example.com",
|
||||
hashed_password="x",
|
||||
full_name="Other",
|
||||
organization_id=test_user.organization_id,
|
||||
role_id=test_user.role_id,
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
theirs = Notification(
|
||||
user_id=other_user.id,
|
||||
type=NotificationType.CHAT,
|
||||
title="Theirs",
|
||||
message="Theirs",
|
||||
is_read=False,
|
||||
)
|
||||
db.add(theirs)
|
||||
db.commit()
|
||||
db.refresh(theirs)
|
||||
|
||||
response = client.delete(f"/api/notifications/{theirs.id}")
|
||||
assert response.status_code == 404
|
||||
assert db.query(Notification).filter(Notification.id == theirs.id).first() is not None
|
||||
|
||||
|
||||
def test_clear_all_notifications(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
response = client.delete("/api/notifications")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted"] == len(test_notifications)
|
||||
|
||||
assert db.query(Notification).filter(
|
||||
Notification.user_id == test_user.id
|
||||
).count() == 0
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
|
||||
Direct tests of the "belongs to my org" guards. Several of the endpoints that
|
||||
use them (Shopify/Jira agent-config) resolve auth by calling get_current_user
|
||||
inside the handler rather than via a dependency, which makes them awkward to
|
||||
drive through TestClient — so the security boundary itself is exercised here.
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User, UserGroup
|
||||
from app.repositories.agent import AgentRepository
|
||||
from app.api.shopify import _require_shopify_agent_in_org
|
||||
from app.api.jira import _require_jira_agent_in_org
|
||||
from app.api.tickets import _validate_assignment_references
|
||||
from app.api import knowledge as knowledge_router
|
||||
from app.core.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.permission import Permission
|
||||
from app.models.role import Role
|
||||
from app.models.user import User
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_org(db) -> Organization:
|
||||
org = Organization(id=uuid4(), name="Other Org", domain="other-guard.example.com", timezone="UTC")
|
||||
db.add(org)
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
return org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def foreign_agent(db, other_org) -> Agent:
|
||||
agent = Agent(
|
||||
id=uuid4(),
|
||||
name="Foreign Agent",
|
||||
agent_type=AgentType.CUSTOMER_SUPPORT,
|
||||
instructions=["x"],
|
||||
organization_id=other_org.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
def _raises_404(fn, *args):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
fn(*args)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
class TestAgentRepositoryScoping:
|
||||
def test_get_agent_in_org_matches_only_same_org(self, db, test_agent, test_organization, other_org):
|
||||
repo = AgentRepository(db)
|
||||
assert repo.get_agent_in_org(test_agent.id, test_organization.id) is not None
|
||||
assert repo.get_agent_in_org(test_agent.id, other_org.id) is None
|
||||
|
||||
|
||||
class TestAgentConfigGuards:
|
||||
"""Shopify + Jira + knowledge all gate agent_id the same way."""
|
||||
|
||||
def test_shopify_allows_own_agent(self, db, test_agent, test_organization):
|
||||
_require_shopify_agent_in_org(db, str(test_agent.id), test_organization.id)
|
||||
|
||||
def test_shopify_rejects_foreign_agent(self, db, foreign_agent, test_organization):
|
||||
_raises_404(_require_shopify_agent_in_org, db, str(foreign_agent.id), test_organization.id)
|
||||
|
||||
def test_shopify_rejects_malformed_agent_id(self, db, test_organization):
|
||||
_raises_404(_require_shopify_agent_in_org, db, "not-a-uuid", test_organization.id)
|
||||
|
||||
def test_jira_allows_own_agent(self, db, test_agent, test_organization):
|
||||
_require_jira_agent_in_org(db, str(test_agent.id), test_organization.id)
|
||||
|
||||
def test_jira_rejects_foreign_agent(self, db, foreign_agent, test_organization):
|
||||
_raises_404(_require_jira_agent_in_org, db, str(foreign_agent.id), test_organization.id)
|
||||
|
||||
|
||||
class TestTicketAssignmentGuard:
|
||||
def test_allows_assignee_and_group_in_org(self, db, test_user, test_organization):
|
||||
group = UserGroup(id=uuid4(), name="G", organization_id=test_organization.id)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
# No raise
|
||||
_validate_assignment_references(db, test_organization.id, test_user.id, group.id)
|
||||
|
||||
def test_rejects_foreign_assignee(self, db, other_org, test_organization):
|
||||
outsider = User(
|
||||
id=uuid4(),
|
||||
email="outsider@other-guard.example.com",
|
||||
hashed_password="x",
|
||||
organization_id=other_org.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(outsider)
|
||||
db.commit()
|
||||
_raises_404(_validate_assignment_references, db, test_organization.id, outsider.id, None)
|
||||
|
||||
def test_rejects_foreign_group(self, db, other_org, test_organization):
|
||||
foreign_group = UserGroup(id=uuid4(), name="FG", organization_id=other_org.id)
|
||||
db.add(foreign_group)
|
||||
db.commit()
|
||||
_raises_404(_validate_assignment_references, db, test_organization.id, None, foreign_group.id)
|
||||
|
||||
|
||||
class TestKnowledgeByAgentScoping:
|
||||
"""GET /knowledge/agent/{agent_id} filtered on the agent id alone.
|
||||
|
||||
count_by_agent/get_by_agent take a UUID and no organization, so any
|
||||
authenticated user who knew another tenant's agent id read that tenant's
|
||||
knowledge inventory. link/unlink on the same router have always checked
|
||||
agent.organization_id; this read never did.
|
||||
"""
|
||||
|
||||
def _client(self, db, user):
|
||||
api = FastAPI()
|
||||
api.include_router(knowledge_router.router, prefix="/api/v1/knowledge")
|
||||
api.dependency_overrides[get_current_user] = lambda: user
|
||||
api.dependency_overrides[get_db] = lambda: (yield db)
|
||||
return TestClient(api)
|
||||
|
||||
def _knowledge_reader(self, db, organization):
|
||||
role = Role(name="Knowledge Reader", organization_id=organization.id)
|
||||
role.permissions = [Permission(name="view_knowledge")]
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email=f"reader-{uuid4().hex[:6]}@example.com",
|
||||
full_name="Reader",
|
||||
hashed_password="x",
|
||||
is_active=True,
|
||||
organization_id=organization.id,
|
||||
role_id=role.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
def test_rejects_another_orgs_agent(self, db, test_organization, foreign_agent):
|
||||
user = self._knowledge_reader(db, test_organization)
|
||||
|
||||
response = self._client(db, user).get(f"/api/v1/knowledge/agent/{foreign_agent.id}")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_allows_an_agent_in_the_callers_org(self, db, test_organization, test_agent):
|
||||
user = self._knowledge_reader(db, test_organization)
|
||||
|
||||
response = self._client(db, user).get(f"/api/v1/knowledge/agent/{test_agent.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,548 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 app.database import get_db
|
||||
from fastapi import FastAPI
|
||||
from app.models.user import User
|
||||
from app.models.organization import Organization
|
||||
from app.models.role import Role
|
||||
from app.models.auth_session import AuthSession
|
||||
from app.core.security import verify_token
|
||||
from app.models.permission import (
|
||||
Permission,
|
||||
role_permissions,
|
||||
DEFAULT_AGENT_ROLE_PERMISSIONS,
|
||||
)
|
||||
from uuid import UUID, uuid4
|
||||
from datetime import datetime, timezone
|
||||
from app.api import organizations as organizations_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, Base
|
||||
|
||||
# The disposable-address gate is enterprise-only; a community checkout has no
|
||||
# blocklist and accepts everything.
|
||||
try:
|
||||
from app.enterprise.services.email_validation import DISPOSABLE_EMAIL_MESSAGE
|
||||
|
||||
HAS_EMAIL_VALIDATION = True
|
||||
except ImportError:
|
||||
DISPOSABLE_EMAIL_MESSAGE = ""
|
||||
HAS_EMAIL_VALIDATION = False
|
||||
|
||||
requires_email_validation = pytest.mark.skipif(
|
||||
not HAS_EMAIL_VALIDATION, reason="enterprise email validation not installed"
|
||||
)
|
||||
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
organizations_router.router,
|
||||
prefix="/api/v1/organizations", # Match the prefix in main.py
|
||||
tags=["organizations"]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db():
|
||||
"""Create a fresh database for each test."""
|
||||
# Drop all tables first
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
# Create tables except enterprise ones
|
||||
create_tables()
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture
|
||||
def test_permissions(db) -> list[Permission]:
|
||||
"""Create test permissions"""
|
||||
permissions = []
|
||||
for name in ["manage_organization", "view_organization"]:
|
||||
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_permissions) -> Role:
|
||||
"""Create a test role with required permissions"""
|
||||
role = Role(
|
||||
id=1,
|
||||
name="Test Role",
|
||||
description="Test Role Description",
|
||||
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_organization(db) -> Organization:
|
||||
"""Create a test organization"""
|
||||
org = Organization(
|
||||
id=uuid4(),
|
||||
name="Test Organization",
|
||||
domain="test.com",
|
||||
timezone="UTC",
|
||||
business_hours={
|
||||
"monday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"tuesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"wednesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"thursday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"friday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"saturday": {"start": "09:00", "end": "17:00", "enabled": False},
|
||||
"sunday": {"start": "09:00", "end": "17:00", "enabled": False}
|
||||
},
|
||||
settings={},
|
||||
is_active=True
|
||||
)
|
||||
db.add(org)
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
return org
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db, test_role, test_organization) -> 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 client(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
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[require_permissions] = override_require_permissions
|
||||
app.dependency_overrides[get_db] = lambda: TestingSessionLocal()
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
# Test cases
|
||||
def test_create_organization(client, db, monkeypatch):
|
||||
"""Test creating a new organization"""
|
||||
# Delete existing users and organization first since we only allow one
|
||||
# We need to override the dependency to use our test session
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
# Delete users first (due to foreign key constraints)
|
||||
db.query(User).delete()
|
||||
db.commit()
|
||||
|
||||
# Then delete organizations
|
||||
db.query(Organization).delete()
|
||||
db.commit()
|
||||
|
||||
# Mock the agent repository to avoid SQLite issues with JSON
|
||||
from app.repositories.agent import AgentRepository
|
||||
original_create_agent = AgentRepository.create_agent
|
||||
|
||||
def mock_create_agent(self, **kwargs):
|
||||
# Skip agent creation in tests
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(AgentRepository, "create_agent", mock_create_agent)
|
||||
|
||||
org_data = {
|
||||
"name": "New Organization",
|
||||
"domain": "new.com",
|
||||
"timezone": "UTC",
|
||||
"business_hours": {
|
||||
"monday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"tuesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"wednesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"thursday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"friday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"saturday": {"start": "09:00", "end": "17:00", "enabled": False},
|
||||
"sunday": {"start": "09:00", "end": "17:00", "enabled": False}
|
||||
},
|
||||
"admin_email": "admin@new.com",
|
||||
"admin_name": "Admin User",
|
||||
"admin_password": "adminpass123"
|
||||
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/organizations", json=org_data)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
|
||||
# Basic organization data validation
|
||||
assert data["name"] == org_data["name"]
|
||||
assert data["domain"] == org_data["domain"]
|
||||
assert data["timezone"] == org_data["timezone"]
|
||||
assert data["business_hours"] == org_data["business_hours"]
|
||||
assert data["settings"] == {}
|
||||
assert data["is_active"] == True
|
||||
assert "id" in data
|
||||
|
||||
# Token validation
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
# Both tokens belong to the admin's first session, so signing out ends them
|
||||
sid = verify_token(data["access_token"])["sid"]
|
||||
assert verify_token(data["refresh_token"])["sid"] == sid
|
||||
session = db.query(AuthSession).filter(AuthSession.id == UUID(sid)).one()
|
||||
assert str(session.user_id) == data["user"]["id"]
|
||||
|
||||
# User validation
|
||||
assert "user" in data
|
||||
user = data["user"]
|
||||
assert user["email"] == org_data["admin_email"]
|
||||
assert user["full_name"] == org_data["admin_name"]
|
||||
assert "id" in user
|
||||
assert "organization_id" in user
|
||||
assert user["organization_id"] == data["id"] # User org ID should match org ID
|
||||
|
||||
# Role validation
|
||||
assert "role" in user
|
||||
role = user["role"]
|
||||
assert role["name"] == "Admin"
|
||||
assert isinstance(role["id"], int)
|
||||
|
||||
# Verify the role in database
|
||||
db_role = db.query(Role).filter(Role.id == role["id"]).first()
|
||||
assert db_role is not None
|
||||
|
||||
# The seeded roles, pinned. The hosted seeder drifted from this one for a
|
||||
# year — two permissions instead of four, and both roles is_default — and
|
||||
# nothing here failed, because the only claim made was the Admin name.
|
||||
# Every assertion below is one that drift broke.
|
||||
roles = db.query(Role).filter(Role.organization_id == UUID(data["id"])).all()
|
||||
by_name = {r.name: r for r in roles}
|
||||
assert set(by_name) == {"Admin", "Agent"}
|
||||
|
||||
# Exactly one default: get_default_role() takes .first() with no ordering,
|
||||
# so a second one means an invited user can land on Admin.
|
||||
assert [r.name for r in roles if r.is_default] == ["Agent"]
|
||||
|
||||
assert {p.name for p in by_name["Agent"].permissions} == set(
|
||||
DEFAULT_AGENT_ROLE_PERMISSIONS
|
||||
)
|
||||
assert {p.name for p in by_name["Admin"].permissions} == {
|
||||
name for name, _ in Permission.default_permissions()
|
||||
}
|
||||
|
||||
|
||||
def test_get_organization(client, test_organization):
|
||||
"""Test getting organization by ID"""
|
||||
response = client.get(f"/api/v1/organizations/{test_organization.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_organization.id)
|
||||
assert data["name"] == test_organization.name
|
||||
assert data["domain"] == test_organization.domain
|
||||
|
||||
def test_update_organization(client, test_organization):
|
||||
"""Test updating organization details"""
|
||||
update_data = {
|
||||
"name": "Updated Organization",
|
||||
"business_hours": {
|
||||
"monday": {"start": "08:00", "end": "16:00", "enabled": True},
|
||||
"tuesday": {"start": "08:00", "end": "16:00", "enabled": True},
|
||||
"wednesday": {"start": "08:00", "end": "16:00", "enabled": True},
|
||||
"thursday": {"start": "08:00", "end": "16:00", "enabled": True},
|
||||
"friday": {"start": "08:00", "end": "16:00", "enabled": True},
|
||||
"saturday": {"start": "09:00", "end": "17:00", "enabled": False},
|
||||
"sunday": {"start": "09:00", "end": "17:00", "enabled": False}
|
||||
}
|
||||
}
|
||||
|
||||
response = client.patch(f"/api/v1/organizations/{test_organization.id}", json=update_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == update_data["name"]
|
||||
assert data["business_hours"]["monday"]["start"] == "08:00"
|
||||
|
||||
# def test_delete_organization(client, test_organization):
|
||||
# """Test deleting (soft-delete) organization"""
|
||||
# response = client.delete(f"/api/v1/organizations/{test_organization.id}")
|
||||
# assert response.status_code == 204
|
||||
|
||||
# # Verify organization is soft-deleted
|
||||
# org = client.get(f"/api/v1/organizations/{test_organization.id}").json()
|
||||
# assert not org["is_active"]
|
||||
|
||||
def test_get_organization_stats(client, test_organization, test_user):
|
||||
"""Test getting organization statistics"""
|
||||
response = client.get(f"/api/v1/organizations/{test_organization.id}/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total_users" in data
|
||||
assert "active_users" in data
|
||||
assert data["total_users"] == 1
|
||||
assert data["active_users"] == 1
|
||||
|
||||
|
||||
# Negative test cases
|
||||
def test_create_organization_duplicate(client, test_organization):
|
||||
"""Test creating organization when one already exists"""
|
||||
org_data = {
|
||||
"name": "Another Organization",
|
||||
"domain": "another.com",
|
||||
"timezone": "UTC",
|
||||
"business_hours": {
|
||||
"monday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"tuesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"wednesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"thursday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"friday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"saturday": {"start": "09:00", "end": "17:00", "enabled": False},
|
||||
"sunday": {"start": "09:00", "end": "17:00", "enabled": False}
|
||||
},
|
||||
"admin_email": "admin@another.com",
|
||||
"admin_name": "Another Admin",
|
||||
"admin_password": "adminpass123"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/organizations", json=org_data)
|
||||
assert response.status_code == 403
|
||||
assert "Organization already exists" in response.json()["detail"]
|
||||
|
||||
@requires_email_validation
|
||||
def test_create_organization_rejects_disposable_admin_email(client, db):
|
||||
"""Signup with a throwaway admin address is refused and creates nothing"""
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
db.query(User).delete()
|
||||
db.commit()
|
||||
db.query(Organization).delete()
|
||||
db.commit()
|
||||
|
||||
org_data = {
|
||||
"name": "Throwaway Org",
|
||||
"domain": "throwaway.com",
|
||||
"timezone": "UTC",
|
||||
"business_hours": {
|
||||
"monday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"tuesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"wednesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"thursday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"friday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"saturday": {"start": "09:00", "end": "17:00", "enabled": False},
|
||||
"sunday": {"start": "09:00", "end": "17:00", "enabled": False}
|
||||
},
|
||||
"admin_email": "someone@yopmail.com",
|
||||
"admin_name": "Throwaway Admin",
|
||||
"admin_password": "adminpass123"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/organizations", json=org_data)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == DISPOSABLE_EMAIL_MESSAGE
|
||||
assert db.query(Organization).count() == 0
|
||||
assert db.query(User).count() == 0
|
||||
|
||||
|
||||
def test_update_organization_invalid_hours(client, test_organization):
|
||||
"""Test updating organization with invalid business hours"""
|
||||
update_data = {
|
||||
"business_hours": {
|
||||
"monday": {"start": "25:00", "end": "17:00", "enabled": True}, # Invalid hour
|
||||
"tuesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"wednesday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"thursday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"friday": {"start": "09:00", "end": "17:00", "enabled": True},
|
||||
"saturday": {"start": "09:00", "end": "17:00", "enabled": False},
|
||||
"sunday": {"start": "09:00", "end": "17:00", "enabled": False}
|
||||
}
|
||||
}
|
||||
|
||||
response = client.patch(f"/api/v1/organizations/{test_organization.id}", json=update_data)
|
||||
assert response.status_code == 400
|
||||
assert "Invalid time format" in response.json()["detail"]
|
||||
|
||||
def test_get_nonexistent_organization(client):
|
||||
"""Test getting a non-existent organization"""
|
||||
response = client.get(f"/api/v1/organizations/{uuid4()}")
|
||||
assert response.status_code == 404
|
||||
assert "Organization not found" in response.json()["detail"]
|
||||
|
||||
@pytest.fixture
|
||||
def other_organization(db) -> Organization:
|
||||
"""An organization the test user does not belong to"""
|
||||
org = Organization(
|
||||
id=uuid4(),
|
||||
name="Other Organization",
|
||||
domain="other.example.com",
|
||||
timezone="UTC",
|
||||
business_hours={},
|
||||
settings={},
|
||||
is_active=True
|
||||
)
|
||||
db.add(org)
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
return org
|
||||
|
||||
|
||||
def test_get_another_organization_is_not_found(client, other_organization):
|
||||
"""Reading another tenant's organization record is refused"""
|
||||
response = client.get(f"/api/v1/organizations/{other_organization.id}")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Organization not found" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_update_another_organization_is_not_found(client, db, other_organization):
|
||||
"""The domain (and therefore CORS) of another tenant can't be rewritten"""
|
||||
original_domain = other_organization.domain
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/organizations/{other_organization.id}",
|
||||
json={"name": "Hijacked", "domain": "attacker.example.com"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
db.refresh(other_organization)
|
||||
assert other_organization.domain == original_domain
|
||||
assert other_organization.name == "Other Organization"
|
||||
|
||||
|
||||
def test_get_another_organizations_stats_is_not_found(client, other_organization):
|
||||
"""Headcount and conversation volumes stay inside the tenant"""
|
||||
response = client.get(f"/api/v1/organizations/{other_organization.id}/stats")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain", ["other.example.com", "Other.Example.COM"])
|
||||
def test_update_organization_domain_taken_is_conflict(client, db, test_organization, other_organization, domain):
|
||||
"""A domain another org already uses is refused with 409, ignoring case"""
|
||||
response = client.patch(
|
||||
f"/api/v1/organizations/{test_organization.id}",
|
||||
json={"domain": domain}
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == organizations_router.DOMAIN_TAKEN_MESSAGE
|
||||
db.refresh(test_organization)
|
||||
assert test_organization.domain == "test.com"
|
||||
|
||||
|
||||
def test_update_organization_domain_taken_race_is_conflict(client, db, test_organization, other_organization, monkeypatch):
|
||||
"""A unique violation at commit (domain claimed after the check) is a 409, not a 500"""
|
||||
from app.repositories.organization import OrganizationRepository
|
||||
monkeypatch.setattr(OrganizationRepository, "is_domain_taken", lambda *args, **kwargs: False)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/organizations/{test_organization.id}",
|
||||
json={"domain": other_organization.domain}
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == organizations_router.DOMAIN_TAKEN_MESSAGE
|
||||
|
||||
|
||||
def test_update_organization_keeps_own_domain(client, test_organization, other_organization):
|
||||
"""Saving the form with the org's current domain unchanged is fine"""
|
||||
response = client.patch(
|
||||
f"/api/v1/organizations/{test_organization.id}",
|
||||
json={"name": "Renamed", "domain": test_organization.domain}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["domain"] == "test.com"
|
||||
assert response.json()["name"] == "Renamed"
|
||||
|
||||
|
||||
def test_update_organization_unique_domain(client, db, test_organization, other_organization):
|
||||
"""A domain nobody else uses is saved"""
|
||||
response = client.patch(
|
||||
f"/api/v1/organizations/{test_organization.id}",
|
||||
json={"domain": "fresh.example.com"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["domain"] == "fresh.example.com"
|
||||
db.refresh(test_organization)
|
||||
assert test_organization.domain == "fresh.example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain", ["localhost", "LocalHost", "localhost:3000", "http://localhost:5173", "127.0.0.1"])
|
||||
def test_update_organization_localhost_domain_is_refused(client, db, test_organization, domain):
|
||||
"""Localhost is refused as an org domain with a plain message"""
|
||||
response = client.patch(
|
||||
f"/api/v1/organizations/{test_organization.id}",
|
||||
json={"domain": domain}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == organizations_router.LOCALHOST_DOMAIN_MESSAGE
|
||||
db.refresh(test_organization)
|
||||
assert test_organization.domain == "test.com"
|
||||
|
||||
|
||||
def test_update_organization_keeps_existing_localhost_domain(client, db, test_organization):
|
||||
"""An org already on localhost can still save other changes"""
|
||||
test_organization.domain = "localhost"
|
||||
db.commit()
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/organizations/{test_organization.id}",
|
||||
json={"name": "Renamed", "domain": "localhost"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Renamed"
|
||||
|
||||
|
||||
def test_update_organization_localhost_lookalike_is_allowed(client, test_organization):
|
||||
"""Only the host itself counts, not domains that merely contain the word"""
|
||||
response = client.patch(
|
||||
f"/api/v1/organizations/{test_organization.id}",
|
||||
json={"domain": "localhost.example.com"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 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
|
||||
import app.api.people as people_api
|
||||
from app.core.application import app
|
||||
from app.core.auth import get_current_user
|
||||
from app.crm.base import CrmPushResult, OAuthTokens
|
||||
from app.crm.hubspot import HubSpotAdapter
|
||||
from app.database import get_db
|
||||
from app.models.customer import Customer
|
||||
from app.repositories.crm import CrmConnectionRepository
|
||||
|
||||
BASE = "/api/v1/people"
|
||||
|
||||
|
||||
@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(autouse=True)
|
||||
def _open_gates(monkeypatch):
|
||||
"""Bypass the People/Lead-Management plan gate and the crm_sync gate so
|
||||
these tests exercise the endpoints regardless of enterprise presence."""
|
||||
monkeypatch.setattr(people_api, "_require_people_access", lambda *a, **k: None)
|
||||
monkeypatch.setattr("app.services.crm_sync.feature_allowed", lambda *a, **k: True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def person(db, test_organization):
|
||||
cust = Customer(organization_id=test_organization.id,
|
||||
email="nadia@example.com", full_name="Nadia Rahman")
|
||||
db.add(cust)
|
||||
db.commit()
|
||||
db.refresh(cust)
|
||||
return cust
|
||||
|
||||
|
||||
@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},
|
||||
access_token_expires_at=datetime.now(timezone.utc) + timedelta(hours=1))
|
||||
|
||||
|
||||
OK = CrmPushResult(ok=True, action="created", contact_id="301",
|
||||
record_url="https://app.hubspot.com/contacts/1/record/0-1/301")
|
||||
|
||||
|
||||
def test_status_no_connection(client, person):
|
||||
r = client.get(f"{BASE}/{person.id}/crm")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["connected_providers"] == []
|
||||
assert body["synced"] == []
|
||||
|
||||
|
||||
def test_status_lists_connection(client, person, connection):
|
||||
r = client.get(f"{BASE}/{person.id}/crm")
|
||||
assert r.json()["connected_providers"] == ["hubspot"]
|
||||
|
||||
|
||||
def test_sync_now_pushes_and_returns_link(client, person, connection, monkeypatch):
|
||||
monkeypatch.setattr(HubSpotAdapter, "push_lead", AsyncMock(return_value=OK))
|
||||
r = client.post(f"{BASE}/{person.id}/crm-sync")
|
||||
assert r.status_code == 200
|
||||
synced = r.json()["synced"]
|
||||
assert len(synced) == 1
|
||||
assert synced[0]["provider"] == "hubspot"
|
||||
assert synced[0]["record_url"] == OK.record_url
|
||||
|
||||
|
||||
def test_sync_now_without_connection_400(client, person):
|
||||
r = client.post(f"{BASE}/{person.id}/crm-sync")
|
||||
assert r.status_code == 400
|
||||
assert "Connect a CRM" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_sync_now_placeholder_email_400(client, db, test_organization, connection):
|
||||
anon = Customer(organization_id=test_organization.id, email="visitor@noemail.com")
|
||||
db.add(anon); db.commit(); db.refresh(anon)
|
||||
r = client.post(f"{BASE}/{anon.id}/crm-sync")
|
||||
assert r.status_code == 400
|
||||
assert "email" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_status_unknown_person_404(client):
|
||||
import uuid
|
||||
r = client.get(f"{BASE}/{uuid.uuid4()}/crm")
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
|
||||
Permission coverage for the People directory.
|
||||
|
||||
Deliberately a separate module from test_people_crm_api.py: that file's autouse
|
||||
fixture stubs `_require_people_access` out entirely so it can focus on CRM
|
||||
behaviour, which means no permission assertion made there can ever fail.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import people as people_api
|
||||
from app.core.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.customer import Customer
|
||||
from app.models.organization import Organization
|
||||
from app.models.permission import Permission
|
||||
from app.models.role import Role
|
||||
from app.models.user import User
|
||||
|
||||
BASE = "/api/v1/people"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def without_the_plan_gate(monkeypatch):
|
||||
"""The Pro (lead_capture) gate runs after the permission check and would
|
||||
otherwise answer first in an enterprise checkout, so these would silently
|
||||
skip exactly where the hosted product runs. Permissions are the subject."""
|
||||
monkeypatch.setattr(people_api, "HAS_ENTERPRISE", False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(people_api.router, prefix=BASE)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def person(db, test_organization) -> Customer:
|
||||
customer = Customer(
|
||||
organization_id=test_organization.id,
|
||||
email="nadia@example.com",
|
||||
full_name="Nadia Rahman",
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
return customer
|
||||
|
||||
|
||||
def _user_with(db, organization, permission_names) -> User:
|
||||
role = Role(name=f"Role {uuid4().hex[:6]}", organization_id=organization.id)
|
||||
role.permissions = [Permission(name=name) for name in permission_names]
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email=f"person-{uuid4().hex[:6]}@example.com",
|
||||
full_name="Someone",
|
||||
hashed_password="x",
|
||||
is_active=True,
|
||||
organization_id=organization.id,
|
||||
role_id=role.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def _client(api: FastAPI, db, user: User) -> TestClient:
|
||||
async def override_user():
|
||||
return user
|
||||
|
||||
def override_db():
|
||||
yield db
|
||||
|
||||
api.dependency_overrides[get_current_user] = override_user
|
||||
api.dependency_overrides[get_db] = override_db
|
||||
return TestClient(api)
|
||||
|
||||
|
||||
def test_view_people_can_read_the_directory(api, db, test_organization, person):
|
||||
user = _user_with(db, test_organization, ["view_people"])
|
||||
|
||||
response = _client(api, db, user).get(BASE)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_view_people_can_correct_a_person(api, db, test_organization, person):
|
||||
"""people.py documents the PATCH as the agent's identification tool — the
|
||||
one path allowed to correct a wrong phone number. It used to need the
|
||||
org-wide chat grants, so the Edit button rendered for an agent and 403'd."""
|
||||
user = _user_with(db, test_organization, ["view_people"])
|
||||
|
||||
response = _client(api, db, user).patch(
|
||||
f"{BASE}/{person.id}", json={"full_name": "Corrected Name"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_view_people_can_mark_a_customer(api, db, test_organization, person):
|
||||
user = _user_with(db, test_organization, ["view_people"])
|
||||
|
||||
response = _client(api, db, user).post(f"{BASE}/{person.id}/mark-customer")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_an_assigned_chats_agent_can_read_the_directory(api, db, test_organization, person):
|
||||
"""PEOPLE_READ includes the inbox grants, not just view_people"""
|
||||
user = _user_with(db, test_organization, ["view_assigned_chats"])
|
||||
|
||||
assert _client(api, db, user).get(BASE).status_code == 200
|
||||
|
||||
|
||||
def test_a_role_without_a_people_grant_is_refused(api, db, test_organization, person):
|
||||
"""Widening the write set must not widen who gets in at all"""
|
||||
user = _user_with(db, test_organization, ["manage_knowledge"])
|
||||
client = _client(api, db, user)
|
||||
|
||||
assert client.get(BASE).status_code == 403
|
||||
assert client.patch(f"{BASE}/{person.id}", json={"full_name": "Nope"}).status_code == 403
|
||||
assert client.post(f"{BASE}/{person.id}/mark-customer").status_code == 403
|
||||
|
||||
|
||||
def test_people_are_org_scoped(api, db, test_organization, person):
|
||||
"""A person from another tenant is not in this directory"""
|
||||
other_org = Organization(id=uuid4(), name="Other", domain="other-people.example.com")
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
db.add(Customer(
|
||||
organization_id=other_org.id,
|
||||
email="outsider@example.com",
|
||||
full_name="Outsider",
|
||||
))
|
||||
db.commit()
|
||||
user = _user_with(db, test_organization, ["view_people"])
|
||||
|
||||
response = _client(api, db, user).get(BASE)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "outsider@example.com" not in str(response.json())
|
||||
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 app.database import Base, get_db
|
||||
from fastapi import FastAPI
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from uuid import UUID, uuid4
|
||||
from app.api import roles as roles_router
|
||||
from app.core.auth import get_current_user
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
roles_router.router,
|
||||
prefix=f"{settings.API_V1_STR}/roles",
|
||||
tags=["roles"]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db():
|
||||
"""Create a fresh database for each test."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture
|
||||
def test_permissions(db) -> list[Permission]:
|
||||
"""Create test permissions"""
|
||||
permissions = []
|
||||
for name in ["manage_roles", "manage_users", "manage_chats"]:
|
||||
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="Test Role",
|
||||
description="Test Role Description",
|
||||
organization_id=test_organization.id,
|
||||
is_default=False
|
||||
)
|
||||
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: Session, test_organization, test_role: Role) -> User:
|
||||
"""Create a test user with required permissions"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="test@test.com",
|
||||
hashed_password="hashed_password",
|
||||
organization_id=test_organization.id,
|
||||
role_id=test_role.id,
|
||||
is_active=True,
|
||||
full_name="Test User"
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_user: User) -> TestClient:
|
||||
"""Client authenticated as a user whose role really holds manage_roles.
|
||||
|
||||
There used to be a `dependency_overrides[require_permissions]` line here.
|
||||
It never did anything — require_permissions is a factory and FastAPI keys
|
||||
overrides on the closure it returns — so it read as coverage while the real
|
||||
gates ran unchecked. The fixture role grants the permissions instead.
|
||||
"""
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
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_create_role(client: TestClient, test_permissions):
|
||||
"""Test creating a new role"""
|
||||
role_data = {
|
||||
"name": "New Role",
|
||||
"description": "New role description",
|
||||
"is_default": False,
|
||||
"permissions": [{"id": perm.id, "name": perm.name, "description": perm.description} for perm in test_permissions[:2]]
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/roles", json=role_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == role_data["name"]
|
||||
assert data["description"] == role_data["description"]
|
||||
assert data["is_default"] == role_data["is_default"]
|
||||
assert len(data["permissions"]) == 2
|
||||
|
||||
def test_create_default_role_when_exists(client: TestClient, test_role, db):
|
||||
"""Test creating a default role when one already exists"""
|
||||
# First make the test_role default
|
||||
test_role.is_default = True
|
||||
db.commit()
|
||||
|
||||
role_data = {
|
||||
"name": "Another Default Role",
|
||||
"description": "This should fail",
|
||||
"is_default": True,
|
||||
"permissions": []
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/roles", json=role_data)
|
||||
assert response.status_code == 400
|
||||
assert "Organization already has a default role" in response.json()["detail"]
|
||||
|
||||
def test_list_roles(client: TestClient, test_role):
|
||||
"""Test listing all roles"""
|
||||
response = client.get("/api/v1/roles")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == test_role.name
|
||||
assert data[0]["description"] == test_role.description
|
||||
|
||||
def test_get_role(client: TestClient, test_role):
|
||||
"""Test getting a specific role"""
|
||||
response = client.get(f"/api/v1/roles/{test_role.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == test_role.name
|
||||
assert data["description"] == test_role.description
|
||||
|
||||
def test_update_role(client: TestClient, test_role, test_permissions):
|
||||
"""Test updating a role"""
|
||||
update_data = {
|
||||
"name": "Updated Role Name",
|
||||
"description": "Updated description",
|
||||
"permissions": [{"id": perm.id, "name": perm.name, "description": perm.description} for perm in test_permissions[:1]]
|
||||
}
|
||||
|
||||
response = client.put(f"/api/v1/roles/{test_role.id}", json=update_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == update_data["name"]
|
||||
assert data["description"] == update_data["description"]
|
||||
assert len(data["permissions"]) == 1
|
||||
|
||||
def test_delete_role(client: TestClient, db, test_organization):
|
||||
"""Test deleting a role"""
|
||||
# Create a new role that won't be used by any users
|
||||
role = Role(
|
||||
name="Role To Delete",
|
||||
description="This role will be deleted",
|
||||
organization_id=test_organization.id,
|
||||
is_default=False
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
|
||||
response = client.delete(f"/api/v1/roles/{role.id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_add_permission_to_role(client: TestClient, test_role, test_permissions):
|
||||
"""Test adding a permission to a role"""
|
||||
permission = test_permissions[0]
|
||||
response = client.post(f"/api/v1/roles/{test_role.id}/permissions/{permission.name}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Permission added to role"
|
||||
|
||||
def test_remove_permission_from_role(client: TestClient, test_role, test_permissions):
|
||||
"""Test removing a permission from a role"""
|
||||
permission = test_permissions[0]
|
||||
response = client.delete(f"/api/v1/roles/{test_role.id}/permissions/{permission.name}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Permission removed from role"
|
||||
|
||||
def test_list_permissions(client: TestClient, test_permissions):
|
||||
"""Test listing all available permissions"""
|
||||
response = client.get("/api/v1/roles/permissions/all")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == len(test_permissions)
|
||||
|
||||
# Negative test cases
|
||||
|
||||
def test_create_role_duplicate_name(client: TestClient, test_role):
|
||||
"""Test creating a role with duplicate name"""
|
||||
role_data = {
|
||||
"name": test_role.name, # Same name as existing role
|
||||
"description": "This should fail",
|
||||
"is_default": False,
|
||||
"permissions": []
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/roles", json=role_data)
|
||||
assert response.status_code == 400
|
||||
assert "Role with this name already exists" in response.json()["detail"]
|
||||
|
||||
def test_update_default_role(client: TestClient, test_role, db):
|
||||
"""Test updating a default role"""
|
||||
# Make the role default
|
||||
test_role.is_default = True
|
||||
db.commit()
|
||||
|
||||
update_data = {
|
||||
"name": "Updated Name",
|
||||
"description": "This should fail"
|
||||
}
|
||||
|
||||
response = client.put(f"/api/v1/roles/{test_role.id}", json=update_data)
|
||||
assert response.status_code == 400
|
||||
assert "Cannot modify default role" in response.json()["detail"]
|
||||
|
||||
def test_delete_default_role(client: TestClient, test_role, db):
|
||||
"""Test deleting a default role"""
|
||||
# Make the role default
|
||||
test_role.is_default = True
|
||||
db.commit()
|
||||
|
||||
response = client.delete(f"/api/v1/roles/{test_role.id}")
|
||||
assert response.status_code == 400
|
||||
assert "Cannot delete default role" in response.json()["detail"]
|
||||
|
||||
def test_delete_role_in_use(client: TestClient, test_role, test_user):
|
||||
"""Test deleting a role that is assigned to users"""
|
||||
response = client.delete(f"/api/v1/roles/{test_role.id}")
|
||||
assert response.status_code == 400
|
||||
assert "Cannot delete role that is assigned to users" in response.json()["detail"]
|
||||
|
||||
def test_get_nonexistent_role(client: TestClient):
|
||||
"""Test getting a nonexistent role"""
|
||||
response = client.get("/api/v1/roles/999")
|
||||
assert response.status_code == 404
|
||||
assert "Role not found" in response.json()["detail"]
|
||||
|
||||
def test_update_nonexistent_role(client: TestClient):
|
||||
"""Test updating a nonexistent role"""
|
||||
update_data = {
|
||||
"name": "Updated Name",
|
||||
"description": "This should fail"
|
||||
}
|
||||
|
||||
response = client.put("/api/v1/roles/999", json=update_data)
|
||||
assert response.status_code == 404
|
||||
assert "Role not found" in response.json()["detail"]
|
||||
|
||||
def test_delete_nonexistent_role(client: TestClient):
|
||||
"""Test deleting a nonexistent role"""
|
||||
response = client.delete("/api/v1/roles/999")
|
||||
assert response.status_code == 404
|
||||
assert "Role not found" in response.json()["detail"]
|
||||
|
||||
def test_add_invalid_permission(client: TestClient, test_role):
|
||||
"""Test adding an invalid permission to a role"""
|
||||
response = client.post(f"/api/v1/roles/{test_role.id}/permissions/invalid_permission")
|
||||
assert response.status_code == 404
|
||||
assert "Permission not found" in response.json()["detail"]
|
||||
|
||||
def test_remove_invalid_permission(client: TestClient, test_role):
|
||||
"""Test removing an invalid permission from a role"""
|
||||
response = client.delete(f"/api/v1/roles/{test_role.id}/permissions/invalid_permission")
|
||||
assert response.status_code == 404
|
||||
assert "Permission not found" in response.json()["detail"]
|
||||
|
||||
# ------------------------------------------------- permission gating & escalation
|
||||
|
||||
@pytest.fixture
|
||||
def unprivileged_client(db: Session, test_organization) -> TestClient:
|
||||
"""A client whose role grants nothing.
|
||||
|
||||
Every endpoint here except POST used to be Depends(get_current_user) with
|
||||
only an org check, so any authenticated user could rewrite roles — including
|
||||
granting their own role super_admin.
|
||||
"""
|
||||
# Explicit id: the test_role fixture hardcodes id=1, so an autoincrement
|
||||
# here collides depending on which fixture resolves first.
|
||||
role = Role(id=99, name="No Permissions", organization_id=test_organization.id)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="nopermissions@test.com",
|
||||
hashed_password="hashed_password",
|
||||
organization_id=test_organization.id,
|
||||
role_id=role.id,
|
||||
is_active=True,
|
||||
full_name="No Permissions",
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
async def override_get_current_user():
|
||||
return user
|
||||
|
||||
def override_get_db():
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
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_role_endpoints_require_permissions(unprivileged_client: TestClient, test_role: Role):
|
||||
"""None of the role endpoints are reachable without manage_roles/manage_users"""
|
||||
role_id = test_role.id
|
||||
assert unprivileged_client.get("/api/v1/roles").status_code == 403
|
||||
assert unprivileged_client.get(f"/api/v1/roles/{role_id}").status_code == 403
|
||||
assert unprivileged_client.get("/api/v1/roles/permissions/all").status_code == 403
|
||||
assert unprivileged_client.post("/api/v1/roles", json={"name": "X", "permissions": []}).status_code == 403
|
||||
assert unprivileged_client.put(f"/api/v1/roles/{role_id}", json={"name": "X"}).status_code == 403
|
||||
assert unprivileged_client.delete(f"/api/v1/roles/{role_id}").status_code == 403
|
||||
assert unprivileged_client.post(f"/api/v1/roles/{role_id}/permissions/manage_roles").status_code == 403
|
||||
assert unprivileged_client.delete(f"/api/v1/roles/{role_id}/permissions/manage_roles").status_code == 403
|
||||
|
||||
|
||||
def test_cannot_grant_permission_caller_does_not_hold(client: TestClient, db: Session, test_role: Role):
|
||||
"""The reported hole: POST /roles/{own_role_id}/permissions/super_admin"""
|
||||
db.add(Permission(name="super_admin", description="Has all permissions"))
|
||||
db.commit()
|
||||
|
||||
response = client.post(f"/api/v1/roles/{test_role.id}/permissions/super_admin")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "do not hold" in response.json()["detail"]
|
||||
db.refresh(test_role)
|
||||
assert "super_admin" not in {p.name for p in test_role.permissions}
|
||||
|
||||
|
||||
def test_cannot_grant_unheld_permission_via_update(client: TestClient, db: Session, test_role: Role):
|
||||
"""The same escalation through PUT rather than the permissions route"""
|
||||
super_admin = Permission(name="super_admin", description="Has all permissions")
|
||||
db.add(super_admin)
|
||||
db.commit()
|
||||
db.refresh(super_admin)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1/roles/{test_role.id}",
|
||||
json={
|
||||
"name": test_role.name,
|
||||
"permissions": [
|
||||
{"id": super_admin.id, "name": "super_admin", "description": "Has all permissions"}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
db.refresh(test_role)
|
||||
assert "super_admin" not in {p.name for p in test_role.permissions}
|
||||
|
||||
|
||||
def test_cannot_create_role_with_unheld_permission(client: TestClient, db: Session):
|
||||
"""Creating a role is a grant too — otherwise the rule is trivially bypassed"""
|
||||
super_admin = Permission(name="super_admin", description="Has all permissions")
|
||||
db.add(super_admin)
|
||||
db.commit()
|
||||
db.refresh(super_admin)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/roles",
|
||||
json={
|
||||
"name": "Backdoor",
|
||||
"description": "",
|
||||
"permissions": [{"id": super_admin.id, "name": "super_admin", "description": "Has all permissions"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_can_grant_permission_caller_holds(client: TestClient, db: Session, test_organization):
|
||||
"""Granting something you do hold is unaffected"""
|
||||
other_role = Role(name="Other Role", organization_id=test_organization.id)
|
||||
db.add(other_role)
|
||||
db.commit()
|
||||
db.refresh(other_role)
|
||||
|
||||
response = client.post(f"/api/v1/roles/{other_role.id}/permissions/manage_users")
|
||||
|
||||
assert response.status_code == 200
|
||||
db.refresh(other_role)
|
||||
assert "manage_users" in {p.name for p in other_role.permissions}
|
||||
|
||||
|
||||
def test_super_admin_can_grant_anything(db: Session, test_organization):
|
||||
"""check_permissions short-circuits on super_admin, so an owner is unaffected"""
|
||||
super_admin = Permission(name="super_admin", description="Has all permissions")
|
||||
db.add(super_admin)
|
||||
db.commit()
|
||||
owner_role = Role(name="Owner", organization_id=test_organization.id)
|
||||
db.add(owner_role)
|
||||
db.commit()
|
||||
db.execute(role_permissions.insert().values(role_id=owner_role.id, permission_id=super_admin.id))
|
||||
db.commit()
|
||||
owner = User(
|
||||
id=uuid4(),
|
||||
email="owner@test.com",
|
||||
hashed_password="hashed_password",
|
||||
organization_id=test_organization.id,
|
||||
role_id=owner_role.id,
|
||||
is_active=True,
|
||||
full_name="Owner",
|
||||
)
|
||||
db.add(owner)
|
||||
target = Role(name="Target", organization_id=test_organization.id)
|
||||
db.add(target)
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
|
||||
async def override_get_current_user():
|
||||
return owner
|
||||
|
||||
def override_get_db():
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
try:
|
||||
response = TestClient(app).post(f"/api/v1/roles/{target.id}/permissions/super_admin")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,875 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 app.database import get_db
|
||||
from fastapi import FastAPI, status
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.customer import Customer
|
||||
from uuid import UUID, uuid4
|
||||
from app.api import session_to_agent as session_to_agent_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from typing import Generator
|
||||
from datetime import datetime
|
||||
from app.models.schemas.chat import ChatDetailResponse, CustomerInfo, AgentInfo, Message
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, Base
|
||||
from app.models.organization import Organization
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from unittest.mock import patch
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
session_to_agent_router.router,
|
||||
prefix="/api/v1/session-to-agent",
|
||||
tags=["session-to-agent"]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db() -> Generator:
|
||||
"""Create a fresh database for each test."""
|
||||
# Drop all tables first
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
# Create tables except enterprise ones
|
||||
create_tables()
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture
|
||||
def test_permissions(db) -> list[Permission]:
|
||||
"""Create test permissions"""
|
||||
permissions = []
|
||||
# manage_all_chats, not "manage_chats": the latter was never in
|
||||
# Permission.default_permissions(), so the endpoint check it fed never
|
||||
# matched anyone. view_unassigned_chats is what lets an agent see (and
|
||||
# therefore claim) a chat the AI is still handling.
|
||||
for name in ["manage_all_chats", "manage_assigned_chats", "view_unassigned_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_with_manage_chats(db, test_permissions) -> Role:
|
||||
"""Create a test role with manage_all_chats permission"""
|
||||
role = Role(
|
||||
id=1,
|
||||
name="Manage Chats Role",
|
||||
description="Role with manage_all_chats permission",
|
||||
is_default=False
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
|
||||
# Add manage_all_chats permission
|
||||
manage_chats_perm = next(p for p in test_permissions if p.name == "manage_all_chats")
|
||||
db.execute(
|
||||
role_permissions.insert().values(
|
||||
role_id=role.id,
|
||||
permission_id=manage_chats_perm.id
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
@pytest.fixture
|
||||
def test_role_with_manage_assigned_chats(db, test_permissions) -> Role:
|
||||
"""Create a test role with manage_assigned_chats permission"""
|
||||
role = Role(
|
||||
id=2,
|
||||
name="Manage Assigned Chats Role",
|
||||
description="Role with manage_assigned_chats permission",
|
||||
is_default=False
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
|
||||
# Add manage_assigned_chats + view_unassigned_chats, mirroring the seeded
|
||||
# Agent role: claiming an unclaimed chat requires being able to see it.
|
||||
for perm_name in ("manage_assigned_chats", "view_unassigned_chats"):
|
||||
perm = next(p for p in test_permissions if p.name == perm_name)
|
||||
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_role_without_permissions(db) -> Role:
|
||||
"""Create a test role without any permissions"""
|
||||
role = Role(
|
||||
id=3,
|
||||
name="No Permissions Role",
|
||||
description="Role without permissions",
|
||||
is_default=False
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
@pytest.fixture
|
||||
def test_organization(db) -> Organization:
|
||||
"""Create a test organization"""
|
||||
organization = Organization(
|
||||
id=uuid4(),
|
||||
name="Test Organization",
|
||||
domain="test.example.com",
|
||||
business_hours={"monday": {"start": "09:00", "end": "17:00"}},
|
||||
settings={"timezone": "UTC"}
|
||||
)
|
||||
db.add(organization)
|
||||
db.commit()
|
||||
db.refresh(organization)
|
||||
return organization
|
||||
|
||||
@pytest.fixture
|
||||
def user_with_manage_chats_permission(db, test_role_with_manage_chats, test_organization) -> User:
|
||||
"""Create a test user with manage_chats permission"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="manage_chats@example.com",
|
||||
hashed_password="hashed_password",
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
full_name="Manage Chats User",
|
||||
role_id=test_role_with_manage_chats.id
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def user_with_manage_assigned_chats(db, test_role_with_manage_assigned_chats, test_organization, test_agent) -> User:
|
||||
"""Create a test user with manage_assigned_chats permission"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="manage_assigned@example.com",
|
||||
hashed_password="hashed_password",
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
full_name="Manage Assigned Chats User",
|
||||
role_id=test_role_with_manage_assigned_chats.id
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def regular_user(db, test_role_without_permissions, test_organization) -> User:
|
||||
"""Create a test user without special permissions"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="regular@example.com",
|
||||
hashed_password="hashed_password",
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
full_name="Regular User",
|
||||
role_id=test_role_without_permissions.id
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent(db, test_organization) -> Agent:
|
||||
"""Create a test agent"""
|
||||
agent = Agent(
|
||||
id=uuid4(),
|
||||
name="Test Agent",
|
||||
display_name="Test Display Name",
|
||||
agent_type=AgentType.CUSTOMER_SUPPORT,
|
||||
instructions=["Test instruction"],
|
||||
is_active=True,
|
||||
organization_id=test_organization.id
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
@pytest.fixture
|
||||
def test_customer(db, test_organization) -> Customer:
|
||||
"""Create a test customer"""
|
||||
customer = Customer(
|
||||
id=uuid4(),
|
||||
organization_id=test_organization.id,
|
||||
email="customer@example.com",
|
||||
full_name="Test Customer"
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
return customer
|
||||
|
||||
@pytest.fixture
|
||||
def create_chat_session(db, test_agent, test_customer):
|
||||
"""Create a test chat session"""
|
||||
def _create_session():
|
||||
session = SessionToAgent(
|
||||
session_id=uuid4(),
|
||||
organization_id=test_agent.organization_id,
|
||||
customer_id=test_customer.id,
|
||||
agent_id=test_agent.id
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
|
||||
# Create a test message for the session
|
||||
message = ChatHistory(
|
||||
message="Test message",
|
||||
message_type="agent",
|
||||
session_id=session.session_id,
|
||||
organization_id=session.organization_id,
|
||||
agent_id=session.agent_id,
|
||||
customer_id=session.customer_id
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
|
||||
return session
|
||||
return _create_session
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_response(test_agent, test_customer) -> ChatDetailResponse:
|
||||
"""Create a mock chat response"""
|
||||
return ChatDetailResponse(
|
||||
customer=CustomerInfo(
|
||||
id=test_customer.id,
|
||||
email=test_customer.email,
|
||||
full_name=test_customer.full_name
|
||||
),
|
||||
agent=AgentInfo(
|
||||
id=test_agent.id,
|
||||
name=test_agent.name,
|
||||
display_name=test_agent.display_name
|
||||
),
|
||||
messages=[
|
||||
Message(
|
||||
message="Test message",
|
||||
message_type="agent",
|
||||
created_at=datetime.now(),
|
||||
attributes={}
|
||||
)
|
||||
],
|
||||
status=SessionStatus.OPEN,
|
||||
group_id=None,
|
||||
session_id=uuid4(),
|
||||
user_id=None,
|
||||
user_name=None,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def client(user_with_manage_chats_permission) -> TestClient:
|
||||
"""Create test client with mocked dependencies.
|
||||
|
||||
The takeover endpoint itself is the real one: these tests assert on the
|
||||
status codes it raises (403/404/400/500), so it must not be swapped for a
|
||||
stub. Do not rebuild the sub-router's routes here — the app copied them at
|
||||
include time, and on current FastAPI a late swap would replace the endpoint
|
||||
under test.
|
||||
"""
|
||||
async def override_get_current_user():
|
||||
return user_with_manage_chats_permission
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_db] = lambda: TestingSessionLocal()
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_error_mock(user_with_manage_chats_permission, mock_chat_response) -> TestClient:
|
||||
"""Create test client with error mocking capabilities"""
|
||||
async def override_get_current_user():
|
||||
return user_with_manage_chats_permission
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_db] = lambda: TestingSessionLocal()
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
def test_takeover_chat_success(client, db, user_with_manage_chats_permission,
|
||||
create_chat_session, mock_chat_response):
|
||||
"""Test successful chat takeover"""
|
||||
# Create test data
|
||||
session = create_chat_session()
|
||||
|
||||
# Test takeover with direct auth
|
||||
response = client.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
|
||||
print("Response:", response.json()) # Add this line to print the error message
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert isinstance(data, dict)
|
||||
assert "customer" in data
|
||||
assert "agent" in data
|
||||
assert "messages" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == SessionStatus.OPEN.value
|
||||
|
||||
def test_takeover_chat_no_permission(client, db, regular_user, create_chat_session):
|
||||
"""Test chat takeover with insufficient permissions"""
|
||||
session = create_chat_session()
|
||||
|
||||
# Override get_current_user to return regular_user
|
||||
async def override_get_current_user():
|
||||
return regular_user
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
# Test takeover
|
||||
response = client.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert "Not enough permissions" in response.json()["detail"]
|
||||
|
||||
def test_takeover_chat_invalid_session(client, db, user_with_manage_chats_permission):
|
||||
"""Test chat takeover with invalid session ID"""
|
||||
# Test takeover with invalid session ID
|
||||
invalid_session_id = str(uuid4())
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/session-to-agent/{invalid_session_id}/takeover"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert "Chat session not found" in response.json()["detail"]
|
||||
|
||||
def test_takeover_chat_unauthorized(client, db, create_chat_session):
|
||||
"""Test chat takeover without authentication"""
|
||||
session = create_chat_session()
|
||||
|
||||
# Remove all dependency overrides
|
||||
app.dependency_overrides = {}
|
||||
|
||||
# Test takeover without auth
|
||||
response = client.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_takeover_chat_with_manage_assigned_chats(client, db,
|
||||
user_with_manage_assigned_chats,
|
||||
create_chat_session,
|
||||
mock_chat_response):
|
||||
"""Test chat takeover with manage_assigned_chats permission"""
|
||||
session = create_chat_session()
|
||||
|
||||
# Override get_current_user to return user with manage_assigned_chats
|
||||
async def override_get_current_user():
|
||||
return user_with_manage_assigned_chats
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
# Test takeover
|
||||
response = client.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
|
||||
print("Response:", response.json()) # Add this line to print the error message
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert isinstance(data, dict)
|
||||
assert "customer" in data
|
||||
assert "agent" in data
|
||||
assert "messages" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == SessionStatus.OPEN.value
|
||||
|
||||
def test_takeover_chat_update_failure(client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session):
|
||||
"""Test chat takeover when session update fails"""
|
||||
session = create_chat_session()
|
||||
|
||||
# Mock the repository to simulate update failure
|
||||
def mock_takeover_session(*args, **kwargs):
|
||||
return False
|
||||
|
||||
with patch('app.repositories.session_to_agent.SessionToAgentRepository.takeover_session',
|
||||
side_effect=mock_takeover_session):
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Failed to take over chat" in response.json()["detail"]
|
||||
|
||||
def test_takeover_chat_details_failure(client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session):
|
||||
"""Test chat takeover when getting chat details fails"""
|
||||
session = create_chat_session()
|
||||
|
||||
# Mock the repository to simulate chat details failure
|
||||
def mock_get_chat_detail(*args, **kwargs):
|
||||
return None
|
||||
|
||||
with patch('app.repositories.chat.ChatRepository.get_chat_detail',
|
||||
side_effect=mock_get_chat_detail):
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert "Failed to get chat details after takeover" in response.json()["detail"]
|
||||
|
||||
def test_takeover_chat_unexpected_error(client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session):
|
||||
"""Test chat takeover with unexpected error"""
|
||||
session = create_chat_session()
|
||||
|
||||
# Mock the repository to simulate unexpected error
|
||||
def mock_get_session(*args, **kwargs):
|
||||
raise Exception("Unexpected error occurred")
|
||||
|
||||
with patch('app.repositories.session_to_agent.SessionToAgentRepository.get_session',
|
||||
side_effect=mock_get_session):
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert "Failed to take over chat" in response.json()["detail"]
|
||||
|
||||
def test_takeover_chat_already_taken(client, db, user_with_manage_chats_permission, create_chat_session):
|
||||
"""Test chat takeover when session is already taken"""
|
||||
session = create_chat_session()
|
||||
|
||||
# First takeover should succeed
|
||||
response1 = client.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
assert response1.status_code == status.HTTP_200_OK
|
||||
|
||||
# Second takeover should fail
|
||||
response2 = client.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
assert response2.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Failed to take over chat" in response2.json()["detail"]
|
||||
|
||||
def test_takeover_chat_closed_session(client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session):
|
||||
"""Test chat takeover when session is closed"""
|
||||
session = create_chat_session()
|
||||
|
||||
# Close the session
|
||||
session_repo = SessionToAgentRepository(db)
|
||||
session_repo.update_session_status(session.session_id, SessionStatus.CLOSED)
|
||||
|
||||
# Mock the repository to simulate takeover failure
|
||||
def mock_takeover_session(*args, **kwargs):
|
||||
return False
|
||||
|
||||
with patch('app.repositories.session_to_agent.SessionToAgentRepository.takeover_session',
|
||||
side_effect=mock_takeover_session):
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/takeover"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Failed to take over chat" in response.json()["detail"]
|
||||
|
||||
@pytest.fixture
|
||||
def other_org_user(db, test_role_with_manage_chats) -> User:
|
||||
"""A user (and their org) entirely separate from the caller's."""
|
||||
other_org = Organization(
|
||||
id=uuid4(),
|
||||
name="Other Organization",
|
||||
domain="other.example.com",
|
||||
business_hours={},
|
||||
settings={}
|
||||
)
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="outsider@example.com",
|
||||
hashed_password="hashed_password",
|
||||
is_active=True,
|
||||
organization_id=other_org.id,
|
||||
full_name="Outsider",
|
||||
role_id=test_role_with_manage_chats.id
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def _assigned_session(db, create_chat_session, user):
|
||||
"""An open session already handled by a human — reassignable."""
|
||||
session = create_chat_session()
|
||||
session.user_id = user.id
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
def test_reassign_chat_success(client_with_error_mock, db, user_with_manage_chats_permission,
|
||||
user_with_manage_assigned_chats, create_chat_session):
|
||||
"""Reassigning to a colleague in the same org pushes them a notification"""
|
||||
session = _assigned_session(db, create_chat_session, user_with_manage_chats_permission)
|
||||
|
||||
with patch('app.api.session_to_agent.notify_chat_assigned') as mock_notify:
|
||||
mock_notify.return_value = None
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/reassign",
|
||||
params={"to_user_id": str(user_with_manage_assigned_chats.id)}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
db.refresh(session)
|
||||
assert session.user_id == user_with_manage_assigned_chats.id
|
||||
assert mock_notify.called
|
||||
|
||||
|
||||
def test_reassign_refuses_a_same_org_chat_outside_the_callers_scope(
|
||||
client_with_error_mock, db, user_with_manage_chats_permission,
|
||||
user_with_manage_assigned_chats, create_chat_session
|
||||
):
|
||||
"""A scoped agent cannot steal another agent's chat by reassigning it."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
owner = user_with_manage_chats_permission
|
||||
caller = user_with_manage_assigned_chats
|
||||
session = _assigned_session(db, create_chat_session, owner)
|
||||
app.dependency_overrides[get_current_user] = lambda: caller
|
||||
try:
|
||||
with patch('app.api.session_to_agent.ChatRepository.check_session_access',
|
||||
new=AsyncMock(return_value=False)) as check_access, \
|
||||
patch('app.api.session_to_agent.SessionToAgentRepository.reassign_session') as reassign:
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/reassign",
|
||||
params={"to_user_id": str(caller.id)}
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides[get_current_user] = lambda: owner
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json()["detail"] == "Chat session not found"
|
||||
check_access.assert_awaited_once()
|
||||
reassign.assert_not_called()
|
||||
db.refresh(session)
|
||||
assert session.user_id == owner.id
|
||||
|
||||
|
||||
def test_reassign_notifies_previous_assignee_not_new_one(
|
||||
client_with_error_mock, db, user_with_manage_chats_permission,
|
||||
user_with_manage_assigned_chats, create_chat_session
|
||||
):
|
||||
"""The 'reassigned_from_you' event must target the PREVIOUS owner's room.
|
||||
|
||||
reassign_session mutates the loaded session in place, so reading
|
||||
session.user_id after it would wrongly point at the new assignee.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
previous = user_with_manage_chats_permission
|
||||
new = user_with_manage_assigned_chats
|
||||
session = _assigned_session(db, create_chat_session, previous)
|
||||
|
||||
with patch('app.api.session_to_agent.notify_chat_assigned', new=AsyncMock()), \
|
||||
patch('app.api.session_to_agent.sio.emit', new=AsyncMock()) as mock_emit:
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/reassign",
|
||||
params={"to_user_id": str(new.id)}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
from_you = [
|
||||
c for c in mock_emit.call_args_list
|
||||
if c.args and isinstance(c.args[1], dict)
|
||||
and c.args[1].get('type') == 'reassigned_from_you'
|
||||
]
|
||||
assert len(from_you) == 1, "expected exactly one reassigned_from_you emit"
|
||||
assert from_you[0].kwargs.get('room') == f"user_{previous.id}"
|
||||
assert from_you[0].kwargs.get('room') != f"user_{new.id}"
|
||||
|
||||
|
||||
def test_reassign_to_same_user_skips_from_you_event(
|
||||
client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session
|
||||
):
|
||||
"""Reassigning a chat to its current owner is a no-op — no contradictory ping."""
|
||||
from unittest.mock import AsyncMock
|
||||
owner = user_with_manage_chats_permission
|
||||
session = _assigned_session(db, create_chat_session, owner)
|
||||
|
||||
with patch('app.api.session_to_agent.notify_chat_assigned', new=AsyncMock()), \
|
||||
patch('app.api.session_to_agent.sio.emit', new=AsyncMock()) as mock_emit:
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/reassign",
|
||||
params={"to_user_id": str(owner.id)}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
from_you = [
|
||||
c for c in mock_emit.call_args_list
|
||||
if c.args and isinstance(c.args[1], dict)
|
||||
and c.args[1].get('type') == 'reassigned_from_you'
|
||||
]
|
||||
assert from_you == []
|
||||
|
||||
|
||||
def test_reassign_chat_other_org_session(client_with_error_mock, db, other_org_user,
|
||||
user_with_manage_assigned_chats,
|
||||
user_with_manage_chats_permission, create_chat_session):
|
||||
"""A session belonging to another org is invisible, not reassignable"""
|
||||
session = _assigned_session(db, create_chat_session, user_with_manage_chats_permission)
|
||||
session.organization_id = other_org_user.organization_id
|
||||
db.commit()
|
||||
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/reassign",
|
||||
params={"to_user_id": str(user_with_manage_assigned_chats.id)}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json()["detail"] == "Chat session not found"
|
||||
db.refresh(session)
|
||||
assert session.user_id == user_with_manage_chats_permission.id
|
||||
|
||||
|
||||
def test_reassign_chat_target_in_another_org(client_with_error_mock, db, other_org_user,
|
||||
user_with_manage_chats_permission, create_chat_session):
|
||||
"""A chat can't be handed to someone outside the caller's org"""
|
||||
session = _assigned_session(db, create_chat_session, user_with_manage_chats_permission)
|
||||
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/reassign",
|
||||
params={"to_user_id": str(other_org_user.id)}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json()["detail"] == "User not found"
|
||||
db.refresh(session)
|
||||
assert session.user_id == user_with_manage_chats_permission.id
|
||||
|
||||
|
||||
def test_reassign_chat_unknown_target(client_with_error_mock, db,
|
||||
user_with_manage_chats_permission, create_chat_session):
|
||||
"""An unknown user id is a 404, and a malformed one isn't a 500"""
|
||||
session = _assigned_session(db, create_chat_session, user_with_manage_chats_permission)
|
||||
|
||||
for target in (str(uuid4()), "not-a-uuid"):
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/reassign",
|
||||
params={"to_user_id": target}
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND, target
|
||||
assert response.json()["detail"] == "User not found"
|
||||
|
||||
|
||||
def test_reassign_chat_no_permission(client_with_error_mock, db, regular_user,
|
||||
user_with_manage_chats_permission,
|
||||
user_with_manage_assigned_chats, create_chat_session):
|
||||
"""Without a manage grant, reassignment is forbidden"""
|
||||
session = _assigned_session(db, create_chat_session, user_with_manage_chats_permission)
|
||||
|
||||
async def override_get_current_user():
|
||||
return regular_user
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
try:
|
||||
response = client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{session.session_id}/reassign",
|
||||
params={"to_user_id": str(user_with_manage_assigned_chats.id)}
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides[get_current_user] = lambda: user_with_manage_chats_permission
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def _route_to_human(client, session):
|
||||
return client.post(f"/api/v1/session-to-agent/{session.session_id}/route-to-human")
|
||||
|
||||
|
||||
def test_route_to_human_queues_the_chat_without_claiming_it(
|
||||
client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session
|
||||
):
|
||||
"""The AI stops answering, but the chat stays claimable by anyone."""
|
||||
from unittest.mock import AsyncMock
|
||||
session = create_chat_session()
|
||||
|
||||
with patch('app.services.human_routing.notify_chat_event', new=AsyncMock()) as mock_notify:
|
||||
response = _route_to_human(client_with_error_mock, session)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
db.refresh(session)
|
||||
assert session.status == SessionStatus.TRANSFERRED
|
||||
# Not claimed: every backend guard reads "a human has this" off user_id.
|
||||
assert session.user_id is None
|
||||
assert mock_notify.called
|
||||
|
||||
|
||||
def test_route_to_human_is_idempotent(
|
||||
client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session
|
||||
):
|
||||
"""A second click must not re-notify the team."""
|
||||
from unittest.mock import AsyncMock
|
||||
session = create_chat_session()
|
||||
|
||||
with patch('app.services.human_routing.notify_chat_event', new=AsyncMock()):
|
||||
assert _route_to_human(client_with_error_mock, session).status_code == status.HTTP_200_OK
|
||||
|
||||
with patch('app.services.human_routing.notify_chat_event', new=AsyncMock()) as mock_notify:
|
||||
response = _route_to_human(client_with_error_mock, session)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not mock_notify.called
|
||||
|
||||
|
||||
def test_route_to_human_refuses_a_chat_a_human_holds(
|
||||
client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session
|
||||
):
|
||||
"""Someone is already on it — there is no AI to stop."""
|
||||
session = _assigned_session(db, create_chat_session, user_with_manage_chats_permission)
|
||||
|
||||
response = _route_to_human(client_with_error_mock, session)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
db.refresh(session)
|
||||
assert session.user_id == user_with_manage_chats_permission.id
|
||||
|
||||
|
||||
def test_route_to_human_refuses_a_closed_chat(
|
||||
client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session
|
||||
):
|
||||
session = create_chat_session()
|
||||
session.status = SessionStatus.CLOSED
|
||||
db.commit()
|
||||
|
||||
response = _route_to_human(client_with_error_mock, session)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
db.refresh(session)
|
||||
assert session.status == SessionStatus.CLOSED
|
||||
|
||||
|
||||
def test_route_to_human_no_permission(
|
||||
client_with_error_mock, db, regular_user, user_with_manage_chats_permission,
|
||||
create_chat_session
|
||||
):
|
||||
session = create_chat_session()
|
||||
|
||||
async def override_get_current_user():
|
||||
return regular_user
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
try:
|
||||
response = _route_to_human(client_with_error_mock, session)
|
||||
finally:
|
||||
app.dependency_overrides[get_current_user] = lambda: user_with_manage_chats_permission
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
db.refresh(session)
|
||||
assert session.status == SessionStatus.OPEN
|
||||
|
||||
|
||||
def test_route_to_human_other_org_session(
|
||||
client_with_error_mock, db, other_org_user, user_with_manage_chats_permission,
|
||||
create_chat_session
|
||||
):
|
||||
"""A guessable session id must not let an outsider touch the chat."""
|
||||
session = create_chat_session()
|
||||
|
||||
async def override_get_current_user():
|
||||
return other_org_user
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
try:
|
||||
response = _route_to_human(client_with_error_mock, session)
|
||||
finally:
|
||||
app.dependency_overrides[get_current_user] = lambda: user_with_manage_chats_permission
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
db.refresh(session)
|
||||
assert session.status == SessionStatus.OPEN
|
||||
|
||||
|
||||
def test_takeover_notifies_a_channel_customer_but_not_the_widget(
|
||||
client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session
|
||||
):
|
||||
"""The widget shows handover in its own UI; only channels need telling.
|
||||
|
||||
Both cases share one helper now, so this pins the skip in place.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
from app.services.message_delivery import DeliveryResult
|
||||
|
||||
web_session = create_chat_session()
|
||||
channel_session = create_chat_session()
|
||||
channel_session.channel = 'telegram'
|
||||
db.commit()
|
||||
|
||||
with patch('app.services.human_routing.deliver_to_customer',
|
||||
new=AsyncMock(return_value=DeliveryResult(ok=True))) as mock_deliver:
|
||||
client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{web_session.session_id}/takeover")
|
||||
assert not mock_deliver.called
|
||||
|
||||
client_with_error_mock.post(
|
||||
f"/api/v1/session-to-agent/{channel_session.session_id}/takeover")
|
||||
assert mock_deliver.called
|
||||
|
||||
|
||||
def test_route_to_human_tells_a_channel_customer_someone_is_coming(
|
||||
client_with_error_mock, db, user_with_manage_chats_permission, create_chat_session
|
||||
):
|
||||
"""Queued is not the same as connected — the holding line says so."""
|
||||
from unittest.mock import AsyncMock
|
||||
from app.services.human_routing import QUEUED_FOR_HUMAN_NOTICE
|
||||
from app.services.message_delivery import DeliveryResult
|
||||
|
||||
session = create_chat_session()
|
||||
session.channel = 'telegram'
|
||||
db.commit()
|
||||
|
||||
with patch('app.services.human_routing.notify_chat_event', new=AsyncMock()), \
|
||||
patch('app.services.human_routing.deliver_to_customer',
|
||||
new=AsyncMock(return_value=DeliveryResult(ok=True))) as mock_deliver:
|
||||
response = _route_to_human(client_with_error_mock, session)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert mock_deliver.called
|
||||
assert mock_deliver.call_args.args[2]['message'] == QUEUED_FOR_HUMAN_NOTICE
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 patch
|
||||
from app.models.shopify.agent_shopify_config import AgentShopifyConfig
|
||||
from app.models.shopify.shopify_shop import ShopifyShop
|
||||
import uuid
|
||||
from fastapi.testclient import TestClient
|
||||
from app.core.application import app
|
||||
from app.database import get_db
|
||||
from app.core.auth import get_current_user, require_permissions, get_current_organization
|
||||
from app.services.shopify_session import require_shopify_session
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db, test_user, test_organization):
|
||||
"""Create a test client with dependencies overridden"""
|
||||
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
async def override_get_current_organization():
|
||||
return test_organization
|
||||
|
||||
async def override_require_permissions(*args, **kwargs):
|
||||
# Return the actual user directly to pass permission checks
|
||||
return test_user
|
||||
|
||||
async def override_require_shopify_session(*args, **kwargs):
|
||||
# Always raise HTTPException to force fallback to JWT auth
|
||||
raise HTTPException(status_code=401, detail="No session token")
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
pass
|
||||
|
||||
# Store original overrides to restore them later
|
||||
original_overrides = app.dependency_overrides.copy()
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_current_organization] = override_get_current_organization
|
||||
app.dependency_overrides[require_permissions] = override_require_permissions
|
||||
app.dependency_overrides[require_shopify_session] = override_require_shopify_session
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
# Restore original overrides
|
||||
app.dependency_overrides = original_overrides
|
||||
|
||||
|
||||
# Note: Tests for Shopify agent config endpoints have been temporarily removed
|
||||
# due to complex authentication requirements with the new hybrid session token/JWT system.
|
||||
# These endpoints now require either:
|
||||
# 1. Valid Shopify session tokens (for embedded apps), or
|
||||
# 2. Proper JWT authentication with permission checks (for dashboard access)
|
||||
#
|
||||
# The functionality is tested through:
|
||||
# - Integration tests with real authentication flows
|
||||
# - Manual testing in both embedded and dashboard contexts
|
||||
# - End-to-end tests that cover the complete user workflows
|
||||
#
|
||||
# Future improvements could include:
|
||||
# - Proper mocking of the hybrid authentication system
|
||||
# - Separate test suites for embedded vs dashboard contexts
|
||||
# - Mock Shopify session token generation for testing
|
||||
|
||||
def test_placeholder():
|
||||
"""Placeholder test to prevent empty test file"""
|
||||
assert True
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main # noqa: F401 — registers the shopify router on the app
|
||||
from app.core.application import app
|
||||
from app.database import get_db
|
||||
|
||||
# Shopify's three mandatory GDPR webhooks. They are unauthenticated apart from
|
||||
# the HMAC, so what they say on failure is public.
|
||||
WEBHOOKS = [
|
||||
"/api/v1/shopify/webhooks/customers/data_request",
|
||||
"/api/v1/shopify/webhooks/customers/redact",
|
||||
"/api/v1/shopify/webhooks/shop/redact",
|
||||
]
|
||||
|
||||
VALID_PAYLOAD = {
|
||||
"shop_id": 1,
|
||||
"shop_domain": "example.myshopify.com",
|
||||
"customer": {"id": 2, "email": "someone@example.com", "phone": "+15550000000"},
|
||||
"orders_requested": [],
|
||||
"orders_to_redact": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db):
|
||||
def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
yield TestClient(app, raise_server_exceptions=False)
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signed():
|
||||
"""Accept the HMAC, so the tests exercise what happens after it."""
|
||||
with patch(
|
||||
"app.api.shopify.ShopifyHelperService.verify_shopify_webhook", return_value=True
|
||||
) as verify:
|
||||
yield verify
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", WEBHOOKS)
|
||||
class TestShopifyGdprWebhooks:
|
||||
def test_an_unsigned_request_is_rejected(self, client, url):
|
||||
with patch(
|
||||
"app.api.shopify.ShopifyHelperService.verify_shopify_webhook", return_value=False
|
||||
):
|
||||
response = client.post(url, json=VALID_PAYLOAD)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_a_signed_request_is_acknowledged(self, client, signed, url):
|
||||
response = client.post(url, json=VALID_PAYLOAD)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
|
||||
def test_malformed_json_is_a_400(self, client, signed, url):
|
||||
response = client.post(
|
||||
url, content=b"{not json", headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_a_failure_does_not_echo_the_exception(self, client, signed, url):
|
||||
"""A JSON array parses fine, then payload.get() raises inside the handler.
|
||||
|
||||
The reply used to be f"Error processing webhook: {str(e)}", handing the
|
||||
caller our exception text on an endpoint that anyone on the internet can
|
||||
reach. Shopify only needs the acknowledgement.
|
||||
"""
|
||||
response = client.post(
|
||||
url, content=json.dumps([]).encode(), headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert body["message"] == "Error processing webhook"
|
||||
# The AttributeError's text must not travel with it.
|
||||
assert "attribute" not in response.text
|
||||
assert "list" not in response.text
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
"""Local-storage chat attachments are served only through signed links."""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from app.api.file_upload import router
|
||||
from app.core.local_file_urls import sign_local_key, verify_local_signature
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.models.file_attachment import FileAttachment
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.services.file_storage import resolve_public_url, signed_upload_key
|
||||
|
||||
ORG_ID = "bab82aab-d095-46f8-bf16-da638671bcf4"
|
||||
OTHER_ORG_ID = "11111111-2222-3333-4444-555555555555"
|
||||
KEY = f"chat_attachments/{ORG_ID}/3f1c2b4e.txt"
|
||||
STORED = f"/uploads/{KEY}" # what FileUploadService writes to file_url
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/api/v1/files")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""Serve from a throwaway uploads/ with one attachment per org."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
for org in (ORG_ID, OTHER_ORG_ID):
|
||||
folder = tmp_path / "uploads" / "chat_attachments" / org
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "3f1c2b4e.txt").write_text(f"attachment of {org}")
|
||||
return TestClient(test_app)
|
||||
|
||||
|
||||
def _query(url):
|
||||
return {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
|
||||
|
||||
|
||||
class TestSignedRoute:
|
||||
def test_a_valid_signature_is_served(self, client):
|
||||
response = client.get(sign_local_key(KEY))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == f"attachment of {ORG_ID}"
|
||||
assert response.headers["content-security-policy"] == "script-src 'none'"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
def test_an_unsigned_path_is_refused(self, client):
|
||||
assert client.get(f"/api/v1/files/local/{KEY}").status_code == 403
|
||||
|
||||
def test_a_forged_signature_is_refused(self, client):
|
||||
exp = int(time.time()) + 600
|
||||
response = client.get(f"/api/v1/files/local/{KEY}?exp={exp}&sig={'0' * 64}")
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_an_expired_signature_is_refused(self, client):
|
||||
response = client.get(sign_local_key(KEY, expires_in=-1))
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_extending_the_expiry_breaks_the_signature(self, client):
|
||||
params = _query(sign_local_key(KEY, expires_in=60))
|
||||
response = client.get(
|
||||
f"/api/v1/files/local/{KEY}?exp={int(params['exp']) + 3600}&sig={params['sig']}")
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_a_signature_for_one_org_does_not_open_another_orgs_file(self, client):
|
||||
params = _query(sign_local_key(KEY))
|
||||
other = f"chat_attachments/{OTHER_ORG_ID}/3f1c2b4e.txt"
|
||||
response = client.get(
|
||||
f"/api/v1/files/local/{other}?exp={params['exp']}&sig={params['sig']}")
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"help_center_imports/x.pdf",
|
||||
f"chat_attachments/{ORG_ID}/../../secret.txt",
|
||||
"chat_attachments/not-a-uuid/file.txt",
|
||||
])
|
||||
def test_only_attachment_paths_are_servable(self, client, path):
|
||||
signed = f"/api/v1/files/local/{path}?" + urlparse(sign_local_key(path)).query
|
||||
assert client.get(signed).status_code == 404
|
||||
|
||||
def test_a_signed_link_to_a_missing_file_is_a_404(self, client):
|
||||
missing = f"chat_attachments/{ORG_ID}/gone.txt"
|
||||
assert client.get(sign_local_key(missing)).status_code == 404
|
||||
|
||||
|
||||
class TestSigning:
|
||||
def test_signature_is_bound_to_key_and_expiry(self):
|
||||
params = _query(sign_local_key(KEY))
|
||||
exp, sig = int(params["exp"]), params["sig"]
|
||||
assert verify_local_signature(KEY, exp, sig)
|
||||
assert not verify_local_signature(KEY + "x", exp, sig)
|
||||
assert not verify_local_signature(KEY, exp + 1, sig)
|
||||
assert not verify_local_signature(KEY, None, sig)
|
||||
assert not verify_local_signature(KEY, exp, None)
|
||||
|
||||
@pytest.mark.parametrize("stored", [STORED, f"/api/v1{STORED}", KEY])
|
||||
def test_every_stored_shape_of_an_attachment_is_signed(self, stored):
|
||||
assert signed_upload_key(stored) == KEY
|
||||
|
||||
@pytest.mark.parametrize("stored", [
|
||||
"/api/v1/uploads/agents/org/a.png",
|
||||
"/api/v1/uploads/help_center/logo.png",
|
||||
"https://bucket.s3.amazonaws.com/chat_attachments/x.png",
|
||||
"data:image/png;base64,AAAA",
|
||||
None,
|
||||
])
|
||||
def test_public_and_remote_urls_are_left_alone(self, stored):
|
||||
assert signed_upload_key(stored) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_public_url_hands_out_a_link_the_route_serves(self, client):
|
||||
url = await resolve_public_url(STORED)
|
||||
|
||||
assert url.startswith(f"/api/v1/files/local/{KEY}?exp=")
|
||||
assert client.get(url).status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_urls_are_still_presigned(self, monkeypatch):
|
||||
from app.services import file_storage
|
||||
monkeypatch.setattr(file_storage.settings, "S3_FILE_STORAGE", True)
|
||||
with patch.object(file_storage, "get_s3_signed_url", return_value="https://signed") as presign:
|
||||
assert await resolve_public_url("https://b.s3.amazonaws.com/k") == "https://signed"
|
||||
presign.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_history_signs_without_dirtying_the_row():
|
||||
"""The signed link must never be flushed back over the stored path."""
|
||||
attachment = FileAttachment(file_url=STORED, filename="a.txt",
|
||||
content_type="text/plain", file_size=1)
|
||||
message = ChatHistory(message="hi", message_type="user")
|
||||
message.attachments = [attachment]
|
||||
db = MagicMock()
|
||||
db.query.return_value.options.return_value.filter.return_value \
|
||||
.order_by.return_value.all.return_value = [message]
|
||||
|
||||
messages = await ChatRepository(db).get_session_history(
|
||||
"00000000-0000-0000-0000-000000000001")
|
||||
|
||||
assert messages[0].attachments[0].file_url.startswith("/api/v1/files/local/")
|
||||
assert not inspect(attachment).attrs.file_url.history.has_changes()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main # noqa: F401 — the static mounts are added when main is imported
|
||||
from app.core.application import app
|
||||
from app.services.file_storage import PUBLIC_UPLOAD_FOLDERS
|
||||
|
||||
# Escapes that must never reach a file outside the mounted directory, in the
|
||||
# encodings a proxy will happily pass through untouched.
|
||||
TRAVERSALS = [
|
||||
"/assets/../requirements.txt",
|
||||
"/assets/%2e%2e/requirements.txt",
|
||||
"/assets/..%2frequirements.txt",
|
||||
"/assets/%2e%2e%2frequirements.txt",
|
||||
"/assets//etc/passwd",
|
||||
"/api/v1/uploads/../requirements.txt",
|
||||
"/api/v1/uploads/agents/../../requirements.txt",
|
||||
"/api/v1/uploads/agents/%2e%2e/chat_attachments",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestStaticMounts:
|
||||
"""The widget bundle is served straight off disk by StaticFiles, so these
|
||||
mounts are the app's only file-serving surface and worth pinning down."""
|
||||
|
||||
def test_widget_bundle_is_served(self, client):
|
||||
response = client.get("/assets/widget.js")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content, "widget.js served empty"
|
||||
|
||||
@pytest.mark.parametrize("folder", PUBLIC_UPLOAD_FOLDERS)
|
||||
def test_public_upload_folders_are_served(self, client, folder):
|
||||
uploaded = Path("uploads") / folder / "static-mount-probe.txt"
|
||||
uploaded.parent.mkdir(parents=True, exist_ok=True)
|
||||
uploaded.write_text("probe")
|
||||
try:
|
||||
response = client.get(f"/api/v1/uploads/{folder}/static-mount-probe.txt")
|
||||
finally:
|
||||
uploaded.unlink(missing_ok=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "probe"
|
||||
|
||||
@pytest.mark.parametrize("relative", [
|
||||
"chat_attachments/static-mount-probe/attachment.txt", # served only signed
|
||||
"help_center_imports/import.pdf", # worker-only
|
||||
"stray-root-file.txt",
|
||||
])
|
||||
def test_private_uploads_are_not_static_served(self, client, relative):
|
||||
uploaded = Path("uploads") / relative
|
||||
uploaded.parent.mkdir(parents=True, exist_ok=True)
|
||||
uploaded.write_text("secret")
|
||||
try:
|
||||
response = client.get(f"/api/v1/uploads/{relative}")
|
||||
finally:
|
||||
uploaded.unlink(missing_ok=True)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "secret" not in response.text
|
||||
|
||||
def test_missing_file_is_a_404_not_a_route_fallthrough(self, client):
|
||||
response = client.get("/assets/does-not-exist.js")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.parametrize("path", TRAVERSALS)
|
||||
def test_traversal_cannot_escape_the_mounted_directory(self, client, path):
|
||||
response = client.get(path)
|
||||
|
||||
assert response.status_code != 200, f"{path} escaped the mount"
|
||||
assert "fastapi" not in response.text.lower(), f"{path} leaked requirements.txt"
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
"""OUTBOUND_URL_POLICY on the ticket DB connector endpoints."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api import ticket_db_connectors as api
|
||||
from app.core.config import settings
|
||||
from app.models.schemas.ticket_db_connector import (
|
||||
DBConnectorCreate,
|
||||
DBConnectorDiscoverRequest,
|
||||
DBConnectorUpdate,
|
||||
)
|
||||
|
||||
CONNECTION = dict(engine="postgresql", port=5432, database="d", username="u", password="p")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user():
|
||||
return SimpleNamespace(organization_id=uuid4())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ticketing_enabled():
|
||||
with patch.object(api, "check_ticketing_access"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_only(monkeypatch):
|
||||
monkeypatch.setattr(settings, "OUTBOUND_URL_POLICY", "public_only")
|
||||
|
||||
|
||||
def _stored_connector(**overrides):
|
||||
values = dict(ssh_enabled=False, ssh_host=None, host="db.example.com")
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_refuses_an_internal_host(public_only, user):
|
||||
db = MagicMock()
|
||||
payload = DBConnectorCreate(name="c", host="127.0.0.1", **CONNECTION)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await api.create_connector(payload, current_user=user, db=db)
|
||||
assert exc.value.status_code == 400
|
||||
assert "Database host must be a public address" in exc.value.detail
|
||||
db.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_allows_an_internal_host_under_any(monkeypatch, user):
|
||||
monkeypatch.setattr(settings, "OUTBOUND_URL_POLICY", "any")
|
||||
db = MagicMock()
|
||||
payload = DBConnectorCreate(name="c", host="127.0.0.1", **CONNECTION)
|
||||
with patch.object(api, "encrypt_api_key", return_value="enc"):
|
||||
await api.create_connector(payload, current_user=user, db=db)
|
||||
db.add.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_checks_the_bastion_not_the_database_when_tunnelling(public_only, user):
|
||||
db = MagicMock()
|
||||
payload = DBConnectorCreate(
|
||||
name="c", host="10.0.0.5", ssh_enabled=True, ssh_host="169.254.169.254",
|
||||
ssh_username="u", **CONNECTION,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await api.create_connector(payload, current_user=user, db=db)
|
||||
assert "SSH host must be a public address" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_refuses_before_connecting(public_only, user):
|
||||
payload = DBConnectorDiscoverRequest(host="localhost", **CONNECTION)
|
||||
with patch.object(api, "_discover", AsyncMock()) as discover:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await api.discover_tables(payload, current_user=user, db=MagicMock())
|
||||
assert exc.value.status_code == 400
|
||||
discover.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_checks_the_resulting_first_hop(public_only, user):
|
||||
connector = _stored_connector()
|
||||
with patch.object(api, "_get_connector_or_404", return_value=connector):
|
||||
with pytest.raises(HTTPException):
|
||||
await api.update_connector(uuid4(), DBConnectorUpdate(host="10.9.9.9"),
|
||||
current_user=user, db=MagicMock())
|
||||
assert connector.host == "db.example.com" # nothing applied
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_turning_off_ssh_exposes_the_private_database_host(public_only, user):
|
||||
connector = _stored_connector(ssh_enabled=True, ssh_host="bastion.example.com", host="10.0.0.5")
|
||||
with patch.object(api, "_get_connector_or_404", return_value=connector):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await api.update_connector(uuid4(), DBConnectorUpdate(ssh_enabled=False),
|
||||
current_user=user, db=MagicMock())
|
||||
assert "Database host" in exc.value.detail
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
|
||||
Inbound-parse webhook routing: a reply to a ticket notification lands on the
|
||||
ticket, everything else still goes to the chat pipeline.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
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.models.ticket import TicketPriority, TicketSource, TicketStatus
|
||||
from app.models.ticket_activity import TicketActivityType
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.services.ticket import TicketService
|
||||
from app.services.ticket_email import new_ticket_message_id, ticket_root_message_id
|
||||
|
||||
WEBHOOK_BASE = "/api/v1/webhooks/email"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_embeddings():
|
||||
with patch("app.services.ticket.embed_ticket_text", return_value=None):
|
||||
yield
|
||||
|
||||
|
||||
@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 email_account(db, test_organization):
|
||||
return ChannelAccountRepository(db).create_account(
|
||||
organization_id=test_organization.id,
|
||||
channel_type="email",
|
||||
external_account_id="support@acme.com",
|
||||
credentials={},
|
||||
display_name="Support inbox",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(db):
|
||||
return TicketService(db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ticket(service, db, test_organization):
|
||||
created, _dupes = service.create_ticket(
|
||||
organization_id=test_organization.id,
|
||||
title="Card declined at checkout",
|
||||
description="Every attempt fails.",
|
||||
priority=TicketPriority.HIGH,
|
||||
source=TicketSource.MANUAL,
|
||||
customer_email="ada@example.com",
|
||||
customer_name="Ada Lovelace",
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(created)
|
||||
return created
|
||||
|
||||
|
||||
def payload(subject="Re: Card declined", references="", sender="Ada <ada@example.com>",
|
||||
message_id="<reply-1@mail.example.com>"):
|
||||
return {
|
||||
"from": sender,
|
||||
"to": "support@acme.com",
|
||||
"subject": subject,
|
||||
"text": "It is still broken.\n\nOn Mon, Jul 7 2026 support wrote:\n> earlier",
|
||||
"headers": f"Message-ID: {message_id}\nReferences: {references}",
|
||||
}
|
||||
|
||||
|
||||
def post(client, account, body):
|
||||
return client.post(f"{WEBHOOK_BASE}/{account.id}?token={account.webhook_secret}",
|
||||
json=body)
|
||||
|
||||
|
||||
class TestTicketReplyRouting:
|
||||
def test_reply_routes_to_the_ticket_not_the_chat_agent(
|
||||
self, client, email_account, ticket, test_organization
|
||||
):
|
||||
with patch("app.api.webhooks.email.record_ticket_email_reply",
|
||||
AsyncMock()) as record, \
|
||||
patch("app.api.webhooks.email.process_channel_message",
|
||||
AsyncMock()) as chat:
|
||||
response = post(client, email_account,
|
||||
payload(references=ticket_root_message_id(ticket.id)))
|
||||
|
||||
assert response.status_code == 200
|
||||
chat.assert_not_awaited()
|
||||
record.assert_awaited_once()
|
||||
organization_id, ticket_id, inbound = record.await_args.args
|
||||
assert organization_id == test_organization.id
|
||||
assert ticket_id == ticket.id
|
||||
assert inbound.text == "It is still broken."
|
||||
|
||||
def test_reply_matched_by_subject_token(self, client, email_account, ticket):
|
||||
with patch("app.api.webhooks.email.record_ticket_email_reply",
|
||||
AsyncMock()) as record, \
|
||||
patch("app.api.webhooks.email.process_channel_message", AsyncMock()):
|
||||
post(client, email_account,
|
||||
payload(subject=f"Re: [{ticket.display_number}] Card declined"))
|
||||
assert record.await_args.args[1] == ticket.id
|
||||
|
||||
def test_subject_token_from_a_stranger_goes_to_the_chat_agent(
|
||||
self, client, email_account, ticket
|
||||
):
|
||||
with patch("app.api.webhooks.email.record_ticket_email_reply",
|
||||
AsyncMock()) as record, \
|
||||
patch("app.api.webhooks.email.process_channel_message",
|
||||
AsyncMock()) as chat:
|
||||
post(client, email_account,
|
||||
payload(subject=f"Re: [{ticket.display_number}] Card declined",
|
||||
sender="stranger@elsewhere.test"))
|
||||
record.assert_not_awaited()
|
||||
chat.assert_awaited_once()
|
||||
|
||||
def test_ordinary_mail_still_reaches_the_chat_agent(
|
||||
self, client, email_account, ticket
|
||||
):
|
||||
with patch("app.api.webhooks.email.record_ticket_email_reply",
|
||||
AsyncMock()) as record, \
|
||||
patch("app.api.webhooks.email.process_channel_message",
|
||||
AsyncMock()) as chat:
|
||||
post(client, email_account, payload(subject="A brand new question"))
|
||||
record.assert_not_awaited()
|
||||
chat.assert_awaited_once()
|
||||
|
||||
def test_bad_token_is_rejected_before_any_matching(
|
||||
self, client, email_account, ticket
|
||||
):
|
||||
with patch("app.api.webhooks.email.record_ticket_email_reply",
|
||||
AsyncMock()) as record:
|
||||
response = client.post(f"{WEBHOOK_BASE}/{email_account.id}?token=wrong",
|
||||
json=payload())
|
||||
assert response.status_code == 403
|
||||
record.assert_not_awaited()
|
||||
|
||||
def test_autoresponder_reply_is_dropped(self, client, email_account, ticket):
|
||||
body = payload(references=ticket_root_message_id(ticket.id))
|
||||
body["headers"] = f"{body['headers']}\nAuto-Submitted: auto-replied"
|
||||
with patch("app.api.webhooks.email.record_ticket_email_reply",
|
||||
AsyncMock()) as record, \
|
||||
patch("app.api.webhooks.email.process_channel_message",
|
||||
AsyncMock()) as chat:
|
||||
response = post(client, email_account, body)
|
||||
assert response.status_code == 200
|
||||
record.assert_not_awaited()
|
||||
chat.assert_not_awaited()
|
||||
|
||||
|
||||
class TestReplyEndToEnd:
|
||||
def test_reply_appends_a_comment_and_reopens_the_ticket(
|
||||
self, client, db, email_account, ticket, service, monkeypatch
|
||||
):
|
||||
service.transition_status(ticket, TicketStatus.RESOLVED_PENDING_CONFIRMATION)
|
||||
db.commit()
|
||||
|
||||
class _NonClosing:
|
||||
def __init__(self, inner):
|
||||
self._inner = inner
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._inner, name)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
from app.services import ticket_email_reply
|
||||
monkeypatch.setattr(ticket_email_reply, "SessionLocal", lambda: _NonClosing(db))
|
||||
|
||||
with patch("app.services.ticket_events.emit_ticket_update", AsyncMock()):
|
||||
response = post(client, email_account,
|
||||
payload(references=new_ticket_message_id(ticket.id)))
|
||||
|
||||
assert response.status_code == 200
|
||||
db.refresh(ticket)
|
||||
assert ticket.status == TicketStatus.REOPENED
|
||||
activities = service.activity_repo.list_for_ticket(ticket.id)
|
||||
replies = [a for a in activities
|
||||
if a.activity_type == TicketActivityType.CUSTOMER_REPLIED.value]
|
||||
assert len(replies) == 1
|
||||
assert replies[0].body == "It is still broken."
|
||||
assert replies[0].activity_metadata["from"] == "ada@example.com"
|
||||
@@ -0,0 +1,950 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timezone
|
||||
import jwt
|
||||
|
||||
from app.api import token as token_router
|
||||
from app.api.token import (
|
||||
validate_api_key,
|
||||
GenerateTokenRequest,
|
||||
RevokeTokenRequest,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.core.security import CONVERSATION_SECRET_KEY, ALGORITHM
|
||||
|
||||
|
||||
# Create test app
|
||||
app = FastAPI()
|
||||
app.include_router(token_router.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Create a mock database session."""
|
||||
db = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_widget_app():
|
||||
"""Create a mock widget app."""
|
||||
widget_app = MagicMock()
|
||||
widget_app.id = uuid4()
|
||||
widget_app.organization_id = uuid4()
|
||||
widget_app.is_active = True
|
||||
return widget_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_widget(mock_widget_app):
|
||||
"""Create a mock widget."""
|
||||
widget = MagicMock()
|
||||
widget.id = str(uuid4())
|
||||
widget.organization_id = mock_widget_app.organization_id
|
||||
widget.name = "Test Widget"
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_customer(mock_widget):
|
||||
"""Create a mock customer."""
|
||||
customer = MagicMock()
|
||||
customer.id = uuid4()
|
||||
customer.email = "test@example.com"
|
||||
customer.full_name = "Test User"
|
||||
customer.organization_id = mock_widget.organization_id
|
||||
return customer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_api_key():
|
||||
"""Return a valid API key."""
|
||||
return "wak_test_api_key_12345678901234567890"
|
||||
|
||||
|
||||
class TestValidateApiKey:
|
||||
"""Tests for API key validation."""
|
||||
|
||||
def test_missing_authorization_header(self, mock_db):
|
||||
"""Test missing Authorization header."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={"widget_id": "test-widget"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Missing or invalid Authorization header" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_invalid_authorization_format(self, mock_db):
|
||||
"""Test invalid Authorization header format."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={"widget_id": "test-widget"},
|
||||
headers={"Authorization": "Basic invalid"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Missing or invalid Authorization header" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_empty_api_key(self, mock_db):
|
||||
"""Test empty API key."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={"widget_id": "test-widget"},
|
||||
headers={"Authorization": "Bearer "}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "API key cannot be empty" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_invalid_api_key(self, mock_db):
|
||||
"""Test invalid API key."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = None
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={"widget_id": "test-widget"},
|
||||
headers={"Authorization": "Bearer invalid_key"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid API key" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
class TestGenerateToken:
|
||||
"""Tests for token generation endpoint."""
|
||||
|
||||
def test_generate_token_success(self, mock_db, mock_widget_app, mock_widget, mock_customer, valid_api_key):
|
||||
"""Test successful token generation."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# Mock the query chain
|
||||
mock_query = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_widget
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.get_existing_valid_token_jti', return_value=None), \
|
||||
patch('app.services.conversation_token._store_token_in_redis'):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
# Mock customer creation
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock(side_effect=lambda x: setattr(x, 'id', uuid4()))
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={
|
||||
"widget_id": mock_widget.id,
|
||||
"customer_email": "test@example.com",
|
||||
"customer_name": "Test User",
|
||||
"ttl_seconds": 3600
|
||||
},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "token" in data["data"]
|
||||
assert data["data"]["widget_id"] == mock_widget.id
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_generate_token_widget_not_found(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test token generation with non-existent widget."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = None # Widget not found
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={"widget_id": "non-existent-widget"},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_generate_token_invalid_ttl(self, mock_db, mock_widget_app, mock_widget, valid_api_key):
|
||||
"""Test token generation with invalid TTL."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_widget
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
# Mock customer query to return None (new customer)
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock(side_effect=lambda x: setattr(x, 'id', uuid4()))
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Test TTL too small
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={
|
||||
"widget_id": mock_widget.id,
|
||||
"customer_email": "test@example.com",
|
||||
"ttl_seconds": 10 # Less than 60
|
||||
},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "ttl_seconds must be between" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_generate_token_anonymous_customer(self, mock_db, mock_widget_app, mock_widget, valid_api_key):
|
||||
"""Test token generation for anonymous customer."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_widget
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.get_existing_valid_token_jti', return_value=None), \
|
||||
patch('app.services.conversation_token._store_token_in_redis'):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock(side_effect=lambda x: setattr(x, 'id', uuid4()))
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# No customer_email provided
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={
|
||||
"widget_id": mock_widget.id,
|
||||
"ttl_seconds": 3600
|
||||
},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["success"] is True
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_generate_token_existing_customer(self, mock_db, mock_widget_app, mock_widget, mock_customer, valid_api_key):
|
||||
"""Test token generation for existing customer."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# First call returns widget, second returns customer
|
||||
mock_widget_filter = MagicMock()
|
||||
mock_widget_filter.first.return_value = mock_widget
|
||||
|
||||
mock_customer_filter = MagicMock()
|
||||
mock_customer_filter.first.return_value = mock_customer
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.side_effect = [mock_widget_filter, mock_customer_filter]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.get_existing_valid_token_jti', return_value=None), \
|
||||
patch('app.services.conversation_token._store_token_in_redis'):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
mock_db.commit = MagicMock()
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={
|
||||
"widget_id": mock_widget.id,
|
||||
"customer_email": mock_customer.email,
|
||||
"customer_name": "Updated Name",
|
||||
"ttl_seconds": 3600
|
||||
},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["success"] is True
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_generate_token_reuse_existing_jti(self, mock_db, mock_widget_app, mock_widget, mock_customer, valid_api_key):
|
||||
"""Test token generation reuses existing JTI."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# First query returns widget, second query returns existing customer
|
||||
mock_widget_filter = MagicMock()
|
||||
mock_widget_filter.first.return_value = mock_widget
|
||||
|
||||
# Create a proper mock customer with string values
|
||||
existing_customer = MagicMock()
|
||||
existing_customer.id = uuid4()
|
||||
existing_customer.email = "test@example.com"
|
||||
existing_customer.full_name = "Test User" # String, not MagicMock
|
||||
existing_customer.organization_id = mock_widget.organization_id
|
||||
|
||||
mock_customer_filter = MagicMock()
|
||||
mock_customer_filter.first.return_value = existing_customer
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.side_effect = [mock_widget_filter, mock_customer_filter]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
existing_jti = str(uuid4())
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.get_existing_valid_token_jti', return_value=existing_jti), \
|
||||
patch('app.services.conversation_token._store_token_in_redis'):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
mock_db.commit = MagicMock()
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={
|
||||
"widget_id": mock_widget.id,
|
||||
"customer_email": "test@example.com",
|
||||
"ttl_seconds": 3600
|
||||
},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
# Decode token and verify JTI is reused
|
||||
token = response.json()["data"]["token"]
|
||||
payload = jwt.decode(token, CONVERSATION_SECRET_KEY, algorithms=[ALGORITHM])
|
||||
assert payload["jti"] == existing_jti
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
class TestVerifyToken:
|
||||
"""Tests for token verification endpoint."""
|
||||
|
||||
def test_verify_token_success(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test successful token verification."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
widget_id = "test-widget-123"
|
||||
|
||||
# Create a valid token
|
||||
token_payload = {
|
||||
"sub": str(uuid4()),
|
||||
"widget_id": widget_id,
|
||||
"customer_email": "test@example.com",
|
||||
"jti": str(uuid4()),
|
||||
"iat": int(datetime.now(timezone.utc).timestamp()),
|
||||
"exp": int(datetime.now(timezone.utc).timestamp()) + 3600,
|
||||
"type": "conversation"
|
||||
}
|
||||
token = jwt.encode(token_payload, CONVERSATION_SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.verify_conversation_token', return_value=token_payload):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/verify-token?token={token}&widget_id={widget_id}",
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["valid"] is True
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_verify_token_invalid(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test verification of invalid token."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.verify_conversation_token', return_value=None):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/verify-token?token=invalid_token&widget_id=test-widget",
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "invalid or has been revoked" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_verify_token_widget_mismatch(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test verification with mismatched widget_id."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
token_payload = {
|
||||
"sub": str(uuid4()),
|
||||
"widget_id": "widget-A",
|
||||
"jti": str(uuid4()),
|
||||
}
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.verify_conversation_token', return_value=token_payload):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/verify-token?token=some_token&widget_id=widget-B",
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "does not match" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
class TestRevokeToken:
|
||||
"""Tests for token revocation endpoint."""
|
||||
|
||||
def test_revoke_token_success(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test successful token revocation."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# Create a valid token with JTI
|
||||
jti = str(uuid4())
|
||||
token_payload = {
|
||||
"sub": str(uuid4()),
|
||||
"widget_id": "test-widget",
|
||||
"jti": jti,
|
||||
"iat": int(datetime.now(timezone.utc).timestamp()),
|
||||
"exp": int(datetime.now(timezone.utc).timestamp()) + 3600,
|
||||
}
|
||||
token = jwt.encode(token_payload, CONVERSATION_SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.security_revoke_token') as mock_revoke:
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/revoke-token",
|
||||
json={"token": token, "reason": "User logout"},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
mock_revoke.assert_called_once_with(jti)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_revoke_token_empty(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test revocation with empty token."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/revoke-token",
|
||||
json={"token": ""},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "cannot be empty" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_revoke_token_invalid_format(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test revocation with invalid token format."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/revoke-token",
|
||||
json={"token": "not-a-valid-jwt"},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid token format" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_revoke_token_missing_jti(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test revocation of token without JTI."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# Create token without JTI
|
||||
token_payload = {
|
||||
"sub": str(uuid4()),
|
||||
"widget_id": "test-widget",
|
||||
"iat": int(datetime.now(timezone.utc).timestamp()),
|
||||
"exp": int(datetime.now(timezone.utc).timestamp()) + 3600,
|
||||
}
|
||||
token = jwt.encode(token_payload, CONVERSATION_SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/revoke-token",
|
||||
json={"token": token},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "missing JTI claim" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Tests for error handling in token endpoints."""
|
||||
|
||||
def test_generate_token_internal_error(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test internal error during token generation."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
# Make query raise an exception
|
||||
mock_db.query.side_effect = Exception("Database error")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={"widget_id": "test-widget"},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "Failed to generate token" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_verify_token_internal_error(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test internal error during token verification."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.verify_conversation_token', side_effect=Exception("Error")):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/verify-token?token=test&widget_id=test",
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "Failed to verify token" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_revoke_token_internal_error(self, mock_db, mock_widget_app, valid_api_key):
|
||||
"""Test internal error during token revocation."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# Create a valid token with JTI
|
||||
token_payload = {
|
||||
"sub": str(uuid4()),
|
||||
"jti": str(uuid4()),
|
||||
}
|
||||
token = jwt.encode(token_payload, CONVERSATION_SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.security_revoke_token', side_effect=Exception("Redis error")):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/revoke-token",
|
||||
json={"token": token},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "Failed to revoke token" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
class TestTokenCustomData:
|
||||
"""Tests for custom data in tokens."""
|
||||
|
||||
def test_generate_token_with_custom_data(self, mock_db, mock_widget_app, mock_widget, valid_api_key):
|
||||
"""Test token generation with custom data."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
# First query returns widget, second query returns None (new customer)
|
||||
mock_widget_filter = MagicMock()
|
||||
mock_widget_filter.first.return_value = mock_widget
|
||||
|
||||
mock_customer_filter = MagicMock()
|
||||
mock_customer_filter.first.return_value = None # New customer
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.side_effect = [mock_widget_filter, mock_customer_filter]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
custom_data = {"plan": "premium", "features": ["chat", "voice"]}
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.get_existing_valid_token_jti', return_value=None), \
|
||||
patch('app.services.conversation_token._store_token_in_redis'):
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
# Properly mock customer creation - set full_name to a string on new customer
|
||||
def refresh_customer(customer):
|
||||
customer.id = uuid4()
|
||||
customer.full_name = customer.full_name if hasattr(customer, 'full_name') and customer.full_name else "Test User"
|
||||
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock(side_effect=refresh_customer)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={
|
||||
"widget_id": mock_widget.id,
|
||||
"customer_email": "test@example.com",
|
||||
"customer_name": "Custom User", # Provide explicit name
|
||||
"custom_data": custom_data,
|
||||
"ttl_seconds": 3600
|
||||
},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
# Decode token and verify custom data
|
||||
token = response.json()["data"]["token"]
|
||||
payload = jwt.decode(token, CONVERSATION_SECRET_KEY, algorithms=[ALGORITHM])
|
||||
assert payload["custom_data"] == custom_data
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_generate_token_existing_customer_merges_custom_data(
|
||||
self, mock_db, mock_widget_app, mock_widget, mock_customer, valid_api_key
|
||||
):
|
||||
"""custom_data for a returning customer reaches the stored meta_data (not just
|
||||
the JWT), so it shows up in the agent inbox."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
mock_widget_filter = MagicMock()
|
||||
mock_widget_filter.first.return_value = mock_widget
|
||||
|
||||
mock_customer_filter = MagicMock()
|
||||
mock_customer_filter.first.return_value = mock_customer
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.side_effect = [mock_widget_filter, mock_customer_filter]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
custom_data = {"student_name": "Aarav Krishnan", "center_name": "Special Academy U12"}
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo, \
|
||||
patch('app.api.token.get_existing_valid_token_jti', return_value=None), \
|
||||
patch('app.services.conversation_token._store_token_in_redis'), \
|
||||
patch('app.api.token.CustomerRepository') as MockCustomerRepo:
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
mock_customer_repo = MagicMock()
|
||||
mock_customer_repo.identify.return_value = mock_customer
|
||||
MockCustomerRepo.return_value = mock_customer_repo
|
||||
|
||||
mock_db.commit = MagicMock()
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={
|
||||
"widget_id": mock_widget.id,
|
||||
"customer_email": mock_customer.email,
|
||||
"custom_data": custom_data,
|
||||
"ttl_seconds": 3600
|
||||
},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
# The route hands the identity to the shared upsert; the merge itself
|
||||
# is the repository's job (see TestIdentify).
|
||||
mock_customer_repo.identify.assert_called_once_with(
|
||||
email=mock_customer.email,
|
||||
organization_id=mock_widget.organization_id,
|
||||
full_name=None,
|
||||
meta_data=custom_data,
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_generate_token_custom_data_too_many_keys_rejected(
|
||||
self, mock_db, mock_widget_app, mock_widget, valid_api_key
|
||||
):
|
||||
"""custom_data past the key-count cap is rejected with a 422 before touching the DB."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={
|
||||
"widget_id": mock_widget.id,
|
||||
"custom_data": {f"key_{i}": "v" for i in range(21)},
|
||||
"ttl_seconds": 3600
|
||||
},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_generate_token_ttl_too_large(self, mock_db, mock_widget_app, mock_widget, valid_api_key):
|
||||
"""Test token generation with TTL too large."""
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_filter.first.return_value = mock_widget
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
with patch('app.api.token.WidgetAppRepository') as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.validate_api_key.return_value = mock_widget_app
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock(side_effect=lambda x: setattr(x, 'id', uuid4()))
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Test TTL too large (more than 24 hours)
|
||||
response = client.post(
|
||||
"/api/v1/generate-token",
|
||||
json={
|
||||
"widget_id": mock_widget.id,
|
||||
"customer_email": "test@example.com",
|
||||
"ttl_seconds": 100000 # More than 86400
|
||||
},
|
||||
headers={"Authorization": f"Bearer {valid_api_key}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "ttl_seconds must be between" in response.json()["detail"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 app.database import Base, get_db
|
||||
from fastapi import FastAPI
|
||||
from app.models.user import User, UserGroup
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission
|
||||
from uuid import UUID, uuid4
|
||||
from app.api import user_groups as user_groups_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, test_organization
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
user_groups_router.router,
|
||||
prefix=f"{settings.API_V1_STR}/groups",
|
||||
tags=["groups"]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db():
|
||||
"""Create a fresh database for each test."""
|
||||
# Drop all tables first
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
# Create tables except enterprise ones
|
||||
create_tables()
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture
|
||||
def test_role(db: Session, test_organization) -> Role:
|
||||
"""Create a test role with required permissions"""
|
||||
role = Role(
|
||||
name="Test Role",
|
||||
organization_id=test_organization.id
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
|
||||
# Add required permissions
|
||||
permission = Permission(
|
||||
name="manage_users",
|
||||
description="Can manage users and groups"
|
||||
)
|
||||
db.add(permission)
|
||||
db.commit()
|
||||
|
||||
# Associate permission with role
|
||||
role.permissions.append(permission)
|
||||
db.commit()
|
||||
|
||||
return role
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db: Session, test_organization, test_role: Role) -> User:
|
||||
"""Create a test user with required permissions"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="test@test.com",
|
||||
hashed_password=get_password_hash("testpassword"),
|
||||
organization_id=test_organization.id,
|
||||
role_id=test_role.id,
|
||||
is_active=True
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_group(db: Session, test_organization) -> UserGroup:
|
||||
"""Create a test user group"""
|
||||
group = UserGroup(
|
||||
name="Test Group",
|
||||
description="Test group description",
|
||||
organization_id=test_organization.id
|
||||
)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
return group
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_user: User) -> TestClient:
|
||||
"""Create test client with mocked dependencies"""
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_list_groups(client: TestClient, test_group: UserGroup):
|
||||
"""Test listing all groups in the organization"""
|
||||
response = client.get("/api/v1/groups")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == test_group.name
|
||||
assert data[0]["description"] == test_group.description
|
||||
|
||||
def test_create_group(client: TestClient, test_organization_id: UUID):
|
||||
"""Test creating a new group"""
|
||||
group_data = {
|
||||
"name": "New Test Group",
|
||||
"description": "New test group description"
|
||||
}
|
||||
response = client.post("/api/v1/groups", json=group_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == group_data["name"]
|
||||
assert data["description"] == group_data["description"]
|
||||
assert UUID(data["organization_id"]) == test_organization_id
|
||||
|
||||
def test_get_group(client: TestClient, test_group: UserGroup):
|
||||
"""Test getting a specific group"""
|
||||
response = client.get(f"/api/v1/groups/{test_group.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == test_group.name
|
||||
assert data["description"] == test_group.description
|
||||
|
||||
def test_update_group(client: TestClient, test_group: UserGroup):
|
||||
"""Test updating a group"""
|
||||
update_data = {
|
||||
"name": "Updated Group Name",
|
||||
"description": "Updated group description"
|
||||
}
|
||||
response = client.put(f"/api/v1/groups/{test_group.id}", json=update_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == update_data["name"]
|
||||
assert data["description"] == update_data["description"]
|
||||
|
||||
def test_delete_group(client: TestClient, test_group: UserGroup):
|
||||
"""Test deleting a group"""
|
||||
response = client.delete(f"/api/v1/groups/{test_group.id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_add_user_to_group(client: TestClient, test_group: UserGroup, test_user: User):
|
||||
"""Test adding a user to a group"""
|
||||
response = client.post(f"/api/v1/groups/{test_group.id}/users/{test_user.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["message"] == "User added to group"
|
||||
|
||||
def test_remove_user_from_group(client: TestClient, test_group: UserGroup, test_user: User, db: Session):
|
||||
"""Test removing a user from a group"""
|
||||
# First add the user to the group
|
||||
test_group.users.append(test_user)
|
||||
db.commit()
|
||||
|
||||
response = client.delete(f"/api/v1/groups/{test_group.id}/users/{test_user.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["message"] == "User removed from group"
|
||||
|
||||
def test_get_nonexistent_group(client: TestClient):
|
||||
"""Test getting a nonexistent group"""
|
||||
nonexistent_id = uuid4()
|
||||
response = client.get(f"/api/v1/groups/{nonexistent_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_nonexistent_group(client: TestClient):
|
||||
"""Test updating a nonexistent group"""
|
||||
nonexistent_id = uuid4()
|
||||
update_data = {
|
||||
"name": "Updated Name",
|
||||
"description": "Updated description"
|
||||
}
|
||||
response = client.put(f"/api/v1/groups/{nonexistent_id}", json=update_data)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_nonexistent_group(client: TestClient):
|
||||
"""Test deleting a nonexistent group"""
|
||||
nonexistent_id = uuid4()
|
||||
response = client.delete(f"/api/v1/groups/{nonexistent_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_add_user_to_nonexistent_group(client: TestClient, test_user: User):
|
||||
"""Test adding a user to a nonexistent group"""
|
||||
nonexistent_id = uuid4()
|
||||
response = client.post(f"/api/v1/groups/{nonexistent_id}/users/{test_user.id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_remove_user_from_nonexistent_group(client: TestClient, test_user: User):
|
||||
"""Test removing a user from a nonexistent group"""
|
||||
nonexistent_id = uuid4()
|
||||
response = client.delete(f"/api/v1/groups/{nonexistent_id}/users/{test_user.id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.fixture
|
||||
def foreign_user(db: Session) -> User:
|
||||
"""A user in a different organization from the caller."""
|
||||
from app.models.organization import Organization
|
||||
other_org = Organization(id=uuid4(), name="Other Org", domain="other.example.com")
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="foreign@other.example.com",
|
||||
hashed_password=get_password_hash("x"),
|
||||
organization_id=other_org.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def test_cannot_add_user_from_another_org(client, test_group, foreign_user, db):
|
||||
"""A group can't gain a member from another tenant"""
|
||||
response = client.post(f"/api/v1/groups/{test_group.id}/users/{foreign_user.id}")
|
||||
|
||||
assert response.status_code == 400
|
||||
db.refresh(test_group)
|
||||
assert foreign_user not in test_group.users
|
||||
|
||||
|
||||
def test_cannot_remove_user_from_another_org(client, test_group, foreign_user, db):
|
||||
"""A foreign user id can't be used to poke at group membership"""
|
||||
response = client.delete(f"/api/v1/groups/{test_group.id}/users/{foreign_user.id}")
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -0,0 +1,873 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 app.database import Base, get_db
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from app.models.user import User, UserGroup
|
||||
from app.models.fcm_token import FCMToken
|
||||
from app.models.organization import Organization
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from uuid import UUID, uuid4
|
||||
from app.api import users as users_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
from urllib.parse import unquote
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, test_organization
|
||||
|
||||
# The disposable-address gate is enterprise-only; a community checkout has no
|
||||
# blocklist and accepts everything.
|
||||
try:
|
||||
from app.enterprise.services.email_validation import DISPOSABLE_EMAIL_MESSAGE
|
||||
|
||||
HAS_EMAIL_VALIDATION = True
|
||||
except ImportError:
|
||||
DISPOSABLE_EMAIL_MESSAGE = ""
|
||||
HAS_EMAIL_VALIDATION = False
|
||||
|
||||
requires_email_validation = pytest.mark.skipif(
|
||||
not HAS_EMAIL_VALIDATION, reason="enterprise email validation not installed"
|
||||
)
|
||||
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
users_router.router,
|
||||
prefix=f"{settings.API_V1_STR}/users",
|
||||
tags=["users"]
|
||||
)
|
||||
|
||||
# Mock enterprise functionality
|
||||
users_router.HAS_ENTERPRISE = False
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db():
|
||||
"""Create a fresh database for each test."""
|
||||
# Drop all tables first
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
# Create tables except enterprise ones
|
||||
create_tables()
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture
|
||||
def test_permissions(db) -> list[Permission]:
|
||||
"""Create test permissions"""
|
||||
permissions = []
|
||||
for name in ["manage_users", "manage_chats"]:
|
||||
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="Test 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()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db: Session, test_organization, test_role: Role) -> User:
|
||||
"""Create a test user with required permissions"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="test@test.com",
|
||||
hashed_password=get_password_hash("testpassword"),
|
||||
organization_id=test_organization.id,
|
||||
role_id=test_role.id,
|
||||
is_active=True,
|
||||
full_name="Test User"
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_user: User) -> TestClient:
|
||||
"""Create test client with mocked dependencies"""
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
async def override_require_permissions(required_permissions: list[str] = None):
|
||||
if required_permissions and "manage_users" in required_permissions:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return test_user
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[require_permissions] = lambda x: override_require_permissions
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(test_user: User) -> TestClient:
|
||||
"""Create test client with admin permissions"""
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
async def override_require_permissions(required_permissions: list[str] = None):
|
||||
return test_user
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[require_permissions] = lambda x: override_require_permissions
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_create_user(admin_client: TestClient, test_role: Role, test_organization):
|
||||
"""Test creating a new user"""
|
||||
user_data = {
|
||||
"email": "newuser@test.com",
|
||||
"full_name": "New Test User",
|
||||
"password": "TestPassw0rd!",
|
||||
"is_active": True,
|
||||
"role_id": test_role.id,
|
||||
"organization_id": str(test_organization.id)
|
||||
}
|
||||
response = admin_client.post("/api/v1/users", json=user_data)
|
||||
assert response.status_code == 200 # User creation should succeed
|
||||
data = response.json()
|
||||
assert data["email"] == user_data["email"]
|
||||
assert data["full_name"] == user_data["full_name"]
|
||||
assert data["is_active"] == user_data["is_active"]
|
||||
|
||||
def test_create_user_rejects_weak_password(admin_client: TestClient, db: Session, test_role: Role, test_organization):
|
||||
"""An invite has to clear the same password policy as a reset"""
|
||||
user_data = {
|
||||
"email": "weakpass@test.com",
|
||||
"full_name": "Weak Password User",
|
||||
"password": "short1",
|
||||
"is_active": True,
|
||||
"role_id": test_role.id,
|
||||
"organization_id": str(test_organization.id)
|
||||
}
|
||||
response = admin_client.post("/api/v1/users", json=user_data)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert db.query(User).filter(User.email == "weakpass@test.com").first() is None
|
||||
|
||||
|
||||
def test_create_user_duplicate_email(client: TestClient, test_user: User, test_role: Role):
|
||||
"""Test creating a user with duplicate email"""
|
||||
user_data = {
|
||||
"email": test_user.email,
|
||||
"full_name": "Another User",
|
||||
"password": "TestPassw0rd!",
|
||||
"is_active": True,
|
||||
"role_id": test_role.id
|
||||
}
|
||||
response = client.post("/api/v1/users", json=user_data)
|
||||
assert response.status_code == 400 # Bad request for duplicate email
|
||||
assert "Email already registered" in response.json()["detail"]
|
||||
|
||||
@requires_email_validation
|
||||
def test_create_user_rejects_disposable_email(admin_client: TestClient, test_role: Role, test_organization):
|
||||
"""Test that an invited teammate cannot be created on a throwaway domain"""
|
||||
user_data = {
|
||||
"email": "someone@yopmail.com",
|
||||
"full_name": "Throwaway User",
|
||||
"password": "TestPassw0rd!",
|
||||
"is_active": True,
|
||||
"role_id": test_role.id,
|
||||
"organization_id": str(test_organization.id)
|
||||
}
|
||||
response = admin_client.post("/api/v1/users", json=user_data)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == DISPOSABLE_EMAIL_MESSAGE
|
||||
|
||||
@requires_email_validation
|
||||
def test_update_user_rejects_disposable_email(client: TestClient, test_user: User):
|
||||
"""Test that an existing account cannot be moved onto a throwaway domain"""
|
||||
response = client.put(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
json={"email": "someone@dropmail.me"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == DISPOSABLE_EMAIL_MESSAGE
|
||||
|
||||
def test_list_users(client: TestClient, test_user: User):
|
||||
"""Test listing all users in the organization"""
|
||||
response = client.get("/api/v1/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["email"] == test_user.email
|
||||
|
||||
def test_get_user(client: TestClient, test_user: User):
|
||||
"""Test getting a specific user"""
|
||||
response = client.get(f"/api/v1/users/{test_user.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == test_user.email
|
||||
assert data["full_name"] == test_user.full_name
|
||||
|
||||
def test_update_user(client: TestClient, test_user: User):
|
||||
"""Test updating a user"""
|
||||
update_data = {
|
||||
"full_name": "Updated Name",
|
||||
"is_active": True
|
||||
}
|
||||
response = client.put(f"/api/v1/users/{test_user.id}", json=update_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["full_name"] == update_data["full_name"]
|
||||
assert data["is_active"] == update_data["is_active"]
|
||||
|
||||
def test_delete_user(client: TestClient, db: Session, test_organization, test_role: Role):
|
||||
"""Test deleting a user"""
|
||||
# Create a user to delete
|
||||
user_to_delete = User(
|
||||
id=uuid4(),
|
||||
email="delete@test.com",
|
||||
hashed_password=get_password_hash("testpassword"),
|
||||
organization_id=test_organization.id,
|
||||
role_id=test_role.id,
|
||||
is_active=True
|
||||
)
|
||||
db.add(user_to_delete)
|
||||
db.commit()
|
||||
db.refresh(user_to_delete)
|
||||
|
||||
response = client.delete(f"/api/v1/users/{user_to_delete.id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_login_success(client: TestClient, test_user: User):
|
||||
"""Test successful login"""
|
||||
response = client.post(
|
||||
"/api/v1/users/login",
|
||||
data={
|
||||
"username": test_user.email,
|
||||
"password": "testpassword"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["user"]["email"] == test_user.email
|
||||
|
||||
# Check cookies
|
||||
cookies = response.cookies
|
||||
assert "access_token" in cookies
|
||||
assert "refresh_token" in cookies
|
||||
assert "user_info" in cookies
|
||||
|
||||
def test_login_invalid_credentials(client: TestClient):
|
||||
"""Test login with invalid credentials"""
|
||||
response = client.post(
|
||||
"/api/v1/users/login",
|
||||
data={
|
||||
"username": "wrong@email.com",
|
||||
"password": "wrongpassword"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_refresh_token(client: TestClient, test_user: User):
|
||||
"""Test token refresh"""
|
||||
# First login to get tokens
|
||||
login_response = client.post(
|
||||
"/api/v1/users/login",
|
||||
data={
|
||||
"username": test_user.email,
|
||||
"password": "testpassword"
|
||||
}
|
||||
)
|
||||
|
||||
# Get refresh token from cookie
|
||||
refresh_token = login_response.cookies.get("refresh_token")
|
||||
|
||||
# Test refresh endpoint
|
||||
response = client.post(
|
||||
"/api/v1/users/refresh",
|
||||
cookies={"refresh_token": refresh_token}
|
||||
)
|
||||
assert response.status_code == 200 # Token refresh should succeed
|
||||
assert "access_token" in response.json()
|
||||
assert "refresh_token" in response.json()
|
||||
assert response.cookies.get("access_token") is not None
|
||||
assert response.cookies.get("refresh_token") is not None
|
||||
|
||||
def test_logout(client: TestClient, test_user: User):
|
||||
"""Test logout"""
|
||||
response = client.post("/api/v1/users/logout")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check that cookies are cleared
|
||||
for cookie in ["access_token", "refresh_token", "user_info"]:
|
||||
assert response.cookies.get(cookie) is None or response.cookies[cookie].value == ""
|
||||
|
||||
def test_update_profile(client: TestClient, test_user: User):
|
||||
"""Test updating user's own profile"""
|
||||
update_data = {
|
||||
"full_name": "Updated Profile Name",
|
||||
"email": "updated@test.com"
|
||||
}
|
||||
response = client.patch("/api/v1/users/me", json=update_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["full_name"] == update_data["full_name"]
|
||||
assert data["email"] == update_data["email"]
|
||||
|
||||
def test_update_password(client: TestClient, db: Session, test_user: User):
|
||||
"""Test updating user's password"""
|
||||
update_data = {
|
||||
"current_password": "testpassword",
|
||||
"password": "NewPassw0rd!"
|
||||
}
|
||||
response = client.patch("/api/v1/users/me", json=update_data)
|
||||
assert response.status_code == 200
|
||||
# The new hash has to reach the column, not a stray ORM attribute
|
||||
db.refresh(test_user)
|
||||
assert verify_password("NewPassw0rd!", test_user.hashed_password)
|
||||
|
||||
|
||||
def test_update_password_rejects_weak_password(client: TestClient, db: Session, test_user: User):
|
||||
"""A self-service change has to clear the same policy as an admin reset"""
|
||||
response = client.patch(
|
||||
"/api/v1/users/me",
|
||||
json={"current_password": "testpassword", "password": "short1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
db.refresh(test_user)
|
||||
assert verify_password("testpassword", test_user.hashed_password)
|
||||
|
||||
|
||||
def test_update_password_rejects_wrong_current_password(client: TestClient, db: Session, test_user: User):
|
||||
"""The old password still has to be proven"""
|
||||
response = client.patch(
|
||||
"/api/v1/users/me",
|
||||
json={"current_password": "wrongpassword", "password": "NewPassw0rd!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Incorrect current password"
|
||||
db.refresh(test_user)
|
||||
assert verify_password("testpassword", test_user.hashed_password)
|
||||
|
||||
def test_update_status(client: TestClient, test_user: User):
|
||||
"""Test updating user's online status"""
|
||||
status_data = {
|
||||
"is_online": True
|
||||
}
|
||||
response = client.post(f"/api/v1/users/{test_user.id}/status", json=status_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_online"] == status_data["is_online"]
|
||||
assert "last_seen" in data
|
||||
|
||||
def test_get_nonexistent_user(client: TestClient):
|
||||
"""Test getting a nonexistent user"""
|
||||
nonexistent_id = uuid4()
|
||||
response = client.get(f"/api/v1/users/{nonexistent_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_nonexistent_user(client: TestClient):
|
||||
"""Test updating a nonexistent user"""
|
||||
nonexistent_id = uuid4()
|
||||
update_data = {
|
||||
"full_name": "Updated Name"
|
||||
}
|
||||
response = client.put(f"/api/v1/users/{nonexistent_id}", json=update_data)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_nonexistent_user(client: TestClient):
|
||||
"""Test deleting a nonexistent user"""
|
||||
nonexistent_id = uuid4()
|
||||
response = client.delete(f"/api/v1/users/{nonexistent_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_register_fcm_token(client: TestClient, test_user: User, db: Session):
|
||||
"""Registering a token stores a row for this device"""
|
||||
response = client.post("/api/v1/users/token/fcm-token",
|
||||
json={"token": "test_fcm_token_123"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "FCM token registered successfully"
|
||||
|
||||
tokens = db.query(FCMToken).filter(FCMToken.user_id == test_user.id).all()
|
||||
assert [t.token for t in tokens] == ["test_fcm_token_123"]
|
||||
|
||||
|
||||
def test_register_fcm_token_is_idempotent(client: TestClient, test_user: User, db: Session):
|
||||
"""Re-posting the same token on every app load must not pile up rows"""
|
||||
for _ in range(3):
|
||||
response = client.post("/api/v1/users/token/fcm-token",
|
||||
json={"token": "same_token"})
|
||||
assert response.status_code == 200
|
||||
|
||||
assert db.query(FCMToken).filter(FCMToken.user_id == test_user.id).count() == 1
|
||||
|
||||
|
||||
def test_register_fcm_token_keeps_other_devices(client: TestClient, test_user: User, db: Session):
|
||||
"""A second device adds a token instead of replacing the first"""
|
||||
client.post("/api/v1/users/token/fcm-token", json={"token": "phone_token"})
|
||||
client.post("/api/v1/users/token/fcm-token", json={"token": "laptop_token"})
|
||||
|
||||
tokens = {t.token for t in db.query(FCMToken).filter(
|
||||
FCMToken.user_id == test_user.id).all()}
|
||||
assert tokens == {"phone_token", "laptop_token"}
|
||||
|
||||
|
||||
def test_clear_fcm_token_only_removes_that_device(client: TestClient, test_user: User, db: Session):
|
||||
"""Signing out on one device must leave the user's other devices on push"""
|
||||
db.add_all([
|
||||
FCMToken(user_id=test_user.id, token="phone_token"),
|
||||
FCMToken(user_id=test_user.id, token="laptop_token"),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
response = client.request("DELETE", "/api/v1/users/token/fcm-token",
|
||||
json={"token": "laptop_token"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "FCM token cleared successfully"
|
||||
|
||||
tokens = [t.token for t in db.query(FCMToken).filter(
|
||||
FCMToken.user_id == test_user.id).all()]
|
||||
assert tokens == ["phone_token"]
|
||||
|
||||
|
||||
def test_clear_unknown_fcm_token_succeeds(client: TestClient, test_user: User):
|
||||
"""Logging out twice, or after the token was pruned, is not an error"""
|
||||
response = client.request("DELETE", "/api/v1/users/token/fcm-token",
|
||||
json={"token": "never_registered"})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def foreign_role(db) -> Role:
|
||||
"""A role defined in a different organization."""
|
||||
other_org = Organization(id=uuid4(), name="Other Org", domain="other-role.example.com")
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
role = Role(name="Foreign Admin", organization_id=other_org.id)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
def test_create_user_rejects_foreign_role(admin_client, foreign_role, test_organization):
|
||||
"""A user can't be created with a role from another tenant"""
|
||||
user_data = {
|
||||
"email": "roletest@test.com",
|
||||
"full_name": "Role Test",
|
||||
"password": "TestPassw0rd!",
|
||||
"is_active": True,
|
||||
"role_id": foreign_role.id,
|
||||
"organization_id": str(test_organization.id),
|
||||
}
|
||||
response = admin_client.post("/api/v1/users", json=user_data)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Role not found"
|
||||
|
||||
|
||||
def test_update_user_rejects_foreign_role(admin_client, db, test_user, foreign_role):
|
||||
"""A user can't be moved onto a role from another tenant"""
|
||||
response = admin_client.put(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
json={"role_id": foreign_role.id},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Role not found"
|
||||
db.refresh(test_user)
|
||||
assert test_user.role_id != foreign_role.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- admin reset
|
||||
|
||||
@pytest.fixture
|
||||
def agent_user(db: Session, test_organization, test_role: Role) -> User:
|
||||
"""A second user in the same org — the one an admin resets."""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="agent@test.com",
|
||||
hashed_password=get_password_hash("oldpassword"),
|
||||
organization_id=test_organization.id,
|
||||
role_id=test_role.id,
|
||||
is_active=True,
|
||||
full_name="Agent User",
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def test_reset_user_password(admin_client: TestClient, db: Session, agent_user: User):
|
||||
"""An admin can set a new password for an agent in their organization"""
|
||||
response = admin_client.post(
|
||||
f"/api/v1/users/{agent_user.id}/reset-password",
|
||||
json={"new_password": "NewPassw0rd!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Password reset successfully"
|
||||
db.refresh(agent_user)
|
||||
assert verify_password("NewPassw0rd!", agent_user.hashed_password)
|
||||
assert not verify_password("oldpassword", agent_user.hashed_password)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_client(db: Session, test_organization, agent_user: User) -> TestClient:
|
||||
"""A client authenticated as a user whose role grants nothing.
|
||||
|
||||
The `client` fixture's require_permissions override targets the factory
|
||||
rather than the closure Depends() actually calls, so it authenticates as a
|
||||
user who *does* hold manage_users. Gate tests need a real unprivileged user.
|
||||
"""
|
||||
plain_role = Role(name="Agent Only", organization_id=test_organization.id)
|
||||
db.add(plain_role)
|
||||
db.commit()
|
||||
agent_user.role_id = plain_role.id
|
||||
db.commit()
|
||||
db.refresh(agent_user)
|
||||
|
||||
async def override_get_current_user():
|
||||
return agent_user
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
session = TestingSessionLocal()
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
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_reset_user_password_requires_permission(agent_client: TestClient, test_user: User):
|
||||
"""Without manage_users the reset is refused"""
|
||||
response = agent_client.post(
|
||||
f"/api/v1/users/{test_user.id}/reset-password",
|
||||
json={"new_password": "NewPassw0rd!"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_reset_user_password_rejects_weak_password(admin_client: TestClient, db: Session, agent_user: User):
|
||||
"""A password below the policy is rejected and nothing is written"""
|
||||
response = admin_client.post(
|
||||
f"/api/v1/users/{agent_user.id}/reset-password",
|
||||
json={"new_password": "short1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
db.refresh(agent_user)
|
||||
assert verify_password("oldpassword", agent_user.hashed_password)
|
||||
|
||||
|
||||
def test_reset_own_password_rejected(admin_client: TestClient, test_user: User):
|
||||
"""Changing your own password still requires proving the current one"""
|
||||
response = admin_client.post(
|
||||
f"/api/v1/users/{test_user.id}/reset-password",
|
||||
json={"new_password": "NewPassw0rd!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "profile settings" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_reset_password_rejects_other_org_user(admin_client: TestClient, db: Session, test_role: Role):
|
||||
"""A user in another organization is invisible, not resettable"""
|
||||
other_org = Organization(id=uuid4(), name="Other Org", domain="other-reset.example.com")
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
outsider = User(
|
||||
id=uuid4(),
|
||||
email="outsider@test.com",
|
||||
hashed_password=get_password_hash("oldpassword"),
|
||||
organization_id=other_org.id,
|
||||
is_active=True,
|
||||
full_name="Outsider",
|
||||
)
|
||||
db.add(outsider)
|
||||
db.commit()
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/v1/users/{outsider.id}/reset-password",
|
||||
json={"new_password": "NewPassw0rd!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
db.refresh(outsider)
|
||||
assert verify_password("oldpassword", outsider.hashed_password)
|
||||
|
||||
|
||||
def test_update_user_ignores_password_field(admin_client: TestClient, db: Session, agent_user: User):
|
||||
"""PUT /users/{id} no longer pretends to accept a password"""
|
||||
response = admin_client.put(
|
||||
f"/api/v1/users/{agent_user.id}",
|
||||
json={"full_name": "Renamed", "password": "IgnoredPassw0rd!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
db.refresh(agent_user)
|
||||
assert agent_user.full_name == "Renamed"
|
||||
assert verify_password("oldpassword", agent_user.hashed_password)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ teammates
|
||||
|
||||
@pytest.fixture
|
||||
def inbox_agent_client(db: Session, test_organization, agent_user: User) -> TestClient:
|
||||
"""Authenticated as a seeded Human Agent — the four permissions and no more."""
|
||||
role = Role(id=88, name="Human Agent", organization_id=test_organization.id)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
for name in ("view_assigned_chats", "manage_assigned_chats",
|
||||
"view_unassigned_chats", "view_people"):
|
||||
perm = db.query(Permission).filter(Permission.name == name).first()
|
||||
if perm is None:
|
||||
perm = Permission(name=name, description=name)
|
||||
db.add(perm)
|
||||
db.commit()
|
||||
db.refresh(perm)
|
||||
db.execute(role_permissions.insert().values(role_id=role.id, permission_id=perm.id))
|
||||
db.commit()
|
||||
agent_user.role_id = role.id
|
||||
db.commit()
|
||||
db.refresh(agent_user)
|
||||
|
||||
async def override_get_current_user():
|
||||
return agent_user
|
||||
|
||||
def override_get_db():
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
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_teammates_visible_to_inbox_roles(inbox_agent_client: TestClient, db: Session, test_organization, test_role: Role):
|
||||
"""An agent can list who to hand a chat to, without manage_users.
|
||||
|
||||
The inbox used to call GET /users for this, which 403'd — so Reassign, an
|
||||
action the API allows an agent to perform, had an empty dropdown.
|
||||
"""
|
||||
colleague = User(
|
||||
id=uuid4(),
|
||||
email="colleague@test.com",
|
||||
hashed_password=get_password_hash("pw"),
|
||||
organization_id=test_organization.id,
|
||||
role_id=test_role.id,
|
||||
is_active=True,
|
||||
full_name="Colleague",
|
||||
)
|
||||
db.add(colleague)
|
||||
db.commit()
|
||||
|
||||
response = inbox_agent_client.get("/api/v1/users/teammates")
|
||||
|
||||
assert response.status_code == 200
|
||||
emails = {u["email"] for u in response.json()}
|
||||
assert "colleague@test.com" in emails
|
||||
|
||||
|
||||
def test_teammates_does_not_leak_roles_or_permissions(inbox_agent_client: TestClient):
|
||||
"""The payload is a name and a face — not the org's permission matrix"""
|
||||
response = inbox_agent_client.get("/api/v1/users/teammates")
|
||||
|
||||
assert response.status_code == 200
|
||||
for teammate in response.json():
|
||||
assert set(teammate) <= {"id", "full_name", "email", "profile_pic", "is_online"}
|
||||
|
||||
|
||||
def test_teammates_is_org_scoped(inbox_agent_client: TestClient, db: Session):
|
||||
"""A teammate from another organization is not a teammate"""
|
||||
other_org = Organization(id=uuid4(), name="Other", domain="other-teammates.example.com")
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
outsider = User(
|
||||
id=uuid4(),
|
||||
email="outsider@other.com",
|
||||
hashed_password=get_password_hash("pw"),
|
||||
organization_id=other_org.id,
|
||||
is_active=True,
|
||||
full_name="Outsider",
|
||||
)
|
||||
db.add(outsider)
|
||||
db.commit()
|
||||
|
||||
response = inbox_agent_client.get("/api/v1/users/teammates")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "outsider@other.com" not in {u["email"] for u in response.json()}
|
||||
|
||||
|
||||
def test_teammates_requires_an_inbox_permission(db: Session, test_organization) -> None:
|
||||
"""Someone with no chat grant at all has no business reading the directory"""
|
||||
role = Role(id=77, name="Nothing", organization_id=test_organization.id)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="nothing@test.com",
|
||||
hashed_password=get_password_hash("pw"),
|
||||
organization_id=test_organization.id,
|
||||
role_id=role.id,
|
||||
is_active=True,
|
||||
full_name="Nothing",
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
async def override_get_current_user():
|
||||
return user
|
||||
|
||||
def override_get_db():
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
try:
|
||||
response = TestClient(app).get("/api/v1/users/teammates")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_teammates_route_is_not_shadowed_by_the_user_id_route(inbox_agent_client: TestClient):
|
||||
"""Declared above GET /{user_id}.
|
||||
|
||||
Below it, "teammates" parses as a user id and the request lands on the
|
||||
manage_users route — a 403 for exactly the agents the endpoint is for. A
|
||||
200 here is the proof it resolved to the right handler.
|
||||
"""
|
||||
response = inbox_agent_client.get("/api/v1/users/teammates")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
|
||||
def test_avatar_redirects_to_a_freshly_signed_url(client: TestClient, test_user: User, db: Session):
|
||||
"""The point of the endpoint: a URL signed now, not at login.
|
||||
|
||||
The dashboard used to render the link held in the cached user_info blob,
|
||||
which is written once at login. S3 presigned URLs expire an hour later, so
|
||||
every session outlived its own avatar and only a re-login fixed it.
|
||||
"""
|
||||
test_user.profile_pic = "/uploads/user/org/user/profile.png"
|
||||
db.commit()
|
||||
|
||||
response = client.get("/api/v1/users/me/avatar", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 307
|
||||
# Local storage is served under the API prefix; the bare stored path 404s.
|
||||
assert response.headers["location"] == "/api/v1/uploads/user/org/user/profile.png"
|
||||
# The redirect must not be cached, or the browser keeps replaying a
|
||||
# signature that has since expired — the bug all over again.
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_avatar_is_404_without_a_picture(client: TestClient, test_user: User, db: Session):
|
||||
test_user.profile_pic = None
|
||||
db.commit()
|
||||
|
||||
assert client.get("/api/v1/users/me/avatar").status_code == 404
|
||||
|
||||
|
||||
def test_avatar_route_is_not_shadowed_by_the_user_id_route(client: TestClient, test_user: User, db: Session):
|
||||
"""Declared above GET /{user_id}, or "me" parses as a user id."""
|
||||
test_user.profile_pic = "/uploads/user/org/user/profile.png"
|
||||
db.commit()
|
||||
|
||||
response = client.get("/api/v1/users/me/avatar", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 307
|
||||
|
||||
|
||||
def test_get_user_with_malformed_id_is_a_client_error(client: TestClient):
|
||||
"""A malformed id is the caller's mistake (422), never a server error."""
|
||||
assert client.get("/api/v1/users/0").status_code == 422
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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, patch
|
||||
|
||||
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
|
||||
|
||||
# Starlette's own defaults for request.form(); a body past either one is refused.
|
||||
MAX_FIELDS = 1000
|
||||
MAX_FIELD_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@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 sms_account(db, test_organization):
|
||||
return ChannelAccountRepository(db).create_account(
|
||||
organization_id=test_organization.id, channel_type="sms",
|
||||
external_account_id="+15550001111",
|
||||
credentials={"api_key": "k", "api_secret": "s"},
|
||||
display_name="SMS", settings={"provider": "vonage"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def email_account(db, test_organization):
|
||||
return ChannelAccountRepository(db).create_account(
|
||||
organization_id=test_organization.id, channel_type="email",
|
||||
external_account_id="support@example.com",
|
||||
credentials={"api_key": "k"},
|
||||
display_name="Email", settings={})
|
||||
|
||||
|
||||
def sms_url(account):
|
||||
return f"/api/v1/webhooks/sms/vonage/{account.id}?token={account.webhook_secret}"
|
||||
|
||||
|
||||
def email_url(account):
|
||||
return f"/api/v1/webhooks/email/{account.id}?token={account.webhook_secret}"
|
||||
|
||||
|
||||
class TestWebhookFormLimits:
|
||||
"""Providers post urlencoded bodies to these routes. Starlette enforces the
|
||||
form field-count and field-size caps (it silently ignored them for
|
||||
urlencoded bodies before 1.3.1, which was the DoS). What matters here is the
|
||||
status we answer with: a 5xx tells Twilio and friends to redeliver, so an
|
||||
oversized body has to come back as a 4xx or one bad request becomes a
|
||||
retry storm.
|
||||
"""
|
||||
|
||||
def test_normal_form_body_is_still_processed(self, client, sms_account):
|
||||
with patch("app.api.webhooks.sms.process_channel_message", AsyncMock()) as proc:
|
||||
response = client.post(sms_url(sms_account), data={
|
||||
"msisdn": "+44700", "to": sms_account.external_account_id,
|
||||
"text": "hello", "messageId": "m1"})
|
||||
|
||||
assert response.status_code == 200
|
||||
proc.assert_awaited_once()
|
||||
assert proc.await_args.args[1].text == "hello"
|
||||
|
||||
def test_too_many_fields_is_rejected_without_a_retry_signal(self, client, sms_account):
|
||||
payload = {f"f{i}": "v" for i in range(MAX_FIELDS + 500)}
|
||||
|
||||
with patch("app.api.webhooks.sms.process_channel_message", AsyncMock()) as proc:
|
||||
response = client.post(sms_url(sms_account), data=payload)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code < 500, "5xx would make the provider redeliver"
|
||||
proc.assert_not_awaited()
|
||||
|
||||
def test_oversized_field_is_rejected(self, client, sms_account):
|
||||
payload = {"text": "x" * (MAX_FIELD_BYTES + 1024)}
|
||||
|
||||
with patch("app.api.webhooks.sms.process_channel_message", AsyncMock()) as proc:
|
||||
response = client.post(sms_url(sms_account), data=payload)
|
||||
|
||||
assert response.status_code == 400
|
||||
proc.assert_not_awaited()
|
||||
|
||||
def test_email_webhook_rejects_an_oversized_body_too(self, client, email_account):
|
||||
payload = {f"f{i}": "v" for i in range(MAX_FIELDS + 500)}
|
||||
|
||||
response = client.post(email_url(email_account), data=payload)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 patch
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import Base, get_db
|
||||
from fastapi import FastAPI
|
||||
from app.models.widget import Widget
|
||||
from app.models.user import User
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.agent import AgentCustomization
|
||||
from uuid import UUID, uuid4
|
||||
from app.api import widget as widget_router
|
||||
from app.core.auth import get_current_user
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from tests.conftest import engine, TestingSessionLocal
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
widget_router.router,
|
||||
prefix=f"{settings.API_V1_STR}/widgets",
|
||||
tags=["widgets"]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db():
|
||||
"""Create a fresh database for each test."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_user: User) -> TestClient:
|
||||
"""Create test client with mocked dependencies"""
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
db = TestingSessionLocal()
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_create_widget(client: TestClient, test_agent: Agent):
|
||||
"""Test creating a new widget"""
|
||||
widget_data = {
|
||||
"name": "New Widget",
|
||||
"agent_id": str(test_agent.id)
|
||||
}
|
||||
response = client.post("/api/v1/widgets", json=widget_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["agent"]["id"] == str(test_agent.id)
|
||||
assert data["agent"]["name"] == test_agent.name
|
||||
assert "organization_id" in data
|
||||
|
||||
def test_get_widget_details(client: TestClient, test_widget: Widget):
|
||||
"""Test getting list of widgets (authenticated)"""
|
||||
response = client.get("/api/v1/widgets")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == str(test_widget.id)
|
||||
assert UUID(data[0]["agent"]["id"]) == test_widget.agent_id
|
||||
|
||||
def test_list_widgets(client: TestClient, test_widget: Widget):
|
||||
"""Test listing all widgets"""
|
||||
response = client.get("/api/v1/widgets")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == str(test_widget.id)
|
||||
|
||||
def test_delete_widget(client: TestClient, test_widget: Widget):
|
||||
"""Test deleting a widget"""
|
||||
response = client.delete(f"/api/v1/widgets/{test_widget.id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Widget deleted"
|
||||
|
||||
# Verify widget is deleted
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}/details")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_nonexistent_widget(client: TestClient):
|
||||
"""Test getting a nonexistent widget"""
|
||||
response = client.get(f"/api/v1/widgets/{uuid4()}/details")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_widget_ui(client: TestClient, test_widget: Widget):
|
||||
"""Test getting widget UI"""
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}/data")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert str(test_widget.id) in response.text
|
||||
assert test_widget.agent.display_name or test_widget.agent.name in response.text
|
||||
|
||||
def test_widget_ui_injects_app_config(client: TestClient, test_widget: Widget, monkeypatch):
|
||||
"""The iframe HTML must inject window.APP_CONFIG derived from BACKEND_URL so a
|
||||
self-hosted widget connects to the configured backend, not the cloud default."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "BACKEND_URL", "https://support.example.com")
|
||||
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}/data")
|
||||
assert response.status_code == 200
|
||||
|
||||
assert "window.APP_CONFIG" in response.text
|
||||
assert '"API_URL": "https://support.example.com/api/v1"' in response.text
|
||||
assert '"WS_URL": "wss://support.example.com"' in response.text
|
||||
# APP_CONFIG must be declared before the widget module so it exists at load time.
|
||||
assert response.text.index("window.APP_CONFIG") < response.text.index("assets/widget.js")
|
||||
|
||||
|
||||
def test_widget_ui_app_config_falls_back_to_request_origin(
|
||||
client: TestClient, test_widget: Widget, monkeypatch
|
||||
):
|
||||
"""BACKEND_URL defaults to localhost and neither compose file sets it, so an
|
||||
install that never overrode it served every visitor a widget whose socket
|
||||
dialled the visitor's own machine. Loopback can never be right for someone
|
||||
else's browser: use the origin the widget was actually fetched over."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "BACKEND_URL", "http://localhost:8000")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}/data",
|
||||
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "chat.example.com"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
assert '"API_URL": "https://chat.example.com/api/v1"' in response.text
|
||||
assert '"WS_URL": "wss://chat.example.com"' in response.text
|
||||
|
||||
|
||||
def test_widget_ui_app_config_assumes_tls_behind_a_host_rewriting_proxy(
|
||||
client: TestClient, test_widget: Widget, monkeypatch
|
||||
):
|
||||
"""Some proxies rewrite the host but not the protocol. Reading the scheme off
|
||||
the proxy's own plaintext hop would hand an https page a ws:// socket, which
|
||||
the browser refuses as mixed content."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "BACKEND_URL", "http://localhost:8000")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}/data",
|
||||
headers={"X-Forwarded-Host": "chat.example.com"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
assert '"API_URL": "https://chat.example.com/api/v1"' in response.text
|
||||
assert '"WS_URL": "wss://chat.example.com"' in response.text
|
||||
|
||||
|
||||
def test_widget_ui_app_config_takes_the_browsers_end_of_a_proxy_chain(
|
||||
client: TestClient, test_widget: Widget, monkeypatch
|
||||
):
|
||||
"""Each proxy appends to these headers, so the browser's own value is first.
|
||||
Taking the last would point the widget at an internal hop no visitor can reach."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "BACKEND_URL", "http://localhost:8000")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}/data",
|
||||
headers={
|
||||
"X-Forwarded-Host": "chat.example.com, internal-lb.local",
|
||||
"X-Forwarded-Proto": "https, http",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
assert '"API_URL": "https://chat.example.com/api/v1"' in response.text
|
||||
assert '"WS_URL": "wss://chat.example.com"' in response.text
|
||||
|
||||
|
||||
def test_widget_ui_app_config_keeps_loopback_for_local_development(
|
||||
client: TestClient, test_widget: Widget, monkeypatch
|
||||
):
|
||||
"""Running everything on one machine is the case loopback is right for, and
|
||||
the request origin says so too — nothing to substitute."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "BACKEND_URL", "http://localhost:8000")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}/data", headers={"Host": "localhost:8000"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
assert '"API_URL": "http://localhost:8000/api/v1"' in response.text
|
||||
assert '"WS_URL": "ws://localhost:8000"' in response.text
|
||||
|
||||
|
||||
def test_widget_ui_presence_is_ai_when_the_ai_answers(client: TestClient, test_widget: Widget):
|
||||
"""The AI really does reply instantly, at any hour — no hours check."""
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}/data")
|
||||
assert response.status_code == 200
|
||||
assert '"mode": "ai"' in response.text
|
||||
assert '"available": true' in response.text
|
||||
|
||||
|
||||
def test_widget_ui_presence_follows_business_hours_for_a_human_only_agent(
|
||||
client: TestClient, db, test_widget: Widget, test_agent: Agent
|
||||
):
|
||||
"""A person has to reply, so the header may only claim availability while
|
||||
the organization is actually open."""
|
||||
test_agent.ai_replies_enabled = False
|
||||
db.commit()
|
||||
|
||||
with patch('app.api.widget.is_within_business_hours', return_value=True):
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}/data")
|
||||
assert '"mode": "human"' in response.text
|
||||
assert '"available": true' in response.text
|
||||
|
||||
with patch('app.api.widget.is_within_business_hours', return_value=False):
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}/data")
|
||||
assert '"mode": "human"' in response.text
|
||||
assert '"available": false' in response.text
|
||||
|
||||
|
||||
def test_get_widget_data(client: TestClient, test_widget: Widget):
|
||||
"""Test getting widget data with conversation token"""
|
||||
# First get the widget UI to get the initial token
|
||||
ui_response = client.get(f"/api/v1/widgets/{test_widget.id}/data")
|
||||
assert ui_response.status_code == 200
|
||||
|
||||
# Extract the initial token from the HTML response
|
||||
import re
|
||||
html_content = ui_response.text
|
||||
match = re.search(r'initialToken: "([^"]+)"', html_content)
|
||||
assert match is not None, "Initial token not found in HTML response"
|
||||
conversation_token = match.group(1)
|
||||
|
||||
# Test getting widget data with token and email to create customer
|
||||
response = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}",
|
||||
params={"email": "test@example.com"},
|
||||
headers={"Authorization": f"Bearer {conversation_token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_widget.id)
|
||||
assert data["organization_id"] == str(test_widget.organization_id)
|
||||
assert data["agent"]["id"] == str(test_widget.agent_id)
|
||||
assert "human_agent" in data # Verify human_agent info is present
|
||||
assert "token" in data # Verify new token is present in response
|
||||
|
||||
def test_get_widget_data_without_token(client: TestClient, test_widget: Widget):
|
||||
"""Test getting widget data without conversation token - should fail if token auth required"""
|
||||
# For a normal widget without token auth requirement, this returns 200
|
||||
# For a widget with token auth requirement, this returns 401
|
||||
# Since test_widget doesn't set require_token_auth, it should return 200
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}")
|
||||
# This endpoint may return 200 if no auth is required, or 401 if auth is required
|
||||
# The test setup determines what's required
|
||||
assert response.status_code in [200, 401]
|
||||
|
||||
def test_widgets_for_malformed_agent_id_is_a_client_error(client: TestClient):
|
||||
"""A malformed id is the caller's mistake (422), never a server error."""
|
||||
assert client.get("/api/v1/widgets/agent/0").status_code == 422
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,550 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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, MagicMock, patch
|
||||
from app.models.widget import Widget
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.ai_config import AIConfig, AIModelType
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
from app.models.workflow import Workflow, WorkflowStatus
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
|
||||
from app.repositories.agent import AgentRepository
|
||||
from app.repositories.widget import WidgetRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.models.schemas.widget import WidgetCreate
|
||||
from app.core.security import encrypt_api_key
|
||||
from uuid import UUID, uuid4
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_ai_config(db, test_organization) -> AIConfig:
|
||||
"""Create a test AI config"""
|
||||
ai_config = AIConfig(
|
||||
organization_id=test_organization.id,
|
||||
model_type=AIModelType.OPENAI,
|
||||
model_name="gpt-4",
|
||||
encrypted_api_key=encrypt_api_key("test_key"),
|
||||
is_active=True
|
||||
)
|
||||
db.add(ai_config)
|
||||
db.commit()
|
||||
db.refresh(ai_config)
|
||||
return ai_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent(db, test_organization) -> Agent:
|
||||
"""Create a test agent"""
|
||||
agent_repo = AgentRepository(db)
|
||||
agent = agent_repo.create_agent(
|
||||
name="Test Agent",
|
||||
agent_type=AgentType.CUSTOMER_SUPPORT,
|
||||
instructions=["Test instructions"],
|
||||
org_id=test_organization.id
|
||||
)
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_workflow(db, test_organization, test_user) -> Workflow:
|
||||
"""Create a test workflow"""
|
||||
workflow = Workflow(
|
||||
id=uuid4(),
|
||||
name="Test Workflow",
|
||||
description="Test workflow",
|
||||
status=WorkflowStatus.PUBLISHED,
|
||||
version=1,
|
||||
is_template=False,
|
||||
default_language="en",
|
||||
canvas_data={},
|
||||
settings={},
|
||||
organization_id=test_organization.id,
|
||||
created_by=test_user.id
|
||||
)
|
||||
db.add(workflow)
|
||||
db.commit()
|
||||
db.refresh(workflow)
|
||||
return workflow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_widget(db, test_agent) -> Widget:
|
||||
"""Create a test widget"""
|
||||
widget_repo = WidgetRepository(db)
|
||||
widget_create = WidgetCreate(
|
||||
name="Test Widget",
|
||||
agent_id=test_agent.id
|
||||
)
|
||||
widget = widget_repo.create_widget(
|
||||
widget=widget_create,
|
||||
organization_id=test_agent.organization_id
|
||||
)
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_customer(db, test_organization) -> Customer:
|
||||
"""Create a test customer"""
|
||||
customer = Customer(
|
||||
id=uuid4(),
|
||||
organization_id=test_organization.id,
|
||||
email="test.customer@example.com",
|
||||
full_name="Test Customer"
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
return customer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_session(db, test_widget, test_customer) -> SessionToAgent:
|
||||
"""Create a test session"""
|
||||
session_id = uuid4()
|
||||
session = SessionToAgent(
|
||||
session_id=session_id,
|
||||
agent_id=test_widget.agent_id,
|
||||
customer_id=test_customer.id,
|
||||
organization_id=test_widget.organization_id,
|
||||
status=SessionStatus.OPEN
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sio():
|
||||
"""Create a mock socket.io instance"""
|
||||
mock = MagicMock()
|
||||
mock.emit = AsyncMock()
|
||||
mock.get_session = AsyncMock()
|
||||
mock.get_environ = MagicMock()
|
||||
return mock
|
||||
|
||||
|
||||
class TestHandleWidgetChat:
|
||||
"""Test the handle_widget_chat function"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_widget_chat_authentication_failure(self, mock_sio, monkeypatch):
|
||||
"""Test widget chat with authentication failure"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
|
||||
# Mock failed authentication
|
||||
mock_sio.get_session.return_value = {"test": "session"}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=(None, None, None, None))
|
||||
)
|
||||
|
||||
sid = "test_sid"
|
||||
data = {"message": "Hello"}
|
||||
|
||||
await widget_chat.handle_widget_chat(sid, data)
|
||||
|
||||
# Verify error was emitted
|
||||
mock_sio.emit.assert_called_with(
|
||||
'error',
|
||||
{'error': 'Authentication failed', 'type': 'auth_error'},
|
||||
room=sid,
|
||||
namespace='/widget'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_widget_chat_empty_message(self, mock_sio, monkeypatch, test_widget, test_customer):
|
||||
"""Test widget chat with empty message"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
|
||||
# Mock successful authentication
|
||||
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
|
||||
mock_sio.get_session.return_value = {
|
||||
"widget_id": str(test_widget.id),
|
||||
"org_id": str(test_widget.organization_id),
|
||||
"customer_id": str(test_customer.id),
|
||||
"session_id": str(uuid4()),
|
||||
"agent_id": str(test_widget.agent_id)
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=mock_auth_result)
|
||||
)
|
||||
|
||||
sid = "test_sid"
|
||||
data = {"message": " "} # Empty/whitespace message
|
||||
|
||||
await widget_chat.handle_widget_chat(sid, data)
|
||||
|
||||
# Should return early without processing, no error emitted
|
||||
assert mock_sio.emit.call_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_widget_chat_session_mismatch(self, mock_sio, monkeypatch, test_widget, test_customer):
|
||||
"""Test widget chat with session data mismatch"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([MagicMock()]))
|
||||
|
||||
# Mock successful authentication but mismatched session data
|
||||
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
|
||||
mock_sio.get_session.return_value = {
|
||||
"widget_id": "different_widget_id", # Mismatch
|
||||
"org_id": str(test_widget.organization_id),
|
||||
"customer_id": str(test_customer.id),
|
||||
"session_id": str(uuid4()),
|
||||
"agent_id": str(test_widget.agent_id)
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=mock_auth_result)
|
||||
)
|
||||
|
||||
sid = "test_sid"
|
||||
data = {"message": "Hello"}
|
||||
|
||||
await widget_chat.handle_widget_chat(sid, data)
|
||||
|
||||
# Should emit error due to session mismatch
|
||||
mock_sio.emit.assert_called_with(
|
||||
'error',
|
||||
{'error': 'Unable to process your request, please try again later.', 'type': 'chat_error'},
|
||||
to=sid,
|
||||
namespace='/widget'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_widget_chat_no_active_session(self, mock_sio, monkeypatch, test_widget, test_customer, db):
|
||||
"""Test widget chat when no active session exists"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
|
||||
session_id = uuid4()
|
||||
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
|
||||
mock_sio.get_session.return_value = {
|
||||
"widget_id": str(test_widget.id),
|
||||
"org_id": str(test_widget.organization_id),
|
||||
"customer_id": str(test_customer.id),
|
||||
"session_id": str(session_id),
|
||||
"agent_id": str(test_widget.agent_id)
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=mock_auth_result)
|
||||
)
|
||||
|
||||
# Mock no active session found
|
||||
with patch('app.api.widget_chat._own_session', return_value=None):
|
||||
with patch.object(SessionToAgentRepository, 'get_latest_customer_session', return_value=None):
|
||||
sid = "test_sid"
|
||||
data = {"message": "Hello"}
|
||||
|
||||
await widget_chat.handle_widget_chat(sid, data)
|
||||
|
||||
# Should emit error due to no session
|
||||
mock_sio.emit.assert_called_with(
|
||||
'error',
|
||||
{'error': 'Unable to process your request, please try again later.', 'type': 'chat_error'},
|
||||
to=sid,
|
||||
namespace='/widget'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_widget_chat_workflow_execution(self, mock_sio, monkeypatch, test_widget, test_customer, test_workflow, test_ai_config, db):
|
||||
"""Test widget chat with workflow execution"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
|
||||
session_id = uuid4()
|
||||
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
|
||||
mock_sio.get_session.return_value = {
|
||||
"widget_id": str(test_widget.id),
|
||||
"org_id": str(test_widget.organization_id),
|
||||
"customer_id": str(test_customer.id),
|
||||
"session_id": str(session_id),
|
||||
"agent_id": str(test_widget.agent_id),
|
||||
"ai_config": test_ai_config
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=mock_auth_result)
|
||||
)
|
||||
|
||||
# Mock active session with workflow
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = session_id
|
||||
mock_session.workflow_id = test_workflow.id
|
||||
mock_session.status = SessionStatus.OPEN
|
||||
mock_session.user_id = None # No human agent
|
||||
mock_session.current_node_id = None
|
||||
mock_session.workflow_state = {}
|
||||
|
||||
# Mock workflow execution result
|
||||
mock_workflow_result = MagicMock()
|
||||
mock_workflow_result.success = True
|
||||
mock_workflow_result.form_data = {}
|
||||
mock_workflow_result.next_node_id = None
|
||||
mock_workflow_result.workflow_state = {}
|
||||
mock_workflow_result.response_messages = ["AI Response"]
|
||||
mock_workflow_result.end_conversation = False
|
||||
mock_workflow_result.transfer_to_human = False
|
||||
|
||||
with patch('app.api.widget_chat._own_session', return_value=mock_session):
|
||||
with patch.object(ChatRepository, 'create_message', return_value=MagicMock()):
|
||||
with patch('app.api.widget_chat.WorkflowChatService') as mock_workflow_chat_service:
|
||||
mock_workflow_chat_service.return_value.handle_workflow_chat = AsyncMock(return_value=mock_workflow_result)
|
||||
|
||||
sid = "test_sid"
|
||||
data = {"message": "Hello"}
|
||||
|
||||
await widget_chat.handle_widget_chat(sid, data)
|
||||
|
||||
# Verify workflow chat was executed
|
||||
mock_workflow_chat_service.return_value.handle_workflow_chat.assert_called_once()
|
||||
|
||||
|
||||
class TestGetWidgetChatHistory:
|
||||
"""Test the get_widget_chat_history function"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_widget_chat_history_authentication_failure(self, mock_sio, monkeypatch):
|
||||
"""Test get chat history with authentication failure"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
|
||||
# Mock failed authentication
|
||||
mock_sio.get_session.return_value = {"test": "session"}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=(None, None, None, None))
|
||||
)
|
||||
|
||||
sid = "test_sid"
|
||||
|
||||
await widget_chat.get_widget_chat_history(sid)
|
||||
|
||||
# Verify error was emitted
|
||||
mock_sio.emit.assert_called_with(
|
||||
'error',
|
||||
{'error': 'Authentication failed', 'type': 'auth_error'},
|
||||
room=sid,
|
||||
namespace='/widget'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_widget_chat_history_serves_an_expired_token(
|
||||
self, mock_sio, monkeypatch, test_widget, test_customer, db
|
||||
):
|
||||
"""A token that has since expired must not break the open connection.
|
||||
|
||||
The widget kept a socket for longer than the token's TTL and every event
|
||||
on it started failing with "Authentication failed" (#315). Identity comes
|
||||
from the session the connect handler saved.
|
||||
"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
|
||||
mock_sio.get_session.return_value = {
|
||||
"widget_id": str(test_widget.id),
|
||||
"org_id": str(test_widget.organization_id),
|
||||
"customer_id": str(test_customer.id),
|
||||
"agent_id": str(test_widget.agent_id),
|
||||
"conversation_token": "expired-token",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=(None, None, None, None))
|
||||
)
|
||||
|
||||
sid = "test_sid"
|
||||
|
||||
with patch('app.api.widget_chat._own_session', return_value=None):
|
||||
await widget_chat.get_widget_chat_history(sid)
|
||||
|
||||
mock_sio.emit.assert_called_with(
|
||||
'chat_history',
|
||||
{'messages': [], 'type': 'chat_history'},
|
||||
to=sid,
|
||||
namespace='/widget'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_widget_chat_history_no_active_session(self, mock_sio, monkeypatch, test_widget, test_customer, db):
|
||||
"""Test get chat history when no active session exists"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
|
||||
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
|
||||
mock_sio.get_session.return_value = {
|
||||
"widget_id": str(test_widget.id),
|
||||
"org_id": str(test_widget.organization_id),
|
||||
"customer_id": str(test_customer.id),
|
||||
"agent_id": str(test_widget.agent_id)
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=mock_auth_result)
|
||||
)
|
||||
|
||||
# Mock no active session found
|
||||
with patch('app.api.widget_chat._own_session', return_value=None):
|
||||
sid = "test_sid"
|
||||
|
||||
await widget_chat.get_widget_chat_history(sid)
|
||||
|
||||
# Should return empty history
|
||||
mock_sio.emit.assert_called_with(
|
||||
'chat_history',
|
||||
{'messages': [], 'type': 'chat_history'},
|
||||
to=sid,
|
||||
namespace='/widget'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_widget_chat_history_success(self, mock_sio, monkeypatch, test_widget, test_customer, test_session, db):
|
||||
"""Test get chat history success case"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
|
||||
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
|
||||
mock_sio.get_session.return_value = {
|
||||
"widget_id": str(test_widget.id),
|
||||
"org_id": str(test_widget.organization_id),
|
||||
"customer_id": str(test_customer.id),
|
||||
"agent_id": str(test_widget.agent_id)
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=mock_auth_result)
|
||||
)
|
||||
|
||||
# Create mock messages
|
||||
mock_message1 = MagicMock()
|
||||
mock_message1.message = "Hello"
|
||||
mock_message1.message_type = "user"
|
||||
mock_message1.created_at = datetime.now(timezone.utc)
|
||||
mock_message1.attributes = {}
|
||||
mock_message1.user = None
|
||||
mock_message1.agent = None
|
||||
|
||||
mock_message2 = MagicMock()
|
||||
mock_message2.message = "Hi there!"
|
||||
mock_message2.message_type = "bot"
|
||||
mock_message2.created_at = datetime.now(timezone.utc)
|
||||
mock_message2.attributes = {}
|
||||
mock_message2.user = None
|
||||
mock_message2.agent = None
|
||||
|
||||
with patch('app.api.widget_chat._own_session', return_value=test_session):
|
||||
with patch.object(ChatRepository, 'get_session_history', return_value=[mock_message1, mock_message2]):
|
||||
with patch('app.api.widget_chat.format_datetime', return_value="2024-01-01T00:00:00Z"):
|
||||
sid = "test_sid"
|
||||
|
||||
await widget_chat.get_widget_chat_history(sid)
|
||||
|
||||
# Verify chat history was emitted with formatted messages
|
||||
expected_messages = [
|
||||
{
|
||||
'message': 'Hello',
|
||||
'message_type': 'user',
|
||||
'timestamp': '2024-01-01T00:00:00Z',
|
||||
'attributes': {},
|
||||
'user_name': None,
|
||||
'agent_name': None,
|
||||
'attachments': []
|
||||
},
|
||||
{
|
||||
'message': 'Hi there!',
|
||||
'message_type': 'bot',
|
||||
'timestamp': '2024-01-01T00:00:00Z',
|
||||
'attributes': {},
|
||||
'user_name': None,
|
||||
'agent_name': None,
|
||||
'attachments': []
|
||||
}
|
||||
]
|
||||
|
||||
mock_sio.emit.assert_called_with(
|
||||
'chat_history',
|
||||
{'messages': expected_messages, 'type': 'chat_history'},
|
||||
to=sid,
|
||||
namespace='/widget'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_widget_chat_history_database_error(self, mock_sio, monkeypatch, test_widget, test_customer, db):
|
||||
"""Test get chat history with database error"""
|
||||
from app.api import widget_chat
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
|
||||
mock_auth_result = (str(test_widget.id), str(test_widget.organization_id), str(test_customer.id), "token")
|
||||
mock_sio.get_session.return_value = {
|
||||
"widget_id": str(test_widget.id),
|
||||
"org_id": str(test_widget.organization_id),
|
||||
"customer_id": str(test_customer.id),
|
||||
"agent_id": str(test_widget.agent_id)
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=mock_auth_result)
|
||||
)
|
||||
|
||||
# Mock database error
|
||||
with patch('app.api.widget_chat._own_session', side_effect=Exception("Database error")):
|
||||
sid = "test_sid"
|
||||
|
||||
await widget_chat.get_widget_chat_history(sid)
|
||||
|
||||
# Should emit chat history error
|
||||
mock_sio.emit.assert_called_with(
|
||||
'error',
|
||||
{'error': 'Failed to get chat history', 'type': 'chat_history_error'},
|
||||
to=sid,
|
||||
namespace='/widget'
|
||||
)
|
||||
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
|
||||
What the widget socket tells the page about the conversation it landed in, and
|
||||
adopting a rotated token on a connection that is already open.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api import widget_chat
|
||||
from app.models.customer import Customer
|
||||
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.services import conversation_token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sio():
|
||||
mock = MagicMock()
|
||||
mock.enter_room = AsyncMock()
|
||||
mock.emit = AsyncMock()
|
||||
mock.save_session = AsyncMock()
|
||||
mock.get_environ = MagicMock()
|
||||
mock.get_session = AsyncMock()
|
||||
mock.leave_room = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def identified_customer(db, test_organization) -> Customer:
|
||||
customer = Customer(
|
||||
organization_id=test_organization.id,
|
||||
email="signed.in@example.com",
|
||||
full_name="Signed In",
|
||||
is_authenticated=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
return customer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connected(db, test_widget, test_ai_config, identified_customer, mock_sio, monkeypatch):
|
||||
"""A widget socket connected as the identified visitor."""
|
||||
token, _, _ = conversation_token.mint(
|
||||
{
|
||||
"sub": str(identified_customer.id),
|
||||
"widget_id": str(test_widget.id),
|
||||
"customer_email": identified_customer.email,
|
||||
},
|
||||
ttl_seconds=600,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=(
|
||||
str(test_widget.id),
|
||||
str(test_widget.organization_id),
|
||||
str(identified_customer.id),
|
||||
token,
|
||||
)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.repositories.widget.WidgetRepository.get_widget",
|
||||
lambda self, widget_id: test_widget,
|
||||
)
|
||||
ai_repo = MagicMock()
|
||||
ai_repo.get_active_config.return_value = test_ai_config
|
||||
monkeypatch.setattr(widget_chat, "AIConfigRepository", lambda db: ai_repo)
|
||||
return token
|
||||
|
||||
|
||||
def _session_initialized(mock_sio) -> dict:
|
||||
for call in mock_sio.emit.call_args_list:
|
||||
if call[0][0] == 'session_initialized':
|
||||
return call[0][1]
|
||||
raise AssertionError("session_initialized was never emitted")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_new_conversation_is_reported_as_created_and_authenticated(mock_sio, connected):
|
||||
assert await widget_chat.widget_connect("sid-1", {}, {}) is True
|
||||
|
||||
payload = _session_initialized(mock_sio)
|
||||
assert payload['created'] is True
|
||||
assert payload['authenticated'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resuming_a_conversation_is_not_reported_as_created(
|
||||
db, test_widget, identified_customer, mock_sio, connected
|
||||
):
|
||||
open_session = SessionToAgent(
|
||||
session_id=uuid4(),
|
||||
customer_id=identified_customer.id,
|
||||
agent_id=test_widget.agent_id,
|
||||
organization_id=test_widget.organization_id,
|
||||
status=SessionStatus.OPEN,
|
||||
)
|
||||
db.add(open_session)
|
||||
db.commit()
|
||||
|
||||
assert await widget_chat.widget_connect("sid-2", {}, {}) is True
|
||||
|
||||
payload = _session_initialized(mock_sio)
|
||||
assert payload['created'] is False
|
||||
assert payload['session_id'] == str(open_session.session_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_anonymous_visitor_is_not_reported_as_authenticated(
|
||||
db, test_widget, test_ai_config, test_customer, mock_sio, monkeypatch
|
||||
):
|
||||
anonymous_token, _, _ = conversation_token.mint(
|
||||
{"sub": str(test_customer.id), "widget_id": str(test_widget.id)}, ttl_seconds=600
|
||||
)
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=(
|
||||
str(test_widget.id), str(test_widget.organization_id),
|
||||
str(test_customer.id), anonymous_token,
|
||||
)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.repositories.widget.WidgetRepository.get_widget",
|
||||
lambda self, widget_id: test_widget,
|
||||
)
|
||||
ai_repo = MagicMock()
|
||||
ai_repo.get_active_config.return_value = test_ai_config
|
||||
monkeypatch.setattr(widget_chat, "AIConfigRepository", lambda db: ai_repo)
|
||||
|
||||
assert await widget_chat.widget_connect("sid-3", {}, {}) is True
|
||||
|
||||
assert _session_initialized(mock_sio)['authenticated'] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rotated_token_replaces_the_one_the_socket_connected_with(
|
||||
test_widget, identified_customer, mock_sio, connected
|
||||
):
|
||||
await widget_chat.widget_connect("sid-4", {}, {})
|
||||
session = mock_sio.save_session.call_args[0][1]
|
||||
mock_sio.get_session.return_value = session
|
||||
rotated, _, _ = conversation_token.mint(
|
||||
{
|
||||
"sub": str(identified_customer.id),
|
||||
"widget_id": str(test_widget.id),
|
||||
"customer_email": identified_customer.email,
|
||||
},
|
||||
ttl_seconds=600,
|
||||
)
|
||||
|
||||
await widget_chat.handle_refresh_token("sid-4", {"conversation_token": rotated})
|
||||
|
||||
assert mock_sio.save_session.call_args[0][1]['conversation_token'] == rotated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_token_for_another_customer_is_refused(
|
||||
db, test_widget, test_customer, mock_sio, connected
|
||||
):
|
||||
await widget_chat.widget_connect("sid-5", {}, {})
|
||||
session = mock_sio.save_session.call_args[0][1]
|
||||
mock_sio.get_session.return_value = session
|
||||
someone_else, _, _ = conversation_token.mint(
|
||||
{"sub": str(test_customer.id), "widget_id": str(test_widget.id)}, ttl_seconds=600
|
||||
)
|
||||
|
||||
await widget_chat.handle_refresh_token("sid-5", {"conversation_token": someone_else})
|
||||
|
||||
# The socket keeps its own conversation; a refresh may never move it to another.
|
||||
assert mock_sio.save_session.call_args[0][1]['conversation_token'] == connected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_conversation_survives_the_token_it_started_with(
|
||||
db, test_widget, identified_customer, mock_sio, connected, monkeypatch
|
||||
):
|
||||
"""The failure this whole path exists for: a chat open longer than its token.
|
||||
|
||||
end_chat re-authenticates from the token the socket connected with, so a
|
||||
conversation whose token was rotated in the page must close on the rotated one -
|
||||
otherwise the visitor's New chat silently does nothing.
|
||||
"""
|
||||
await widget_chat.widget_connect("sid-7", {}, {})
|
||||
session = mock_sio.save_session.call_args[0][1]
|
||||
mock_sio.get_session.return_value = session
|
||||
session_id = session['session_id']
|
||||
|
||||
rotated, _, _ = conversation_token.mint(
|
||||
{
|
||||
"sub": str(identified_customer.id),
|
||||
"widget_id": str(test_widget.id),
|
||||
"customer_email": identified_customer.email,
|
||||
},
|
||||
ttl_seconds=600,
|
||||
)
|
||||
await widget_chat.handle_refresh_token("sid-7", {"conversation_token": rotated})
|
||||
mock_sio.get_session.return_value = mock_sio.save_session.call_args[0][1]
|
||||
|
||||
# The original token is now gone, exactly as it would be past its TTL.
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(side_effect=lambda sid, sess: (
|
||||
(str(test_widget.id), str(test_widget.organization_id),
|
||||
str(identified_customer.id), sess.get('conversation_token'))
|
||||
if sess.get('conversation_token') == rotated
|
||||
else (None, None, None, None)
|
||||
)),
|
||||
)
|
||||
|
||||
await widget_chat.handle_end_chat("sid-7", {"reason": "CUSTOMER_REQUEST"})
|
||||
|
||||
closed = SessionToAgentRepository(db).get_session(session_id)
|
||||
assert closed.status == SessionStatus.CLOSED
|
||||
assert any(call[0][0] == 'chat_ended' for call in mock_sio.emit.call_args_list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_taken_over_conversation_can_still_adopt_a_rotated_token(
|
||||
db, test_widget, test_user, identified_customer, mock_sio, connected
|
||||
):
|
||||
"""A handover lasts far longer than an hour-long token. Refreshing must keep
|
||||
working while a person is handling the chat, or the socket ends up holding a
|
||||
token that no longer authenticates mid-conversation."""
|
||||
await widget_chat.widget_connect("sid-8", {}, {})
|
||||
session = mock_sio.save_session.call_args[0][1]
|
||||
mock_sio.get_session.return_value = session
|
||||
|
||||
# A human agent takes the conversation over.
|
||||
repo = SessionToAgentRepository(db)
|
||||
taken_over = repo.get_session(session['session_id'])
|
||||
taken_over.user_id = test_user.id
|
||||
db.commit()
|
||||
|
||||
rotated, _, _ = conversation_token.mint(
|
||||
{
|
||||
"sub": str(identified_customer.id),
|
||||
"widget_id": str(test_widget.id),
|
||||
"customer_email": identified_customer.email,
|
||||
},
|
||||
ttl_seconds=600,
|
||||
)
|
||||
await widget_chat.handle_refresh_token("sid-8", {"conversation_token": rotated})
|
||||
|
||||
assert mock_sio.save_session.call_args[0][1]['conversation_token'] == rotated
|
||||
assert repo.get_session(session['session_id']).user_id == test_user.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unusable_token_is_refused(mock_sio, connected):
|
||||
await widget_chat.widget_connect("sid-6", {}, {})
|
||||
session = mock_sio.save_session.call_args[0][1]
|
||||
mock_sio.get_session.return_value = session
|
||||
|
||||
await widget_chat.handle_refresh_token("sid-6", {"conversation_token": "not-a-jwt"})
|
||||
|
||||
assert mock_sio.save_session.call_args[0][1]['conversation_token'] == connected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closing_a_chat_does_not_depend_on_the_token_still_being_live(
|
||||
db, mock_sio, connected, monkeypatch
|
||||
):
|
||||
"""The symptom the visitor reported: "Authentication failed" on closing the chat.
|
||||
|
||||
end_chat used to re-verify the conversation token, so a chat open longer than
|
||||
its TTL - or one whose JTI Redis had dropped - could not be closed at all. This
|
||||
kills the token by every measure the old path used and expects the close to go
|
||||
through regardless.
|
||||
"""
|
||||
await widget_chat.widget_connect("sid-closing", {}, {})
|
||||
session = mock_sio.save_session.call_args[0][1]
|
||||
mock_sio.get_session.return_value = session
|
||||
session_id = session['session_id']
|
||||
|
||||
# Expired signature, and a JTI Redis no longer knows about.
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=(None, None, None, None)),
|
||||
)
|
||||
monkeypatch.setattr(widget_chat, "verify_conversation_token", lambda token: None)
|
||||
monkeypatch.setattr(
|
||||
"app.core.security.verify_conversation_token", lambda token: None
|
||||
)
|
||||
|
||||
await widget_chat.handle_end_chat("sid-closing", {"reason": "CUSTOMER_REQUEST"})
|
||||
|
||||
closed = SessionToAgentRepository(db).get_session(session_id)
|
||||
assert closed.status == SessionStatus.CLOSED
|
||||
assert any(call[0][0] == 'chat_ended' for call in mock_sio.emit.call_args_list)
|
||||
assert not _auth_errors(mock_sio)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_contact_form_gets_past_the_guard_after_the_token_lapses(
|
||||
mock_sio, connected, monkeypatch
|
||||
):
|
||||
"""The handoff contact form posts on a connection that may be hours old.
|
||||
|
||||
Refusing it at the identity guard would lose the email a person is waiting on
|
||||
to follow up. This covers the guard only - what the handler then does with the
|
||||
address is the subject of the contact-capture tests.
|
||||
"""
|
||||
await widget_chat.widget_connect("sid-contact", {}, {})
|
||||
session = mock_sio.save_session.call_args[0][1]
|
||||
mock_sio.get_session.return_value = session
|
||||
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=(None, None, None, None)),
|
||||
)
|
||||
|
||||
# An address the handler must reject on its own merits: reaching the
|
||||
# validation error proves it got past the identity guard.
|
||||
await widget_chat.handle_contact_info(
|
||||
"sid-contact", {"form_data": {"email": "not-an-address"}}
|
||||
)
|
||||
|
||||
assert not _auth_errors(mock_sio)
|
||||
assert any(
|
||||
call[0][0] == 'error' and call[0][1].get('type') == 'validation_error'
|
||||
for call in mock_sio.emit.call_args_list
|
||||
)
|
||||
|
||||
|
||||
# Every widget event that used to re-verify the conversation token, with an
|
||||
# argument shape each one accepts. What the handler goes on to do differs; what
|
||||
# they must agree on is that a lapsed token is not a reason to refuse.
|
||||
GUARDED_HANDLERS = [
|
||||
("chat", lambda sid: widget_chat.handle_widget_chat(sid, {"message": "hello"})),
|
||||
("get_chat_history", lambda sid: widget_chat.get_widget_chat_history(sid)),
|
||||
("end_chat", lambda sid: widget_chat.handle_end_chat(sid, {"reason": "CUSTOMER_REQUEST"})),
|
||||
("submit_rating", lambda sid: widget_chat.handle_rating_submission(sid, {"rating": 5})),
|
||||
("get_workflow_state", lambda sid: widget_chat.handle_get_workflow_state(sid)),
|
||||
("proceed_workflow", lambda sid: widget_chat.handle_proceed_workflow(sid, {})),
|
||||
("submit_contact_info", lambda sid: widget_chat.handle_contact_info(
|
||||
sid, {"form_data": {"email": "someone@example.com"}})),
|
||||
("submit_form", lambda sid: widget_chat.handle_form_submission(
|
||||
sid, {"form_data": {"name": "Someone"}})),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("name,call", GUARDED_HANDLERS, ids=[h[0] for h in GUARDED_HANDLERS])
|
||||
async def test_no_widget_event_refuses_a_live_connection_over_a_lapsed_token(
|
||||
name, call, mock_sio, connected, monkeypatch
|
||||
):
|
||||
"""One rule, eight handlers: the token authenticates the connection, and a
|
||||
connection that was authenticated stays authenticated for its lifetime.
|
||||
|
||||
Anything else and a chat open longer than its TTL starts failing an event at a
|
||||
time - which is how this reached us, as messages that stopped sending and a
|
||||
New chat button that did nothing (#315).
|
||||
"""
|
||||
await widget_chat.widget_connect(f"sid-{name}", {}, {})
|
||||
session = mock_sio.save_session.call_args[0][1]
|
||||
mock_sio.get_session.return_value = session
|
||||
mock_sio.emit.reset_mock()
|
||||
|
||||
# Dead by every measure the old per-event check used.
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=(None, None, None, None)),
|
||||
)
|
||||
monkeypatch.setattr(widget_chat, "verify_conversation_token", lambda token: None)
|
||||
|
||||
await call(f"sid-{name}")
|
||||
|
||||
assert not _auth_errors(mock_sio), f"{name} refused a live connection"
|
||||
|
||||
|
||||
def _auth_errors(mock_sio) -> list:
|
||||
return [
|
||||
call for call in mock_sio.emit.call_args_list
|
||||
if call[0][0] == 'error' and call[0][1].get('type') == 'auth_error'
|
||||
]
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 Mock, MagicMock, patch, AsyncMock
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.models.workflow import Workflow, WorkflowStatus
|
||||
from app.models.workflow_node import WorkflowNode, NodeType
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from uuid import UUID, uuid4
|
||||
from app.services.conversation_token import VERIFIED_SCOPE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_organization(db):
|
||||
"""Create a test organization"""
|
||||
organization = Organization(
|
||||
id=uuid4(),
|
||||
name="Test Organization",
|
||||
domain="test.com"
|
||||
)
|
||||
db.add(organization)
|
||||
db.commit()
|
||||
db.refresh(organization)
|
||||
return organization
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db, test_organization):
|
||||
"""Create a test user"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="test@example.com",
|
||||
hashed_password="hashed_password",
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
full_name="Test User"
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent(db, test_organization):
|
||||
"""Create a test agent with workflow enabled"""
|
||||
agent = Agent(
|
||||
id=uuid4(),
|
||||
name="Test Agent",
|
||||
display_name="Test Agent",
|
||||
description="Test agent with workflow",
|
||||
agent_type=AgentType.CUSTOMER_SUPPORT,
|
||||
instructions=["Test instruction"],
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
use_workflow=True
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_workflow(db, test_organization, test_agent, test_user):
|
||||
"""Create a test workflow"""
|
||||
workflow = Workflow(
|
||||
id=uuid4(),
|
||||
name="Test Workflow",
|
||||
description="Test workflow",
|
||||
status=WorkflowStatus.PUBLISHED,
|
||||
version=1,
|
||||
is_template=False,
|
||||
default_language="en",
|
||||
canvas_data={
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "start",
|
||||
"data": {"label": "Start"}
|
||||
}
|
||||
]
|
||||
},
|
||||
settings={},
|
||||
organization_id=test_organization.id,
|
||||
agent_id=test_agent.id,
|
||||
created_by=test_user.id
|
||||
)
|
||||
db.add(workflow)
|
||||
db.commit()
|
||||
db.refresh(workflow)
|
||||
|
||||
# Update agent to use this workflow
|
||||
test_agent.active_workflow_id = workflow.id
|
||||
db.commit()
|
||||
|
||||
return workflow
|
||||
|
||||
|
||||
class TestWidgetChatWorkflow:
|
||||
|
||||
def test_agent_has_workflow_enabled(self, test_agent, test_workflow):
|
||||
"""Test that the agent has workflow properly configured"""
|
||||
assert test_agent.use_workflow is True
|
||||
assert test_agent.active_workflow_id == test_workflow.id
|
||||
|
||||
def test_workflow_is_published(self, test_workflow):
|
||||
"""Test that the workflow is in published status"""
|
||||
assert test_workflow.status == WorkflowStatus.PUBLISHED
|
||||
assert test_workflow.canvas_data is not None
|
||||
assert "nodes" in test_workflow.canvas_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_execution_service_mock(self, test_agent, test_workflow):
|
||||
"""Test that WorkflowExecutionService can be mocked properly"""
|
||||
from app.services.workflow_execution import WorkflowExecutionService
|
||||
|
||||
with patch.object(WorkflowExecutionService, '__init__', return_value=None) as mock_init, \
|
||||
patch.object(WorkflowExecutionService, 'execute_workflow', new_callable=AsyncMock) as mock_execute:
|
||||
|
||||
mock_execute.return_value = {
|
||||
"success": True,
|
||||
"message": "Workflow executed",
|
||||
"next_node": None
|
||||
}
|
||||
|
||||
# Create service instance
|
||||
service = WorkflowExecutionService(None)
|
||||
|
||||
# Call execute_workflow
|
||||
result = await service.execute_workflow(
|
||||
session_id="test_session",
|
||||
message="Hello",
|
||||
agent_id=test_agent.id,
|
||||
workflow_id=test_workflow.id
|
||||
)
|
||||
|
||||
# Verify the mock was called and returned expected result
|
||||
mock_execute.assert_called_once()
|
||||
assert result["success"] is True
|
||||
assert result["message"] == "Workflow executed"
|
||||
|
||||
def test_workflow_data_structure(self, test_workflow):
|
||||
"""Test that workflow has proper data structure"""
|
||||
assert isinstance(test_workflow.canvas_data, dict)
|
||||
assert "nodes" in test_workflow.canvas_data
|
||||
assert len(test_workflow.canvas_data["nodes"]) >= 1
|
||||
|
||||
# Check first node structure
|
||||
first_node = test_workflow.canvas_data["nodes"][0]
|
||||
assert "id" in first_node
|
||||
assert "type" in first_node
|
||||
assert "data" in first_node
|
||||
|
||||
def test_agent_workflow_relationship(self, test_agent, test_workflow):
|
||||
"""Test the relationship between agent and workflow"""
|
||||
assert test_agent.active_workflow_id == test_workflow.id
|
||||
assert test_workflow.agent_id == test_agent.id
|
||||
assert test_agent.organization_id == test_workflow.organization_id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def one_message_session(db, test_workflow, test_agent, test_customer, test_ai_config, monkeypatch):
|
||||
"""A widget session on a workflow that is a single MESSAGE node."""
|
||||
from app.api import widget_chat
|
||||
from app.services import message_delivery
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
|
||||
db.add(WorkflowNode(
|
||||
id=uuid4(),
|
||||
workflow_id=test_workflow.id,
|
||||
node_type=NodeType.MESSAGE,
|
||||
name="Greeting",
|
||||
config={"message_text": "Hello from the workflow"},
|
||||
))
|
||||
db.commit()
|
||||
|
||||
session_id = uuid4()
|
||||
active_session = SessionToAgentRepository(db).create_session(
|
||||
session_id=session_id,
|
||||
agent_id=test_agent.id,
|
||||
customer_id=test_customer.id,
|
||||
organization_id=test_agent.organization_id,
|
||||
)
|
||||
assert active_session.workflow_id == test_workflow.id
|
||||
|
||||
sio = MagicMock()
|
||||
sio.emit = AsyncMock()
|
||||
sio.get_session = AsyncMock(return_value={
|
||||
"widget_id": str(uuid4()),
|
||||
"org_id": str(test_agent.organization_id),
|
||||
"agent_id": str(test_agent.id),
|
||||
"customer_id": str(test_customer.id),
|
||||
"session_id": str(session_id),
|
||||
"ai_config": test_ai_config,
|
||||
# A keyless session is resumed by a token the embedding app vouched for.
|
||||
"conversation_scope": VERIFIED_SCOPE,
|
||||
})
|
||||
monkeypatch.setattr(widget_chat, "sio", sio)
|
||||
monkeypatch.setattr(message_delivery, "sio", sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
monkeypatch.setattr(widget_chat, "notify_new_chat", AsyncMock())
|
||||
return sio, str(session_id)
|
||||
|
||||
|
||||
def _chat_responses(sio):
|
||||
return [c.args[1]["message"] for c in sio.emit.call_args_list if c.args[0] == "chat_response"]
|
||||
|
||||
|
||||
def _stored_bot_messages(db, session_id):
|
||||
return [m.message for m in db.query(ChatHistory).filter(
|
||||
ChatHistory.session_id == UUID(session_id), ChatHistory.message_type == "bot").all()]
|
||||
|
||||
|
||||
class TestOneMessageWorkflowIsSentOnce:
|
||||
"""A workflow ending on a MESSAGE node used to send and store that message
|
||||
twice: once as an intermediate message and once as the final reply."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_load(self, db, one_message_session):
|
||||
from app.api import widget_chat
|
||||
sio, session_id = one_message_session
|
||||
|
||||
await widget_chat.handle_get_workflow_state("test_sid")
|
||||
|
||||
assert _chat_responses(sio) == ["Hello from the workflow"]
|
||||
states = [c.args[1] for c in sio.emit.call_args_list if c.args[0] == "workflow_state"]
|
||||
assert [(s["type"], s["message"]) for s in states] == [("message", "Hello from the workflow")]
|
||||
assert _stored_bot_messages(db, session_id) == ["Hello from the workflow"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_first_message(self, db, one_message_session):
|
||||
from app.api import widget_chat
|
||||
sio, session_id = one_message_session
|
||||
|
||||
await widget_chat.handle_widget_chat("test_sid", {"message": "hi"})
|
||||
|
||||
assert _chat_responses(sio) == ["Hello from the workflow"]
|
||||
assert _stored_bot_messages(db, session_id) == ["Hello from the workflow"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 pydantic import ValidationError
|
||||
|
||||
from app.models.schemas.agent_customization import CustomizationCreate
|
||||
|
||||
|
||||
def _create(metadata):
|
||||
return CustomizationCreate(customization_metadata=metadata)
|
||||
|
||||
|
||||
class TestWidgetDisplayValidation:
|
||||
def test_valid_full_config_is_normalized(self):
|
||||
result = _create({
|
||||
"widget_display": {
|
||||
"mode": "sidebar-left",
|
||||
"side": "left",
|
||||
"launcher": False,
|
||||
"width": 420,
|
||||
"height": 700,
|
||||
"sidebar_width": 480,
|
||||
"search_placeholder": "Ask us anything",
|
||||
"offset_bottom": 40,
|
||||
"offset_side": 24,
|
||||
"z_index": 5000,
|
||||
"junk_key": "dropped",
|
||||
}
|
||||
})
|
||||
display = result.customization_metadata["widget_display"]
|
||||
assert display["mode"] == "sidebar-left"
|
||||
assert display["launcher"] is False
|
||||
assert display["sidebar_width"] == 480
|
||||
# Unknown keys are dropped on write.
|
||||
assert "junk_key" not in display
|
||||
|
||||
def test_partial_config_stores_only_set_keys(self):
|
||||
result = _create({"widget_display": {"mode": "search-bar"}})
|
||||
assert result.customization_metadata["widget_display"] == {"mode": "search-bar"}
|
||||
|
||||
def test_metadata_without_widget_display_passes_through(self):
|
||||
result = _create({"avatar_style": "orb"})
|
||||
assert result.customization_metadata == {"avatar_style": "orb"}
|
||||
|
||||
def test_explicit_null_clears_settings(self):
|
||||
result = _create({"widget_display": None, "avatar_style": "orb"})
|
||||
assert result.customization_metadata["widget_display"] is None
|
||||
|
||||
def test_other_metadata_keys_survive_alongside(self):
|
||||
result = _create({
|
||||
"avatar_style": "orb",
|
||||
"widget_display": {"side": "right"},
|
||||
})
|
||||
assert result.customization_metadata["avatar_style"] == "orb"
|
||||
assert result.customization_metadata["widget_display"] == {"side": "right"}
|
||||
|
||||
@pytest.mark.parametrize("bad_display", [
|
||||
{"mode": "popup"}, # unknown mode
|
||||
{"side": "top"}, # unknown side
|
||||
{"width": 100}, # below minimum
|
||||
{"width": 2000}, # above maximum
|
||||
{"height": 200}, # below minimum
|
||||
{"sidebar_width": 10_000}, # above maximum
|
||||
{"offset_bottom": -5}, # negative offset
|
||||
{"offset_side": 9999}, # above maximum
|
||||
{"z_index": 0}, # below minimum
|
||||
{"search_placeholder": "x" * 200}, # too long
|
||||
{"launcher": "maybe"}, # not bool-coercible
|
||||
"not-a-dict", # wrong shape entirely
|
||||
])
|
||||
def test_invalid_values_rejected(self, bad_display):
|
||||
with pytest.raises(ValidationError):
|
||||
_create({"widget_display": bad_display})
|
||||
|
||||
def test_no_metadata_is_fine(self):
|
||||
assert CustomizationCreate().customization_metadata == {}
|
||||
|
||||
def test_allow_new_chat_defaults_off_and_round_trips(self):
|
||||
"""Off by default: starting a new chat closes the session, which would cut
|
||||
off a human agent mid-handover."""
|
||||
assert CustomizationCreate().allow_new_chat is False
|
||||
assert CustomizationCreate(allow_new_chat=True).allow_new_chat is True
|
||||
|
||||
def test_normalized_values_are_json_primitives(self):
|
||||
"""The dict is stored in a JSON column — enum members must not leak in."""
|
||||
import json
|
||||
|
||||
result = _create({"widget_display": {"mode": "sidebar-left", "side": "left"}})
|
||||
display = result.customization_metadata["widget_display"]
|
||||
assert type(display["mode"]) is str
|
||||
assert type(display["side"]) is str
|
||||
# Round-trips through the stdlib serializer the DB engine uses.
|
||||
assert json.loads(json.dumps(display)) == display
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
|
||||
POST /widgets/{widget_id}/end-chat — the endpoint the widget calls to close a
|
||||
conversation. It shipped untested, and a client bug calling it on every message
|
||||
closed live chats the moment a human agent joined.
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import widget as widget_router
|
||||
from app.core.security import create_conversation_token
|
||||
from app.database import get_db
|
||||
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(widget_router.router, prefix="/api/v1/widgets")
|
||||
|
||||
def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
BROWSER_KEY = "browser-a"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def widget_token(test_widget, test_customer) -> str:
|
||||
"""The token the widget issued to the browser that opened the session."""
|
||||
return create_conversation_token(
|
||||
widget_id=str(test_widget.id), customer_id=str(test_customer.id), ckey=BROWSER_KEY
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_session(db, test_widget, test_customer, test_agent) -> SessionToAgent:
|
||||
session = SessionToAgent(
|
||||
session_id=uuid4(),
|
||||
customer_id=test_customer.id,
|
||||
agent_id=test_agent.id,
|
||||
organization_id=test_customer.organization_id,
|
||||
status=SessionStatus.OPEN,
|
||||
conversation_key=BROWSER_KEY,
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
def _url(widget_id, session_id, **params) -> str:
|
||||
query = "".join(f"&{k}={v}" for k, v in params.items())
|
||||
return f"/api/v1/widgets/{widget_id}/end-chat?session_id={session_id}{query}"
|
||||
|
||||
|
||||
def _auth(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_closes_the_session(client, db, test_widget, open_session, widget_token):
|
||||
response = client.post(
|
||||
_url(test_widget.id, open_session.session_id, reason="CUSTOMER_REQUEST"),
|
||||
headers=_auth(widget_token),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.CLOSED
|
||||
|
||||
|
||||
def test_requires_a_token(client, db, test_widget, open_session):
|
||||
"""Regression: the token block used to be optional, so a request with no
|
||||
Authorization header fell through and closed any session by id."""
|
||||
response = client.post(_url(test_widget.id, open_session.session_id))
|
||||
|
||||
assert response.status_code == 401
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.OPEN
|
||||
|
||||
|
||||
def test_rejects_a_garbage_token(client, db, test_widget, open_session):
|
||||
response = client.post(
|
||||
_url(test_widget.id, open_session.session_id),
|
||||
headers=_auth("not-a-jwt"),
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.OPEN
|
||||
|
||||
|
||||
def test_rejects_a_token_issued_for_another_widget(client, db, test_widget, open_session, test_customer):
|
||||
other_widget_token = create_conversation_token(
|
||||
widget_id=str(uuid4()), customer_id=str(test_customer.id)
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
_url(test_widget.id, open_session.session_id),
|
||||
headers=_auth(other_widget_token),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.OPEN
|
||||
|
||||
|
||||
def test_cannot_close_another_customers_session(client, db, test_widget, open_session):
|
||||
"""The token proves who the caller is, not that the session is theirs."""
|
||||
stranger_token = create_conversation_token(
|
||||
widget_id=str(test_widget.id), customer_id=str(uuid4())
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
_url(test_widget.id, open_session.session_id),
|
||||
headers=_auth(stranger_token),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.OPEN
|
||||
|
||||
|
||||
def test_cannot_close_another_browsers_session_of_the_same_customer(
|
||||
client, db, test_widget, open_session, test_customer
|
||||
):
|
||||
"""Naming the customer is not enough: a token the widget issued to another
|
||||
browser (e.g. after someone typed this customer's email) is out of scope."""
|
||||
other_browser_token = create_conversation_token(
|
||||
widget_id=str(test_widget.id), customer_id=str(test_customer.id), ckey="browser-b"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
_url(test_widget.id, open_session.session_id),
|
||||
headers=_auth(other_browser_token),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.OPEN
|
||||
|
||||
|
||||
def test_unknown_session_is_not_found(client, test_widget, widget_token):
|
||||
response = client.post(
|
||||
_url(test_widget.id, uuid4()),
|
||||
headers=_auth(widget_token),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
|
||||
Keeping an identified visitor identified.
|
||||
|
||||
A visitor the embedding app vouched for used to become a brand-new anonymous
|
||||
customer the moment their token lapsed and anything re-checked it - which, on a page
|
||||
left open, is exactly when they start a new chat. The widget now rotates a live token
|
||||
before it expires, and a lapsed one is refused rather than quietly replaced.
|
||||
"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import token as token_router
|
||||
from app.api import widget as widget_router
|
||||
from app.database import get_db
|
||||
from app.models.customer import Customer
|
||||
from app.services import conversation_token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(widget_router.router, prefix="/api/v1/widgets")
|
||||
app.include_router(token_router.router, prefix="/api/v1")
|
||||
|
||||
def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def identified_customer(db, test_organization) -> Customer:
|
||||
"""The customer POST /generate-token creates for a signed-in visitor."""
|
||||
customer = Customer(
|
||||
organization_id=test_organization.id,
|
||||
email="signed.in@example.com",
|
||||
full_name="Signed In",
|
||||
meta_data={"plan": "pro"},
|
||||
is_authenticated=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
return customer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def identified_token(test_widget, identified_customer) -> str:
|
||||
token, _, _ = conversation_token.mint(
|
||||
{
|
||||
"sub": str(identified_customer.id),
|
||||
"customer_id": str(identified_customer.id),
|
||||
"widget_id": str(test_widget.id),
|
||||
"customer_email": identified_customer.email,
|
||||
"customer_name": identified_customer.full_name,
|
||||
"custom_data": {"plan": "pro"},
|
||||
},
|
||||
ttl_seconds=600,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_has_lapsed(monkeypatch):
|
||||
"""Every token's JTI is gone from Redis - expired, or revoked."""
|
||||
monkeypatch.setattr(conversation_token, "_is_token_in_redis", lambda jti: False)
|
||||
|
||||
|
||||
def _auth(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
class TestRefreshToken:
|
||||
def test_rotates_a_live_token_and_keeps_the_visitor(self, client, test_widget, identified_token):
|
||||
response = client.post(
|
||||
"/api/v1/refresh-token",
|
||||
json={"widget_id": str(test_widget.id)},
|
||||
headers=_auth(identified_token),
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
rotated = response.json()["data"]["token"]
|
||||
assert rotated != identified_token
|
||||
|
||||
claims = conversation_token.inspect(rotated, str(test_widget.id)).payload
|
||||
assert claims["customer_email"] == "signed.in@example.com"
|
||||
assert claims["custom_data"] == {"plan": "pro"}
|
||||
assert response.json()["data"]["expires_in"] == 600
|
||||
|
||||
def test_refuses_a_lapsed_token_instead_of_issuing_a_new_identity(
|
||||
self, client, test_widget, identified_token, token_has_lapsed
|
||||
):
|
||||
response = client.post(
|
||||
"/api/v1/refresh-token",
|
||||
json={"widget_id": str(test_widget.id)},
|
||||
headers=_auth(identified_token),
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"]["code"] == conversation_token.IDENTITY_EXPIRED_CODE
|
||||
|
||||
def test_refuses_a_token_issued_for_another_widget(self, client, identified_token):
|
||||
response = client.post(
|
||||
"/api/v1/refresh-token",
|
||||
json={"widget_id": "some-other-widget"},
|
||||
headers=_auth(identified_token),
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_requires_a_token(self, client, test_widget):
|
||||
response = client.post("/api/v1/refresh-token", json={"widget_id": str(test_widget.id)})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"]["code"] == conversation_token.INVALID_TOKEN_CODE
|
||||
|
||||
|
||||
class TestWidgetDataIdentity:
|
||||
def test_keeps_the_identity_of_a_live_token(self, client, test_widget, identified_token):
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}", headers=_auth(identified_token))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
# No replacement token: the widget keeps the identity it arrived with.
|
||||
assert not response.json().get("token")
|
||||
|
||||
def test_does_not_swap_a_lapsed_identity_for_an_anonymous_customer(
|
||||
self, client, db, test_widget, identified_token, token_has_lapsed
|
||||
):
|
||||
customers_before = db.query(Customer).count()
|
||||
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}", headers=_auth(identified_token))
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"]["code"] == conversation_token.IDENTITY_EXPIRED_CODE
|
||||
# The silent "@noemail.com" customer is the bug; there must be no new one.
|
||||
assert db.query(Customer).count() == customers_before
|
||||
|
||||
def test_a_visitor_with_no_token_still_starts_anonymously(self, client, test_widget):
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["token"]
|
||||
|
||||
def test_visitors_arriving_in_the_same_millisecond_each_get_a_customer(
|
||||
self, client, db, test_widget, monkeypatch
|
||||
):
|
||||
# The placeholder email used to be the clock in ms, so the second visitor
|
||||
# hit the (email, org) unique key and their widget never loaded.
|
||||
monkeypatch.setattr(time, "time", lambda: 1_791_833_067.582)
|
||||
customers_before = db.query(Customer).count()
|
||||
|
||||
first = client.get(f"/api/v1/widgets/{test_widget.id}")
|
||||
second = client.get(f"/api/v1/widgets/{test_widget.id}")
|
||||
|
||||
assert first.status_code == 200, first.text
|
||||
assert second.status_code == 200, second.text
|
||||
assert db.query(Customer).count() == customers_before + 2
|
||||
|
||||
def test_widget_frame_refuses_to_render_a_lapsed_identity(
|
||||
self, client, test_widget, identified_token, token_has_lapsed
|
||||
):
|
||||
response = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}/data", headers=_auth(identified_token)
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"]["code"] == conversation_token.IDENTITY_EXPIRED_CODE
|
||||
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
|
||||
A widget visitor can be bound to an existing customer by an email they only
|
||||
typed. That keeps one person in the inbox, but must never hand them that
|
||||
person's conversations: a widget-issued token reaches only the conversations
|
||||
started from its own browser, and may not stand for a person the embedding app
|
||||
has signed in.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from socketio.exceptions import ConnectionRefusedError
|
||||
|
||||
from app.api import token as token_router
|
||||
from app.api import widget as widget_router
|
||||
from app.api import widget_chat
|
||||
from app.core.security import create_conversation_token
|
||||
from app.database import get_db
|
||||
from app.models.customer import Customer
|
||||
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||||
from app.repositories.customer import CustomerRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.services import conversation_token
|
||||
from app.services.contact_capture import CONTACT_EMAIL_META_KEY
|
||||
|
||||
VICTIM_BROWSER = "victim-browser"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(widget_router.router, prefix="/api/v1/widgets")
|
||||
app.include_router(token_router.router, prefix="/api/v1")
|
||||
|
||||
def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sio():
|
||||
mock = MagicMock()
|
||||
mock.enter_room = AsyncMock()
|
||||
mock.emit = AsyncMock()
|
||||
mock.save_session = AsyncMock()
|
||||
mock.get_session = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def socket(db, test_widget, test_ai_config, mock_sio, monkeypatch):
|
||||
"""Connect a widget socket with a given token; returns its saved session."""
|
||||
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
monkeypatch.setattr(
|
||||
"app.repositories.widget.WidgetRepository.get_widget",
|
||||
lambda self, widget_id: test_widget,
|
||||
)
|
||||
ai_repo = MagicMock()
|
||||
ai_repo.get_active_config.return_value = test_ai_config
|
||||
monkeypatch.setattr(widget_chat, "AIConfigRepository", lambda db: ai_repo)
|
||||
|
||||
async def connect(token: str, sid: str):
|
||||
claims = conversation_token.claims_ignoring_expiry(token)
|
||||
monkeypatch.setattr(
|
||||
widget_chat,
|
||||
"authenticate_socket_conversation_token",
|
||||
AsyncMock(return_value=(
|
||||
str(test_widget.id), str(test_widget.organization_id), claims.get("sub"), token,
|
||||
)),
|
||||
)
|
||||
mock_sio.save_session.reset_mock()
|
||||
if not await widget_chat.widget_connect(sid, {}, {}):
|
||||
return None
|
||||
return mock_sio.save_session.call_args[0][1]
|
||||
|
||||
return connect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def victim_session(db, test_widget, test_customer, test_user) -> SessionToAgent:
|
||||
"""The victim's live conversation, with a human agent on it."""
|
||||
session = SessionToAgent(
|
||||
session_id=uuid4(),
|
||||
customer_id=test_customer.id,
|
||||
agent_id=test_widget.agent_id,
|
||||
organization_id=test_widget.organization_id,
|
||||
user_id=test_user.id,
|
||||
status=SessionStatus.OPEN,
|
||||
conversation_key=VICTIM_BROWSER,
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
return session
|
||||
|
||||
|
||||
def _victim_token(test_widget, test_customer) -> str:
|
||||
return create_conversation_token(
|
||||
widget_id=str(test_widget.id), customer_id=str(test_customer.id), ckey=VICTIM_BROWSER
|
||||
)
|
||||
|
||||
|
||||
def _customer_of(data: dict) -> str:
|
||||
"""The customer a GET /widgets response bound the visitor to."""
|
||||
return conversation_token.claims_ignoring_expiry(data["token"])["sub"]
|
||||
|
||||
|
||||
def _emitted(mock_sio, event: str) -> dict:
|
||||
for call in reversed(mock_sio.emit.call_args_list):
|
||||
if call[0][0] == event:
|
||||
return call[0][1]
|
||||
raise AssertionError(f"{event} was never emitted")
|
||||
|
||||
|
||||
class TestEmailDoesNotOpenAnotherBrowsersConversation:
|
||||
"""GHSA-rfh4-6ghv-m2rr: GET /widgets/{id}?email=<victim>."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_reported_takeover_lands_in_a_new_empty_conversation(
|
||||
self, client, socket, mock_sio, test_widget, test_customer, victim_session
|
||||
):
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email})
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
|
||||
# Still one person in the inbox...
|
||||
assert _customer_of(data) == str(test_customer.id)
|
||||
# ...but nothing of theirs: not even who is helping them.
|
||||
assert not (data["human_agent"] or {}).get("human_agent_name")
|
||||
claims = conversation_token.inspect(data["token"], str(test_widget.id)).payload
|
||||
assert claims["ckey"] != VICTIM_BROWSER
|
||||
|
||||
attacker = await socket(data["token"], "attacker")
|
||||
assert attacker["session_id"] != str(victim_session.session_id)
|
||||
assert _emitted(mock_sio, "session_initialized")["created"] is True
|
||||
joined = [call[0][1] for call in mock_sio.enter_room.call_args_list]
|
||||
assert str(victim_session.session_id) not in joined
|
||||
|
||||
mock_sio.get_session.return_value = attacker
|
||||
await widget_chat.get_widget_chat_history("attacker")
|
||||
assert _emitted(mock_sio, "chat_history")["messages"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_victim_still_lands_in_their_own_conversation(
|
||||
self, client, socket, test_widget, test_customer, victim_session
|
||||
):
|
||||
attacker_token = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}
|
||||
).json()["token"]
|
||||
await socket(attacker_token, "attacker")
|
||||
|
||||
victim = await socket(_victim_token(test_widget, test_customer), "victim")
|
||||
|
||||
assert victim["session_id"] == str(victim_session.session_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_token_issued_before_conversation_keys_reaches_no_existing_conversation(
|
||||
self, db, socket, test_widget, test_customer
|
||||
):
|
||||
keyless = SessionToAgent(
|
||||
session_id=uuid4(), customer_id=test_customer.id, agent_id=test_widget.agent_id,
|
||||
organization_id=test_widget.organization_id, status=SessionStatus.OPEN,
|
||||
)
|
||||
db.add(keyless)
|
||||
db.commit()
|
||||
legacy_token = create_conversation_token(
|
||||
widget_id=str(test_widget.id), customer_id=str(test_customer.id)
|
||||
)
|
||||
|
||||
connected = await socket(legacy_token, "legacy")
|
||||
|
||||
assert connected["session_id"] != str(keyless.session_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unverifiable_token_is_refused(self, socket, mock_sio, test_widget, test_customer, monkeypatch):
|
||||
monkeypatch.setattr(widget_chat, "verify_conversation_token", lambda token: None)
|
||||
|
||||
assert await socket(_victim_token(test_widget, test_customer), "sid") is None
|
||||
assert _emitted(mock_sio, "error")["type"] == "connection_error"
|
||||
|
||||
|
||||
class TestOwnConversationOnly:
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_closed_conversation_reopens_despite_a_newer_one_from_another_browser(
|
||||
self, db, socket, mock_sio, test_widget, test_customer, victim_session, monkeypatch
|
||||
):
|
||||
victim = await socket(_victim_token(test_widget, test_customer), "victim")
|
||||
victim_session.status = SessionStatus.CLOSED
|
||||
victim_session.user_id = None
|
||||
victim_session.assigned_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
db.add(SessionToAgent(
|
||||
session_id=uuid4(), customer_id=test_customer.id, agent_id=test_widget.agent_id,
|
||||
organization_id=test_widget.organization_id, status=SessionStatus.OPEN,
|
||||
conversation_key="someone-else", assigned_at=datetime.now(timezone.utc),
|
||||
))
|
||||
db.commit()
|
||||
monkeypatch.setattr(widget_chat, "notify_new_chat", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
widget_chat.ChatAgent, "create_async", AsyncMock(side_effect=RuntimeError("stop here"))
|
||||
)
|
||||
|
||||
mock_sio.get_session.return_value = victim
|
||||
await widget_chat.handle_widget_chat("victim", {"message": "still there?"})
|
||||
|
||||
db.refresh(victim_session)
|
||||
assert victim_session.status == SessionStatus.OPEN
|
||||
|
||||
def test_there_is_no_lookup_of_a_conversation_by_customer_alone(self):
|
||||
assert not hasattr(SessionToAgentRepository, "get_active_customer_session")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_claimed_email_cannot_rename_the_customer(
|
||||
self, db, client, socket, mock_sio, test_widget, test_customer, victim_session
|
||||
):
|
||||
test_customer.full_name = "Real Name"
|
||||
db.commit()
|
||||
attacker_token = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}
|
||||
).json()["token"]
|
||||
attacker = await socket(attacker_token, "attacker")
|
||||
|
||||
mock_sio.get_session.return_value = attacker
|
||||
await widget_chat.handle_contact_info("attacker", {"form_data": {"name": "Attacker"}})
|
||||
|
||||
db.refresh(test_customer)
|
||||
assert test_customer.full_name == "Real Name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_claimed_email_cannot_leave_a_follow_up_address(
|
||||
self, db, client, socket, mock_sio, test_widget, test_customer, victim_session
|
||||
):
|
||||
attacker_token = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}
|
||||
).json()["token"]
|
||||
attacker = await socket(attacker_token, "attacker")
|
||||
|
||||
mock_sio.get_session.return_value = attacker
|
||||
await widget_chat.handle_contact_info("attacker", {"form_data": {"email": "attacker@evil.com"}})
|
||||
|
||||
db.refresh(test_customer)
|
||||
assert CONTACT_EMAIL_META_KEY not in (test_customer.meta_data or {})
|
||||
|
||||
|
||||
class TestMergedToken:
|
||||
"""A device token naming a customer that lead capture merged into another."""
|
||||
|
||||
def test_a_token_for_a_merged_customer_is_judged_on_the_survivor(
|
||||
self, db, client, test_widget, test_organization
|
||||
):
|
||||
survivor = Customer(email="member@acme.com", organization_id=test_organization.id,
|
||||
is_authenticated=True)
|
||||
db.add(survivor); db.commit(); db.refresh(survivor)
|
||||
merged = Customer(email="123@noemail.com", organization_id=test_organization.id,
|
||||
conversation_key="browser-a", merged_into_customer_id=survivor.id)
|
||||
db.add(merged); db.commit(); db.refresh(merged)
|
||||
token = create_conversation_token(
|
||||
widget_id=str(test_widget.id), customer_id=str(merged.id), ckey="browser-a")
|
||||
|
||||
data = client.get(f"/api/v1/widgets/{test_widget.id}",
|
||||
headers={"Authorization": f"Bearer {token}"}).json()
|
||||
|
||||
# Swapped now, rather than accepted here and refused on every connect.
|
||||
assert data["token"]
|
||||
assert _customer_of(data) not in (str(survivor.id), str(merged.id))
|
||||
|
||||
|
||||
class TestPreClaimedAddress:
|
||||
"""An address claimed through the widget before its owner signs in."""
|
||||
|
||||
@pytest.fixture
|
||||
def pre_claim(self, db, client, test_widget, test_organization):
|
||||
"""The attacker claims new@x; later the app signs the real user in."""
|
||||
data = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}", params={"email": "new.user@example.com"}
|
||||
).json()
|
||||
claimed = CustomerRepository(db).get_by_id(_customer_of(data))
|
||||
assert claimed.email == "new.user@example.com"
|
||||
|
||||
signed_in = CustomerRepository(db).identify("new.user@example.com", test_organization.id)
|
||||
assert signed_in.id == claimed.id and signed_in.is_authenticated
|
||||
users_chat = SessionToAgent(
|
||||
session_id=uuid4(), customer_id=signed_in.id, agent_id=test_widget.agent_id,
|
||||
organization_id=test_widget.organization_id, status=SessionStatus.OPEN,
|
||||
)
|
||||
db.add(users_chat)
|
||||
db.commit()
|
||||
return data["token"], signed_in, users_chat
|
||||
|
||||
def test_the_old_token_is_swapped_for_a_customer_of_its_own(self, db, client, test_widget, pre_claim):
|
||||
attacker_token, signed_in, _ = pre_claim
|
||||
|
||||
response = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {attacker_token}"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["token"]
|
||||
assert _customer_of(data) != str(signed_in.id)
|
||||
swapped = CustomerRepository(db).get_by_id(_customer_of(data))
|
||||
assert swapped.meta_data[CONTACT_EMAIL_META_KEY] == "new.user@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_old_token_is_sent_back_to_authorize(self, socket, pre_claim):
|
||||
"""Refused the way an unbound token is, so the widget authorizes again
|
||||
and GET /widgets/{id} hands it a customer of its own."""
|
||||
attacker_token, _, _ = pre_claim
|
||||
|
||||
with pytest.raises(ConnectionRefusedError) as refused:
|
||||
await socket(attacker_token, "attacker")
|
||||
|
||||
assert refused.value.error_args["data"]["type"] == "customer_required"
|
||||
|
||||
def test_typing_the_address_again_gets_an_anonymous_customer(self, db, client, test_widget, pre_claim):
|
||||
_, signed_in, _ = pre_claim
|
||||
|
||||
data = client.get(
|
||||
f"/api/v1/widgets/{test_widget.id}", params={"email": "new.user@example.com"}
|
||||
).json()
|
||||
|
||||
assert _customer_of(data) != str(signed_in.id)
|
||||
fresh = CustomerRepository(db).get_by_id(_customer_of(data))
|
||||
assert CustomerRepository.is_placeholder_email(fresh.email)
|
||||
assert fresh.meta_data[CONTACT_EMAIL_META_KEY] == "new.user@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_signed_in_user_keeps_their_conversation(self, socket, test_widget, pre_claim):
|
||||
_, signed_in, users_chat = pre_claim
|
||||
verified_token, _, _ = conversation_token.mint({
|
||||
"sub": str(signed_in.id),
|
||||
"widget_id": str(test_widget.id),
|
||||
"customer_email": signed_in.email,
|
||||
}, ttl_seconds=600)
|
||||
|
||||
connected = await socket(verified_token, "user")
|
||||
|
||||
assert connected["session_id"] == str(users_chat.session_id)
|
||||
|
||||
|
||||
def _mint(widget, customer, **claims) -> str:
|
||||
token, _, _ = conversation_token.mint(
|
||||
{"sub": str(customer.id), "widget_id": str(widget.id), **claims}, ttl_seconds=600)
|
||||
return token
|
||||
|
||||
|
||||
class TestAccountScope:
|
||||
"""Our in-app support chat: a token our backend vouched for on behalf of one
|
||||
dashboard account, keyed to that account rather than to the address."""
|
||||
|
||||
@pytest.fixture
|
||||
def signed_in(self, db, test_organization) -> Customer:
|
||||
customer = Customer(email="member@acme.com", organization_id=test_organization.id, is_authenticated=True)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
return customer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_account_keeps_its_customer_and_its_conversation(self, client, socket, test_widget, signed_in):
|
||||
key = conversation_token.account_conversation_key("account-1")
|
||||
token = _mint(test_widget, signed_in, customer_email=signed_in.email, ckey=key)
|
||||
|
||||
data = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {token}"}).json()
|
||||
assert "token" not in data or data["token"] is None # no swap to an anonymous customer
|
||||
|
||||
first = await socket(token, "drawer-1")
|
||||
again = await socket(token, "drawer-2")
|
||||
assert again["session_id"] == first["session_id"]
|
||||
|
||||
def test_a_widget_token_never_carries_an_account_key_forward(self, client, test_widget, signed_in):
|
||||
"""An unverified token claiming an account key is refused, and the widget's
|
||||
replacement starts a key of its own."""
|
||||
forged = create_conversation_token(widget_id=str(test_widget.id),
|
||||
ckey=conversation_token.account_conversation_key("account-1"))
|
||||
|
||||
data = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {forged}"}).json()
|
||||
|
||||
claims = conversation_token.claims_ignoring_expiry(data["token"])
|
||||
assert not conversation_token.is_account_key(claims["ckey"])
|
||||
|
||||
|
||||
class TestEdges:
|
||||
def test_a_token_for_a_customer_that_is_gone_gets_a_new_one(self, client, test_widget):
|
||||
"""Answering without a token here meant every connect was refused, forever."""
|
||||
ghost = create_conversation_token(widget_id=str(test_widget.id), customer_id=str(uuid4()), ckey="browser-a")
|
||||
|
||||
data = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {ghost}"}).json()
|
||||
|
||||
assert data["token"]
|
||||
assert conversation_token.claims_ignoring_expiry(data["token"])["ckey"] == "browser-a"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_refresh_cannot_move_the_socket_to_another_scope(self, socket, mock_sio, test_widget, test_customer):
|
||||
connected = await socket(_victim_token(test_widget, test_customer), "victim")
|
||||
other_scope = create_conversation_token(widget_id=str(test_widget.id), customer_id=str(test_customer.id),
|
||||
ckey="another-browser")
|
||||
mock_sio.get_session.return_value = dict(connected)
|
||||
mock_sio.save_session.reset_mock()
|
||||
|
||||
await widget_chat.handle_refresh_token("victim", {"conversation_token": other_scope})
|
||||
|
||||
mock_sio.save_session.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_visitor_who_cannot_save_contact_details_still_hears_back(
|
||||
self, db, client, socket, mock_sio, test_widget, test_customer, victim_session
|
||||
):
|
||||
stranger = client.get(f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}).json()["token"]
|
||||
mock_sio.get_session.return_value = await socket(stranger, "stranger")
|
||||
mock_sio.emit.reset_mock()
|
||||
|
||||
await widget_chat.handle_contact_info("stranger", {"form_data": {"name": "Someone"}})
|
||||
|
||||
assert _emitted(mock_sio, "chat_response")["message"] == "Thanks — a teammate will follow up here."
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.agent import Agent, AgentType
|
||||
from app.models.workflow import Workflow, WorkflowStatus
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.models.widget import Widget
|
||||
from app.models.customer import Customer
|
||||
from uuid import uuid4
|
||||
from app.api import widget as widget_router
|
||||
from app.database import get_db
|
||||
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
widget_router.router,
|
||||
prefix="/api/widget",
|
||||
tags=["widget"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_organization(db):
|
||||
"""Create a test organization"""
|
||||
organization = Organization(
|
||||
id=uuid4(),
|
||||
name="Test Organization",
|
||||
domain="test.com"
|
||||
)
|
||||
db.add(organization)
|
||||
db.commit()
|
||||
db.refresh(organization)
|
||||
return organization
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db, test_organization):
|
||||
"""Create a test user"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="test@example.com",
|
||||
hashed_password="hashed_password",
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
full_name="Test User"
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent(db, test_organization):
|
||||
"""Create a test agent with workflow enabled"""
|
||||
agent = Agent(
|
||||
id=uuid4(),
|
||||
name="Test Agent",
|
||||
display_name="Test Agent",
|
||||
description="Test agent with workflow",
|
||||
agent_type=AgentType.CUSTOMER_SUPPORT,
|
||||
instructions=["Test instruction"], # Required field
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
use_workflow=True
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_workflow(db, test_organization, test_agent, test_user):
|
||||
"""Create a test workflow"""
|
||||
workflow = Workflow(
|
||||
id=uuid4(),
|
||||
name="Test Workflow",
|
||||
description="Test workflow",
|
||||
status=WorkflowStatus.PUBLISHED,
|
||||
version=1,
|
||||
is_template=False,
|
||||
default_language="en",
|
||||
canvas_data={
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "start",
|
||||
"data": {"label": "Start"}
|
||||
}
|
||||
]
|
||||
},
|
||||
settings={},
|
||||
organization_id=test_organization.id,
|
||||
agent_id=test_agent.id,
|
||||
created_by=test_user.id
|
||||
)
|
||||
db.add(workflow)
|
||||
db.commit()
|
||||
db.refresh(workflow)
|
||||
|
||||
# Update agent to use this workflow
|
||||
test_agent.active_workflow_id = workflow.id
|
||||
db.commit()
|
||||
|
||||
return workflow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_widget(db, test_agent):
|
||||
"""Create a test widget"""
|
||||
widget = Widget(
|
||||
name="Test Widget",
|
||||
agent_id=test_agent.id,
|
||||
organization_id=test_agent.organization_id
|
||||
)
|
||||
db.add(widget)
|
||||
db.commit()
|
||||
db.refresh(widget)
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_customer(db, test_organization):
|
||||
"""Create a test customer"""
|
||||
customer = Customer(
|
||||
id=uuid4(),
|
||||
email="customer@example.com",
|
||||
organization_id=test_organization.id,
|
||||
metadata={}
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
return customer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db) -> TestClient:
|
||||
"""Create test client with mocked dependencies"""
|
||||
def override_get_db():
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
pass
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestWidgetWorkflow:
|
||||
|
||||
def test_widget_created_successfully(self, test_widget, test_agent):
|
||||
"""Test that widget is created with the right agent"""
|
||||
assert test_widget.name == "Test Widget"
|
||||
assert test_widget.agent_id == test_agent.id
|
||||
assert test_widget.organization_id == test_agent.organization_id
|
||||
|
||||
def test_agent_has_workflow_enabled(self, test_agent, test_workflow):
|
||||
"""Test that the agent has workflow properly configured"""
|
||||
assert test_agent.use_workflow is True
|
||||
assert test_agent.active_workflow_id == test_workflow.id
|
||||
|
||||
def test_workflow_is_published(self, test_workflow):
|
||||
"""Test that the workflow is in published status"""
|
||||
assert test_workflow.status == WorkflowStatus.PUBLISHED
|
||||
assert test_workflow.canvas_data is not None
|
||||
assert "nodes" in test_workflow.canvas_data
|
||||
|
||||
def test_widget_agent_relationship(self, test_widget, test_agent):
|
||||
"""Test the relationship between widget and agent"""
|
||||
assert test_widget.agent_id == test_agent.id
|
||||
assert test_widget.organization_id == test_agent.organization_id
|
||||
|
||||
def test_agent_workflow_relationship(self, test_agent, test_workflow):
|
||||
"""Test the relationship between agent and workflow"""
|
||||
assert test_agent.active_workflow_id == test_workflow.id
|
||||
assert test_workflow.agent_id == test_agent.id
|
||||
assert test_agent.organization_id == test_workflow.organization_id
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.application import app
|
||||
from app.models.workflow import Workflow, WorkflowStatus
|
||||
from app.models.user import User
|
||||
from app.models.agent import Agent
|
||||
from app.models.organization import Organization
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission
|
||||
from app.models.schemas.workflow import WorkflowCreate, WorkflowUpdate
|
||||
from app.services.workflow import WorkflowService
|
||||
from app.core.config import settings
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.database import get_db
|
||||
from app.api import workflow as workflow_router
|
||||
|
||||
# Include the workflow router in the app for testing
|
||||
app.include_router(
|
||||
workflow_router.router,
|
||||
prefix=f"{settings.API_V1_STR}/workflow",
|
||||
tags=["workflow"]
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def test_organization(db: Session) -> Organization:
|
||||
"""Create a test organization"""
|
||||
organization = Organization(
|
||||
name="Test Organization",
|
||||
domain="test.com"
|
||||
)
|
||||
db.add(organization)
|
||||
db.commit()
|
||||
db.refresh(organization)
|
||||
return organization
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_permissions(db: Session) -> list[Permission]:
|
||||
"""Create permissions for managing agents and viewing all resources"""
|
||||
permissions = []
|
||||
for name in ["manage_agents", "view_all"]:
|
||||
permission = Permission(
|
||||
name=name,
|
||||
description=f"Can {name}"
|
||||
)
|
||||
db.add(permission)
|
||||
permissions.append(permission)
|
||||
|
||||
db.commit()
|
||||
for perm in permissions:
|
||||
db.refresh(perm)
|
||||
return permissions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_role(db: Session, test_organization: Organization, test_permissions: list[Permission]) -> Role:
|
||||
"""Create a test role with manage_agents and view_all permissions"""
|
||||
role = Role(
|
||||
name="Test Admin",
|
||||
description="Test role with manage_agents and view_all permissions",
|
||||
organization_id=test_organization.id
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
|
||||
# Add permissions to role
|
||||
role.permissions = test_permissions
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db: Session, test_organization: Organization, test_role: Role) -> User:
|
||||
"""Create a test user with manage_agents and view_all permissions via role"""
|
||||
user = User(
|
||||
email="test@example.com",
|
||||
full_name="Test User",
|
||||
hashed_password="hashed_password",
|
||||
organization_id=test_organization.id,
|
||||
is_active=True,
|
||||
role_id=test_role.id
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent(db: Session, test_organization: Organization) -> Agent:
|
||||
"""Create a test agent"""
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
display_name="Test Agent",
|
||||
description="Test agent description",
|
||||
agent_type="CUSTOMER_SUPPORT",
|
||||
organization_id=test_organization.id,
|
||||
is_active=True,
|
||||
instructions=["Test instructions"] # Adding required instructions field
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_workflow(db: Session, test_organization: Organization, test_agent: Agent, test_user: User) -> Workflow:
|
||||
"""Create a test workflow"""
|
||||
workflow = Workflow(
|
||||
name="Test Workflow",
|
||||
description="Test workflow description",
|
||||
status=WorkflowStatus.DRAFT,
|
||||
version=1,
|
||||
is_template=False,
|
||||
default_language="en",
|
||||
canvas_data={},
|
||||
settings={},
|
||||
organization_id=test_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: Session, test_user: User):
|
||||
"""Create a test client with authentication"""
|
||||
# Override the dependency to return our test user
|
||||
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
|
||||
|
||||
# Set up dependency 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
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
# Clean up
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
class TestWorkflowAPI:
|
||||
|
||||
def test_create_workflow(self, client: TestClient, test_agent: Agent):
|
||||
"""Test creating a new workflow"""
|
||||
# Prepare request data
|
||||
workflow_data = {
|
||||
"name": "New Workflow",
|
||||
"description": "New workflow description",
|
||||
"agent_id": str(test_agent.id)
|
||||
}
|
||||
|
||||
# Make the request
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/workflow",
|
||||
json=workflow_data
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == workflow_data["name"]
|
||||
assert data["description"] == workflow_data["description"]
|
||||
assert data["agent_id"] == str(test_agent.id)
|
||||
assert data["status"] == "draft"
|
||||
assert "id" in data
|
||||
|
||||
def test_create_workflow_duplicate_name(self, client: TestClient, test_agent: Agent, test_workflow: Workflow):
|
||||
"""Test creating a workflow with a duplicate name"""
|
||||
# Prepare request data with the same name as test_workflow
|
||||
workflow_data = {
|
||||
"name": test_workflow.name,
|
||||
"description": "Another workflow description",
|
||||
"agent_id": str(test_agent.id)
|
||||
}
|
||||
|
||||
# Make the request
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/workflow",
|
||||
json=workflow_data
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 400
|
||||
assert "Agent already has a workflow" in response.json()["detail"]
|
||||
|
||||
def test_create_workflow_agent_has_workflow(self, client: TestClient, test_agent: Agent, test_workflow: Workflow):
|
||||
"""Test creating a workflow for an agent that already has one"""
|
||||
# Prepare request data
|
||||
workflow_data = {
|
||||
"name": "Another Workflow",
|
||||
"description": "Another workflow description",
|
||||
"agent_id": str(test_agent.id)
|
||||
}
|
||||
|
||||
# Make the request
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/workflow",
|
||||
json=workflow_data
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 400
|
||||
assert "Agent already has a workflow" in response.json()["detail"]
|
||||
|
||||
def test_get_workflow_by_agent_id(self, client: TestClient, test_agent: Agent, test_workflow: Workflow):
|
||||
"""Test getting a workflow by agent ID"""
|
||||
# Make the request
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/workflow/agent/{test_agent.id}"
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_workflow.id)
|
||||
assert data["name"] == test_workflow.name
|
||||
assert data["agent_id"] == str(test_agent.id)
|
||||
|
||||
def test_get_workflow_by_agent_id_not_found(self, client: TestClient):
|
||||
"""Test getting a workflow for an agent that doesn't have one"""
|
||||
# Generate a random UUID for a non-existent agent
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
# Make the request
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/workflow/agent/{agent_id}"
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 400
|
||||
assert "Agent not found" in response.json()["detail"]
|
||||
|
||||
def test_update_workflow(self, client: TestClient, test_workflow: Workflow):
|
||||
"""Test updating a workflow"""
|
||||
# Prepare update data
|
||||
update_data = {
|
||||
"name": "Updated Workflow",
|
||||
"description": "Updated workflow description",
|
||||
"status": "published"
|
||||
}
|
||||
|
||||
# Make the request
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/workflow/{test_workflow.id}",
|
||||
json=update_data
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_workflow.id)
|
||||
assert data["name"] == update_data["name"]
|
||||
assert data["description"] == update_data["description"]
|
||||
assert data["status"] == update_data["status"]
|
||||
|
||||
def test_update_workflow_not_found(self, client: TestClient):
|
||||
"""Test updating a workflow that doesn't exist"""
|
||||
# Generate a random UUID for a non-existent workflow
|
||||
workflow_id = uuid.uuid4()
|
||||
|
||||
# Prepare update data
|
||||
update_data = {
|
||||
"name": "Updated Workflow",
|
||||
"description": "Updated workflow description"
|
||||
}
|
||||
|
||||
# Make the request
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/workflow/{workflow_id}",
|
||||
json=update_data
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 400
|
||||
assert "Workflow not found" in response.json()["detail"]
|
||||
|
||||
def test_delete_workflow(self, client: TestClient, test_workflow: Workflow, test_agent: Agent):
|
||||
"""Test deleting a workflow"""
|
||||
# Make the request
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/workflow/{test_workflow.id}"
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 204
|
||||
assert response.content == b'' # No content for 204 response
|
||||
|
||||
# Create a new workflow for the agent to test if the workflow was actually deleted
|
||||
workflow_data = {
|
||||
"name": "New Workflow After Delete",
|
||||
"description": "New workflow description after delete",
|
||||
"agent_id": str(test_agent.id)
|
||||
}
|
||||
|
||||
create_response = client.post(
|
||||
f"{settings.API_V1_STR}/workflow",
|
||||
json=workflow_data
|
||||
)
|
||||
|
||||
# If we can create a new workflow for the same agent, it means the previous one was deleted
|
||||
assert create_response.status_code == 201
|
||||
assert create_response.json()["name"] == workflow_data["name"]
|
||||
|
||||
def test_delete_workflow_not_found(self, client: TestClient):
|
||||
"""Test deleting a workflow that doesn't exist"""
|
||||
# Generate a random UUID for a non-existent workflow
|
||||
workflow_id = uuid.uuid4()
|
||||
|
||||
# Make the request
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/workflow/{workflow_id}"
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 400
|
||||
assert "Workflow not found" in response.json()["detail"]
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 uuid import uuid4
|
||||
from app.models import (
|
||||
Agent,
|
||||
User,
|
||||
Organization,
|
||||
Workflow,
|
||||
WorkflowNode
|
||||
)
|
||||
from app.models.schemas.workflow import WorkflowNodeCreate, WorkflowNodeUpdate
|
||||
from app.models.workflow import WorkflowStatus
|
||||
from app.models.workflow_node import NodeType
|
||||
from app.api import workflow_node as workflow_node_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.database import get_db
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
workflow_node_router.router,
|
||||
prefix="/api/workflow-nodes",
|
||||
tags=["workflow-nodes"]
|
||||
)
|
||||
|
||||
@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)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_data():
|
||||
return {
|
||||
"node_id": "node-1",
|
||||
"node_type": "action",
|
||||
"name": "Test Node",
|
||||
"description": "A test node",
|
||||
"position": {"x": 100, "y": 200},
|
||||
"config": {"action_type": "send_message", "message": "Hello"},
|
||||
"connections": [{"source": "node-1", "target": "node-2"}]
|
||||
}
|
||||
|
||||
def test_update_workflow_node_success(client, db, test_user, test_organization, test_agent):
|
||||
"""Test successful update of workflow node"""
|
||||
|
||||
# Create a workflow
|
||||
workflow = Workflow(
|
||||
id=uuid4(),
|
||||
name="Test Workflow",
|
||||
description="Test workflow for node update",
|
||||
agent_id=test_agent.id,
|
||||
organization_id=test_organization.id,
|
||||
created_by=test_user.id,
|
||||
status=WorkflowStatus.DRAFT
|
||||
)
|
||||
db.add(workflow)
|
||||
|
||||
# Create a workflow node
|
||||
node = WorkflowNode(
|
||||
id=uuid4(),
|
||||
workflow_id=workflow.id,
|
||||
node_type=NodeType.MESSAGE,
|
||||
name="Test Node",
|
||||
description="Test node for update",
|
||||
position_x=100,
|
||||
position_y=200,
|
||||
config={"message_text": "Hello World"}
|
||||
)
|
||||
db.add(node)
|
||||
db.commit()
|
||||
|
||||
# Update node data
|
||||
update_data = {
|
||||
"name": "Updated Test Node",
|
||||
"description": "Updated description",
|
||||
"config": {"message_text": "Updated Hello World"}
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/api/workflow-nodes/{workflow.id}/nodes/{node.id}",
|
||||
json=update_data
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify node was updated
|
||||
assert data["name"] == "Updated Test Node"
|
||||
assert data["description"] == "Updated description"
|
||||
assert data["config"]["message_text"] == "Updated Hello World"
|
||||
|
||||
|
||||
def test_get_workflow_node_success(client, db, test_user, test_organization, test_agent):
|
||||
"""Test successful retrieval of workflow node"""
|
||||
|
||||
# Create a workflow
|
||||
workflow = Workflow(
|
||||
id=uuid4(),
|
||||
name="Test Workflow",
|
||||
description="Test workflow for node retrieval",
|
||||
agent_id=test_agent.id,
|
||||
organization_id=test_organization.id,
|
||||
created_by=test_user.id,
|
||||
status=WorkflowStatus.DRAFT
|
||||
)
|
||||
db.add(workflow)
|
||||
|
||||
# Create a workflow node
|
||||
node = WorkflowNode(
|
||||
id=uuid4(),
|
||||
workflow_id=workflow.id,
|
||||
node_type=NodeType.MESSAGE,
|
||||
name="Test Node",
|
||||
description="Test node for retrieval",
|
||||
position_x=100,
|
||||
position_y=200,
|
||||
config={"message_text": "Hello World"}
|
||||
)
|
||||
db.add(node)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
f"/api/workflow-nodes/{workflow.id}/nodes/{node.id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify node data
|
||||
assert data["id"] == str(node.id)
|
||||
assert data["name"] == "Test Node"
|
||||
assert data["config"]["message_text"] == "Hello World"
|
||||
|
||||
|
||||
def test_update_workflow_node_not_found(client, db, test_user, test_organization, test_agent):
|
||||
"""Test update of non-existent workflow node"""
|
||||
|
||||
workflow_id = uuid4()
|
||||
node_id = uuid4()
|
||||
|
||||
update_data = {
|
||||
"name": "Updated Test Node"
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/api/workflow-nodes/{workflow_id}/nodes/{node_id}",
|
||||
json=update_data
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Workflow not found"
|
||||
|
||||
|
||||
def test_get_workflow_node_not_found(client, db, test_user, test_organization, test_agent):
|
||||
"""Test retrieval of non-existent workflow node"""
|
||||
|
||||
workflow_id = uuid4()
|
||||
node_id = uuid4()
|
||||
|
||||
response = client.get(
|
||||
f"/api/workflow-nodes/{workflow_id}/nodes/{node_id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Workflow not found"
|
||||
|
||||
|
||||
def test_update_workflow_node_unauthorized(client, db):
|
||||
"""Test update of workflow node without proper permissions"""
|
||||
|
||||
workflow_id = uuid4()
|
||||
node_id = uuid4()
|
||||
|
||||
update_data = {
|
||||
"name": "Updated Test Node"
|
||||
}
|
||||
|
||||
# Override auth to return None (no user)
|
||||
async def override_get_current_user():
|
||||
return None
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
response = client.put(
|
||||
f"/api/workflow-nodes/{workflow_id}/nodes/{node_id}",
|
||||
json=update_data
|
||||
)
|
||||
|
||||
# Should return 422 due to validation error (no user)
|
||||
assert response.status_code in [401, 422, 500]
|
||||
Reference in new issue
Block a user