Skip to content

Commit 9d6a36d

Browse files
committed
tests: Add unit tests for auth service
1 parent e247d14 commit 9d6a36d

File tree

1 file changed

+345
-0
lines changed

1 file changed

+345
-0
lines changed

tests/unit/test_auth_service.py

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
"""Unit tests for Privy authentication service."""
2+
3+
import json
4+
import time
5+
from unittest.mock import Mock, patch
6+
7+
import pytest
8+
9+
from src.amp.auth.models import AuthStorage
10+
from src.amp.auth.service import AuthService
11+
12+
13+
@pytest.mark.unit
14+
class TestAuthService:
15+
"""Test AuthService functionality."""
16+
17+
def test_is_authenticated_when_no_file(self, tmp_path):
18+
"""Test is_authenticated returns False when config file doesn't exist."""
19+
config_path = tmp_path / 'nonexistent.json'
20+
auth = AuthService(config_path=config_path)
21+
22+
assert auth.is_authenticated() is False
23+
24+
def test_is_authenticated_when_no_auth_data(self, tmp_path):
25+
"""Test is_authenticated returns False when config exists but has no auth data."""
26+
config_path = tmp_path / 'config.json'
27+
config_path.write_text('{}')
28+
29+
auth = AuthService(config_path=config_path)
30+
31+
assert auth.is_authenticated() is False
32+
33+
def test_is_authenticated_when_auth_exists(self, tmp_path):
34+
"""Test is_authenticated returns True when valid auth exists."""
35+
config_path = tmp_path / 'amp_cli_auth'
36+
auth_data = {
37+
'accessToken': 'test-access-token',
38+
'refreshToken': 'test-refresh-token',
39+
'userId': 'did:privy:test123',
40+
'accounts': ['0x123'],
41+
'expiry': int(time.time() * 1000) + 3600000, # 1 hour from now
42+
}
43+
config_path.write_text(json.dumps(auth_data))
44+
45+
auth = AuthService(config_path=config_path)
46+
47+
assert auth.is_authenticated() is True
48+
49+
def test_load_auth_success(self, tmp_path):
50+
"""Test loading auth from config file."""
51+
config_path = tmp_path / 'amp_cli_auth'
52+
auth_data = {
53+
'accessToken': 'test-access-token',
54+
'refreshToken': 'test-refresh-token',
55+
'userId': 'did:privy:test123',
56+
'accounts': ['0x123'],
57+
'expiry': 1234567890000,
58+
}
59+
config_path.write_text(json.dumps(auth_data))
60+
61+
auth = AuthService(config_path=config_path)
62+
result = auth.load_auth()
63+
64+
assert result is not None
65+
assert result.accessToken == 'test-access-token'
66+
assert result.refreshToken == 'test-refresh-token'
67+
assert result.userId == 'did:privy:test123'
68+
assert result.accounts == ['0x123']
69+
assert result.expiry == 1234567890000
70+
71+
def test_load_auth_returns_none_when_missing(self, tmp_path):
72+
"""Test load_auth returns None when config doesn't exist."""
73+
config_path = tmp_path / 'nonexistent.json'
74+
auth = AuthService(config_path=config_path)
75+
76+
result = auth.load_auth()
77+
78+
assert result is None
79+
80+
def test_save_auth(self, tmp_path):
81+
"""Test saving auth to config file."""
82+
config_path = tmp_path / 'amp_cli_auth'
83+
auth = AuthService(config_path=config_path)
84+
85+
auth_storage = AuthStorage(
86+
accessToken='new-access-token',
87+
refreshToken='new-refresh-token',
88+
userId='did:privy:test456',
89+
accounts=['0x456'],
90+
expiry=9876543210000,
91+
)
92+
93+
auth.save_auth(auth_storage)
94+
95+
# Verify file was created
96+
assert config_path.exists()
97+
98+
# Verify content
99+
with open(config_path) as f:
100+
saved_data = json.load(f)
101+
102+
assert saved_data['accessToken'] == 'new-access-token'
103+
assert saved_data['userId'] == 'did:privy:test456'
104+
105+
def test_save_auth_overwrites_existing(self, tmp_path):
106+
"""Test saving auth overwrites existing auth data."""
107+
config_path = tmp_path / 'amp_cli_auth'
108+
initial_data = {
109+
'accessToken': 'old-token',
110+
'refreshToken': 'old-refresh',
111+
'userId': 'did:privy:old',
112+
'accounts': [],
113+
'expiry': 12345,
114+
}
115+
config_path.write_text(json.dumps(initial_data))
116+
117+
auth = AuthService(config_path=config_path)
118+
auth_storage = AuthStorage(
119+
accessToken='new-token',
120+
refreshToken='new-refresh',
121+
userId='did:privy:new',
122+
accounts=['0x123'],
123+
expiry=67890,
124+
)
125+
126+
auth.save_auth(auth_storage)
127+
128+
# Verify new data saved
129+
with open(config_path) as f:
130+
saved_data = json.load(f)
131+
132+
assert saved_data['accessToken'] == 'new-token'
133+
assert saved_data['userId'] == 'did:privy:new'
134+
assert saved_data['accounts'] == ['0x123']
135+
136+
def test_get_token_raises_when_not_authenticated(self, tmp_path):
137+
"""Test get_token raises error when not authenticated."""
138+
config_path = tmp_path / 'nonexistent.json'
139+
auth = AuthService(config_path=config_path)
140+
141+
with pytest.raises(FileNotFoundError, match='Not authenticated'):
142+
auth.get_token()
143+
144+
def test_get_token_returns_valid_token(self, tmp_path):
145+
"""Test get_token returns token when valid and not expired."""
146+
config_path = tmp_path / 'amp_cli_auth'
147+
future_expiry = int(time.time() * 1000) + 3600000 # 1 hour from now
148+
auth_data = {
149+
'accessToken': 'valid-token',
150+
'refreshToken': 'refresh-token',
151+
'userId': 'did:privy:test',
152+
'accounts': ['0x123'],
153+
'expiry': future_expiry,
154+
}
155+
config_path.write_text(json.dumps(auth_data))
156+
157+
auth = AuthService(config_path=config_path)
158+
token = auth.get_token()
159+
160+
assert token == 'valid-token'
161+
162+
163+
@pytest.mark.unit
164+
class TestAuthServiceRefresh:
165+
"""Test token refresh functionality."""
166+
167+
def test_needs_refresh_when_missing_expiry(self):
168+
"""Test needs refresh when expiry field is missing."""
169+
auth = AuthService()
170+
auth_storage = AuthStorage(
171+
accessToken='token',
172+
refreshToken='refresh',
173+
userId='did:privy:test',
174+
accounts=['0x123'],
175+
expiry=None, # Missing expiry
176+
)
177+
178+
assert auth._needs_refresh(auth_storage) is True
179+
180+
def test_needs_refresh_when_missing_accounts(self):
181+
"""Test needs refresh when accounts field is missing."""
182+
auth = AuthService()
183+
auth_storage = AuthStorage(
184+
accessToken='token',
185+
refreshToken='refresh',
186+
userId='did:privy:test',
187+
accounts=None, # Missing accounts
188+
expiry=int(time.time() * 1000) + 3600000,
189+
)
190+
191+
assert auth._needs_refresh(auth_storage) is True
192+
193+
def test_needs_refresh_when_expired(self):
194+
"""Test needs refresh when token is expired."""
195+
auth = AuthService()
196+
past_expiry = int(time.time() * 1000) - 1000 # 1 second ago
197+
auth_storage = AuthStorage(
198+
accessToken='token',
199+
refreshToken='refresh',
200+
userId='did:privy:test',
201+
accounts=['0x123'],
202+
expiry=past_expiry,
203+
)
204+
205+
assert auth._needs_refresh(auth_storage) is True
206+
207+
def test_needs_refresh_when_expiring_soon(self):
208+
"""Test needs refresh when token expires within 5 minutes."""
209+
auth = AuthService()
210+
soon_expiry = int(time.time() * 1000) + (4 * 60 * 1000) # 4 minutes from now
211+
auth_storage = AuthStorage(
212+
accessToken='token',
213+
refreshToken='refresh',
214+
userId='did:privy:test',
215+
accounts=['0x123'],
216+
expiry=soon_expiry,
217+
)
218+
219+
assert auth._needs_refresh(auth_storage) is True
220+
221+
def test_does_not_need_refresh_when_valid(self):
222+
"""Test does not need refresh when token is valid and not expiring soon."""
223+
auth = AuthService()
224+
future_expiry = int(time.time() * 1000) + (10 * 60 * 1000) # 10 minutes from now
225+
auth_storage = AuthStorage(
226+
accessToken='token',
227+
refreshToken='refresh',
228+
userId='did:privy:test',
229+
accounts=['0x123'],
230+
expiry=future_expiry,
231+
)
232+
233+
assert auth._needs_refresh(auth_storage) is False
234+
235+
@patch('httpx.Client.post')
236+
def test_refresh_token_success(self, mock_post, tmp_path):
237+
"""Test successful token refresh."""
238+
config_path = tmp_path / 'config.json'
239+
240+
# Mock HTTP response
241+
mock_response = Mock()
242+
mock_response.status_code = 200
243+
mock_response.json.return_value = {
244+
'token': 'new-access-token',
245+
'refresh_token': 'new-refresh-token',
246+
'session_update_action': 'update',
247+
'expires_in': 3600,
248+
'user': {'id': 'did:privy:test', 'accounts': ['0x123', '0x456']},
249+
}
250+
mock_post.return_value = mock_response
251+
252+
auth = AuthService(config_path=config_path)
253+
old_auth = AuthStorage(
254+
accessToken='old-token',
255+
refreshToken='old-refresh',
256+
userId='did:privy:test',
257+
accounts=['0x123'],
258+
expiry=12345,
259+
)
260+
261+
new_auth = auth.refresh_token(old_auth)
262+
263+
# Verify new tokens
264+
assert new_auth.accessToken == 'new-access-token'
265+
assert new_auth.refreshToken == 'new-refresh-token'
266+
assert new_auth.userId == 'did:privy:test'
267+
assert new_auth.accounts == ['0x123', '0x456']
268+
assert new_auth.expiry > int(time.time() * 1000)
269+
270+
# Verify saved to file
271+
assert config_path.exists()
272+
273+
@patch('httpx.Client.post')
274+
def test_refresh_token_user_id_mismatch(self, mock_post, tmp_path):
275+
"""Test refresh fails when user ID doesn't match."""
276+
config_path = tmp_path / 'config.json'
277+
278+
# Mock HTTP response with different user ID
279+
mock_response = Mock()
280+
mock_response.status_code = 200
281+
mock_response.json.return_value = {
282+
'token': 'new-token',
283+
'refresh_token': 'new-refresh',
284+
'session_update_action': 'update',
285+
'expires_in': 3600,
286+
'user': {'id': 'did:privy:different-user', 'accounts': []},
287+
}
288+
mock_post.return_value = mock_response
289+
290+
auth = AuthService(config_path=config_path)
291+
old_auth = AuthStorage(
292+
accessToken='old-token',
293+
refreshToken='old-refresh',
294+
userId='did:privy:original-user',
295+
accounts=[],
296+
expiry=12345,
297+
)
298+
299+
with pytest.raises(ValueError, match='User ID mismatch'):
300+
auth.refresh_token(old_auth)
301+
302+
@patch('httpx.Client.post')
303+
def test_refresh_token_401_error(self, mock_post, tmp_path):
304+
"""Test refresh handles 401 authentication error."""
305+
config_path = tmp_path / 'config.json'
306+
307+
# Mock 401 response
308+
mock_response = Mock()
309+
mock_response.status_code = 401
310+
mock_post.return_value = mock_response
311+
312+
auth = AuthService(config_path=config_path)
313+
old_auth = AuthStorage(
314+
accessToken='old-token',
315+
refreshToken='old-refresh',
316+
userId='did:privy:test',
317+
accounts=[],
318+
expiry=12345,
319+
)
320+
321+
with pytest.raises(ValueError, match='Authentication expired'):
322+
auth.refresh_token(old_auth)
323+
324+
@patch('httpx.Client.post')
325+
def test_refresh_token_429_rate_limit(self, mock_post, tmp_path):
326+
"""Test refresh handles 429 rate limit error."""
327+
config_path = tmp_path / 'config.json'
328+
329+
# Mock 429 response
330+
mock_response = Mock()
331+
mock_response.status_code = 429
332+
mock_response.headers = {'retry-after': '120'}
333+
mock_post.return_value = mock_response
334+
335+
auth = AuthService(config_path=config_path)
336+
old_auth = AuthStorage(
337+
accessToken='old-token',
338+
refreshToken='old-refresh',
339+
userId='did:privy:test',
340+
accounts=[],
341+
expiry=12345,
342+
)
343+
344+
with pytest.raises(ValueError, match='rate limited'):
345+
auth.refresh_token(old_auth)

0 commit comments

Comments
 (0)