Skip to content

Commit 1ddaf1c

Browse files
test(auth): add edge-case regressions for device hints, refresh replay/race, and double logout
1 parent 88235e0 commit 1ddaf1c

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

tests/app/e2e/domains/auth/test_auth_routes.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

288385
class 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

314424
class TestFullAuthFlow:
315425
"""Complete auth lifecycle in a single test."""

tests/app/integration/domains/auth/test_session_repository.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from sqlalchemy.exc import IntegrityError
88
from sqlalchemy.ext.asyncio import AsyncSession
99

10-
from app.core.http.schemas import SessionDeviceInfo
10+
from app.core.http.schemas import DeviceType, SessionDeviceInfo
1111
from app.domains.auth.entities import Session
1212
from app.domains.auth.enums import SessionStatus
1313
from app.domains.auth.models import User as UserModel
@@ -160,6 +160,27 @@ async def test_create_session_success(
160160
assert session.expires_at == dto.expires_at
161161
assert session.is_valid()
162162

163+
@pytest.mark.asyncio
164+
async def test_create_session_with_device_type_enum_persists_jsonb(
165+
self,
166+
create_dto: CreateSessionDTO,
167+
session_repo: SessionRepository,
168+
) -> None:
169+
dto = create_dto
170+
dto.device_info = SessionDeviceInfo(
171+
user_agent="Mozilla/5.0",
172+
browser="Chromium",
173+
app_version="146",
174+
os="Android",
175+
device_type=DeviceType.MOBILE,
176+
)
177+
178+
session = await session_repo.create(dto)
179+
assert session is not None
180+
assert session.device_info is not None
181+
assert session.device_info.device_type == DeviceType.MOBILE
182+
assert session.device_info.browser == "Chromium"
183+
163184
@pytest.mark.asyncio
164185
async def test_create_session_existing_token_hash_should_fail(
165186
self, create_dto: CreateSessionDTO, session_repo: SessionRepository

0 commit comments

Comments
 (0)