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