The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.
- widget loader is now webclient/replypier.min.js; both bundles rebuilt
from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
rewritten rather than string-swapped; the demo config no longer ships
the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
documents carrying the old local data over
549 lines
20 KiB
Python
549 lines
20 KiB
Python
"""
|
|
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
|