diff --git a/src/govoplan_calendar/backend/router.py b/src/govoplan_calendar/backend/router.py index 330cb5a..01eb8ab 100644 --- a/src/govoplan_calendar/backend/router.py +++ b/src/govoplan_calendar/backend/router.py @@ -298,7 +298,14 @@ def api_delete_calendar( ): _require_scope(principal, "calendar:calendar:admin") try: - delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload) + delete_calendar( + session, + tenant_id=principal.tenant_id, + calendar_id=calendar_id, + payload=payload, + user_id=principal.user.id, + api_key_id=principal.api_key_id, + ) session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) except CalendarError as exc: @@ -344,7 +351,14 @@ def api_update_sync_source( ): _require_scope(principal, "calendar:calendar:admin") try: - source = update_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload) + source = update_sync_source( + session, + tenant_id=principal.tenant_id, + source_id=source_id, + payload=payload, + user_id=principal.user.id, + api_key_id=principal.api_key_id, + ) session.commit() session.refresh(source) return _sync_source_response(source) @@ -361,7 +375,13 @@ def api_delete_sync_source( ): _require_scope(principal, "calendar:calendar:admin") try: - delete_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id) + delete_sync_source( + session, + tenant_id=principal.tenant_id, + source_id=source_id, + user_id=principal.user.id, + api_key_id=principal.api_key_id, + ) session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) except CalendarError as exc: @@ -479,7 +499,14 @@ def api_update_caldav_source( ): _require_scope(principal, "calendar:calendar:admin") try: - source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload) + source = update_caldav_source( + session, + tenant_id=principal.tenant_id, + source_id=source_id, + payload=payload, + user_id=principal.user.id, + api_key_id=principal.api_key_id, + ) session.commit() session.refresh(source) return _caldav_source_response(source) @@ -496,7 +523,13 @@ def api_delete_caldav_source( ): _require_scope(principal, "calendar:calendar:admin") try: - delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id) + delete_caldav_source( + session, + tenant_id=principal.tenant_id, + source_id=source_id, + user_id=principal.user.id, + api_key_id=principal.api_key_id, + ) session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) except CalendarError as exc: diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index 5ba30a5..7479540 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -23,6 +23,7 @@ from govoplan_core.security.outbound_http import ( build_outbound_http_opener, validate_outbound_http_url, ) +from govoplan_core.audit.logging import audit_event from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource @@ -710,7 +711,15 @@ def _move_calendar_before_delete( return calendar -def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionDeleteRequest | None = None) -> None: +def delete_calendar( + session: Session, + *, + tenant_id: str, + calendar_id: str, + payload: CalendarCollectionDeleteRequest | None = None, + user_id: str | None = None, + api_key_id: str | None = None, +) -> None: payload = payload or CalendarCollectionDeleteRequest() calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) deleted_at = utcnow() @@ -729,7 +738,15 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo if calendar.is_default: calendar.is_default = False calendar.deleted_at = deleted_at - retire_sync_sources_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar.id, deleted_at=deleted_at) + retire_sync_sources_for_calendar( + session, + tenant_id=tenant_id, + calendar_id=calendar.id, + deleted_at=deleted_at, + deletion_reason="calendar_deleted", + user_id=user_id, + api_key_id=api_key_id, + ) for event in calendar.events: if payload.event_action == "delete" and event.deleted_at is None: event.deleted_at = deleted_at @@ -1135,6 +1152,9 @@ def _update_sync_source_credential_and_schedule( tenant_id: str, source: CalendarSyncSource, payload: CalendarSyncSourceUpdateRequest, + previous_auth_type: str, + user_id: str | None, + api_key_id: str | None, ) -> None: credential_value = caldav_secret_from_payload( auth_type=source.auth_type, @@ -1145,15 +1165,39 @@ def _update_sync_source_credential_and_schedule( source.credential_ref = store_caldav_credential( session, tenant_id=tenant_id, - user_id=None, + user_id=user_id, source=source, secret=credential_value, + api_key_id=api_key_id, ) + elif payload.auth_type is not None and source.auth_type != previous_auth_type: + delete_caldav_credential( + session, + tenant_id=tenant_id, + source_id=source.id, + credential_ref=source.credential_ref, + deletion_reason=( + "authentication_disabled" + if source.auth_type == "none" + else "authentication_type_changed" + ), + user_id=user_id, + api_key_id=api_key_id, + ) + source.credential_ref = None if payload.sync_enabled is not None or payload.sync_interval_seconds is not None: source.next_sync_at = utcnow() if source.sync_enabled else None -def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource: +def update_sync_source( + session: Session, + *, + tenant_id: str, + source_id: str, + payload: CalendarSyncSourceUpdateRequest, + user_id: str | None = None, + api_key_id: str | None = None, +) -> CalendarSyncSource: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) source = _lock_sync_source_for_update( session, @@ -1164,6 +1208,7 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl raise CalendarError( "Caller-supplied credential references are not accepted; provide a replacement password or bearer token" ) + previous_auth_type = source.auth_type ( normalized_collection_url, materially_reconfigured, @@ -1194,30 +1239,87 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl tenant_id=tenant_id, source=source, payload=payload, + previous_auth_type=previous_auth_type, + user_id=user_id, + api_key_id=api_key_id, ) mark_calendar_sync_source(calendar, source) session.flush() return source -def update_caldav_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarCalDavSourceUpdateRequest) -> CalendarSyncSource: +def update_caldav_source( + session: Session, + *, + tenant_id: str, + source_id: str, + payload: CalendarCalDavSourceUpdateRequest, + user_id: str | None = None, + api_key_id: str | None = None, +) -> CalendarSyncSource: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") - return update_sync_source(session, tenant_id=tenant_id, source_id=source.id, payload=payload) + return update_sync_source( + session, + tenant_id=tenant_id, + source_id=source.id, + payload=payload, + user_id=user_id, + api_key_id=api_key_id, + ) -def delete_sync_source(session: Session, *, tenant_id: str, source_id: str) -> None: +def delete_sync_source( + session: Session, + *, + tenant_id: str, + source_id: str, + user_id: str | None = None, + api_key_id: str | None = None, +) -> None: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) - retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=utcnow()) + retire_sync_source( + session, + tenant_id=tenant_id, + source=source, + deleted_at=utcnow(), + deletion_reason="sync_source_deleted", + user_id=user_id, + api_key_id=api_key_id, + ) session.flush() -def delete_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> None: +def delete_caldav_source( + session: Session, + *, + tenant_id: str, + source_id: str, + user_id: str | None = None, + api_key_id: str | None = None, +) -> None: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") - retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=utcnow()) + retire_sync_source( + session, + tenant_id=tenant_id, + source=source, + deleted_at=utcnow(), + deletion_reason="sync_source_deleted", + user_id=user_id, + api_key_id=api_key_id, + ) session.flush() -def retire_sync_sources_for_calendar(session: Session, *, tenant_id: str, calendar_id: str, deleted_at: datetime) -> None: +def retire_sync_sources_for_calendar( + session: Session, + *, + tenant_id: str, + calendar_id: str, + deleted_at: datetime, + deletion_reason: str = "calendar_deleted", + user_id: str | None = None, + api_key_id: str | None = None, +) -> None: if not hasattr(session, "query"): return sources = ( @@ -1230,7 +1332,15 @@ def retire_sync_sources_for_calendar(session: Session, *, tenant_id: str, calend .all() ) for source in sources: - retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=deleted_at) + retire_sync_source( + session, + tenant_id=tenant_id, + source=source, + deleted_at=deleted_at, + deletion_reason=deletion_reason, + user_id=user_id, + api_key_id=api_key_id, + ) def retire_caldav_sources_for_calendar(session: Session, *, tenant_id: str, calendar_id: str, deleted_at: datetime) -> None: @@ -1252,7 +1362,13 @@ def retire_stale_sync_sources_for_url(session: Session, *, tenant_id: str, sourc now = utcnow() for source in sources: if source.calendar is None or source.calendar.deleted_at is not None: - retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=now) + retire_sync_source( + session, + tenant_id=tenant_id, + source=source, + deleted_at=now, + deletion_reason="stale_sync_source_retired", + ) session.flush() @@ -1281,7 +1397,16 @@ def active_caldav_source_for_url(session: Session, *, tenant_id: str, collection return active_sync_source_for_url(session, tenant_id=tenant_id, source_kind="caldav", collection_url=collection_url) -def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None: +def retire_sync_source( + session: Session, + *, + tenant_id: str, + source: CalendarSyncSource, + deleted_at: datetime, + deletion_reason: str = "sync_source_retired", + user_id: str | None = None, + api_key_id: str | None = None, +) -> None: from govoplan_calendar.backend.outbox import ( calendar_outbox_has_live_lease, cancel_calendar_outbox_for_source, @@ -1302,18 +1427,26 @@ def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSync "CalDAV source cannot be retired while an outbound operation has an active lease" ) + # Delete credentials first. An external secret provider cannot participate + # in the SQL transaction, so provider failure must leave the source and its + # queued work untouched. If the later SQL commit fails after provider + # success, the source fails closed and the idempotent delete can be retried. + delete_caldav_credential( + session, + tenant_id=tenant_id, + source_id=source.id, + credential_ref=source.credential_ref, + deletion_reason=deletion_reason, + user_id=user_id, + api_key_id=api_key_id, + ) + source.credential_ref = None cancel_calendar_outbox_for_source( session, source_id=source.id, reason="CalDAV source was retired before queued external changes were delivered", ) source.deleted_at = deleted_at - delete_caldav_credential( - session, - tenant_id=tenant_id, - source_id=source.id, - credential_ref=source.credential_ref, - ) def retire_caldav_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None: @@ -1343,6 +1476,7 @@ def store_caldav_credential( user_id: str | None, source: CalendarSyncSource, secret: str, + api_key_id: str | None = None, ) -> str: existing = internal_caldav_credential( session, @@ -1350,6 +1484,7 @@ def store_caldav_credential( source_id=source.id, credential_ref=source.credential_ref, ) + replacing_existing = existing is not None if existing is None: existing = CalendarSyncCredential( tenant_id=tenant_id, @@ -1361,10 +1496,45 @@ def store_caldav_credential( session.add(existing) session.flush() + old_provider_ref = _credential_provider_ref(existing) provider = secret_provider() name = f"caldav:{source.id}:{source.auth_type}" + if old_provider_ref and provider is None: + raise CalendarError( + "Stored sync credential cannot be replaced while its secret provider is unavailable" + ) if provider is not None: - provider_ref = str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret)) + try: + provider_ref = str( + provider.store_secret( + scope=f"calendar:{tenant_id}", + name=name, + value=secret, + ) + ).strip() + except Exception as exc: + raise CalendarError("Stored sync credential could not be written to its secret provider") from exc + if not provider_ref: + raise CalendarError("Secret provider returned an empty credential reference") + if old_provider_ref and old_provider_ref != provider_ref: + try: + provider.delete_secret(old_provider_ref) + except Exception as exc: + if not _provider_secret_is_absent(provider, old_provider_ref): + compensation_failed = False + try: + provider.delete_secret(provider_ref) + except Exception: + compensation_failed = not _provider_secret_is_absent(provider, provider_ref) + compensation_detail = ( + "; replacement cleanup also failed and requires provider-side reconciliation" + if compensation_failed + else "" + ) + raise CalendarError( + "Previous sync credential could not be deleted from its secret provider; " + f"the replacement was not activated{compensation_detail}" + ) from exc existing.secret_encrypted = None existing.metadata_ = {"source_id": source.id, "provider_ref": provider_ref} else: @@ -1373,6 +1543,24 @@ def store_caldav_credential( existing.credential_kind = credential_kind_for_auth_type(source.auth_type) existing.label = source.display_name or source.collection_url existing.deleted_at = None + if replacing_existing: + audit_event( + session, + tenant_id=tenant_id, + user_id=user_id, + api_key_id=api_key_id, + action="calendar.sync_credential_rotated", + object_type="calendar_sync_credential", + object_id=existing.id, + details={ + "sync_source_id": source.id, + "storage_backend": ( + "external_secret_provider" + if provider is not None + else "encrypted_database" + ), + }, + ) session.flush() return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}" @@ -1383,9 +1571,12 @@ def delete_caldav_credential( tenant_id: str, source_id: str, credential_ref: str | None, -) -> None: + deletion_reason: str = "sync_source_retired", + user_id: str | None = None, + api_key_id: str | None = None, +) -> bool: if not credential_ref: - return + return False credential = internal_caldav_credential( session, tenant_id=tenant_id, @@ -1395,12 +1586,73 @@ def delete_caldav_credential( if credential is None: # Legacy external/env references have no locally provable ownership and # must never be passed to a provider delete operation. - return + return False + return _delete_calendar_owned_credential( + session, + credential=credential, + source_id=source_id, + deletion_reason=deletion_reason, + user_id=user_id, + api_key_id=api_key_id, + ) + + +def _delete_calendar_owned_credential( + session: Session, + *, + credential: CalendarSyncCredential, + source_id: str | None, + deletion_reason: str, + user_id: str | None = None, + api_key_id: str | None = None, +) -> bool: provider_ref = _credential_provider_ref(credential) + if not credential.secret_encrypted and not provider_ref: + return False provider = secret_provider() - if provider is not None and provider_ref: - provider.delete_secret(provider_ref) + storage_backend = "encrypted_database" + if provider_ref: + if provider is None: + raise CalendarError( + "Stored sync credential cannot be deleted while its secret provider is unavailable; " + "the requested deletion was not completed" + ) + try: + provider.delete_secret(provider_ref) + except Exception as exc: + if not _provider_secret_is_absent(provider, provider_ref): + raise CalendarError( + "Stored sync credential could not be deleted from its secret provider; " + "the requested deletion was not completed" + ) from exc + storage_backend = "external_secret_provider" + credential.secret_encrypted = None + credential.metadata_ = {"source_id": source_id} if source_id else {} credential.deleted_at = utcnow() + audit_event( + session, + tenant_id=credential.tenant_id, + user_id=user_id, + api_key_id=api_key_id, + action="calendar.sync_credential_deleted", + object_type="calendar_sync_credential", + object_id=credential.id, + details={ + "sync_source_id": source_id, + "storage_backend": storage_backend, + "deletion_reason": deletion_reason, + }, + ) + return True + + +def _provider_secret_is_absent(provider: Any, provider_ref: str) -> bool: + """Confirm an idempotent delete when a provider reports missing/error.""" + + try: + return provider.read_secret(provider_ref) is None + except Exception: + return False def resolve_caldav_secret( diff --git a/tests/test_caldav.py b/tests/test_caldav.py index d8bbcdf..1bd6fda 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -27,6 +27,7 @@ from govoplan_calendar.backend.service import ( create_calendar, create_caldav_source, create_event, + delete_caldav_source, delete_calendar, delete_event, discover_caldav_calendars, @@ -365,6 +366,7 @@ class CalDAVSyncTests(unittest.TestCase): def delete_secret(self, secret_ref: str) -> None: self.deleted.append(secret_ref) + self.values.pop(secret_ref, None) provider = Provider() session = self.session() @@ -437,9 +439,23 @@ class CalDAVSyncTests(unittest.TestCase): delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id) self.assertEqual(provider.deleted, []) - delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id) + with patch("govoplan_calendar.backend.service.audit_event") as audit: + delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id) self.assertEqual(provider.deleted, [provider_ref]) + self.assertNotIn(provider_ref, provider.values) + self.assertIsNone(source.credential_ref) + self.assertIsNone(credential.secret_encrypted) + self.assertNotIn("provider_ref", credential.metadata_ or {}) + audit.assert_called_once() + audit_call = audit.call_args.kwargs + self.assertEqual(audit_call["action"], "calendar.sync_credential_deleted") + self.assertEqual(audit_call["object_id"], credential.id) + self.assertEqual(audit_call["details"]["storage_backend"], "external_secret_provider") + self.assertEqual(audit_call["details"]["deletion_reason"], "calendar_deleted") + self.assertNotIn("credential_ref", audit_call["details"]) + self.assertNotIn("provider_ref", audit_call["details"]) + self.assertNotIn(provider_ref, repr(audit_call["details"])) def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None: class Provider: @@ -516,9 +532,241 @@ class CalDAVSyncTests(unittest.TestCase): credential = session.query(CalendarSyncCredential).one() self.assertIsNotNone(source.deleted_at) + self.assertIsNone(source.credential_ref) self.assertIsNotNone(credential.deleted_at) + self.assertIsNone(credential.secret_encrypted) + self.assertEqual(credential.metadata_, {"source_id": source.id}) self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), []) + def test_external_credential_deletion_fails_closed_when_provider_is_unavailable(self) -> None: + class Provider: + def __init__(self) -> None: + self.values: dict[str, str] = {} + + def store_secret(self, *, scope: str, name: str, value: str) -> str: + reference = f"vault:{scope}:{name}" + self.values[reference] = value + return reference + + def read_secret(self, secret_ref: str) -> str | None: + return self.values.get(secret_ref) + + def delete_secret(self, secret_ref: str) -> None: + self.values.pop(secret_ref, None) + + provider = Provider() + session = self.session() + session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + calendar = create_calendar( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCollectionCreateRequest(name="Remote"), + ) + with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider): + source = create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest( + calendar_id=calendar.id, + collection_url="https://dav.example.test/provider-down", + auth_type="bearer", + bearer_token="do-not-log-this", + ), + ) + session.commit() + credential = session.query(CalendarSyncCredential).one() + provider_ref = str((credential.metadata_ or {})["provider_ref"]) + + with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex( + CalendarError, + "secret provider is unavailable", + ): + delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id) + session.rollback() + + self.assertIsNone(source.deleted_at) + self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}") + self.assertIsNone(credential.deleted_at) + self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref) + self.assertIn(provider_ref, provider.values) + + def test_disabling_auth_immediately_scrubs_and_audits_database_credential(self) -> None: + session = self.session() + session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + calendar = create_calendar( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCollectionCreateRequest(name="Remote"), + ) + source = create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest( + calendar_id=calendar.id, + collection_url="https://dav.example.test/disable-auth", + auth_type="basic", + username="ada", + password="do-not-log-this", + ), + ) + session.commit() + credential = session.query(CalendarSyncCredential).one() + self.assertIsNotNone(credential.secret_encrypted) + + with patch("govoplan_calendar.backend.service.audit_event") as audit: + update_caldav_source( + session, + tenant_id="tenant-1", + source_id=source.id, + payload=CalendarCalDavSourceUpdateRequest(auth_type="none"), + ) + session.commit() + + self.assertIsNone(source.credential_ref) + self.assertIsNone(credential.secret_encrypted) + self.assertIsNotNone(credential.deleted_at) + audit.assert_called_once() + self.assertEqual( + audit.call_args.kwargs["details"]["deletion_reason"], + "authentication_disabled", + ) + self.assertNotIn("do-not-log-this", repr(audit.call_args)) + + def test_external_credential_replacement_fails_closed_when_provider_is_unavailable(self) -> None: + class Provider: + def __init__(self) -> None: + self.values: dict[str, str] = {} + + def store_secret(self, *, scope: str, name: str, value: str) -> str: + reference = f"vault:{scope}:{name}" + self.values[reference] = value + return reference + + def read_secret(self, secret_ref: str) -> str | None: + return self.values.get(secret_ref) + + def delete_secret(self, secret_ref: str) -> None: + self.values.pop(secret_ref, None) + + provider = Provider() + session = self.session() + session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + calendar = create_calendar( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCollectionCreateRequest(name="Remote"), + ) + with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider): + source = create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest( + calendar_id=calendar.id, + collection_url="https://dav.example.test/replacement-provider-down", + auth_type="bearer", + bearer_token="old-secret", + ), + ) + session.commit() + credential = session.query(CalendarSyncCredential).one() + provider_ref = str((credential.metadata_ or {})["provider_ref"]) + + with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex( + CalendarError, + "cannot be replaced.*provider is unavailable", + ): + update_caldav_source( + session, + tenant_id="tenant-1", + source_id=source.id, + payload=CalendarCalDavSourceUpdateRequest(bearer_token="new-secret"), + ) + session.rollback() + + self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}") + self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref) + self.assertEqual(provider.values[provider_ref], "old-secret") + + def test_external_credential_delete_is_retryable_after_database_rollback(self) -> None: + class Provider: + def __init__(self) -> None: + self.values: dict[str, str] = {} + self.deletes: list[str] = [] + + def store_secret(self, *, scope: str, name: str, value: str) -> str: + reference = f"vault:{scope}:{name}" + self.values[reference] = value + return reference + + def read_secret(self, secret_ref: str) -> str | None: + return self.values.get(secret_ref) + + def delete_secret(self, secret_ref: str) -> None: + self.deletes.append(secret_ref) + if secret_ref not in self.values: + raise KeyError("already deleted") + self.values.pop(secret_ref, None) + + provider = Provider() + session = self.session() + session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + calendar = create_calendar( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCollectionCreateRequest(name="Remote"), + ) + with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider): + source = create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest( + calendar_id=calendar.id, + collection_url="https://dav.example.test/retry-delete", + auth_type="basic", + username="ada", + password="do-not-log-this", + ), + ) + session.commit() + credential = session.query(CalendarSyncCredential).one() + provider_ref = str((credential.metadata_ or {})["provider_ref"]) + + with patch("govoplan_calendar.backend.service.audit_event"): + delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id) + self.assertNotIn(provider_ref, provider.values) + self.assertIsNotNone(credential.deleted_at) + self.assertIsNone(source.credential_ref) + session.rollback() + + self.assertIsNone(source.deleted_at) + session.refresh(credential) + self.assertIsNone(credential.deleted_at) + self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref) + self.assertIsNone(resolve_caldav_credential_ref( + session, + tenant_id="tenant-1", + source_id=source.id, + credential_ref=source.credential_ref, + )) + + with patch("govoplan_calendar.backend.service.audit_event") as audit: + delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id) + session.commit() + + self.assertEqual(provider.deletes, [provider_ref, provider_ref]) + self.assertIsNotNone(source.deleted_at) + self.assertIsNone(source.credential_ref) + self.assertIsNotNone(credential.deleted_at) + self.assertEqual(audit.call_count, 1) + def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None: session = self.session() session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))