db97a39133
Check Cross-Plugin Imports / check (push) Has been cancelled
- P1-Tests: 12 test files with new cross-tenant isolation + RBAC tests - P3-Tests: 8 fixes (duplicate fixtures, sys.path.insert, unused imports, KeyError) - P3-Frontend: LucideIcons → ICON_MAP (2 files), inline styles → Tailwind (2 files) - P3-Frontend: DOMPurify for iframe XSS, redundant regex removed, console.log → console.debug - P2-Frontend: 2 notification API TODOs retained (requires larger refactor) - conftest.py: create_no_perm_user helper added - pyproject.toml: pythonpath for scripts/ added - All checks green: ruff 0, F821 0, tsc 0, app 495 routes, cross-plugin 0
212 lines
8.0 KiB
Python
212 lines
8.0 KiB
Python
"""Custom fields tests — plugin-defined custom fields on contacts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestCustomFieldsGet:
|
|
"""GET /api/v1/contacts/{id}/custom-fields"""
|
|
|
|
async def test_get_custom_fields_returns_200(self, client: AsyncClient, db_session):
|
|
"""GET custom fields for a contact returns 200 with merged definitions."""
|
|
await seed_tenant_and_users(db_session)
|
|
await login_client(client, "admin@tenanta.com")
|
|
# Create a contact first
|
|
resp = await client.post(
|
|
"/api/v1/contacts",
|
|
json={"type": "company", "name": "Test Corp", "displayname": "Test Corp"},
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
assert resp.status_code == 201
|
|
contact_id = resp.json()["id"]
|
|
|
|
resp = await client.get(
|
|
f"/api/v1/contacts/{contact_id}/custom-fields",
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "fields" in data
|
|
assert isinstance(data["fields"], list)
|
|
|
|
async def test_get_custom_fields_invalid_uuid_returns_400(self, client: AsyncClient, db_session):
|
|
"""GET custom fields with invalid UUID returns 400."""
|
|
await seed_tenant_and_users(db_session)
|
|
await login_client(client, "admin@tenanta.com")
|
|
resp = await client.get(
|
|
"/api/v1/contacts/not-a-uuid/custom-fields",
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
async def test_get_custom_fields_not_found_returns_404(self, client: AsyncClient, db_session):
|
|
"""GET custom fields for non-existent contact returns 404."""
|
|
await seed_tenant_and_users(db_session)
|
|
await login_client(client, "admin@tenanta.com")
|
|
resp = await client.get(
|
|
"/api/v1/contacts/00000000-0000-0000-0000-000000000000/custom-fields",
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestCustomFieldsUpdate:
|
|
"""PATCH /api/v1/contacts/{id}/custom-fields"""
|
|
|
|
async def test_update_custom_fields_returns_200(self, client: AsyncClient, db_session):
|
|
"""PATCH custom fields stores values in contacts.custom JSONB."""
|
|
await seed_tenant_and_users(db_session)
|
|
await login_client(client, "admin@tenanta.com")
|
|
# Create a contact
|
|
resp = await client.post(
|
|
"/api/v1/contacts",
|
|
json={"type": "company", "name": "Custom Corp", "displayname": "Custom Corp"},
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
contact_id = resp.json()["id"]
|
|
|
|
# Update custom fields (empty values is valid since no plugin defines fields)
|
|
resp = await client.patch(
|
|
f"/api/v1/contacts/{contact_id}/custom-fields",
|
|
json={"values": {"test_field": "test_value"}},
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
# Without plugin definitions, unknown fields should be rejected
|
|
assert resp.status_code == 400
|
|
|
|
async def test_update_custom_fields_empty_values_returns_200(self, client: AsyncClient, db_session):
|
|
"""PATCH custom fields with empty values dict returns 200."""
|
|
await seed_tenant_and_users(db_session)
|
|
await login_client(client, "admin@tenanta.com")
|
|
resp = await client.post(
|
|
"/api/v1/contacts",
|
|
json={"type": "company", "name": "Empty Corp", "displayname": "Empty Corp"},
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
contact_id = resp.json()["id"]
|
|
|
|
resp = await client.patch(
|
|
f"/api/v1/contacts/{contact_id}/custom-fields",
|
|
json={"values": {}},
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "fields" in resp.json()
|
|
|
|
async def test_update_custom_fields_not_found_returns_404(self, client: AsyncClient, db_session):
|
|
"""PATCH custom fields for non-existent contact returns 404."""
|
|
await seed_tenant_and_users(db_session)
|
|
await login_client(client, "admin@tenanta.com")
|
|
resp = await client.patch(
|
|
"/api/v1/contacts/00000000-0000-0000-0000-000000000000/custom-fields",
|
|
json={"values": {}},
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestCustomFieldDefinition:
|
|
"""Test CustomFieldDefinition model in manifest."""
|
|
|
|
async def test_custom_field_definition_creation(self):
|
|
"""CustomFieldDefinition can be created with valid field types."""
|
|
from app.plugins.manifest import CustomFieldDefinition
|
|
|
|
cf = CustomFieldDefinition(
|
|
name="lead_source",
|
|
label="Lead Source",
|
|
label_key="custom.leadSource",
|
|
field_type="select",
|
|
options=["website", "referral", "cold_call"],
|
|
required=False,
|
|
entity="contact",
|
|
)
|
|
assert cf.name == "lead_source"
|
|
assert cf.field_type == "select"
|
|
assert len(cf.options) == 3
|
|
|
|
async def test_custom_field_definition_invalid_type_raises(self):
|
|
"""CustomFieldDefinition rejects invalid field_type."""
|
|
from app.plugins.manifest import CustomFieldDefinition
|
|
from pydantic import ValidationError
|
|
|
|
with pytest.raises(ValidationError):
|
|
CustomFieldDefinition(
|
|
name="bad_field",
|
|
field_type="invalid_type",
|
|
)
|
|
|
|
async def test_plugin_manifest_with_custom_fields(self):
|
|
"""PluginManifest accepts custom_fields list."""
|
|
from app.plugins.manifest import PluginManifest, CustomFieldDefinition
|
|
|
|
manifest = PluginManifest(
|
|
name="test_plugin",
|
|
version="1.0.0",
|
|
display_name="Test Plugin",
|
|
custom_fields=[
|
|
CustomFieldDefinition(
|
|
name="score",
|
|
label="Score",
|
|
field_type="number",
|
|
entity="contact",
|
|
),
|
|
],
|
|
)
|
|
assert len(manifest.custom_fields) == 1
|
|
assert manifest.custom_fields[0].name == "score"
|
|
|
|
|
|
# ── Cross-tenant isolation test ──
|
|
@pytest.mark.asyncio
|
|
class TestCustomFieldsCrossTenant:
|
|
"""Custom fields must not leak across tenants."""
|
|
|
|
async def test_cross_tenant_isolation(self, client: AsyncClient, db_session):
|
|
"""Tenant B cannot access custom fields of tenant A's contact."""
|
|
from httpx import ASGITransport
|
|
from httpx import AsyncClient as AC
|
|
|
|
import app.main
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
await login_client(client, "admin@tenanta.com")
|
|
# Create a contact in tenant A
|
|
create_resp = await client.post(
|
|
"/api/v1/contacts",
|
|
json={"type": "company", "name": "Tenant A Custom Corp", "displayname": "Tenant A Custom Corp"},
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
assert create_resp.status_code == 201
|
|
contact_id = create_resp.json()["id"]
|
|
|
|
# Tenant B admin must not access tenant A's contact custom fields
|
|
app_instance = app.main.app
|
|
async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as client_b:
|
|
await login_client(client_b, "admin@tenantb.com")
|
|
resp = await client_b.get(
|
|
f"/api/v1/contacts/{contact_id}/custom-fields",
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
async def test_rbac_no_permission(self, client: AsyncClient, db_session):
|
|
"""User without contacts:read permission gets 403 on GET custom fields."""
|
|
from tests.conftest import create_no_perm_user
|
|
|
|
seed = await seed_tenant_and_users(db_session)
|
|
await create_no_perm_user(db_session, seed)
|
|
await login_client(client, "noperm@tenanta.com")
|
|
resp = await client.get(
|
|
"/api/v1/contacts/00000000-0000-0000-0000-000000000000/custom-fields",
|
|
headers=ORIGIN_HEADER,
|
|
)
|
|
assert resp.status_code == 403
|