/ tests / gateway / test_ws_auth_retry.py
test_ws_auth_retry.py
  1  """Tests for auth-aware retry in Mattermost WS and Matrix sync loops.
  2  
  3  Both Mattermost's _ws_loop and Matrix's _sync_loop previously caught all
  4  exceptions with a broad ``except Exception`` and retried forever. Permanent
  5  auth failures (401, 403, M_UNKNOWN_TOKEN) would loop indefinitely instead
  6  of stopping. These tests verify that auth errors now stop the reconnect.
  7  """
  8  
  9  import asyncio
 10  from unittest.mock import AsyncMock, MagicMock, patch
 11  
 12  import pytest
 13  
 14  
 15  # ---------------------------------------------------------------------------
 16  # Mattermost: _ws_loop auth-aware retry
 17  # ---------------------------------------------------------------------------
 18  
 19  class TestMattermostWSAuthRetry:
 20      """gateway/platforms/mattermost.py — _ws_loop()"""
 21  
 22      def test_401_handshake_stops_reconnect(self):
 23          """A WSServerHandshakeError with status 401 should stop the loop."""
 24          import aiohttp
 25  
 26          exc = aiohttp.WSServerHandshakeError(
 27              request_info=MagicMock(),
 28              history=(),
 29              status=401,
 30              message="Unauthorized",
 31              headers=MagicMock(),
 32          )
 33  
 34          from gateway.platforms.mattermost import MattermostAdapter
 35          adapter = MattermostAdapter.__new__(MattermostAdapter)
 36          adapter._closing = False
 37  
 38          call_count = 0
 39  
 40          async def fake_connect():
 41              nonlocal call_count
 42              call_count += 1
 43              raise exc
 44  
 45          adapter._ws_connect_and_listen = fake_connect
 46  
 47          asyncio.run(adapter._ws_loop())
 48  
 49          # Should have attempted once and stopped, not retried
 50          assert call_count == 1
 51  
 52      def test_403_handshake_stops_reconnect(self):
 53          """A WSServerHandshakeError with status 403 should stop the loop."""
 54          import aiohttp
 55  
 56          exc = aiohttp.WSServerHandshakeError(
 57              request_info=MagicMock(),
 58              history=(),
 59              status=403,
 60              message="Forbidden",
 61              headers=MagicMock(),
 62          )
 63  
 64          from gateway.platforms.mattermost import MattermostAdapter
 65          adapter = MattermostAdapter.__new__(MattermostAdapter)
 66          adapter._closing = False
 67  
 68          call_count = 0
 69  
 70          async def fake_connect():
 71              nonlocal call_count
 72              call_count += 1
 73              raise exc
 74  
 75          adapter._ws_connect_and_listen = fake_connect
 76  
 77          asyncio.run(adapter._ws_loop())
 78          assert call_count == 1
 79  
 80      def test_transient_error_retries(self):
 81          """A transient ConnectionError should retry (not stop immediately)."""
 82          from gateway.platforms.mattermost import MattermostAdapter
 83          adapter = MattermostAdapter.__new__(MattermostAdapter)
 84          adapter._closing = False
 85  
 86          call_count = 0
 87  
 88          async def fake_connect():
 89              nonlocal call_count
 90              call_count += 1
 91              if call_count >= 2:
 92                  # Stop the loop after 2 attempts
 93                  adapter._closing = True
 94                  return
 95              raise ConnectionError("connection reset")
 96  
 97          adapter._ws_connect_and_listen = fake_connect
 98  
 99          async def run():
