@@ -72,6 +72,56 @@ async def test_register_assigns_default_user_role(
7272 role_names = {r ["name" ] for r in roles }
7373 assert "user" in role_names
7474
75+ @pytest .mark .asyncio
76+ async def test_register_with_device_client_hints_headers (
77+ self , client : AsyncClient
78+ ) -> None :
79+ """Regression: DeviceType enum from middleware must be JSON-serializable in session JSONB."""
80+ r = await client .post (
81+ "/api/auth/register" ,
82+ json = {
83+ "email" : "devicehints@test.com" ,
84+ "username" : "devicehints" ,
85+ "password" : "Pass1234!" ,
86+ },
87+ headers = {
88+ "user-agent" : "Mozilla/5.0" ,
89+ "sec-ch-ua" : '"Chromium";v="146", "Not A(Brand)";v="24"' ,
90+ "sec-ch-ua-mobile" : "?1" ,
91+ "sec-ch-ua-platform" : '"Android"' ,
92+ },
93+ )
94+ assert r .status_code == 201
95+ data = r .json ()["data" ]
96+ assert data ["email" ] == "devicehints@test.com"
97+ assert "access_token" in data
98+ assert "refresh_token" in data
99+
100+ @pytest .mark .asyncio
101+ async def test_register_with_malformed_client_hints_still_succeeds (
102+ self , client : AsyncClient
103+ ) -> None :
104+ """Malformed client hints should not break registration/session creation."""
105+ r = await client .post (
106+ "/api/auth/register" ,
107+ json = {
108+ "email" : "badch@test.com" ,
109+ "username" : "badch" ,
110+ "password" : "Pass1234!" ,
111+ },
112+ headers = {
113+ "user-agent" : "OddAgent/1.0" ,
114+ "sec-ch-ua" : "not-a-valid-ch-ua-format" ,
115+ "sec-ch-ua-mobile" : "?2" ,
116+ "sec-ch-ua-platform" : "UnknownOS" ,
117+ },
118+ )
119+ assert r .status_code == 201
120+ data = r .json ()["data" ]
121+ assert data ["email" ] == "badch@test.com"
122+ assert "access_token" in data
123+ assert "refresh_token" in data
124+
75125 @pytest .mark .asyncio
76126 async def test_register_ignores_role_ids_field (
77127 self , client : AsyncClient , auth : AuthActions
@@ -284,6 +334,53 @@ async def test_refresh_no_token(self, client: AsyncClient) -> None:
284334 r = await client .post ("/api/auth/refresh" , json = {"refresh_token" : "nope" })
285335 assert r .status_code == 403
286336
337+ @pytest .mark .asyncio
338+ async def test_refresh_token_replay_revokes_current_session (
339+ self , client : AsyncClient , auth : AuthActions
340+ ) -> None :
341+ """Reusing an already-rotated refresh token should invalidate the session."""
342+ tokens = await auth .register_and_login (email = "replay@test.com" , username = "replay" )
343+ headers = auth .auth_headers (tokens ["access_token" ])
344+
345+ first = await client .post (
346+ "/api/auth/refresh" ,
347+ json = {"refresh_token" : tokens ["refresh_token" ]},
348+ headers = headers ,
349+ )
350+ assert first .status_code == 200
351+
352+ replay = await client .post (
353+ "/api/auth/refresh" ,
354+ json = {"refresh_token" : tokens ["refresh_token" ]},
355+ headers = headers ,
356+ )
357+ assert replay .status_code == 401
358+
359+ me_after_replay = await client .get ("/api/auth/me" , headers = headers )
360+ assert me_after_replay .status_code == 401
361+
362+ @pytest .mark .asyncio
363+ async def test_refresh_same_token_twice_immediately_only_first_succeeds (
364+ self , client : AsyncClient , auth : AuthActions
365+ ) -> None :
366+ """Back-to-back refresh attempts with same token must not both succeed."""
367+ tokens = await auth .register_and_login (email = "race@test.com" , username = "race" )
368+ headers = auth .auth_headers (tokens ["access_token" ])
369+
370+ first = await client .post (
371+ "/api/auth/refresh" ,
372+ json = {"refresh_token" : tokens ["refresh_token" ]},
373+ headers = headers ,
374+ )
375+ second = await client .post (
376+ "/api/auth/refresh" ,
377+ json = {"refresh_token" : tokens ["refresh_token" ]},
378+ headers = headers ,
379+ )
380+
381+ assert first .status_code == 200
382+ assert second .status_code in {401 , 404 }
383+
287384
288385class TestLogout :
289386 """POST /api/auth/logout"""
@@ -310,6 +407,19 @@ async def test_logout_no_token(self, client: AsyncClient) -> None:
310407 r = await client .post ("/api/auth/logout" )
311408 assert r .status_code == 403
312409
410+ @pytest .mark .asyncio
411+ async def test_logout_twice_second_request_is_rejected (
412+ self , client : AsyncClient , auth : AuthActions
413+ ) -> None :
414+ tokens = await auth .register_and_login (email = "logouttwice@test.com" , username = "logouttwice" )
415+ headers = auth .auth_headers (tokens ["access_token" ])
416+
417+ first = await client .post ("/api/auth/logout" , headers = headers )
418+ assert first .status_code == 200
419+
420+ second = await client .post ("/api/auth/logout" , headers = headers )
421+ assert second .status_code == 401
422+
313423
314424class TestFullAuthFlow :
315425 """Complete auth lifecycle in a single test."""
0 commit comments