298 lines
12 KiB
Python
298 lines
12 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 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
|