test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
5da5f910da
commit
2554eb7bc7
97 files changed
+37281
No files matched your search
@@ -0,0 +1,548 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from 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
|
||||
Reference in new issue
Block a user