100              with patch("asyncio.sleep", new_callable=AsyncMock):
101                  await adapter._ws_loop()
102  
103          asyncio.run(run())
104  
105          # Should have retried at least once
106          assert call_count >= 2
107  
108  
109  # ---------------------------------------------------------------------------
110  # Matrix: _sync_loop auth-aware retry
111  # ---------------------------------------------------------------------------
112  
113  class TestMatrixSyncAuthRetry:
114      """gateway/platforms/matrix.py — _sync_loop()"""
115  
116      def test_unknown_token_sync_error_stops_loop(self):
117          """A SyncError with M_UNKNOWN_TOKEN should stop syncing."""
118          import types
119          nio_mock = types.ModuleType("nio")
120  
121          class SyncError:
122              def __init__(self, message):
123                  self.message = message
124  
125          nio_mock.SyncError = SyncError
126  
127          from gateway.platforms.matrix import MatrixAdapter
128          adapter = MatrixAdapter.__new__(MatrixAdapter)
129          adapter._closing = False
130  
131          sync_count = 0
132  
133          async def fake_sync(timeout=30000, since=None):
134              nonlocal sync_count
135              sync_count += 1
136              return SyncError("M_UNKNOWN_TOKEN: Invalid access token")
137  
138          adapter._client = MagicMock()
139          adapter._client.sync = fake_sync
140          adapter._client.sync_store = MagicMock()
141          adapter._client.sync_store.get_next_batch = AsyncMock(return_value=None)
142          adapter._pending_megolm = []
143          adapter._joined_rooms = set()
144  
145          async def run():
146              import sys
147              sys.modules["nio"] = nio_mock
148              try:
149                  await adapter._sync_loop()
150              finally:
151                  del sys.modules["nio"]
152  
153          asyncio.run(run())
154          assert sync_count == 1
155  
156      def test_exception_with_401_stops_loop(self):
157          """An exception containing '401' should stop syncing."""
158          from gateway.platforms.matrix import MatrixAdapter
159          adapter = MatrixAdapter.__new__(MatrixAdapter)
160          adapter._closing = False
161  
162          call_count = 0
163  
164          async def fake_sync(timeout=30000, since=None):
165              nonlocal call_count
166              call_count += 1
167              raise RuntimeError("HTTP 401 Unauthorized")
168  
169          adapter._client = MagicMock()
170          adapter._client.sync = fake_sync
171          adapter._client.sync_store = MagicMock()
172          adapter._client.sync_store.get_next_batch = AsyncMock(return_value=None)
173          adapter._pending_megolm = []
174          adapter._joined_rooms = set()
175  
176          async def run():
177              import types
178              nio_mock = types.ModuleType("nio")
179              nio_mock.SyncError = type("SyncError", (), {})
180  
181              import sys
182              sys.modules["nio"] = nio_mock
183              try:
184                  await adapter._sync_loop()
185              finally:
186                  del sys.modules["nio"]
187  
188          asyncio.run(run())
189          assert call_count == 1
190  
191      def test_transient_error_retries(self):
192          """A transient error should retry (not stop immediately)."""
193          from gateway.platforms.matrix import MatrixAdapter
194          adapter = MatrixAdapter.__new__(MatrixAdapter)
195          adapter._closing = False
196  
197          call_count = 0
198  
199          async def fake_sync(timeout=30000, since=None):
200              nonlocal call_count
201              call_count += 1
202              if call_count >= 2:
203                  adapter._closing = True
204                  return MagicMock()  # Normal response
205              raise ConnectionError("network timeout")
206  
207          adapter._client = MagicMock()
208          adapter._client.sync = fake_sync
209          adapter._client.sync_store = MagicMock()
210          adapter._client.sync_store.get_next_batch = AsyncMock(return_value=None)
211          adapter._pending_megolm = []
212          adapter._joined_rooms = set()
213  
214          async def run():
215              import types
216              nio_mock = types.ModuleType("nio")
217              nio_mock.SyncError = type("SyncError", (), {})
218  
219              import sys
220              sys.modules["nio"] = nio_mock
221              try:
222                  with patch("asyncio.sleep", new_callable=AsyncMock):
223                      await adapter._sync_loop()
224              finally:
225                  del sys.modules["nio"]
226  
227          asyncio.run(run())
228          assert call_count >= 2