feat(customer): 网站客户身份签发与长期负责人绑定

可信客户身份由站点后端签发,长期负责人与本次接待人分开保存。
This commit is contained in:
WangShen007 committed 2026-10-10 21:15:00 +08:00
1 parent 164f5f9615
commit 2c43a13774
18 files changed
+3123

No files matched your search

+360
View File
@@ -0,0 +1,360 @@
"""
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 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"