Implement identity graph stitching and configurable profile storage
Issue persistent signed anonymous IDs and accept cookie/token-bound events. Resolve verified email, phone, OAuth, and namespaced third-party IDs into transactionally merged master identities with historical event aliases. Protect shared-device account switching, tenant isolation, and event retries. Store sparse canonical profiles as PostgreSQL JSONB or local SQLite JSON. Support an optional versioned Cassandra wide-column projection through a transactional outbox, retry worker, and immediate canonical read fallback. Document deployment settings and provide opt-in database integration tests. Validation: 43 local tests passed; 6 external database tests skipped. Ruff checks, formatting, and offline uv lock verification passed.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"""Optional supplied Cassandra environment; never install/start a server here."""
|
||||
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from adcloud_server.database import Database
|
||||
from adcloud_server.identity_models import ProfilePatch, StitchRequest
|
||||
from adcloud_server.identity_service import IdentityService
|
||||
from adcloud_server.profile_store import CassandraProfileStore
|
||||
from adcloud_server.projection import ProfileProjector
|
||||
from adcloud_server.settings import Settings
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.getenv("ADCLOUD_TEST_CASSANDRA_HOSTS"),
|
||||
reason="No Cassandra integration cluster configured",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def components(tmp_path):
|
||||
settings = Settings(
|
||||
database_url="sqlite:///" + str(tmp_path / "graph.db"),
|
||||
profile_backend="cassandra",
|
||||
tenant_id="test-" + str(uuid4()),
|
||||
identity_secret="test-cassandra-" + "x" * 48,
|
||||
cassandra_hosts=tuple(os.environ["ADCLOUD_TEST_CASSANDRA_HOSTS"].split(",")),
|
||||
cassandra_port=int(os.getenv("ADCLOUD_TEST_CASSANDRA_PORT", "9042")),
|
||||
cassandra_keyspace=os.environ["ADCLOUD_TEST_CASSANDRA_KEYSPACE"],
|
||||
cassandra_username=os.getenv("ADCLOUD_TEST_CASSANDRA_USERNAME", ""),
|
||||
cassandra_password=os.getenv("ADCLOUD_TEST_CASSANDRA_PASSWORD", ""),
|
||||
cassandra_tls=os.getenv("ADCLOUD_TEST_CASSANDRA_TLS", "true").lower() == "true",
|
||||
cassandra_ca_file=os.getenv("ADCLOUD_TEST_CASSANDRA_CA_FILE") or None,
|
||||
)
|
||||
db = Database(settings.database_url, settings.tenant_id)
|
||||
db.initialize()
|
||||
store = CassandraProfileStore(settings)
|
||||
try:
|
||||
yield IdentityService(settings, db, store), ProfileProjector(db, store), store
|
||||
finally:
|
||||
store.close()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_cassandra_sparse_snapshot_roundtrip(components):
|
||||
service, _worker, store = components
|
||||
master = str(uuid4())
|
||||
document = {
|
||||
"attributes": {"nested": {"interest": "running"}, "enabled": True, "score": 0},
|
||||
"tags": ["runner"],
|
||||
}
|
||||
store.put(service.tenant, master, 1, document)
|
||||
assert store.get(service.tenant, master, 1) == document
|
||||
assert store.get(service.tenant, master, 2) is None
|
||||
|
||||
|
||||
def test_cassandra_immutable_revision_prevents_stale_attribute_resurrection(components):
|
||||
service, _worker, store = components
|
||||
master = str(uuid4())
|
||||
old = {"attributes": {"remove": "old"}, "tags": []}
|
||||
new = {"attributes": {}, "tags": []}
|
||||
store.put(service.tenant, master, 2, new)
|
||||
store.put(service.tenant, master, 1, old)
|
||||
assert store.get(service.tenant, master, 2) == new
|
||||
|
||||
|
||||
def test_cassandra_worker_publishes_stitched_profile(components):
|
||||
service, worker, _store = components
|
||||
anon = service.anonymous()
|
||||
stitched = service.stitch(
|
||||
anon["anonymous_token"],
|
||||
StitchRequest(
|
||||
identifiers=[{"kind": "email", "value": "alice@example.com"}],
|
||||
attributes={"interest": "running"},
|
||||
),
|
||||
)
|
||||
assert service.profile(stitched["master_id"])["projection_status"] == "pending"
|
||||
worker.flush()
|
||||
current = service.profile(stitched["master_id"])
|
||||
assert current["projection_status"] == "current"
|
||||
assert current["attributes"] == {"interest": "running"}
|
||||
service.patch_profile(
|
||||
stitched["master_id"], ProfilePatch(attributes={"interest": None})
|
||||
)
|
||||
worker.flush()
|
||||
assert service.profile(stitched["master_id"])["attributes"] == {}
|
||||
@@ -0,0 +1,438 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from adcloud_server.database import Database, identifiers
|
||||
from adcloud_server.identity_models import StitchRequest
|
||||
from adcloud_server.identity_service import IdentityService
|
||||
from adcloud_server.main import create_app
|
||||
from adcloud_server.settings import Settings
|
||||
|
||||
SECRET = "identity-test-secret-" + "x" * 32
|
||||
SERVICE_TOKEN = "trusted-test-token-" + "y" * 32
|
||||
AUTH = {"Authorization": "Bearer " + SERVICE_TOKEN}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path):
|
||||
return Settings(
|
||||
database_url="sqlite:///" + str(tmp_path / "identity.db"),
|
||||
identity_secret=SECRET,
|
||||
service_token=SERVICE_TOKEN,
|
||||
cookie_secure=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(settings):
|
||||
with TestClient(create_app(settings)) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def anonymous(client):
|
||||
response = client.post("/v1/identity/anonymous")
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def stitch(client, identity, token=None, **kwargs):
|
||||
body = {"identifiers": identity, **kwargs}
|
||||
if token:
|
||||
body["anonymous_token"] = token
|
||||
response = client.post("/v1/identity/stitch", json=body, headers=AUTH)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def email(value="alice@example.com"):
|
||||
return {"kind": "email", "value": value}
|
||||
|
||||
|
||||
def profile(client, master):
|
||||
response = client.get("/v1/users/" + master, headers=AUTH)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def history(client, master):
|
||||
response = client.get("/v1/events", params={"master_id": master}, headers=AUTH)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_first_event_seeds_persistent_cookie_and_reuses_it(client):
|
||||
first = client.post(
|
||||
"/v1/events", json={"name": "page.view", "properties": {"path": "/shoes"}}
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert "HttpOnly" in first.headers["set-cookie"]
|
||||
assert "Max-Age=31536000" in first.headers["set-cookie"]
|
||||
assert "SameSite=lax" in first.headers["set-cookie"]
|
||||
assert first.headers["cache-control"] == "no-store"
|
||||
second = anonymous(client)
|
||||
assert first.json()["anonymous_id"] == second["anonymous_id"]
|
||||
assert profile(client, second["master_id"])["event_count"] == 1
|
||||
|
||||
|
||||
def test_cross_device_stitch_inherits_events_sparse_traits_and_tags(client):
|
||||
known = anonymous(client)
|
||||
stitch(client, [email()], attributes={"preferred_color": "blue"}, tags=["member"])
|
||||
client.post("/v1/identity/anonymous?rotate=true")
|
||||
visitor = anonymous(client)
|
||||
client.patch(
|
||||
"/v1/users/" + visitor["master_id"],
|
||||
headers=AUTH,
|
||||
json={
|
||||
"attributes": {
|
||||
"interest": "running",
|
||||
"missing": None,
|
||||
"nested": {"present": 1, "empty": None},
|
||||
"preferred_color": "red",
|
||||
},
|
||||
"tags": ["runner"],
|
||||
},
|
||||
)
|
||||
event = client.post(
|
||||
"/v1/events", json={"name": "product.view", "properties": {"sku": "shoe-1"}}
|
||||
).json()
|
||||
result = stitch(client, [email()])
|
||||
assert result["master_id"] == known["master_id"]
|
||||
assert visitor["master_id"] in result["merged_master_ids"]
|
||||
assert result["rotated_anonymous_id"] is False
|
||||
merged = profile(client, result["master_id"])
|
||||
assert merged["attributes"] == {
|
||||
"preferred_color": "blue",
|
||||
"interest": "running",
|
||||
"nested": {"present": 1},
|
||||
}
|
||||
assert merged["tags"] == ["member", "runner"]
|
||||
assert merged["event_count"] == 1
|
||||
assert history(client, visitor["master_id"])["items"][0]["id"] == event["event_id"]
|
||||
graph = client.get(
|
||||
"/v1/identity/" + result["master_id"] + "/graph", headers=AUTH
|
||||
).json()
|
||||
assert len(graph["member_ids"]) == 2
|
||||
assert len(graph["edges"]) == 1
|
||||
assert graph["identifiers"][0]["display"] == "a***@example.com"
|
||||
|
||||
|
||||
def test_transitive_verified_bridge_resolves_every_alias(client):
|
||||
anonymous(client)
|
||||
a = stitch(client, [email()])
|
||||
client.post("/v1/identity/anonymous?rotate=true")
|
||||
phone = {"kind": "phone", "value": "+1 (415) 555-2671"}
|
||||
b = stitch(client, [phone])
|
||||
client.post("/v1/identity/anonymous?rotate=true")
|
||||
oauth = {
|
||||
"kind": "oauth",
|
||||
"namespace": "https://issuer.example/client-a",
|
||||
"value": "subject-123",
|
||||
}
|
||||
c = stitch(client, [oauth])
|
||||
joined = stitch(client, [email(), phone, oauth])
|
||||
assert joined["master_id"] == a["master_id"]
|
||||
for old in [a, b, c]:
|
||||
assert profile(client, old["master_id"])["master_id"] == a["master_id"]
|
||||
resolved = client.post(
|
||||
"/v1/identity/resolve",
|
||||
headers=AUTH,
|
||||
json={
|
||||
"identifiers": [email(), {"kind": "phone", "value": "+14155552671"}, oauth]
|
||||
},
|
||||
).json()
|
||||
assert resolved == {
|
||||
"master_id": a["master_id"],
|
||||
"matches": [a["master_id"]],
|
||||
"ambiguous": False,
|
||||
}
|
||||
|
||||
|
||||
def test_shared_browser_switches_accounts_without_merging_them(client):
|
||||
a = stitch_after_anonymous(client, email())
|
||||
client.post("/v1/events", json={"name": "alice.view"})
|
||||
b = stitch(client, [email("bob@example.com")])
|
||||
assert b["rotated_anonymous_id"] is True
|
||||
assert a["master_id"] != b["master_id"]
|
||||
assert profile(client, b["master_id"])["event_count"] == 0
|
||||
assert profile(client, a["master_id"])["event_count"] == 1
|
||||
|
||||
|
||||
def stitch_after_anonymous(client, identifier):
|
||||
anonymous(client)
|
||||
return stitch(client, [identifier])
|
||||
|
||||
|
||||
def test_authenticated_backend_can_add_new_identity_to_current_account(client):
|
||||
a = stitch_after_anonymous(client, email())
|
||||
result = stitch(
|
||||
client,
|
||||
[{"kind": "phone", "value": "+14155552671"}],
|
||||
account_master_id=a["master_id"],
|
||||
)
|
||||
assert result["master_id"] == a["master_id"]
|
||||
assert result["rotated_anonymous_id"] is False
|
||||
assert (
|
||||
len(
|
||||
client.get(
|
||||
"/v1/identity/" + a["master_id"] + "/graph", headers=AUTH
|
||||
).json()["identifiers"]
|
||||
)
|
||||
== 2
|
||||
)
|
||||
|
||||
|
||||
def test_namespace_isolates_third_party_subject_ids(client):
|
||||
first = {"kind": "oauth", "namespace": "issuer/client-a", "value": "same-subject"}
|
||||
second = {"kind": "oauth", "namespace": "issuer/client-b", "value": "same-subject"}
|
||||
a = stitch_after_anonymous(client, first)
|
||||
client.post("/v1/identity/anonymous?rotate=true")
|
||||
b = stitch(client, [second])
|
||||
assert a["master_id"] != b["master_id"]
|
||||
resolved = client.post(
|
||||
"/v1/identity/resolve", headers=AUTH, json={"identifiers": [first, second]}
|
||||
).json()
|
||||
assert resolved["ambiguous"] is True
|
||||
assert resolved["master_id"] is None
|
||||
|
||||
|
||||
def test_email_domain_normalization_preserves_case_sensitive_local_parts(client):
|
||||
first = stitch_after_anonymous(client, email("Alice@EXAMPLE.com"))
|
||||
resolved = client.post(
|
||||
"/v1/identity/resolve",
|
||||
headers=AUTH,
|
||||
json={"identifiers": [email("Alice@example.com")]},
|
||||
).json()
|
||||
assert resolved["master_id"] == first["master_id"]
|
||||
different = client.post(
|
||||
"/v1/identity/resolve",
|
||||
headers=AUTH,
|
||||
json={"identifiers": [email("alice@example.com")]},
|
||||
).json()
|
||||
assert different["master_id"] is None
|
||||
|
||||
|
||||
def test_stitch_requires_trusted_backend_and_signed_anonymous_proof(client):
|
||||
anon = anonymous(client)
|
||||
response = client.post("/v1/identity/stitch", json={"identifiers": [email()]})
|
||||
assert response.status_code == 401
|
||||
response = client.post(
|
||||
"/v1/identity/stitch",
|
||||
headers=AUTH,
|
||||
json={"identifiers": [email()], "anonymous_token": anon["anonymous_id"]},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
response = client.get("/v1/users/" + anon["master_id"])
|
||||
assert response.status_code == 401
|
||||
response = client.post(
|
||||
"/v1/identity/stitch",
|
||||
headers=AUTH,
|
||||
json={"identifiers": [{**email(), "verified": True}]},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier",
|
||||
[
|
||||
email("not-an-email"),
|
||||
{"kind": "phone", "value": "4155552671"},
|
||||
{"kind": "phone", "value": "+10000000000"},
|
||||
{"kind": "oauth", "value": "subject"},
|
||||
{"kind": "maid", "value": "device-id"},
|
||||
],
|
||||
)
|
||||
def test_invalid_or_weak_identifiers_cannot_create_edges(client, identifier):
|
||||
anon = anonymous(client)
|
||||
response = client.post(
|
||||
"/v1/identity/stitch", headers=AUTH, json={"identifiers": [identifier]}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert (
|
||||
client.get("/v1/identity/" + anon["master_id"] + "/graph", headers=AUTH).json()[
|
||||
"identifiers"
|
||||
]
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_browser_origin_and_credentials(client):
|
||||
denied = client.post(
|
||||
"/v1/events",
|
||||
headers={"Origin": "https://untrusted.example"},
|
||||
json={"name": "page.view"},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
preflight = client.options(
|
||||
"/v1/events",
|
||||
headers={
|
||||
"Origin": "http://localhost:5174",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
},
|
||||
)
|
||||
assert preflight.status_code == 200
|
||||
assert preflight.headers["access-control-allow-credentials"] == "true"
|
||||
assert preflight.headers["access-control-allow-origin"] == "http://localhost:5174"
|
||||
|
||||
|
||||
def test_event_replay_is_idempotent_and_cannot_steal_another_subjects_event(client):
|
||||
event_id = str(uuid4())
|
||||
body = {"event_id": event_id, "name": "page.view"}
|
||||
first = client.post("/v1/events", json=body).json()
|
||||
replay = client.post("/v1/events", json=body).json()
|
||||
assert replay["duplicate"] is True
|
||||
assert profile(client, first["master_id"])["event_count"] == 1
|
||||
client.post("/v1/identity/anonymous?rotate=true")
|
||||
assert client.post("/v1/events", json=body).status_code == 409
|
||||
assert (
|
||||
client.post(
|
||||
"/v1/events",
|
||||
json={"name": "page.view", "occurred_at": "2020-01-01T00:00:00"},
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
|
||||
|
||||
def test_profile_patch_omits_nulls_and_deletes_explicitly(client):
|
||||
anon = anonymous(client)
|
||||
client.patch(
|
||||
"/v1/users/" + anon["master_id"],
|
||||
headers=AUTH,
|
||||
json={"attributes": {"keep": 1, "remove": "later"}},
|
||||
)
|
||||
result = client.patch(
|
||||
"/v1/users/" + anon["master_id"],
|
||||
headers=AUTH,
|
||||
json={"attributes": {"remove": None}},
|
||||
).json()
|
||||
assert result["attributes"] == {"keep": 1}
|
||||
|
||||
|
||||
def test_persistence_survives_restart(settings):
|
||||
with TestClient(create_app(settings)) as first:
|
||||
anon = anonymous(first)
|
||||
first.post("/v1/events", json={"name": "page.view"})
|
||||
stitched = stitch(first, [email()])
|
||||
with TestClient(create_app(settings)) as second:
|
||||
recorded = second.post(
|
||||
"/v1/events",
|
||||
json={"name": "page.return", "anonymous_token": anon["anonymous_token"]},
|
||||
).json()
|
||||
assert recorded["master_id"] == stitched["master_id"]
|
||||
assert profile(second, stitched["master_id"])["event_count"] == 2
|
||||
service = second.app.state.identity_service
|
||||
with service.db.transaction() as conn:
|
||||
stored = conn.execute(select(identifiers)).mappings().one()
|
||||
assert "alice" not in stored["digest"]
|
||||
assert "value" not in stored
|
||||
|
||||
|
||||
def test_tenants_cannot_resolve_or_reuse_each_others_ids(settings):
|
||||
other = Settings(
|
||||
database_url=settings.database_url,
|
||||
tenant_id="other",
|
||||
identity_secret=SECRET,
|
||||
service_token=SERVICE_TOKEN,
|
||||
cookie_secure=False,
|
||||
)
|
||||
with (
|
||||
TestClient(create_app(settings)) as first,
|
||||
TestClient(create_app(other)) as second,
|
||||
):
|
||||
a = stitch_after_anonymous(first, email())
|
||||
b = stitch_after_anonymous(second, email())
|
||||
assert a["master_id"] != b["master_id"]
|
||||
assert (
|
||||
second.get("/v1/users/" + a["master_id"], headers=AUTH).status_code == 404
|
||||
)
|
||||
assert (
|
||||
second.post(
|
||||
"/v1/events",
|
||||
json={"name": "forged", "anonymous_token": a["anonymous_token"]},
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
|
||||
def test_parallel_workers_stitch_same_email_into_one_master(settings):
|
||||
databases = [Database(settings.database_url, settings.tenant_id) for _ in range(2)]
|
||||
for db in databases:
|
||||
db.initialize()
|
||||
services = [IdentityService(settings, db) for db in databases]
|
||||
tokens = [service.anonymous()["anonymous_token"] for service in services]
|
||||
request = StitchRequest(identifiers=[email()])
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(
|
||||
pool.map(lambda i: services[i].stitch(tokens[i], request), [0, 1])
|
||||
)
|
||||
assert results[0]["master_id"] == results[1]["master_id"]
|
||||
assert len(services[0].graph(results[0]["master_id"])["identifiers"]) == 1
|
||||
finally:
|
||||
for db in databases:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_merge_limit_failure_rolls_back_all_alias_and_profile_changes(client):
|
||||
a = stitch_after_anonymous(client, email())
|
||||
client.patch(
|
||||
"/v1/users/" + a["master_id"],
|
||||
headers=AUTH,
|
||||
json={"attributes": {"a" + str(i): i for i in range(150)}},
|
||||
)
|
||||
client.post("/v1/identity/anonymous?rotate=true")
|
||||
b = anonymous(client)
|
||||
client.patch(
|
||||
"/v1/users/" + b["master_id"],
|
||||
headers=AUTH,
|
||||
json={"attributes": {"b" + str(i): i for i in range(150)}},
|
||||
)
|
||||
result = client.post(
|
||||
"/v1/identity/stitch", headers=AUTH, json={"identifiers": [email()]}
|
||||
)
|
||||
assert result.status_code == 409
|
||||
assert profile(client, b["master_id"])["master_id"] == b["master_id"]
|
||||
assert len(profile(client, a["master_id"])["attributes"]) == 150
|
||||
assert len(profile(client, b["master_id"])["attributes"]) == 150
|
||||
|
||||
|
||||
def test_missing_configuration_disables_identity_but_keeps_gateway_available(tmp_path):
|
||||
with TestClient(
|
||||
create_app(Settings(database_url="sqlite:///" + str(tmp_path / "unused.db")))
|
||||
) as client:
|
||||
assert client.get("/health").status_code == 200
|
||||
assert client.post("/v1/identity/anonymous").status_code == 503
|
||||
assert (
|
||||
client.post(
|
||||
"/v1/identity/stitch", json={"identifiers": [email()]}
|
||||
).status_code
|
||||
== 503
|
||||
)
|
||||
assert not (tmp_path / "unused.db").exists()
|
||||
|
||||
|
||||
def test_secure_cookie_configuration():
|
||||
with TestClient(
|
||||
create_app(Settings(database_url="sqlite://", identity_secret=SECRET)),
|
||||
base_url="https://testserver",
|
||||
) as client:
|
||||
result = client.post("/v1/identity/anonymous")
|
||||
assert "Secure" in result.headers["set-cookie"]
|
||||
assert anonymous(client)["master_id"] == result.json()["master_id"]
|
||||
with pytest.raises(ValueError):
|
||||
Settings(cookie_secure=False, cookie_samesite="none")
|
||||
with pytest.raises(ValueError):
|
||||
Settings(profile_backend="unknown")
|
||||
|
||||
|
||||
def test_parallel_workers_initialize_empty_database_safely(settings):
|
||||
databases = [Database(settings.database_url, settings.tenant_id) for _ in range(2)]
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
list(pool.map(lambda db: db.initialize(), databases))
|
||||
assert IdentityService(settings, databases[0]).anonymous()["master_id"]
|
||||
finally:
|
||||
for db in databases:
|
||||
db.close()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Optional real PostgreSQL integration: set ADCLOUD_TEST_POSTGRES_URL."""
|
||||
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from adcloud_server.database import Database
|
||||
from adcloud_server.identity_models import EventRequest, ProfilePatch, StitchRequest
|
||||
from adcloud_server.identity_service import IdentityService
|
||||
from adcloud_server.settings import Settings
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.getenv("ADCLOUD_TEST_POSTGRES_URL"),
|
||||
reason="No PostgreSQL integration URL configured",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
settings = Settings(
|
||||
database_url=os.environ["ADCLOUD_TEST_POSTGRES_URL"],
|
||||
tenant_id="test-" + str(uuid4()),
|
||||
identity_secret="postgres-test-" + "x" * 48,
|
||||
profile_backend="cassandra",
|
||||
)
|
||||
db = Database(settings.database_url, settings.tenant_id)
|
||||
db.initialize()
|
||||
try:
|
||||
yield IdentityService(settings, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_postgres_jsonb_stitch_and_immediate_history(service):
|
||||
known = service.anonymous()
|
||||
identity = StitchRequest(
|
||||
identifiers=[{"kind": "email", "value": "alice@example.com"}]
|
||||
)
|
||||
service.stitch(known["anonymous_token"], identity)
|
||||
visitor = service.anonymous(rotate=True)
|
||||
service.patch_profile(
|
||||
visitor["master_id"],
|
||||
ProfilePatch(
|
||||
attributes={"nested": {"interest": "running"}, "absent": None},
|
||||
tags=["runner"],
|
||||
),
|
||||
)
|
||||
event = service.record_event(
|
||||
visitor["anonymous_token"], EventRequest(name="product.view")
|
||||
)
|
||||
stitched = service.stitch(visitor["anonymous_token"], identity)
|
||||
assert stitched["master_id"] == known["master_id"]
|
||||
assert service.profile(known["master_id"])["attributes"] == {
|
||||
"nested": {"interest": "running"}
|
||||
}
|
||||
assert service.history(visitor["master_id"])["items"][0]["id"] == event["event_id"]
|
||||
with service.db.transaction() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT pg_typeof(document)::text FROM identity_profiles WHERE tenant_id=:tenant LIMIT 1"
|
||||
),
|
||||
{"tenant": service.tenant},
|
||||
).scalar_one()
|
||||
assert result == "jsonb"
|
||||
|
||||
|
||||
def test_postgres_multiple_process_connections_serialize_identity_merges(service):
|
||||
second_db = Database(service.settings.database_url, service.tenant)
|
||||
other = IdentityService(service.settings, second_db)
|
||||
tokens = [
|
||||
service.anonymous()["anonymous_token"],
|
||||
other.anonymous()["anonymous_token"],
|
||||
]
|
||||
instances = [service, other]
|
||||
request = StitchRequest(identifiers=[{"kind": "phone", "value": "+14155552671"}])
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(
|
||||
pool.map(lambda i: instances[i].stitch(tokens[i], request), [0, 1])
|
||||
)
|
||||
assert results[0]["master_id"] == results[1]["master_id"]
|
||||
assert len(service.graph(results[0]["master_id"])["identifiers"]) == 1
|
||||
finally:
|
||||
second_db.close()
|
||||
|
||||
|
||||
def test_postgres_failed_transaction_rolls_back_graph_and_outbox(service, monkeypatch):
|
||||
visitor = service.anonymous()
|
||||
|
||||
def fail(*args):
|
||||
raise OSError("Simulated outbox failure")
|
||||
|
||||
monkeypatch.setattr(service, "_queue", fail)
|
||||
with pytest.raises(OSError):
|
||||
service.stitch(
|
||||
visitor["anonymous_token"],
|
||||
StitchRequest(
|
||||
identifiers=[{"kind": "email", "value": "alice@example.com"}]
|
||||
),
|
||||
)
|
||||
assert service.graph(visitor["master_id"])["identifiers"] == []
|
||||
assert service.profile(visitor["master_id"])["revision"] == 1
|
||||
@@ -0,0 +1,146 @@
|
||||
import copy
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
from adcloud_server.database import Database, outbox, profiles
|
||||
from adcloud_server.identity_models import ProfilePatch, StitchRequest
|
||||
from adcloud_server.identity_service import IdentityService
|
||||
from adcloud_server.projection import ProfileProjector
|
||||
from adcloud_server.settings import Settings
|
||||
|
||||
|
||||
class RecoverableStore:
|
||||
def __init__(self):
|
||||
self.snapshots = {}
|
||||
self.unavailable = False
|
||||
|
||||
def put(self, tenant, master, revision, document):
|
||||
if self.unavailable:
|
||||
raise OSError("Simulated storage outage")
|
||||
self.snapshots[tenant, master, revision] = copy.deepcopy(document)
|
||||
|
||||
def get(self, tenant, master, revision):
|
||||
if self.unavailable:
|
||||
raise OSError("Simulated storage outage")
|
||||
return self.snapshots.get((tenant, master, revision))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def components(tmp_path):
|
||||
settings = Settings(
|
||||
database_url="sqlite:///" + str(tmp_path / "profiles.db"),
|
||||
identity_secret="x" * 48,
|
||||
profile_backend="cassandra",
|
||||
)
|
||||
db = Database(settings.database_url, settings.tenant_id)
|
||||
db.initialize()
|
||||
store = RecoverableStore()
|
||||
service = IdentityService(settings, db, store)
|
||||
try:
|
||||
yield service, ProfileProjector(db, store), store
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_wide_table_outage_preserves_immediate_stitched_profile_and_retries(components):
|
||||
service, worker, store = components
|
||||
anon = service.anonymous()
|
||||
result = service.stitch(
|
||||
anon["anonymous_token"],
|
||||
StitchRequest(
|
||||
identifiers=[{"kind": "email", "value": "alice@example.com"}],
|
||||
attributes={"interest": "running"},
|
||||
tags=["runner"],
|
||||
),
|
||||
)
|
||||
assert service.profile(result["master_id"])["projection_status"] == "pending"
|
||||
store.unavailable = True
|
||||
assert service.profile(result["master_id"])["attributes"] == {"interest": "running"}
|
||||
with pytest.raises(OSError):
|
||||
worker.flush()
|
||||
with service.db.transaction() as conn:
|
||||
pending = (
|
||||
conn.execute(select(outbox).where(outbox.c.delivered == 0)).mappings().all()
|
||||
)
|
||||
assert any(job["revision"] == result["revision"] for job in pending)
|
||||
store.unavailable = False
|
||||
worker.flush()
|
||||
current = service.profile(result["master_id"])
|
||||
assert current["projection_status"] == "current"
|
||||
assert current["tags"] == ["runner"]
|
||||
assert worker.flush() == 0
|
||||
|
||||
|
||||
def test_out_of_order_projection_cannot_resurrect_deleted_attributes(components):
|
||||
service, worker, store = components
|
||||
anon = service.anonymous()
|
||||
first = service.patch_profile(
|
||||
anon["master_id"], ProfilePatch(attributes={"remove": "old", "keep": 1})
|
||||
)
|
||||
store.put(
|
||||
service.tenant,
|
||||
anon["master_id"],
|
||||
first["revision"],
|
||||
{"attributes": first["attributes"], "tags": []},
|
||||
)
|
||||
last = service.patch_profile(
|
||||
anon["master_id"], ProfilePatch(attributes={"remove": None})
|
||||
)
|
||||
# A stale snapshot never satisfies the latest revision lookup.
|
||||
assert service.profile(anon["master_id"])["attributes"] == {"keep": 1}
|
||||
assert service.profile(anon["master_id"])["projection_status"] == "pending"
|
||||
worker.flush()
|
||||
assert service.profile(anon["master_id"])["revision"] == last["revision"]
|
||||
assert service.profile(anon["master_id"])["attributes"] == {"keep": 1}
|
||||
|
||||
|
||||
def test_relational_to_wide_table_switch_backfills_existing_profiles(components):
|
||||
service, worker, _store = components
|
||||
relational = IdentityService(
|
||||
replace(service.settings, profile_backend="relational"), service.db
|
||||
)
|
||||
anon = relational.anonymous()
|
||||
relational.patch_profile(
|
||||
anon["master_id"], ProfilePatch(attributes={"nested": {"count": 3}})
|
||||
)
|
||||
with service.db.transaction() as conn:
|
||||
assert conn.execute(select(outbox)).first() is None
|
||||
assert worker.backfill() == 1
|
||||
worker.flush()
|
||||
assert service.profile(anon["master_id"])["projection_status"] == "current"
|
||||
assert service.profile(anon["master_id"])["attributes"] == {"nested": {"count": 3}}
|
||||
|
||||
|
||||
def test_merge_failure_never_leaves_partial_outbox_records(components, monkeypatch):
|
||||
service, worker, _store = components
|
||||
a = service.anonymous()
|
||||
service.stitch(
|
||||
a["anonymous_token"],
|
||||
StitchRequest(identifiers=[{"kind": "email", "value": "alice@example.com"}]),
|
||||
)
|
||||
b = service.anonymous(rotate=True)
|
||||
worker.flush()
|
||||
|
||||
def fail(*args):
|
||||
raise OSError("Simulated transaction failure")
|
||||
|
||||
monkeypatch.setattr(service, "_queue", fail)
|
||||
with pytest.raises(OSError):
|
||||
service.stitch(
|
||||
b["anonymous_token"],
|
||||
StitchRequest(
|
||||
identifiers=[{"kind": "email", "value": "alice@example.com"}]
|
||||
),
|
||||
)
|
||||
assert service.profile(b["master_id"])["master_id"] == b["master_id"]
|
||||
assert worker.flush() == 0
|
||||
assert len(service.graph(a["master_id"])["member_ids"]) == 1
|
||||
|
||||
|
||||
def test_postgres_document_column_is_jsonb():
|
||||
ddl = str(CreateTable(profiles).compile(dialect=postgresql.dialect()))
|
||||
assert "document JSONB NOT NULL" in ddl
|
||||
Reference in New Issue
Block a user