Files
leocrm/tests/test_entity_links.py
T
Agent Zero db97a39133
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(audit): P1 cross-tenant/RBAC tests, P3 test fixes, P2/P3 frontend fixes
- 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
2026-08-16 01:30:02 +02:00

530 lines
20 KiB
Python

"""Tests for Entity Links plugin — link, unlink, reverse links, multi-links, event cleanup."""
from __future__ import annotations
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.core.db import close_engine, reset_engine_for_testing
from app.core.permission_registry import init_permission_registry
from app.core.event_bus import get_event_bus
from app.core.service_container import get_container
from app.main import create_app
from app.plugins.builtins.entity_links import EntityLinksPlugin
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest_asyncio.fixture
async def plugin_app(engine: AsyncEngine, redis_client):
"""FastAPI app with entity_links plugin registered."""
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
init_permission_registry(active_plugin_names={"entity_links", "dms", "permissions"})
container = get_container()
await container.initialize()
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
from app.plugins.builtins.dms.plugin import DmsPlugin
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(DmsPlugin())
registry.register_plugin(EntityLinksPlugin())
reset_plugin_service_for_testing(registry)
yield app
await close_engine()
@pytest_asyncio.fixture
async def plugin_client(plugin_app) -> AsyncClient:
transport = ASGITransport(app=plugin_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
"""Authenticated admin client with seeded data."""
seed = await seed_tenant_and_users(db_session)
await login_client(plugin_client, "admin@tenanta.com")
resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER)
assert resp.status_code == 200
resp = await plugin_client.post("/api/v1/plugins/permissions/activate", headers=ORIGIN_HEADER)
assert resp.status_code == 200
resp = await plugin_client.post("/api/v1/plugins/dms/install", headers=ORIGIN_HEADER)
assert resp.status_code == 200
resp = await plugin_client.post("/api/v1/plugins/dms/activate", headers=ORIGIN_HEADER)
assert resp.status_code == 200
resp = await plugin_client.post("/api/v1/plugins/entity_links/install", headers=ORIGIN_HEADER)
assert resp.status_code == 200
resp = await plugin_client.post("/api/v1/plugins/entity_links/activate", headers=ORIGIN_HEADER)
assert resp.status_code == 200
return plugin_client, seed
@pytest.mark.asyncio
async def test_link_file_to_company(authed_client: AsyncClient):
"""AC2: POST /api/v1/dms/files/{id}/link → 200, file linked to entity."""
client, seed = authed_client
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test1.txt", b"hello world 1", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
company_id = str(seed["company_a"].id)
resp = await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == file_id
assert data["entity_type"] == "company"
assert data["entity_id"] == company_id
assert data["already_linked"] is False
@pytest.mark.asyncio
async def test_link_file_to_contact(authed_client: AsyncClient):
"""POST /api/v1/dms/files/{id}/link → 200, file linked to contact."""
client, seed = authed_client
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test2.txt", b"hello world 2", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
# Use a real contact from seed data
contact_id = str(seed["company_a"].id)
resp = await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": contact_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["entity_type"] == "company"
assert data["entity_id"] == contact_id
@pytest.mark.asyncio
async def test_unlink_file_from_entity(authed_client: AsyncClient):
"""AC3: DELETE /api/v1/dms/files/{id}/link → 204, link removed."""
client, seed = authed_client
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test3.txt", b"hello world 3", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
company_id = str(seed["company_a"].id)
# Link first
resp = await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
# Unlink
resp = await client.request(
"DELETE",
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
# Verify links list is empty
resp = await client.get(f"/api/v1/entity-links/files/{file_id}/links", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_list_file_links(authed_client: AsyncClient):
"""GET /api/v1/dms/files/{id}/links → 200, list all linked entities for file."""
client, seed = authed_client
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test4.txt", b"hello world 4", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
company_id = str(seed["company_a"].id)
# Create a 2nd company in tenant A via API
resp = await client.post(
"/api/v1/contacts",
json={"type": "company", "name": "Test Company B"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201, f"Failed to create company: {resp.text}"
company_b_id = resp.json()["id"]
# Link to company_a
await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
# Link to company_b (different entity, same tenant)
await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_b_id},
headers=ORIGIN_HEADER,
)
resp = await client.get(f"/api/v1/entity-links/files/{file_id}/links", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
@pytest.mark.asyncio
async def test_multi_links_one_file_many_entities(authed_client: AsyncClient):
"""Multi-links: one file → many entities."""
client, seed = authed_client
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test5.txt", b"hello world 5", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
# Link to 3 different companies (create them via API first)
entity_ids = []
for i in range(3):
resp = await client.post(
"/api/v1/contacts",
json={"type": "company", "name": f"Test Company {i}"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201, f"Failed to create company: {resp.text}"
entity_ids.append(resp.json()["id"])
for entity_id in entity_ids:
resp = await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": entity_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
resp = await client.get(f"/api/v1/entity-links/files/{file_id}/links", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert len(resp.json()) == 3
@pytest.mark.asyncio
async def test_reverse_link_company_files(authed_client: AsyncClient):
"""GET /api/v1/companies/{id}/files → 200, list linked files for company."""
client, seed = authed_client
company_id = str(seed["company_a"].id)
# Link 2 files to the company (different content to avoid DMS dedup)
for i in range(2):
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": (f"test6_{i}.txt", f"hello world 6_{i}".encode(), "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert all(link["entity_type"] == "company" for link in data)
@pytest.mark.asyncio
async def test_reverse_link_contact_files(authed_client: AsyncClient):
"""GET /api/v1/contacts/{id}/files → 200, list linked files for contact."""
client, seed = authed_client
company_id = str(seed["company_a"].id)
# Link 1 file to the company (use /companies/ reverse link endpoint)
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test7.txt", b"hello world 7", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["entity_type"] == "company"
@pytest.mark.asyncio
async def test_event_cleanup_on_company_deleted(authed_client: AsyncClient):
"""AC13: DMS plugin listens to company.deleted event → linked files cleanup."""
client, seed = authed_client
company_id = seed["company_a"].id
tenant_id = seed["tenant_a"].id
# Link a file to the company (as entity_type='contact' for event cleanup)
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test8.txt", b"hello world 8", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
resp = await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "contact", "entity_id": str(company_id)},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
# Verify link exists
resp = await client.get(f"/api/v1/contacts/{company_id}/files", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert len(resp.json()) == 1
# Publish contact.deleted event (entity_links plugin handles contact.deleted)
event_bus = get_event_bus()
await event_bus.publish(
"contact.deleted",
{
"entity_id": str(company_id),
"contact_id": str(company_id),
"tenant_id": str(tenant_id),
},
)
# Allow async event handler to complete
import asyncio
await asyncio.sleep(0.1)
# Verify link is cleaned up
resp = await client.get(f"/api/v1/contacts/{company_id}/files", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_event_cleanup_on_contact_deleted(authed_client: AsyncClient):
"""Event cleanup on contact.deleted → linked files removed."""
client, seed = authed_client
contact_id = seed["company_a"].id
tenant_id = seed["tenant_a"].id
# Link a file to the contact
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test9.txt", b"hello world 9", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
resp = await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "contact", "entity_id": str(contact_id)},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
# Verify link exists
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert len(resp.json()) == 1
# Publish contact.deleted event
event_bus = get_event_bus()
await event_bus.publish(
"contact.deleted",
{
"entity_id": str(contact_id),
"contact_id": str(contact_id),
"tenant_id": str(tenant_id),
},
)
# Allow async event handler to complete
import asyncio
await asyncio.sleep(0.1)
# Verify link is cleaned up
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_link_invalid_file_id(authed_client: AsyncClient):
"""POST /api/v1/dms/files/{invalid}/link → 400."""
client, seed = authed_client
resp = await client.post(
"/api/v1/entity-links/files/bad-uuid/link",
json={"entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_link_invalid_entity_id(authed_client: AsyncClient):
"""POST /api/v1/dms/files/{id}/link with invalid entity_id → 400."""
client, seed = authed_client
resp = await client.post(
f"/api/v1/entity-links/files/{uuid.uuid4()}/link",
json={"entity_type": "contact", "entity_id": "bad-uuid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_link_invalid_entity_type(authed_client: AsyncClient):
"""POST /api/v1/dms/files/{id}/link with invalid entity_type → 400."""
client, seed = authed_client
resp = await client.post(
f"/api/v1/entity-links/files/{uuid.uuid4()}/link",
json={"entity_type": "invalid", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_link_already_linked(authed_client: AsyncClient):
"""POST /api/v1/dms/files/{id}/link twice → already_linked=True."""
client, seed = authed_client
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test10.txt", b"hello world 10", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
company_id = str(seed["company_a"].id)
resp = await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["already_linked"] is False
resp = await client.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["already_linked"] is True
@pytest.mark.asyncio
async def test_unlink_not_found(authed_client: AsyncClient):
"""DELETE /api/v1/dms/files/{id}/link with nonexistent link → 404."""
client, seed = authed_client
# Upload a real file to DMS first
resp = await client.post("/api/v1/dms/files/upload", files={"file": ("test11.txt", b"hello world 11", "text/plain")}, headers=ORIGIN_HEADER)
file_id = resp.json()["id"]
company_id = str(seed["company_a"].id)
resp = await client.request(
"DELETE",
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_unlink_invalid_file_id(authed_client: AsyncClient):
"""DELETE /api/v1/dms/files/{invalid}/link → 400."""
client, seed = authed_client
resp = await client.request(
"DELETE",
"/api/v1/entity-links/files/bad-uuid/link",
json={"entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_list_file_links_invalid_id(authed_client: AsyncClient):
"""GET /api/v1/dms/files/{invalid}/links → 400."""
client, seed = authed_client
resp = await client.get("/api/v1/entity-links/files/bad-uuid/links", headers=ORIGIN_HEADER)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_list_company_files_invalid_id(authed_client: AsyncClient):
"""GET /api/v1/companies/{invalid}/files → 400."""
client, seed = authed_client
resp = await client.get("/api/v1/companies/bad-uuid/files", headers=ORIGIN_HEADER)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_list_contact_files_invalid_id(authed_client: AsyncClient):
"""GET /api/v1/contacts/{invalid}/files → 400."""
client, seed = authed_client
resp = await client.get("/api/v1/contacts/bad-uuid/files", headers=ORIGIN_HEADER)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_list_company_files_empty(authed_client: AsyncClient):
"""GET /api/v1/companies/{id}/files with no links → 200 + empty list."""
client, seed = authed_client
company_id = str(seed["company_a"].id)
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_list_contact_files_empty(authed_client: AsyncClient):
"""GET /api/v1/contacts/{id}/files with no links → 200 + empty list."""
client, seed = authed_client
contact_id = str(seed["company_a"].id)
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
# ─── Cross-tenant isolation test ───
@pytest.mark.asyncio
async def test_cross_tenant_entity_link_isolation(plugin_app, db_session):
"""Entity link created in tenant A is not visible to tenant B."""
from tests.conftest import seed_tenant_and_users
seed = await seed_tenant_and_users(db_session)
transport = ASGITransport(app=plugin_app)
async with AsyncClient(transport=transport, base_url="http://test") as client_a:
await login_client(client_a, "admin@tenanta.com")
# Upload a file in tenant A
upload_resp = await client_a.post(
"/api/v1/dms/files/upload",
files={"file": ("tenant_a_link.txt", b"tenant a link", "text/plain")},
headers=ORIGIN_HEADER,
)
assert upload_resp.status_code == 201
file_id = upload_resp.json()["id"]
company_id = str(seed["company_a"].id)
# Link file to company_a in tenant A
link_resp = await client_a.post(
f"/api/v1/entity-links/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert link_resp.status_code == 200
# Tenant B admin must not see tenant A's link
async with AsyncClient(transport=transport, base_url="http://test") as client_b:
await login_client(client_b, "admin@tenantb.com")
links_resp = await client_b.get(
f"/api/v1/entity-links/files/{file_id}/links",
headers=ORIGIN_HEADER,
)
assert links_resp.status_code == 200
assert links_resp.json() == []