from __future__ import annotations import unittest from dataclasses import asdict from datetime import timedelta from unittest.mock import patch from sqlalchemy import create_engine, inspect from sqlalchemy.orm import sessionmaker from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.distribution_lists import ( CAPABILITY_RECIPIENT_CHANNEL_FACTS, DistributionSourceReference, RecipientChannelFactsRequest, ) from govoplan_core.core.contact_points import ( CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, CONTACT_POINT_CONTRACT_VERSION, ContactPointResolutionRequest, ContactPointSourceRequest, ) from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider from govoplan_core.db.base import Base from govoplan_core.db.base import utcnow from govoplan_core.security.credential_envelopes import ( CredentialEnvelope, create_credential_envelope, ) from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult from govoplan_addresses.backend.capabilities import ( CAPABILITY_ADDRESSES_CONTACT_WRITER, CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, AddressesChannelFactsCapability, AddressesContactPointResolutionCapability, AddressesContactWriterCapability, AddressesLookupCapability, AddressesPeopleSearchProvider, AddressesRecipientSourceCapability, AddressWriterError, ) from govoplan_addresses.backend.db.models import ( AddressBook, AddressList, AddressListEntry, AddressSyncConflict, AddressSyncDiagnostic, AddressSyncSource, AddressSyncTombstone, Contact, ContactChannelRule, ContactEmail, ContactFieldProvenance, ContactMergeRecord, ContactPhone, ContactPointSnapshot, ContactPointQualityDecision, ContactPostalAddress, ContactRedirect, ) from govoplan_addresses.backend.schemas import ( AddressBookCreateRequest, AddressCardDavDiscoveryRequest, AddressListCreateRequest, AddressListEntryCreateRequest, AddressCardDavSourceCreateRequest, AddressSyncAttemptFinishRequest, AddressSyncConflictCreateRequest, AddressSyncConflictResolveRequest, AddressSyncDiagnosticCreateRequest, AddressSyncSourceCreateRequest, AddressSyncSourceUpdateRequest, AddressSyncTombstoneCreateRequest, ContactCreateRequest, ContactChannelRuleCreateRequest, ContactEmailPayload, ContactMergeRecoveryRequest, ContactMergeRequest, ContactPhonePayload, ContactPointQualityDecisionCreateRequest, ContactPostalAddressPayload, ContactPointSnapshotResponse, ContactUpdateRequest, ) from govoplan_addresses.backend.manifest import manifest from govoplan_addresses.backend.router import ( _sync_source_response, api_create_address_list_entry, api_create_contact, api_delete_address_list_entry, api_delete_contact, api_restore_contact, api_update_contact, ) from govoplan_addresses.backend.service import ( AddressBookError, address_quality_summary, address_book_contact_counts, address_list_entry_counts, create_address_book, create_address_list, create_address_list_entry, create_carddav_sync_source, create_contact, create_contact_channel_rule, create_contact_quality_decision, create_sync_source, count_contacts, delete_address_list_entry, delete_contact, end_contact_channel_rule, delete_sync_source, discover_carddav_address_books, export_address_book_vcard, import_vcards, list_address_list_entries, list_address_lists, list_address_books, list_contacts, list_contact_channel_rules, list_contact_field_provenance, list_contact_merges, list_sync_conflicts, list_sync_diagnostics, list_sync_sources, list_sync_tombstones, record_sync_conflict, record_sync_diagnostic, record_sync_tombstone, run_sync_source, preview_sync_source, merge_contacts, recover_contact_merge, restore_contact, resolve_contact_redirect, resolve_sync_conflict, start_sync_attempt, finish_sync_attempt, update_sync_source, update_contact, suggest_duplicate_contacts, resolve_trusted_deployment_carddav_credential_ref, _carddav_client_for_source, ) class FakeCardDavClient: def __init__(self, objects: list[AddressCardDAVObject], *, sync_token: str = "sync-token-1", ctag: str = "ctag-1") -> None: self.objects = objects self.sync_token = sync_token self.ctag = ctag self.puts: list[dict[str, object]] = [] self.deletes: list[dict[str, object]] = [] def propfind_collection(self) -> AddressCardDAVReportResult: return AddressCardDAVReportResult(sync_token=self.sync_token, ctag=self.ctag) def list_objects(self) -> AddressCardDAVReportResult: return AddressCardDAVReportResult(objects=self.objects, sync_token=self.sync_token, ctag=self.ctag) def sync_collection(self, _sync_token: str) -> AddressCardDAVReportResult: return self.list_objects() def fetch_object(self, href: str) -> str: for item in self.objects: if item.href == href and item.address_data: return item.address_data raise RuntimeError(f"missing fake CardDAV object: {href}") def put_object(self, href: str, vcard: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> AddressCardDAVWriteResult: self.puts.append({"href": href, "vcard": vcard, "etag": etag, "create": create, "overwrite": overwrite}) return AddressCardDAVWriteResult(href=href, etag=f'"written-{len(self.puts)}"', status=201 if create else 204) def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> AddressCardDAVWriteResult: self.deletes.append({"href": href, "etag": etag, "overwrite": overwrite}) return AddressCardDAVWriteResult(href=href, etag=None, status=204) class Principal: account_id = "account-1" group_ids = frozenset({"group-1"}) @property def tenant_id(self) -> str: return "tenant-1" def has(self, scope: str) -> bool: return scope in { "addresses:address_book:read", "addresses:address_book:write", "addresses:address_list:read", "addresses:address_list:write", "addresses:address_list:delete", "addresses:contact:read", "addresses:contact:write", "addresses:contact:delete", "addresses:governance:read", "addresses:governance:write", "addresses:sync:read", "addresses:sync:write", } class ReadOnlyPrincipal(Principal): def has(self, scope: str) -> bool: return scope in { "addresses:address_book:read", "addresses:contact:read", } class NoGroupPrincipal(Principal): group_ids = frozenset() class AddressBookAdminPrincipal(NoGroupPrincipal): def has(self, scope: str) -> bool: return scope == "addresses:address_book:admin" or super().has(scope) class AddressServiceTest(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite:///:memory:") Base.metadata.create_all( self.engine, tables=[ AddressBook.__table__, AddressList.__table__, Contact.__table__, ContactEmail.__table__, ContactPhone.__table__, ContactPostalAddress.__table__, ContactChannelRule.__table__, ContactPointSnapshot.__table__, ContactPointQualityDecision.__table__, ContactMergeRecord.__table__, ContactRedirect.__table__, ContactFieldProvenance.__table__, AddressListEntry.__table__, AddressSyncSource.__table__, AddressSyncTombstone.__table__, AddressSyncConflict.__table__, AddressSyncDiagnostic.__table__, ChangeSequenceEntry.__table__, CredentialEnvelope.__table__, ], ) self.session = sessionmaker(bind=self.engine)() self.principal = Principal() def tearDown(self) -> None: self.session.close() Base.metadata.drop_all(bind=self.engine) self.engine.dispose() def test_group_book_creation_requires_membership_or_admin_scope(self) -> None: with self.assertRaisesRegex(AddressBookError, "selected group is not visible"): create_address_book( self.session, NoGroupPrincipal(), AddressBookCreateRequest(scope_type="group", group_id="group-2", name="Unassigned"), ) with self.assertRaisesRegex(AddressBookError, "selected group is not visible"): create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="group", group_id="group-2", name="Other group"), ) admin_book = create_address_book( self.session, AddressBookAdminPrincipal(), AddressBookCreateRequest(scope_type="group", group_id="group-2", name="Admin-created"), ) self.assertEqual(admin_book.scope_id, "group-2") def test_scoped_book_contact_lookup_and_soft_delete(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal")) self.session.commit() self.session.refresh(book) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(given_name="Ada", family_name="Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]), ) self.session.commit() self.session.refresh(contact) self.assertEqual([item.id for item in list_address_books(self.session, self.principal)], [book.id]) self.assertEqual(address_book_contact_counts(self.session, [book.id])[book.id], 1) self.assertEqual([item.id for item in list_contacts(self.session, self.principal, query="ada")], [contact.id]) self.assertEqual(contact.emails[0].email, "ada@example.local") self.assertTrue(contact.emails[0].is_primary) delete_contact(self.session, self.principal, contact.id) self.session.commit() self.assertEqual(list_contacts(self.session, self.principal, address_book_id=book.id), []) self.assertEqual(address_book_contact_counts(self.session, [book.id]).get(book.id, 0), 0) archived = list_contacts(self.session, self.principal, address_book_id=book.id, include_deleted=True) self.assertEqual([item.id for item in archived], [contact.id]) restore_contact(self.session, self.principal, contact.id) self.session.commit() self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) def test_contact_and_relationship_routes_emit_value_free_audit_evidence(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Audited"), ) self.session.commit() with patch("govoplan_addresses.backend.router.audit_from_principal") as audit: created = api_create_contact( book.id, ContactCreateRequest( display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local")], ), self.principal, self.session, ) create_details = audit.call_args.kwargs["details"] self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_created") self.assertEqual(create_details["contact_point_counts"]["email"], 1) self.assertNotIn("ada@example.local", repr(create_details)) original_email_id = create_details["contact_point_ids"]["email"][0] updated = api_update_contact( created.id, ContactUpdateRequest( emails=[ContactEmailPayload(email="ada.new@example.local")] ), self.principal, self.session, ) update_details = audit.call_args.kwargs["details"] self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_updated") self.assertEqual(update_details["previous_contact_point_ids"]["email"], [original_email_id]) self.assertNotEqual(update_details["contact_point_ids"]["email"], [original_email_id]) self.assertNotIn("ada.new@example.local", repr(update_details)) api_delete_contact(created.id, self.principal, self.session) self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_deleted") api_restore_contact(created.id, self.principal, self.session) self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_restored") address_list = create_address_list( self.session, self.principal, book.id, AddressListCreateRequest(name="Audited list"), ) self.session.commit() entry = api_create_address_list_entry( address_list.id, AddressListEntryCreateRequest( contact_id=updated.id, contact_email_id=updated.emails[0].id, ), self.principal, self.session, ) self.assertEqual( audit.call_args.kwargs["action"], "addresses.address_list_entry_created", ) self.assertEqual(audit.call_args.kwargs["details"]["contact_id"], updated.id) api_delete_address_list_entry(entry.id, self.principal, self.session) self.assertEqual( audit.call_args.kwargs["action"], "addresses.address_list_entry_deleted", ) def test_contact_windows_report_exact_totals(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Paged"), ) self.session.flush() contacts = [ create_contact( self.session, self.principal, book.id, ContactCreateRequest( display_name=name, emails=[ContactEmailPayload(email=f"{name.lower()}@example.local")], ), ) for name in ("Ada", "Barbara", "Claude", "Dorothy", "Edsger") ] self.session.commit() page = list_contacts( self.session, self.principal, address_book_id=book.id, limit=2, offset=2, ) self.assertEqual([contact.id for contact in page], [contacts[2].id, contacts[3].id]) self.assertEqual( count_contacts(self.session, self.principal, address_book_id=book.id), 5, ) self.assertEqual( count_contacts( self.session, self.principal, address_book_id=book.id, query="example.local", ), 5, ) def test_vcard_import_and_export_preserves_common_fields(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) self.session.commit() self.session.refresh(book) result = import_vcards( self.session, self.principal, book.id, """BEGIN:VCARD VERSION:4.0 FN:Ada Lovelace N:Lovelace;Ada;;; ORG:Analytical Engine Office TITLE:Mathematician EMAIL;TYPE=work:ada@example.local TEL;TYPE=work:+49 30 123 ADR;TYPE=work:;;Main Street 1;Berlin;BE;10115;Germany CATEGORIES:science,engineering NOTE:First programmer URL:https://example.local/ada END:VCARD """, ) self.session.commit() self.session.refresh(result.contacts[0]) self.assertEqual(result.skipped, 0) self.assertEqual(result.issues, []) contact = result.contacts[0] self.assertEqual(contact.display_name, "Ada Lovelace") self.assertEqual(contact.organization, "Analytical Engine Office") self.assertEqual(contact.role_title, "Mathematician") self.assertEqual(contact.tags, ["science", "engineering"]) self.assertEqual(contact.emails[0].label, "work") self.assertEqual(contact.phones[0].phone, "+49 30 123") self.assertEqual(contact.postal_addresses[0].locality, "Berlin") self.assertEqual(contact.source_kind, "vcard") self.assertEqual(contact.source_payload_kind, "vcard") self.assertIn("BEGIN:VCARD", contact.source_payload_raw) self.assertEqual(contact.provenance["vcard"]["urls"], ["https://example.local/ada"]) _book, content = export_address_book_vcard(self.session, self.principal, book.id) self.assertIn("BEGIN:VCARD", content) self.assertIn("FN:Ada Lovelace", content) self.assertIn("EMAIL;TYPE=work:ada@example.local", content) self.assertIn("CATEGORIES:science,engineering", content) self.assertIn("URL:https://example.local/ada", content) def test_vcard_import_reports_field_issues_without_blocking_valid_cards(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) self.session.commit() self.session.refresh(book) result = import_vcards( self.session, self.principal, book.id, """BEGIN:VCARD VERSION:4.0 FN:Valid Person EMAIL:not-an-email EMAIL;TYPE=work:valid@example.local END:VCARD BEGIN:VCARD VERSION:4.0 NOTE:No identity END:VCARD """, ) self.session.commit() self.assertEqual(len(result.contacts), 1) self.assertEqual(result.skipped, 1) self.assertEqual(result.contacts[0].emails[0].email, "valid@example.local") self.assertTrue(any(issue.field == "EMAIL" and issue.severity == "warning" for issue in result.issues)) self.assertTrue(any(issue.severity == "error" for issue in result.issues)) def test_manifest_and_address_capabilities(self) -> None: provided = {item.name for item in manifest.provides_interfaces} self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided) self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided) self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided) self.assertIn(CAPABILITY_RECIPIENT_CHANNEL_FACTS, provided) self.assertIn(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, provided) self.assertIn( CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, manifest.capability_factories, ) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients")) self.session.commit() self.session.refresh(book) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]), ) self.session.commit() self.session.refresh(contact) lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="ada") self.assertEqual(len(lookup), 1) self.assertEqual(lookup[0].email, "ada@example.local") self.assertEqual(lookup[0].contact_id, contact.id) people_provider = AddressesPeopleSearchProvider() self.assertIsInstance(people_provider, PeopleSearchProvider) people = people_provider.search_people(self.session, self.principal, query="ada") self.assertEqual(people[0].key, "contacts") self.assertEqual(people[0].candidates[0].reference_id, contact.id) self.assertEqual(people[0].candidates[0].source_ref, f"addresses:contact:{contact.id}") self.assertEqual(people[0].candidates[0].metadata["address_book_id"], book.id) warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10) self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"]) self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, manifest.capability_factories) self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces}) recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal) self.assertEqual(len(recipient_sources), 1) self.assertEqual(recipient_sources[0].source_id, f"addresses:address_book:{book.id}") self.assertEqual(recipient_sources[0].recipient_count, 1) snapshot = AddressesRecipientSourceCapability().snapshot_address_book(self.session, self.principal, address_book_id=book.id) self.assertEqual(snapshot.source_id, f"addresses:address_book:{book.id}") self.assertEqual(snapshot.recipients[0].email, "ada@example.local") self.assertEqual(snapshot.recipients[0].provenance["contact_id"], contact.id) writer = AddressesContactWriterCapability() targets = writer.list_write_targets(self.session, self.principal) self.assertEqual(len(targets), 1) self.assertTrue(targets[0].allowed) self.assertEqual(targets[0].reason, "allowed") self.assertEqual(targets[0].required_scopes, ("addresses:address_book:read", "addresses:contact:write")) created = writer.create_contact( self.session, self.principal, address_book_id=book.id, payload=ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]), provenance={"consumer_module": "test"}, ) self.assertEqual(created.email, "grace@example.local") self.assertEqual(created.provenance["source_kind"], "local") self.assertEqual(created.provenance["contact_provenance"]["consumer_module"], "test") self.session.commit() missing_scope = writer.can_write_to_address_book(self.session, ReadOnlyPrincipal(), address_book_id=book.id) self.assertFalse(missing_scope.allowed) self.assertEqual(missing_scope.reason, "missing_scope") self.assertEqual(missing_scope.required_scopes, ("addresses:address_book:read", "addresses:contact:write")) book.read_only = True self.session.commit() read_only = writer.can_write_to_address_book(self.session, self.principal, address_book_id=book.id) self.assertFalse(read_only.allowed) self.assertEqual(read_only.reason, "address_book_read_only") self.assertTrue(read_only.read_only) with self.assertRaises(AddressWriterError) as blocked: writer.create_contact( self.session, self.principal, address_book_id=book.id, payload=ContactCreateRequest(display_name="Blocked Contact", emails=[ContactEmailPayload(email="blocked@example.local")]), ) self.assertEqual(blocked.exception.decision.reason, "address_book_read_only") def test_channel_facts_are_effective_purpose_aware_and_provenance_bearing(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Governed recipients"), ) self.session.flush() contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest( display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local", is_primary=True)], postal_addresses=[ ContactPostalAddressPayload( street="Main Street 1", postal_code="10115", locality="Berlin", country="Germany", is_primary=True, ) ], ), ) self.session.flush() rule = create_contact_channel_rule( self.session, self.principal, contact.id, ContactChannelRuleCreateRequest( channel="email", purpose="campaign_delivery", contact_point_id=contact.emails[0].id, decision="opted_out", legal_basis="consent", evidence_ref="case:consent-42", reason="Recipient withdrew email consent.", ), ) self.session.commit() source = DistributionSourceReference( provider="addresses", resource_type="contact", resource_id=contact.id, ) facts = AddressesChannelFactsCapability().resolve_channel_facts( self.session, self.principal, request=RecipientChannelFactsRequest( tenant_id=self.principal.tenant_id, source=source, recipient_key=f"contact:{contact.id}", effective_at=utcnow() + timedelta(seconds=1), purpose="campaign_delivery", requested_channels=("email", "postal"), ), ) candidates = {item.channel: item for item in facts.candidates} self.assertEqual(candidates["email"].status, "suppressed") self.assertEqual(candidates["email"].reason_code, "addresses.channel.opted_out") self.assertEqual(candidates["email"].decision_provenance["selected_rule_id"], rule.id) self.assertEqual(candidates["email"].decision_provenance["evidence_ref"], "case:consent-42") self.assertEqual(candidates["postal"].status, "usable") self.assertEqual(candidates["postal"].decision_provenance["governance_state"], "unknown") self.assertTrue(facts.source_revision) self.assertEqual(len(facts.source_fingerprint or ""), 64) governed_snapshot = AddressesRecipientSourceCapability().snapshot_address_book( self.session, self.principal, address_book_id=book.id, purpose="campaign_delivery", ) self.assertEqual(governed_snapshot.recipients, ()) self.assertEqual(len(governed_snapshot.excluded), 1) self.assertEqual(governed_snapshot.excluded[0].reason_code, "addresses.channel.opted_out") self.assertTrue(governed_snapshot.provenance["governance_applied"]) unrelated = AddressesChannelFactsCapability().resolve_channel_facts( self.session, self.principal, request=RecipientChannelFactsRequest( tenant_id=self.principal.tenant_id, source=source, recipient_key=f"contact:{contact.id}", effective_at=utcnow() + timedelta(seconds=1), purpose="service_notice", requested_channels=("email",), ), ) self.assertEqual(unrelated.candidates[0].status, "usable") self.assertEqual(unrelated.candidates[0].decision_provenance["governance_state"], "unknown") ended = end_contact_channel_rule(self.session, self.principal, rule.id) self.session.commit() expired = AddressesChannelFactsCapability().resolve_channel_facts( self.session, self.principal, request=RecipientChannelFactsRequest( tenant_id=self.principal.tenant_id, source=source, recipient_key=f"contact:{contact.id}", effective_at=utcnow() + timedelta(seconds=1), purpose="campaign_delivery", requested_channels=("email",), ), ) self.assertIsNotNone(ended.effective_until) self.assertEqual(expired.candidates[0].status, "usable") self.assertEqual(expired.explanations[0].code, "addresses.channel_fact.expired") self.assertEqual(list_contact_channel_rules(self.session, self.principal, contact.id)[0].id, rule.id) def test_contact_quality_preserves_originals_provenance_and_excludes_invalid_targets(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Quality review"), ) self.session.flush() contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest( display_name="Ada Lovelace", emails=[ContactEmailPayload(email=" Ada@Example.LOCAL ")], phones=[ContactPhonePayload(phone="+49 (30) 123 45")], postal_addresses=[ ContactPostalAddressPayload( street=" Main Street 1 ", postal_code=" 10115 ", locality=" Berlin ", country=" Germany ", ) ], provenance={ "field_visibility": { "organization": "restricted", } }, ), ) self.session.commit() self.session.refresh(contact) self.assertEqual(contact.emails[0].email, "Ada@Example.LOCAL") self.assertEqual(contact.emails[0].original_email, " Ada@Example.LOCAL ") self.assertEqual(contact.emails[0].normalized_email, "ada@example.local") self.assertEqual(contact.phones[0].original_phone, "+49 (30) 123 45") self.assertEqual(contact.phones[0].normalized_phone, "+493012345") self.assertEqual(contact.postal_addresses[0].original_value["street"], " Main Street 1 ") self.assertEqual(contact.postal_addresses[0].normalized_value["street"], "main street 1") initial_provenance = list_contact_field_provenance( self.session, self.principal, contact.id, current_only=True, ) self.assertTrue(any(item.field_path == "display_name" for item in initial_provenance)) self.assertTrue(any(item.field_path.endswith(".email") for item in initial_provenance)) self.assertEqual( next(item for item in initial_provenance if item.field_path == "organization").visibility, "restricted", ) update_contact( self.session, self.principal, contact.id, ContactUpdateRequest(organization="Analytical Engine Office"), ) quality = create_contact_quality_decision( self.session, self.principal, contact.id, ContactPointQualityDecisionCreateRequest( channel="email", contact_point_id=contact.emails[0].id, state="undeliverable", reason_code="addresses.quality.smtp_hard_bounce", reason="The remote server rejected this address permanently.", evidence_ref="mail:delivery:42", ), ) self.session.commit() history = list_contact_field_provenance( self.session, self.principal, contact.id, ) self.assertTrue(any(not item.selected for item in history)) current_organization = next( item for item in history if item.field_path == "organization" and item.selected ) self.assertEqual(current_organization.value, "Analytical Engine Office") self.assertEqual(current_organization.reason_code, "addresses.contact.quality_updated") facts = AddressesChannelFactsCapability().resolve_channel_facts( self.session, self.principal, request=RecipientChannelFactsRequest( tenant_id=self.principal.tenant_id, source=DistributionSourceReference( provider="addresses", resource_type="contact", resource_id=contact.id, ), recipient_key=f"contact:{contact.id}", effective_at=utcnow() + timedelta(seconds=1), purpose="campaign_delivery", requested_channels=("email",), ), ) self.assertEqual(facts.candidates[0].status, "invalid") self.assertEqual(facts.candidates[0].reason_code, "addresses.quality.smtp_hard_bounce") self.assertEqual(facts.candidates[0].decision_provenance["quality_decision_id"], quality.id) snapshot = AddressesRecipientSourceCapability().snapshot_address_book( self.session, self.principal, address_book_id=book.id, purpose="campaign_delivery", ) self.assertEqual(snapshot.recipients, ()) self.assertEqual(snapshot.excluded[0].reason_code, "addresses.quality.smtp_hard_bounce") summary = address_quality_summary( self.session, self.principal, address_book_id=book.id, ) self.assertEqual(summary.contact_count, 1) self.assertEqual(summary.contact_point_count, 3) self.assertEqual(summary.quality_counts["undeliverable"], 1) self.assertEqual(summary.correction_count, 1) self.assertEqual(summary.corrections[0].contact_id, contact.id) def test_duplicate_merge_recovery_preserves_references_and_rejects_tampering(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Duplicate review"), ) self.session.flush() winner = create_contact( self.session, self.principal, book.id, ContactCreateRequest( display_name="Ada Lovelace", organization="Analytical Engine Office", emails=[ContactEmailPayload(email="ada@example.local")], ), ) loser = create_contact( self.session, self.principal, book.id, ContactCreateRequest( display_name="Ada Lovelace", organization="Analytical Engine Office", role_title="Mathematician", emails=[ ContactEmailPayload(email="ADA@example.local"), ContactEmailPayload(email="ada.private@example.local"), ], ), ) address_list = create_address_list( self.session, self.principal, book.id, AddressListCreateRequest(name="Recipients"), ) self.session.flush() original_loser_email_id = loser.emails[1].id entry = create_address_list_entry( self.session, self.principal, address_list.id, AddressListEntryCreateRequest( contact_id=loser.id, contact_email_id=original_loser_email_id, ), ) create_contact_quality_decision( self.session, self.principal, loser.id, ContactPointQualityDecisionCreateRequest( channel="email", contact_point_id=original_loser_email_id, state="stale", reason="This private address needs confirmation.", ), ) self.session.commit() scan = suggest_duplicate_contacts( self.session, self.principal, address_book_id=book.id, ) self.assertEqual(scan.scanned_contacts, 2) self.assertEqual(scan.candidate_pairs, 1) self.assertEqual(scan.suggestions[0].score, 100) self.assertEqual(scan.suggestions[0].confidence, "strong") self.assertEqual( {feature.code for feature in scan.suggestions[0].features}, {"email_exact", "name_organization_exact"}, ) merge = merge_contacts( self.session, self.principal, ContactMergeRequest( winner_contact_id=winner.id, duplicate_contact_ids=[loser.id], reason="Confirmed duplicate record.", field_sources={"role_title": loser.id}, contact_point_strategy="union", ), ) merge_id = merge.id after_hash = merge.after_hash winner_id = winner.id loser_id = loser.id entry_id = entry.id self.session.commit() self.session.expire_all() resolved = resolve_contact_redirect(self.session, self.principal, loser_id) self.assertTrue(resolved.redirected) self.assertEqual(resolved.resolved_contact_id, winner_id) merged_winner = self.session.get(Contact, winner_id) merged_loser = self.session.get(Contact, loser_id) assert merged_winner is not None assert merged_loser is not None self.assertEqual(merged_winner.role_title, "Mathematician") self.assertEqual( {item.normalized_email for item in merged_winner.emails}, {"ada@example.local", "ada.private@example.local"}, ) self.assertIsNotNone(merged_loser.deleted_at) merged_entry = self.session.get(AddressListEntry, entry_id) assert merged_entry is not None self.assertEqual(merged_entry.contact_id, winner_id) self.assertNotEqual(merged_entry.contact_email_id, original_loser_email_id) self.assertTrue( any( item.state == "stale" and item.contact_point_id == merged_entry.contact_email_id for item in merged_winner.quality_decisions ) ) retained_role_title = next( item for item in list_contact_field_provenance( self.session, self.principal, winner_id, current_only=True, ) if item.field_path == "role_title" ) self.assertEqual(retained_role_title.source_ref, f"addresses:contact:{loser_id}") self.assertEqual(retained_role_title.metadata_["source_contact_id"], loser_id) self.assertEqual(list_contact_merges(self.session, self.principal)[0].id, merge_id) merged_winner.note = "Changed after merge" self.session.commit() with self.assertRaisesRegex(AddressBookError, "changed after this merge"): recover_contact_merge( self.session, self.principal, merge_id, ContactMergeRecoveryRequest( reason="Correct the duplicate decision.", expected_after_hash=after_hash, ), action="undo", ) self.session.rollback() merged_winner = self.session.get(Contact, winner_id) assert merged_winner is not None merged_winner.note = None self.session.commit() recovered = recover_contact_merge( self.session, self.principal, merge_id, ContactMergeRecoveryRequest( reason="Correct the duplicate decision.", expected_after_hash=after_hash, ), action="undo", ) self.session.commit() self.assertEqual(recovered.status, "undone") self.session.expire_all() restored_winner = self.session.get(Contact, winner_id) restored_loser = self.session.get(Contact, loser_id) restored_entry = self.session.get(AddressListEntry, entry_id) assert restored_winner is not None assert restored_loser is not None assert restored_entry is not None self.assertIsNone(restored_winner.role_title) self.assertIsNone(restored_loser.deleted_at) self.assertEqual(restored_entry.contact_id, loser_id) self.assertEqual(restored_entry.contact_email_id, original_loser_email_id) self.assertFalse(resolve_contact_redirect(self.session, self.principal, loser_id).redirected) second_merge = merge_contacts( self.session, self.principal, ContactMergeRequest( winner_contact_id=winner_id, duplicate_contact_ids=[loser_id], reason="Re-run duplicate decision.", ), ) self.session.commit() split = recover_contact_merge( self.session, self.principal, second_merge.id, ContactMergeRecoveryRequest( reason="Split records after review.", expected_after_hash=second_merge.after_hash, ), action="split", ) self.session.commit() self.assertEqual(split.status, "split") def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal")) other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other")) self.session.commit() contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest( display_name="Ada Lovelace", emails=[ ContactEmailPayload(label="work", email="ada.work@example.local", is_primary=True), ContactEmailPayload(label="private", email="ada.private@example.local"), ], postal_addresses=[ ContactPostalAddressPayload(label="office", street="Main Street 1", postal_code="10115", locality="Berlin", country="Germany", is_primary=True), ], ), ) other_contact = create_contact( self.session, self.principal, other_book.id, ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]), ) self.session.commit() self.session.refresh(contact) self.session.refresh(other_contact) address_list = create_address_list(self.session, self.principal, book.id, AddressListCreateRequest(name="Mathematicians")) self.session.commit() self.session.refresh(address_list) entry = create_address_list_entry( self.session, self.principal, address_list.id, AddressListEntryCreateRequest(contact_id=contact.id, contact_email_id=contact.emails[1].id, label="Private address"), ) postal_entry = create_address_list_entry( self.session, self.principal, address_list.id, AddressListEntryCreateRequest(contact_id=contact.id, contact_postal_address_id=contact.postal_addresses[0].id, label="Office address"), ) self.session.commit() self.session.refresh(entry) self.session.refresh(postal_entry) self.assertEqual([item.id for item in list_address_lists(self.session, self.principal, address_book_id=book.id)], [address_list.id]) self.assertEqual(address_list_entry_counts(self.session, [address_list.id])[address_list.id], 2) entries = list_address_list_entries(self.session, self.principal, address_list.id) self.assertEqual(entries[0].contact_email.email, "ada.private@example.local") self.assertEqual(entries[1].target_kind, "postal_address") self.assertEqual(entries[1].contact_postal_address.locality, "Berlin") self.assertEqual( [ item.id for item in list_contacts( self.session, self.principal, address_book_id=book.id, address_list_id=address_list.id, ) ], [contact.id], ) self.assertEqual( count_contacts( self.session, self.principal, address_book_id=book.id, address_list_id=address_list.id, ), 1, ) with self.assertRaisesRegex(ValueError, "same address book"): create_address_list_entry( self.session, self.principal, address_list.id, AddressListEntryCreateRequest(contact_id=other_contact.id), ) recipient_source = AddressesRecipientSourceCapability() sources = recipient_source.list_sources(self.session, self.principal) self.assertIn(f"addresses:address_list:{address_list.id}", {source.source_id for source in sources}) snapshot = recipient_source.snapshot(self.session, self.principal, source_id=f"addresses:address_list:{address_list.id}") self.assertEqual(snapshot.source_kind, "address_list") self.assertEqual(len(snapshot.recipients), 1) self.assertEqual(snapshot.recipients[0].email, "ada.private@example.local") self.assertEqual(snapshot.recipients[0].provenance["address_list_id"], address_list.id) self.assertEqual(snapshot.recipients[0].provenance["address_list_entry_id"], entry.id) delete_address_list_entry(self.session, self.principal, entry.id) delete_address_list_entry(self.session, self.principal, postal_entry.id) self.session.commit() self.assertEqual(list_address_list_entries(self.session, self.principal, address_list.id), []) def test_contact_point_resolution_supports_external_refs_and_frozen_postal_snapshots(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Official contacts"), ) self.session.commit() self.session.refresh(book) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest( display_name="Ada Lovelace", emails=[ ContactEmailPayload( label="private", email="ada.private@example.local", is_primary=True, ) ], postal_addresses=[ ContactPostalAddressPayload( label="official", street="Main Street 1", postal_code="10115", locality="Berlin", country="Germany", is_primary=True, ), ContactPostalAddressPayload( label="private", street="Side Street 2", postal_code="10117", locality="Berlin", country="Germany", ), ], ), ) contact.source_kind = "idm" contact.source_ref = "idm:identity:identity-1" self.session.flush() create_contact_channel_rule( self.session, self.principal, contact.id, ContactChannelRuleCreateRequest( channel="postal", purpose="official_notice", contact_point_id=contact.postal_addresses[0].id, decision="preferred", legal_basis="public_task", evidence_ref="idm:function-assignment:17", preference_rank=1, locale="de-DE", ), ) address_list = create_address_list( self.session, self.principal, book.id, AddressListCreateRequest(name="Postal recipients"), ) self.session.flush() postal_entry = create_address_list_entry( self.session, self.principal, address_list.id, AddressListEntryCreateRequest( contact_id=contact.id, contact_postal_address_id=contact.postal_addresses[0].id, ), ) # Providers must also work before the surrounding transaction commits; # SQLite aggregate timestamps are naive while new ORM rows are UTC-aware. self.session.flush() capability = AddressesContactPointResolutionCapability() direct = capability.resolve_contact_points( self.session, self.principal, request=ContactPointResolutionRequest( tenant_id=self.principal.tenant_id, subject=DistributionSourceReference( provider="idm", resource_type="identity", resource_id="identity-1", ), effective_at=utcnow(), purpose="official_notice", requested_channels=("postal",), address_purpose="official", fallback_rule="none", locale="de-DE", postal_format="international", ), ) self.assertEqual(CONTACT_POINT_CONTRACT_VERSION, direct.contract_version) self.assertEqual("usable", direct.status) self.assertEqual(contact.id, direct.contact_id) self.assertEqual(1, len(direct.candidates)) self.assertEqual(contact.postal_addresses[0].id, direct.candidates[0].contact_point_id) self.assertEqual("official", direct.candidates[0].address_purpose) self.assertIn("Ada Lovelace", direct.candidates[0].target) self.assertIn("Germany", direct.candidates[0].target) self.assertEqual("de-DE", direct.candidates[0].locale) self.assertTrue(direct.candidates[0].preference_revision) self.assertEqual(1, len(direct.excluded)) self.assertEqual( "addresses.address_purpose.not_selected", direct.excluded[0].reason_code, ) source_request = ContactPointSourceRequest( tenant_id=self.principal.tenant_id, source_id=f"addresses:address_list:{address_list.id}", effective_at=utcnow(), purpose="official_notice", requested_channels=("email", "postal"), address_purpose="official", fallback_rule="none", locale="de-DE", postal_format="international", ) preview = capability.preview_source( self.session, self.principal, request=source_request, limit=1, ) self.assertEqual(1, preview.total_count) self.assertEqual(1, preview.usable_count) self.assertFalse(preview.has_more) self.assertEqual(postal_entry.id, preview.resolutions[0].provenance["address_list_entry_ids"][0]) self.assertEqual("postal", preview.resolutions[0].candidates[0].channel) snapshot = capability.freeze_source( self.session, self.principal, request=source_request, ) self.session.commit() original_target = snapshot.resolutions[0].candidates[0].target contact.postal_addresses[0].street = "Changed Street 99" contact.postal_addresses[0].country = "France" self.session.commit() frozen = capability.get_snapshot( self.session, self.principal, snapshot_id=snapshot.id, ) self.assertIsNotNone(frozen) assert frozen is not None self.assertEqual(original_target, frozen.resolutions[0].candidates[0].target) self.assertIn("Main Street 1", frozen.resolutions[0].candidates[0].target) self.assertNotIn("Changed Street 99", frozen.resolutions[0].candidates[0].target) self.assertEqual(snapshot.snapshot_hash, frozen.snapshot_hash) self.assertEqual(1, frozen.recipient_count) response = ContactPointSnapshotResponse.model_validate(asdict(frozen)) self.assertEqual(snapshot.id, response.id) self.assertEqual("postal", response.resolutions[0].candidates[0].channel) def test_sync_source_marks_read_only_books_and_can_be_made_writable(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="CardDAV")) self.session.commit() self.session.refresh(book) sync_source = create_sync_source( self.session, self.principal, book.id, AddressSyncSourceCreateRequest( connector_type="carddav", display_name="CardDAV Personal", external_account_ref="account://carddav/personal", external_address_book_ref="/remote/addressbooks/personal/", sync_direction="read_only", metadata={"base_url": "https://carddav.example.local"}, ), ) self.session.commit() self.session.refresh(book) self.session.refresh(sync_source) self.assertEqual(book.source_kind, "carddav") self.assertEqual(book.source_ref, sync_source.id) self.assertTrue(book.read_only) self.assertEqual(book.sync_status, "idle") self.assertEqual([item.id for item in list_sync_sources(self.session, self.principal)], [sync_source.id]) decision = AddressesContactWriterCapability().can_write_to_address_book(self.session, self.principal, address_book_id=book.id) self.assertFalse(decision.allowed) self.assertEqual(decision.reason, "address_book_read_only") with self.assertRaisesRegex(ValueError, "read-only"): create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Read Only", emails=[ContactEmailPayload(email="readonly@example.local")]), ) sync_source = update_sync_source( self.session, self.principal, sync_source.id, AddressSyncSourceUpdateRequest(sync_direction="two_way", read_only=False, metadata={"base_url": "https://carddav.example.local"}), ) self.session.commit() self.session.refresh(book) self.session.refresh(sync_source) self.assertFalse(sync_source.read_only) self.assertFalse(book.read_only) self.assertTrue(AddressesContactWriterCapability().can_write_to_address_book(self.session, self.principal, address_book_id=book.id).allowed) def test_sync_attempts_diagnostics_tombstones_and_conflicts_are_persisted(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Synced")) self.session.commit() self.session.refresh(book) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]), ) sync_source = create_sync_source( self.session, self.principal, book.id, AddressSyncSourceCreateRequest(connector_type="carddav", display_name="Writable CardDAV", sync_direction="two_way", read_only=False), ) self.session.commit() self.session.refresh(contact) self.session.refresh(sync_source) start_sync_attempt(self.session, self.principal, sync_source.id) self.session.commit() self.session.refresh(sync_source) self.assertEqual(sync_source.status, "running") finish_sync_attempt( self.session, self.principal, sync_source.id, AddressSyncAttemptFinishRequest(status="succeeded", sync_token="sync-token-1", etag="etag-1", remote_revision="rev-1"), ) diagnostic = record_sync_diagnostic( self.session, self.principal, sync_source.id, AddressSyncDiagnosticCreateRequest(severity="warning", code="carddav.rate_limit", message="Remote server asked us to slow down."), ) tombstone = record_sync_tombstone( self.session, self.principal, sync_source.id, AddressSyncTombstoneCreateRequest(contact_id=contact.id, remote_uid="ada-uid", resource_href="/contacts/ada.vcf"), ) conflict = record_sync_conflict( self.session, self.principal, sync_source.id, AddressSyncConflictCreateRequest( contact_id=contact.id, remote_uid="ada-uid", resource_href="/contacts/ada.vcf", field_path="emails[0].email", local_value={"email": "ada@example.local"}, remote_value={"email": "ada.remote@example.local"}, ), ) self.session.commit() self.session.refresh(sync_source) self.session.refresh(diagnostic) self.session.refresh(tombstone) self.session.refresh(conflict) self.assertEqual(sync_source.sync_token, "sync-token-1") self.assertEqual(sync_source.status, "conflict") self.assertEqual(list_sync_diagnostics(self.session, self.principal, sync_source.id)[0].id, diagnostic.id) self.assertEqual(list_sync_tombstones(self.session, self.principal, sync_source.id)[0].id, tombstone.id) self.assertEqual(list_sync_conflicts(self.session, self.principal, sync_source.id)[0].id, conflict.id) resolved = resolve_sync_conflict( self.session, self.principal, conflict.id, AddressSyncConflictResolveRequest(resolution="manual", status="resolved"), ) self.session.commit() self.session.refresh(sync_source) self.session.refresh(resolved) self.assertEqual(resolved.status, "resolved") self.assertEqual(resolved.resolution, "manual") self.assertEqual(list_sync_conflicts(self.session, self.principal, sync_source.id), []) self.assertEqual(sync_source.status, "idle") def test_sync_conflict_merge_resolution_applies_merged_payload(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Merged")) self.session.commit() self.session.refresh(book) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Local Name", emails=[ContactEmailPayload(email="local@example.local")]), ) sync_source = create_sync_source( self.session, self.principal, book.id, AddressSyncSourceCreateRequest(connector_type="carddav", display_name="Writable CardDAV", sync_direction="two_way", read_only=False), ) self.session.commit() self.session.refresh(contact) self.session.refresh(sync_source) local_payload = { "display_name": "Local Name", "emails": [{"label": None, "email": "local@example.local", "is_primary": True}], "phones": [], "postal_addresses": [], "tags": [], } remote_payload = { "display_name": "Remote Name", "emails": [{"label": None, "email": "remote@example.local", "is_primary": True}], "phones": [], "postal_addresses": [], "tags": [], } conflict = record_sync_conflict( self.session, self.principal, sync_source.id, AddressSyncConflictCreateRequest( contact_id=contact.id, resource_href="/contacts/local.vcf", field_path="vcard", local_value={"payload": local_payload}, remote_value={"payload": remote_payload, "etag": '"etag-remote"'}, metadata={"remote_payload": remote_payload, "source_revision": '"etag-remote"'}, ), ) self.session.commit() self.session.refresh(conflict) resolve_sync_conflict( self.session, self.principal, conflict.id, AddressSyncConflictResolveRequest( resolution="merge", status="resolved", merged_payload=ContactCreateRequest(display_name="Local Name", emails=[ContactEmailPayload(email="remote@example.local")]), ), ) self.session.commit() self.session.refresh(contact) self.assertEqual(contact.display_name, "Local Name") self.assertEqual(contact.emails[0].email, "remote@example.local") self.assertEqual(contact.source_revision, '"etag-remote"') def test_carddav_run_imports_contacts_and_preview_detects_conflicts(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Remote")) self.session.commit() self.session.refresh(book) sync_source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://carddav.example.local/addressbooks/personal/", display_name="Remote CardDAV", auth_type="none", sync_direction="two_way", read_only=False, ), ) self.session.commit() self.session.refresh(sync_source) first_client = FakeCardDavClient([ AddressCardDAVObject( href="/addressbooks/personal/ada.vcf", etag='"etag-1"', address_data="""BEGIN:VCARD VERSION:4.0 UID:ada-uid FN:Ada Lovelace EMAIL:ada@example.local END:VCARD """, ) ]) plan = run_sync_source(self.session, self.principal, sync_source.id, client=first_client) self.session.commit() self.session.refresh(sync_source) self.assertEqual(plan.stats.created, 1) self.assertEqual(sync_source.status, "succeeded") contacts = list_contacts(self.session, self.principal, address_book_id=book.id) self.assertEqual(len(contacts), 1) self.assertEqual(contacts[0].display_name, "Ada Lovelace") self.assertEqual(contacts[0].source_kind, "carddav") self.assertEqual(contacts[0].source_ref, "/addressbooks/personal/ada.vcf") self.assertEqual(contacts[0].source_revision, '"etag-1"') contacts[0].display_name = "Ada Local" contacts[0].updated_at = utcnow() self.session.commit() conflict_client = FakeCardDavClient( [ AddressCardDAVObject( href="/addressbooks/personal/ada.vcf", etag='"etag-2"', address_data="""BEGIN:VCARD VERSION:4.0 UID:ada-uid FN:Ada Remote EMAIL:ada@example.local END:VCARD """, ) ], sync_token="sync-token-2", ctag="ctag-2", ) preview = preview_sync_source(self.session, self.principal, sync_source.id, client=conflict_client) self.assertEqual(preview.stats.conflicts, 1) self.assertEqual(preview.items[0].action, "conflict") self.assertEqual(preview.items[0].contact_id, contacts[0].id) run_conflict_client = FakeCardDavClient( [ AddressCardDAVObject( href="/addressbooks/personal/ada.vcf", etag='"etag-2"', address_data="""BEGIN:VCARD VERSION:4.0 UID:ada-uid FN:Ada Remote EMAIL:ada.remote@example.local END:VCARD """, ) ], sync_token="sync-token-2", ctag="ctag-2", ) run_sync_source(self.session, self.principal, sync_source.id, client=run_conflict_client) self.session.commit() conflicts = list_sync_conflicts(self.session, self.principal, sync_source.id) self.assertEqual(len(conflicts), 1) self.assertIn("payload", conflicts[0].remote_value) resolved = resolve_sync_conflict( self.session, self.principal, conflicts[0].id, AddressSyncConflictResolveRequest(resolution="use_remote", status="resolved"), ) self.session.commit() self.session.refresh(resolved) self.session.refresh(contacts[0]) self.assertEqual(resolved.status, "resolved") self.assertEqual(contacts[0].display_name, "Ada Remote") self.assertEqual(contacts[0].emails[0].email, "ada.remote@example.local") def test_carddav_credentials_are_server_managed_and_public_metadata_is_redacted(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Secured remote"), ) self.session.commit() self.session.refresh(book) with self.assertRaisesRegex(AddressBookError, "Caller-supplied credential references"): create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://attacker.example.test/addressbooks/personal/", auth_type="bearer", credential_ref="env:MASTER_KEY_B64", ), ) with self.assertRaisesRegex(AddressBookError, "Caller-supplied credential references"): discover_carddav_address_books( self.session, self.principal, AddressCardDavDiscoveryRequest( url="https://attacker.example.test/addressbooks/", auth_type="bearer", credential_ref="env:MASTER_KEY_B64", ), ) with self.assertRaisesRegex(AddressBookError, "server-managed"): create_sync_source( self.session, self.principal, book.id, AddressSyncSourceCreateRequest( connector_type="carddav", display_name="Injected", metadata={"carddav": {"auth_type": "bearer", "credential_ref": "env:DATABASE_URL"}}, ), ) def test_carddav_source_can_use_reusable_core_credential(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Shared credential"), ) credential = create_credential_envelope( self.session, tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Shared DAV login", credential_kind="username_password", public_data={"username": "ada"}, secret_data={"password": "secret"}, allowed_modules=["addresses"], inherit_to_lower_scopes=True, ) source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://dav.example.test/addressbooks/personal/", auth_type="basic", credential_ref=f"credential-envelope:{credential.id}", ), ) self.session.commit() client = _carddav_client_for_source(self.session, source) response_auth = _sync_source_response(source).metadata["carddav"] self.assertEqual(client.username, "ada") self.assertEqual(client.password, "secret") self.assertEqual(response_auth["credential_envelope_id"], credential.id) self.assertTrue(response_auth["has_credential"]) source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://dav.example.test/addressbooks/personal/", auth_type="basic", username="ada", password="secret", ), ) stored_auth = dict((source.metadata_ or {})["carddav"]) self.assertIn("secret_encrypted", stored_auth) self.assertNotEqual(stored_auth["secret_encrypted"], "secret") response_auth = _sync_source_response(source).metadata["carddav"] self.assertNotIn("secret_encrypted", response_auth) self.assertNotIn("credential_ref", response_auth) self.assertTrue(response_auth["has_credential"]) with self.assertRaisesRegex(AddressBookError, "server-managed"): update_sync_source( self.session, self.principal, source.id, AddressSyncSourceUpdateRequest( metadata={"carddav": {"auth_type": "bearer", "secret_encrypted": "copied-ciphertext"}} ), ) def test_legacy_carddav_env_reference_is_not_resolved_by_runtime_sync(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Legacy remote"), ) self.session.commit() self.session.refresh(book) source = create_sync_source( self.session, self.principal, book.id, AddressSyncSourceCreateRequest( connector_type="carddav", display_name="Legacy", external_address_book_ref="https://attacker.example.test/addressbooks/personal/", ), ) source.metadata_ = { "carddav": { "auth_type": "bearer", "credential_ref": "env:MASTER_KEY_B64", } } with patch.dict("os.environ", {"MASTER_KEY_B64": "must-not-leave-process"}), self.assertRaisesRegex( AddressBookError, "not a server-owned credential", ): run_sync_source(self.session, self.principal, source.id) def test_trusted_deployment_carddav_env_resolution_is_explicit(self) -> None: with patch.dict("os.environ", {"CARDDAV_DEPLOYMENT_TOKEN": "trusted-token"}): self.assertEqual( resolve_trusted_deployment_carddav_credential_ref("env:CARDDAV_DEPLOYMENT_TOKEN"), "trusted-token", ) with self.assertRaisesRegex(AddressBookError, "must use the env: prefix"): resolve_trusted_deployment_carddav_credential_ref("vault:token") def test_carddav_two_way_pushes_local_creates_updates_and_deletes(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote")) self.session.commit() self.session.refresh(book) sync_source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://carddav.example.local/addressbooks/personal/", display_name="Writable CardDAV", auth_type="none", sync_direction="two_way", read_only=False, ), ) self.session.commit() self.session.refresh(book) self.session.refresh(sync_source) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]), ) self.session.commit() self.session.refresh(contact) create_client = FakeCardDavClient([], sync_token="sync-token-create", ctag="ctag-create") create_plan = run_sync_source(self.session, self.principal, sync_source.id, client=create_client) self.session.commit() self.session.refresh(contact) self.session.refresh(sync_source) self.assertEqual(create_plan.stats.created, 1) self.assertEqual(create_plan.items[-1].action, "remote_create") self.assertEqual(len(create_client.puts), 1) self.assertTrue(create_client.puts[0]["create"]) self.assertEqual(contact.source_ref, f"/addressbooks/personal/{contact.id}.vcf") self.assertEqual(contact.source_revision, '"written-1"') contact.display_name = "Grace Updated" contact.updated_at = utcnow() self.session.commit() self.session.refresh(contact) update_client = FakeCardDavClient( [ AddressCardDAVObject( href=contact.source_ref or "", etag=contact.source_revision, address_data="""BEGIN:VCARD VERSION:4.0 FN:Grace Hopper EMAIL:grace@example.local END:VCARD """, ) ], sync_token="sync-token-update", ctag="ctag-update", ) update_plan = run_sync_source(self.session, self.principal, sync_source.id, client=update_client) self.session.commit() self.session.refresh(contact) self.session.refresh(sync_source) self.assertEqual(update_plan.stats.updated, 1) self.assertEqual(update_plan.items[-1].action, "remote_update") self.assertEqual(len(update_client.puts), 1) self.assertFalse(update_client.puts[0]["create"]) self.assertEqual(update_client.puts[0]["etag"], '"written-1"') self.assertIn("FN:Grace Updated", str(update_client.puts[0]["vcard"])) delete_contact(self.session, self.principal, contact.id) self.session.commit() self.session.refresh(contact) delete_client = FakeCardDavClient( [ AddressCardDAVObject( href=contact.source_ref or "", etag=contact.source_revision, address_data="""BEGIN:VCARD VERSION:4.0 FN:Grace Updated EMAIL:grace@example.local END:VCARD """, ) ], sync_token="sync-token-delete", ctag="ctag-delete", ) delete_plan = run_sync_source(self.session, self.principal, sync_source.id, client=delete_client) self.session.commit() self.assertEqual(delete_plan.stats.deleted, 1) self.assertEqual(delete_plan.items[-1].action, "remote_delete") self.assertEqual(delete_client.deletes, [{"href": f"/addressbooks/personal/{contact.id}.vcf", "etag": contact.source_revision, "overwrite": False}]) self.assertEqual(len(list_sync_tombstones(self.session, self.principal, sync_source.id)), 1) def test_delete_sync_source_disconnects_without_removing_contacts(self) -> None: book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Disconnectable")) self.session.commit() self.session.refresh(book) sync_source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://carddav.example.local/addressbooks/personal/", display_name="Disconnectable CardDAV", auth_type="none", sync_direction="two_way", read_only=False, ), ) contact = create_contact( self.session, self.principal, book.id, ContactCreateRequest(display_name="Local Contact", emails=[ContactEmailPayload(email="local@example.local")]), ) self.session.commit() self.session.refresh(book) self.session.refresh(sync_source) self.session.refresh(contact) self.assertEqual(book.source_kind, "carddav") self.assertEqual(book.source_ref, sync_source.id) self.assertEqual(len(list_sync_sources(self.session, self.principal, include_disabled=True)), 1) delete_sync_source(self.session, self.principal, sync_source.id) self.session.commit() self.session.refresh(book) self.session.refresh(contact) self.assertEqual(list_sync_sources(self.session, self.principal, include_disabled=True), []) self.assertEqual(book.source_kind, "local") self.assertIsNone(book.source_ref) self.assertFalse(book.read_only) self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) def test_delete_sync_source_deletes_and_audits_encrypted_credential_atomically(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Audited remote"), ) self.session.commit() self.session.refresh(book) source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://carddav.example.local/addressbooks/audited/", auth_type="basic", username="ada", password="do-not-audit-this", ), ) self.session.commit() source_id = source.id with patch("govoplan_addresses.backend.service.audit_event") as audit: delete_sync_source(self.session, self.principal, source_id) self.session.commit() self.assertIsNone(self.session.get(AddressSyncSource, source_id)) audit.assert_called_once() audit_call = audit.call_args.kwargs self.assertEqual(audit_call["action"], "addresses.sync_credential_deleted") self.assertEqual(audit_call["object_id"], source_id) self.assertEqual(audit_call["details"]["storage_backend"], "encrypted_database") self.assertNotIn("credential_ref", audit_call["details"]) self.assertNotIn("secret_encrypted", audit_call["details"]) self.assertNotIn("do-not-audit-this", repr(audit_call)) def test_delete_sync_source_is_not_staged_when_credential_audit_fails(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Audit failure"), ) self.session.commit() self.session.refresh(book) source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://carddav.example.local/addressbooks/audit-failure/", auth_type="bearer", bearer_token="do-not-audit-this", ), ) self.session.commit() with patch( "govoplan_addresses.backend.service.audit_event", side_effect=RuntimeError("audit unavailable"), ), self.assertRaisesRegex(RuntimeError, "audit unavailable"): delete_sync_source(self.session, self.principal, source.id) self.session.rollback() persisted = self.session.get(AddressSyncSource, source.id) self.assertIsNotNone(persisted) self.assertIn("secret_encrypted", (persisted.metadata_ or {})["carddav"]) def test_destructive_module_retirement_audits_credentials_before_tables_are_dropped(self) -> None: book = create_address_book( self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Retired module"), ) self.session.commit() self.session.refresh(book) source = create_carddav_sync_source( self.session, self.principal, book.id, AddressCardDavSourceCreateRequest( collection_url="https://carddav.example.local/addressbooks/module-retirement/", auth_type="bearer", bearer_token="do-not-audit-this", ), ) self.session.commit() source_id = source.id retirement_provider = manifest.migration_spec.retirement_provider assert retirement_provider is not None plan = retirement_provider(self.session, "addresses") assert plan.destroy_data_executor is not None with patch("govoplan_addresses.backend.service.audit_event") as audit: plan.destroy_data_executor(self.session, "addresses") audit.assert_called_once() audit_call = audit.call_args.kwargs self.assertEqual(audit_call["object_id"], source_id) self.assertEqual(audit_call["details"]["deletion_reason"], "module_data_retired") self.assertNotIn("do-not-audit-this", repr(audit_call)) self.assertFalse(inspect(self.engine).has_table("addresses_sync_sources")) if __name__ == "__main__": unittest.main()