feat(customer): 网站客户身份签发与长期负责人绑定
可信客户身份由站点后端签发,长期负责人与本次接待人分开保存。
This commit is contained in:
1 parent
2554eb7bc7
commit
ad90b830c4
18 files changed
+3123
No files matched your search
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
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 json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import app.crm.base as crm_base
|
||||
from app.core.config import settings
|
||||
from app.crm.base import CrmAuthError, LeadPayload, OAuthTokens
|
||||
from app.crm.pipedrive import PipedriveAdapter
|
||||
from app.crm.registry import get_adapter
|
||||
|
||||
API_DOMAIN = "https://acme.pipedrive.com"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
return PipedriveAdapter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tokens():
|
||||
return OAuthTokens(access_token="at", refresh_token="rt", api_domain=API_DOMAIN,
|
||||
external_account_id="777", display_name="Acme")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def payload():
|
||||
return LeadPayload(
|
||||
lead_response_id=uuid.uuid4(),
|
||||
email="jane@acme.com", name="Jane Doe", phone="+15550100",
|
||||
summary="Wants a demo", custom_fields={"Team size": "40"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http(monkeypatch):
|
||||
"""MockTransport routed by url substring; records requests in order."""
|
||||
recorded = []
|
||||
responses = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
recorded.append(request)
|
||||
for fragment, (status, body) in responses.items():
|
||||
if fragment in str(request.url):
|
||||
return httpx.Response(status, json=body)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
monkeypatch.setattr(crm_base, "_http_client", client)
|
||||
return {"requests": recorded, "respond": responses}
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
|
||||
def test_both_adapters_resolve(self):
|
||||
assert get_adapter("hubspot") is not None
|
||||
assert get_adapter("pipedrive") is not None
|
||||
assert get_adapter("salesforce") is None # phase 2
|
||||
|
||||
|
||||
class TestTokenFlows:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exchange_code_captures_api_domain_and_company(self, adapter, http,
|
||||
monkeypatch):
|
||||
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "cid")
|
||||
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_SECRET", "sek")
|
||||
http["respond"]["oauth.pipedrive.com/oauth/token"] = (200, {
|
||||
"access_token": "new-at", "refresh_token": "new-rt",
|
||||
"expires_in": 3600, "api_domain": API_DOMAIN})
|
||||
http["respond"]["users/me"] = (200, {
|
||||
"data": {"company_id": 777, "company_name": "Acme"}})
|
||||
|
||||
tokens = await adapter.exchange_code("code-1", "https://app/cb")
|
||||
|
||||
assert tokens.api_domain == API_DOMAIN
|
||||
assert tokens.external_account_id == "777"
|
||||
assert tokens.display_name == "Acme"
|
||||
# Sliding refresh expiry stamped ~60 days out.
|
||||
assert tokens.refresh_token_expires_at > datetime.now(timezone.utc) + timedelta(days=59)
|
||||
# Token endpoint used HTTP Basic (client_id:secret), not body params.
|
||||
token_request = http["requests"][0]
|
||||
assert token_request.headers["Authorization"].startswith("Basic ")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_non_pipedrive_api_domain(self, adapter, http, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "cid")
|
||||
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_SECRET", "sek")
|
||||
# A non-pipedrive api_domain must not become our request base (SSRF guard).
|
||||
http["respond"]["oauth.pipedrive.com/oauth/token"] = (200, {
|
||||
"access_token": "a", "refresh_token": "r", "expires_in": 3600,
|
||||
"api_domain": "https://evil.example.com"})
|
||||
http["respond"]["users/me"] = (200, {"data": {}})
|
||||
tokens = await adapter.exchange_code("code-1", "https://app/cb")
|
||||
assert tokens.api_domain is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_slides_window_and_keeps_identity(self, adapter, http, tokens):
|
||||
http["respond"]["oauth/token"] = (200, {
|
||||
"access_token": "at2", "expires_in": 3600})
|
||||
refreshed = await adapter.refresh_tokens(tokens)
|
||||
assert refreshed.access_token == "at2"
|
||||
assert refreshed.refresh_token == "rt" # not rotated → kept
|
||||
assert refreshed.api_domain == API_DOMAIN # kept from old tokens
|
||||
assert refreshed.external_account_id == "777"
|
||||
assert refreshed.refresh_token_expires_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_rejection_is_auth_error(self, adapter, http, tokens):
|
||||
http["respond"]["oauth/token"] = (400, {"error": "invalid_grant"})
|
||||
with pytest.raises(CrmAuthError):
|
||||
await adapter.refresh_tokens(tokens)
|
||||
|
||||
|
||||
class TestPushLead:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_person_new_lead_with_note(self, adapter, http, tokens, payload):
|
||||
http["respond"]["persons/search"] = (200, {"data": {"items": []}})
|
||||
http["respond"]["api/v2/persons"] = (201, {"data": {"id": 42}})
|
||||
# Fragment order matters: the open-lead listing (GET …/leads?person_id=…)
|
||||
# must match before the creation POST's fragment.
|
||||
http["respond"]["api/v1/leads?"] = (200, {"data": []})
|
||||
http["respond"]["api/v1/leads"] = (201, {"data": {"id": "lead-1"}})
|
||||
http["respond"]["api/v1/notes"] = (201, {"data": {"id": 9}})
|
||||
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
|
||||
assert result.ok and result.action == "created"
|
||||
assert result.contact_id == "42"
|
||||
assert result.secondary_id == "lead-1"
|
||||
assert result.record_url == f"{API_DOMAIN}/person/42"
|
||||
|
||||
urls = [str(r.url) for r in http["requests"]]
|
||||
assert "persons/search" in urls[0]
|
||||
assert urls[1].endswith("/api/v2/persons")
|
||||
# Lead listing (open-lead check) precedes lead creation.
|
||||
assert "api/v1/leads?" in urls[2]
|
||||
assert urls[3].endswith("/api/v1/leads")
|
||||
assert urls[4].endswith("/api/v1/notes")
|
||||
|
||||
person = json.loads(http["requests"][1].content)
|
||||
assert person["emails"][0]["value"] == "jane@acme.com"
|
||||
note = json.loads(http["requests"][4].content)
|
||||
assert note["lead_id"] == "lead-1"
|
||||
assert "<b>Team size:</b> 40" in note["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_links_organization(self, adapter, http, tokens):
|
||||
payload = LeadPayload(lead_response_id=uuid.uuid4(), email="jane@acme.com",
|
||||
name="Jane Doe", company="Acme Inc")
|
||||
http["respond"]["organizations/search"] = (200, {"data": {"items": []}})
|
||||
http["respond"]["api/v2/organizations"] = (201, {"data": {"id": 77}})
|
||||
http["respond"]["persons/search"] = (200, {"data": {"items": []}})
|
||||
http["respond"]["api/v2/persons"] = (201, {"data": {"id": 42}})
|
||||
http["respond"]["api/v1/leads?"] = (200, {"data": []})
|
||||
http["respond"]["api/v1/leads"] = (201, {"data": {"id": "lead-1"}})
|
||||
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
|
||||
assert result.ok
|
||||
# Org found-or-created, then the person body carries org_id.
|
||||
org_create = next(r for r in http["requests"]
|
||||
if r.method == "POST" and r.url.path.endswith("/api/v2/organizations"))
|
||||
assert json.loads(org_create.content)["name"] == "Acme Inc"
|
||||
person_create = next(r for r in http["requests"]
|
||||
if r.method == "POST" and r.url.path.endswith("/api/v2/persons"))
|
||||
assert json.loads(person_create.content)["org_id"] == 77
|
||||
# The Lead carries its own organization_id (separate from the person's).
|
||||
lead_create = next(r for r in http["requests"]
|
||||
if r.method == "POST" and r.url.path.endswith("/api/v1/leads"))
|
||||
assert json.loads(lead_create.content)["organization_id"] == 77
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_company_skips_org_calls(self, adapter, http, tokens, payload):
|
||||
# payload has no company → no organization API calls at all.
|
||||
http["respond"]["persons/search"] = (200, {"data": {"items": []}})
|
||||
http["respond"]["api/v2/persons"] = (201, {"data": {"id": 42}})
|
||||
http["respond"]["api/v1/leads?"] = (200, {"data": []})
|
||||
http["respond"]["api/v1/leads"] = (201, {"data": {"id": "lead-1"}})
|
||||
await adapter.push_lead(tokens, payload)
|
||||
assert not any("organizations" in str(r.url) for r in http["requests"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_person_gets_blanks_filled_only(self, adapter, http,
|
||||
tokens, payload):
|
||||
http["respond"]["persons/search"] = (200, {"data": {"items": [
|
||||
{"item": {"id": 42, "name": "Jane Doe", "phones": []}}]}})
|
||||
http["respond"]["api/v2/persons/42"] = (200, {"data": {"id": 42}})
|
||||
http["respond"]["api/v1/leads?"] = (200, {"data": []})
|
||||
http["respond"]["api/v1/leads"] = (201, {"data": {"id": "lead-1"}})
|
||||
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
assert result.ok and result.action == "updated"
|
||||
|
||||
patch = next(r for r in http["requests"] if r.method == "PATCH")
|
||||
body = json.loads(patch.content)
|
||||
assert "name" not in body # already set — not clobbered
|
||||
assert body["phones"][0]["value"] == "+15550100" # blank → filled
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_lead_skips_lead_creation(self, adapter, http, tokens, payload):
|
||||
http["respond"]["persons/search"] = (200, {"data": {"items": [
|
||||
{"item": {"id": 42, "name": "Jane Doe", "phones": [{"value": "+1"}]}}]}})
|
||||
http["respond"]["api/v1/leads?"] = (200, {"data": [{"id": "existing-lead"}]})
|
||||
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
|
||||
assert result.ok and result.secondary_id is None
|
||||
# No POST to /api/v1/leads and no note.
|
||||
posts = [r for r in http["requests"] if r.method == "POST"]
|
||||
assert posts == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lead_listing_failure_retries_instead_of_duplicating(
|
||||
self, adapter, http, tokens, payload):
|
||||
http["respond"]["persons/search"] = (200, {"data": {"items": [
|
||||
{"item": {"id": 42, "name": "Jane Doe", "phones": [{"value": "+1"}]}}]}})
|
||||
http["respond"]["api/v1/leads?"] = (500, {"error": "flaky"})
|
||||
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
|
||||
assert not result.ok and result.retryable
|
||||
# No lead was blindly created while the open-lead check was failing.
|
||||
assert [r for r in http["requests"] if r.method == "POST"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_401_flags_auth_failure(self, adapter, http, tokens, payload):
|
||||
http["respond"]["persons/search"] = (401, {"error": "unauthorized"})
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
assert not result.ok and result.auth_failed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_person_create_429_is_retryable(self, adapter, http, monkeypatch,
|
||||
tokens, payload):
|
||||
def handler(request):
|
||||
if "persons/search" in str(request.url):
|
||||
return httpx.Response(200, json={"data": {"items": []}})
|
||||
return httpx.Response(429, headers={"Retry-After": "5"}, json={})
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
monkeypatch.setattr(crm_base, "_http_client", client)
|
||||
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
assert not result.ok and result.retryable
|
||||
assert result.retry_after_seconds == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_is_retryable(self, adapter, monkeypatch, tokens, payload):
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
monkeypatch.setattr(crm_base, "_http_client", client)
|
||||
|
||||
result = await adapter.push_lead(tokens, payload)
|
||||
assert not result.ok and result.retryable
|
||||
|
||||
|
||||
class TestConnectionLifecycle:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_ok(self, adapter, http, tokens):
|
||||
http["respond"]["users/me"] = (200, {"data": {"company_name": "Acme"}})
|
||||
info = await adapter.test_connection(tokens)
|
||||
assert info == {"ok": True, "account_name": "Acme", "error": None}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_never_raises(self, adapter, monkeypatch, tokens):
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
monkeypatch.setattr(crm_base, "_http_client", client)
|
||||
await adapter.revoke(tokens) # must not raise
|
||||
|
||||
|
||||
FAKE_OAUTH_BASE = "http://127.0.0.1:14020/pipedrive-oauth"
|
||||
FAKE_API_BASE = "http://127.0.0.1:14020/pipedrive"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reload_pipedrive(monkeypatch):
|
||||
"""Re-import app.crm.pipedrive under the given Pipedrive bases, then restore it."""
|
||||
import importlib
|
||||
import app.crm.pipedrive as pipedrive_module
|
||||
|
||||
names = ("PIPEDRIVE_OAUTH_BASE", "PIPEDRIVE_API_BASE")
|
||||
|
||||
def _reload(**env):
|
||||
for name in names:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
for name, value in env.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
return importlib.reload(pipedrive_module)
|
||||
|
||||
yield _reload
|
||||
for name in names:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
importlib.reload(pipedrive_module)
|
||||
|
||||
|
||||
class TestHostOverrides:
|
||||
|
||||
def test_urls_default_to_pipedrive(self, reload_pipedrive, monkeypatch):
|
||||
pipedrive = reload_pipedrive()
|
||||
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "cid")
|
||||
assert pipedrive.AUTHORIZE_URL == "https://oauth.pipedrive.com/oauth/authorize"
|
||||
assert pipedrive.TOKEN_URL == "https://oauth.pipedrive.com/oauth/token"
|
||||
assert pipedrive.REVOKE_URL == "https://oauth.pipedrive.com/oauth/revoke"
|
||||
assert pipedrive.PipedriveAdapter().get_authorize_url("s", "https://app/cb").startswith(
|
||||
"https://oauth.pipedrive.com/oauth/authorize?client_id=cid&")
|
||||
# Without the override only https pipedrive.com hosts are trusted.
|
||||
assert pipedrive._validated_api_domain(API_DOMAIN) == API_DOMAIN
|
||||
assert pipedrive._validated_api_domain(FAKE_API_BASE) is None
|
||||
|
||||
def test_bases_are_overridable(self, reload_pipedrive):
|
||||
pipedrive = reload_pipedrive(PIPEDRIVE_OAUTH_BASE=FAKE_OAUTH_BASE + "/",
|
||||
PIPEDRIVE_API_BASE=FAKE_API_BASE + "/")
|
||||
assert pipedrive.AUTHORIZE_URL == f"{FAKE_OAUTH_BASE}/oauth/authorize"
|
||||
assert pipedrive.TOKEN_URL == f"{FAKE_OAUTH_BASE}/oauth/token"
|
||||
assert pipedrive.REVOKE_URL == f"{FAKE_OAUTH_BASE}/oauth/revoke"
|
||||
assert pipedrive._validated_api_domain(FAKE_API_BASE + "/") == FAKE_API_BASE
|
||||
# The override widens the guard by exactly one origin.
|
||||
assert pipedrive._validated_api_domain(API_DOMAIN) == API_DOMAIN
|
||||
assert pipedrive._validated_api_domain("http://127.0.0.1:14020/other") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_flow_reaches_the_override(self, reload_pipedrive, http, monkeypatch):
|
||||
pipedrive = reload_pipedrive(PIPEDRIVE_OAUTH_BASE=FAKE_OAUTH_BASE,
|
||||
PIPEDRIVE_API_BASE=FAKE_API_BASE)
|
||||
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "cid")
|
||||
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_SECRET", "sek")
|
||||
http["respond"]["/oauth/token"] = (200, {
|
||||
"access_token": "at", "refresh_token": "rt", "expires_in": 3600,
|
||||
"api_domain": FAKE_API_BASE})
|
||||
http["respond"]["users/me"] = (200, {
|
||||
"data": {"company_id": 777, "company_name": "Acme"}})
|
||||
|
||||
tokens = await pipedrive.PipedriveAdapter().exchange_code("code-1", "https://app/cb")
|
||||
|
||||
assert [str(r.url) for r in http["requests"]] == [
|
||||
f"{FAKE_OAUTH_BASE}/oauth/token", f"{FAKE_API_BASE}/api/v1/users/me"]
|
||||
assert tokens.api_domain == FAKE_API_BASE
|
||||
assert tokens.external_account_id == "777"
|
||||
Reference in new issue
Block a user