From 86ee1403f026eefee6efef9bcab19eb2036a0a86 Mon Sep 17 00:00:00 2001 From: besendorf Date: Tue, 1 Sep 2026 17:47:44 +0200 Subject: [PATCH] Refresh expired Etebase tokens during sync --- etesync_dav/radicale/etesync_cache.py | 18 +++++++ etesync_dav/radicale/storage.py | 21 +++++--- tests/test_token_refresh.py | 69 +++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 tests/test_token_refresh.py diff --git a/etesync_dav/radicale/etesync_cache.py b/etesync_dav/radicale/etesync_cache.py index bf10c641..be52c593 100644 --- a/etesync_dav/radicale/etesync_cache.py +++ b/etesync_dav/radicale/etesync_cache.py @@ -77,6 +77,19 @@ def etesync_for_user(self, user): return etesync, True + def refresh_etebase_token(self, user): + etesync, _ = self.etesync_for_user(user) + if not isinstance(etesync, Etebase): + return False + + etesync.etebase.fetch_token() + stored_session = etesync.etebase.save(None) + remote_url = self.creds.get_server_url(user) + self.creds.set_etebase(user, stored_session, remote_url) + self.creds.save() + etesync.stored_session = stored_session + return True + _etesync_cache = EteSyncCache( creds_path=config.CREDS_FILE, @@ -93,3 +106,8 @@ def etesync_for_user(user): ret = _etesync_cache.etesync_for_user(user) yield ret + + +def refresh_etebase_token(user): + with _get_etesync_lock: + return _etesync_cache.refresh_etebase_token(user) diff --git a/etesync_dav/radicale/storage.py b/etesync_dav/radicale/storage.py index d6312b12..f1123a92 100644 --- a/etesync_dav/radicale/storage.py +++ b/etesync_dav/radicale/storage.py @@ -22,6 +22,7 @@ import etesync as api import vobject +from etebase import etebase_python from radicale import pathutils from radicale.item import Item, get_etag from radicale.storage import ( @@ -31,7 +32,7 @@ ) from ..local_cache import COL_TYPES, Etebase -from .etesync_cache import etesync_for_user +from .etesync_cache import etesync_for_user, refresh_etebase_token from .href_mapper import HrefMapper from .storage_etebase_collection import Collection as EtebaseCollection @@ -74,14 +75,22 @@ def wait_for_sync(self, timeout=None): raise e return ret + def sync_once(self): + with etesync_for_user(self.user) as (etesync, _): + self.last_sync = time.time() + self._done_syncing.clear() + + try: + etesync.sync() + except etebase_python.Error as e: + if str(e) != "Invalid token." or not refresh_etebase_token(self.user): + raise + etesync.sync() + def run(self): while True: try: - with etesync_for_user(self.user) as (etesync, _): - self.last_sync = time.time() - self._done_syncing.clear() - - etesync.sync() + self.sync_once() except Exception as e: # Print errors but keep on syncing in the background logger.exception(e) diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py new file mode 100644 index 00000000..91d4f423 --- /dev/null +++ b/tests/test_token_refresh.py @@ -0,0 +1,69 @@ +from contextlib import contextmanager +from unittest import TestCase, mock + +from etebase import etebase_python + +from etesync_dav.local_cache import Etebase +from etesync_dav.radicale.etesync_cache import EteSyncCache +from etesync_dav.radicale.storage import SyncThread + + +class TokenRefreshTest(TestCase): + def test_refreshes_and_persists_etebase_session(self): + cache = EteSyncCache("unused-creds", "unused-db") + cache.creds = mock.Mock() + cache.creds.get_server_url.return_value = "https://example.com/" + + etesync = Etebase.__new__(Etebase) + etesync.etebase = mock.Mock() + etesync.stored_session = "old-stored-session" + etesync.etebase.save.return_value = "new-stored-session" + cache.etesync_for_user = mock.Mock(return_value=(etesync, False)) + + self.assertTrue(cache.refresh_etebase_token("alice")) + + etesync.etebase.fetch_token.assert_called_once_with() + etesync.etebase.save.assert_called_once_with(None) + cache.creds.set_etebase.assert_called_once_with("alice", "new-stored-session", "https://example.com/") + cache.creds.save.assert_called_once_with() + self.assertEqual(etesync.stored_session, "new-stored-session") + + def test_does_not_refresh_legacy_session(self): + cache = EteSyncCache("unused-creds", "unused-db") + cache.etesync_for_user = mock.Mock(return_value=(object(), False)) + + self.assertFalse(cache.refresh_etebase_token("alice")) + + @mock.patch("etesync_dav.radicale.storage.refresh_etebase_token", return_value=True) + @mock.patch("etesync_dav.radicale.storage.etesync_for_user") + def test_sync_retries_after_refreshing_invalid_token(self, etesync_for_user_mock, refresh_mock): + etesync = mock.Mock() + etesync.sync.side_effect = [etebase_python.Error("Invalid token."), None] + + @contextmanager + def context(): + yield etesync, False + + etesync_for_user_mock.return_value = context() + + SyncThread("alice").sync_once() + + refresh_mock.assert_called_once_with("alice") + self.assertEqual(etesync.sync.call_count, 2) + + @mock.patch("etesync_dav.radicale.storage.refresh_etebase_token") + @mock.patch("etesync_dav.radicale.storage.etesync_for_user") + def test_sync_does_not_refresh_other_etebase_errors(self, etesync_for_user_mock, refresh_mock): + etesync = mock.Mock() + etesync.sync.side_effect = etebase_python.Error("Invalid stoken.") + + @contextmanager + def context(): + yield etesync, False + + etesync_for_user_mock.return_value = context() + + with self.assertRaisesRegex(etebase_python.Error, "Invalid stoken"): + SyncThread("alice").sync_once() + + refresh_mock.assert_not_called()