test: 补充权限、消息与会话服务回归用例

会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
WangShen007 committed 2026-10-08 22:35:00 +08:00
1 parent 8e3a4d95da
commit 164f5f9615
97 files changed
+37281

No files matched your search

+23
View File
@@ -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
+277
View File
@@ -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)
+774
View File
@@ -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"
+488
View File
@@ -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
+251
View File
@@ -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)
+336
View File
@@ -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
+687
View File
@@ -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)
+340
View File
@@ -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
+297
View File
@@ -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
+546
View File
@@ -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
+429
View File
@@ -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
+625
View File
@@ -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
+544
View File
@@ -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
+460
View File
@@ -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)
+466
View File
@@ -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
+548
View File
@@ -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
+121
View File
@@ -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())
+488
View File
@@ -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
+875
View File
@@ -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()
+95
View File
@@ -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"
+950
View File
@@ -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()
+259
View File
@@ -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
+873
View File
@@ -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
+292
View File
@@ -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
+170
View File
@@ -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
+185
View File
@@ -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
+430
View File
@@ -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."
+198
View File
@@ -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
+354
View File
@@ -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]