11"""
2- Tests for wallet signature authentication (Issue #41).
2+ Tests for wallet signature authentication (Issue #41, #192 ).
33
44Covers:
55- Nonce generation: uniqueness, storage, binding to wallet_id.
66- Nonce consumption: valid path, replay prevention, wrong wallet, unknown nonce.
7- - Nonce expiry: expired nonces are pruned by _clean_expired_nonces.
87- Signature verification: valid key, wrong key, tampered message, bad hex, bad public key.
98- verify_wallet_signature dependency: 401 on bad nonce, 401 on bad sig, wallet_id on success.
109- GET /api/auth/nonce endpoint: returns nonce + expires_in.
1110"""
1211
13- import time
14-
1512import pytest
13+ import fakeredis .aioredis
1614from fastapi import FastAPI , HTTPException
1715from fastapi .testclient import TestClient
16+ from unittest .mock import patch
1817
1918from web_app .api .wallet_auth import (
2019 NONCE_TTL ,
21- _clean_expired_nonces ,
20+ NONCE_KEY_PREFIX ,
2221 _consume_nonce ,
2322 _generate_nonce ,
24- _nonce_store ,
23+ _nonce_key ,
2524 _verify_stellar_signature ,
2625 router ,
2726 verify_wallet_signature ,
3332# ---------------------------------------------------------------------------
3433
3534@pytest .fixture (autouse = True )
36- def _clear_nonce_store ():
37- """Ensure an empty nonce store before and after every test."""
38- _nonce_store .clear ()
39- yield
40- _nonce_store .clear ()
35+ async def _mock_redis ():
36+ """Mock Redis with fakeredis for all tests."""
37+ fake_redis = fakeredis .aioredis .FakeRedis ()
38+ with patch ("web_app.api.wallet_auth._redis" ) as mock_redis :
39+ mock_redis .return_value = fake_redis
40+ # Clear all nonce keys before and after each test
41+ async def clear_keys ():
42+ keys = await fake_redis .keys (f"{ NONCE_KEY_PREFIX } *" )
43+ if keys :
44+ await fake_redis .delete (* keys )
45+ await clear_keys ()
46+ yield fake_redis
47+ await clear_keys ()
4148
4249
4350# ---------------------------------------------------------------------------
4451# Nonce generation
4552# ---------------------------------------------------------------------------
4653
54+ @pytest .mark .asyncio
4755class TestGenerateNonce :
48- def test_returns_64_char_hex_string (self ):
49- nonce = _generate_nonce ("GABCDEF" )
56+ async def test_returns_64_char_hex_string (self ):
57+ nonce = await _generate_nonce ("GABCDEF" )
5058 assert isinstance (nonce , str )
5159 assert len (nonce ) == 64
5260
53- def test_each_call_produces_unique_nonce (self ):
54- n1 = _generate_nonce ("GABCDEF" )
55- n2 = _generate_nonce ("GABCDEF" )
61+ async def test_each_call_produces_unique_nonce (self ):
62+ n1 = await _generate_nonce ("GABCDEF" )
63+ n2 = await _generate_nonce ("GABCDEF" )
5664 assert n1 != n2
5765
58- def test_nonce_stored_with_correct_wallet_id (self ):
66+ async def test_nonce_stored_with_correct_wallet_id (self , _mock_redis ):
5967 wallet_id = "GABCDEF123"
60- nonce = _generate_nonce (wallet_id )
61- assert nonce in _nonce_store
62- stored_wallet , _ = _nonce_store [nonce ]
68+ nonce = await _generate_nonce (wallet_id )
69+ stored_wallet = await _mock_redis .get (_nonce_key (nonce ))
6370 assert stored_wallet == wallet_id
6471
65- def test_nonce_stored_with_recent_timestamp (self ):
66- before = time .monotonic ()
67- nonce = _generate_nonce ("GTEST" )
68- after = time .monotonic ()
69- _ , ts = _nonce_store [nonce ]
70- assert before <= ts <= after
71-
7272
7373# ---------------------------------------------------------------------------
7474# Nonce consumption
7575# ---------------------------------------------------------------------------
7676
77+ @pytest .mark .asyncio
7778class TestConsumeNonce :
78- def test_valid_nonce_and_wallet_returns_true (self ):
79+ async def test_valid_nonce_and_wallet_returns_true (self ):
7980 wallet_id = "GABCDEF"
80- nonce = _generate_nonce (wallet_id )
81- assert _consume_nonce (nonce , wallet_id ) is True
81+ nonce = await _generate_nonce (wallet_id )
82+ assert await _consume_nonce (nonce , wallet_id ) is True
8283
83- def test_nonce_removed_after_consumption (self ):
84+ async def test_nonce_removed_after_consumption (self , _mock_redis ):
8485 wallet_id = "GABCDEF"
85- nonce = _generate_nonce (wallet_id )
86- _consume_nonce (nonce , wallet_id )
87- assert nonce not in _nonce_store
86+ nonce = await _generate_nonce (wallet_id )
87+ await _consume_nonce (nonce , wallet_id )
88+ stored_wallet = await _mock_redis .get (_nonce_key (nonce ))
89+ assert stored_wallet is None
8890
89- def test_replay_attack_fails (self ):
91+ async def test_replay_attack_fails (self ):
9092 wallet_id = "GABCDEF"
91- nonce = _generate_nonce (wallet_id )
92- assert _consume_nonce (nonce , wallet_id ) is True
93- assert _consume_nonce (nonce , wallet_id ) is False
93+ nonce = await _generate_nonce (wallet_id )
94+ assert await _consume_nonce (nonce , wallet_id ) is True
95+ assert await _consume_nonce (nonce , wallet_id ) is False
9496
95- def test_wrong_wallet_id_returns_false (self ):
96- nonce = _generate_nonce ("GOWNER" )
97- assert _consume_nonce (nonce , "GATTACKER" ) is False
97+ async def test_wrong_wallet_id_returns_false (self ):
98+ nonce = await _generate_nonce ("GOWNER" )
99+ assert await _consume_nonce (nonce , "GATTACKER" ) is False
98100
99- def test_unknown_nonce_returns_false (self ):
100- assert _consume_nonce ("deadbeef" * 8 , "GABCDEF" ) is False
101-
102-
103- # ---------------------------------------------------------------------------
104- # Nonce expiry
105- # ---------------------------------------------------------------------------
106-
107- class TestCleanExpiredNonces :
108- def test_removes_expired_nonce (self ):
109- wallet_id = "GEXPIRED"
110- nonce = _generate_nonce (wallet_id )
111- _nonce_store [nonce ] = (wallet_id , time .monotonic () - NONCE_TTL - 1 )
112- _clean_expired_nonces ()
113- assert nonce not in _nonce_store
114-
115- def test_retains_fresh_nonce (self ):
116- wallet_id = "GFRESH"
117- nonce = _generate_nonce (wallet_id )
118- _clean_expired_nonces ()
119- assert nonce in _nonce_store
120-
121- def test_generate_nonce_prunes_expired_entries (self ):
122- wallet_id = "GSTALE"
123- stale_nonce = _generate_nonce (wallet_id )
124- _nonce_store [stale_nonce ] = (wallet_id , time .monotonic () - NONCE_TTL - 1 )
125- _generate_nonce ("GNEW" )
126- assert stale_nonce not in _nonce_store
101+ async def test_unknown_nonce_returns_false (self ):
102+ assert await _consume_nonce ("deadbeef" * 8 , "GABCDEF" ) is False
127103
128104
129105# ---------------------------------------------------------------------------
@@ -181,7 +157,7 @@ async def test_dependency_raises_401_on_invalid_nonce():
181157async def test_dependency_raises_401_on_bad_signature ():
182158 from stellar_sdk import Keypair
183159 kp = Keypair .random ()
184- nonce = _generate_nonce (kp .public_key )
160+ nonce = await _generate_nonce (kp .public_key )
185161 with pytest .raises (HTTPException ) as exc_info :
186162 await verify_wallet_signature (
187163 x_wallet_id = kp .public_key ,
@@ -195,7 +171,7 @@ async def test_dependency_raises_401_on_bad_signature():
195171async def test_dependency_returns_wallet_id_on_valid_signature ():
196172 from stellar_sdk import Keypair
197173 kp = Keypair .random ()
198- nonce = _generate_nonce (kp .public_key )
174+ nonce = await _generate_nonce (kp .public_key )
199175 sig_hex = kp .sign (nonce .encode ()).hex ()
200176 result = await verify_wallet_signature (
201177 x_wallet_id = kp .public_key ,
@@ -209,7 +185,8 @@ async def test_dependency_returns_wallet_id_on_valid_signature():
209185# GET /api/auth/nonce endpoint
210186# ---------------------------------------------------------------------------
211187
212- def test_get_nonce_endpoint_returns_nonce_and_ttl ():
188+ @pytest .mark .asyncio
189+ async def test_get_nonce_endpoint_returns_nonce_and_ttl (_mock_redis ):
213190 mini_app = FastAPI ()
214191 mini_app .include_router (router )
215192 test_client = TestClient (mini_app )
@@ -234,7 +211,8 @@ def test_get_nonce_endpoint_missing_wallet_id_returns_422():
234211 assert response .status_code == 422
235212
236213
237- def test_get_nonce_endpoint_stores_nonce_bound_to_wallet ():
214+ @pytest .mark .asyncio
215+ async def test_get_nonce_endpoint_stores_nonce_bound_to_wallet (_mock_redis ):
238216 mini_app = FastAPI ()
239217 mini_app .include_router (router )
240218 test_client = TestClient (mini_app )
@@ -243,6 +221,5 @@ def test_get_nonce_endpoint_stores_nonce_bound_to_wallet():
243221 response = test_client .get ("/api/auth/nonce" , params = {"wallet_id" : wallet_id })
244222 nonce = response .json ()["nonce" ]
245223
246- assert nonce in _nonce_store
247- stored_wallet , _ = _nonce_store [nonce ]
224+ stored_wallet = await _mock_redis .get (_nonce_key (nonce ))
248225 assert stored_wallet == wallet_id
0 commit comments