2026-02-05 15:13:06 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Callable
|
|
|
|
|
|
2026-02-25 13:34:02 +08:00
|
|
|
import pytest
|
2026-02-05 15:13:06 +08:00
|
|
|
from fastapi import HTTPException
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
from app import app
|
|
|
|
|
from v1.auth.dependencies import get_auth_service
|
2026-02-25 13:34:02 +08:00
|
|
|
from v1.auth.rate_limit import reset_rate_limit_state
|
2026-02-24 16:38:30 +08:00
|
|
|
from v1.auth.schemas import (
|
2026-02-05 15:13:06 +08:00
|
|
|
AuthUser,
|
2026-03-19 18:42:59 +08:00
|
|
|
OtpSendRequest,
|
|
|
|
|
PhoneSessionCreateRequest,
|
2026-02-26 14:08:10 +08:00
|
|
|
SessionRefreshRequest,
|
|
|
|
|
SessionResponse,
|
2026-02-05 15:13:06 +08:00
|
|
|
)
|
|
|
|
|
from v1.auth.service import AuthService
|
|
|
|
|
|
|
|
|
|
|
2026-02-25 13:34:02 +08:00
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def reset_auth_rate_limit_state() -> None:
|
|
|
|
|
reset_rate_limit_state()
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def force_in_memory_rate_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
async def _raise_redis_unavailable() -> None:
|
|
|
|
|
raise RuntimeError("redis unavailable in integration tests")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"v1.auth.rate_limit.get_or_init_redis_client",
|
|
|
|
|
_raise_redis_unavailable,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 15:13:06 +08:00
|
|
|
class FakeAuthService(AuthService):
|
2026-02-26 14:08:10 +08:00
|
|
|
def __init__(self, token_response: SessionResponse) -> None:
|
2026-02-05 15:13:06 +08:00
|
|
|
self._token_response = token_response
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
async def send_otp(self, request: OtpSendRequest) -> None:
|
|
|
|
|
if request.phone == "+8613811111111":
|
|
|
|
|
raise HTTPException(status_code=401, detail="Invalid verification code")
|
|
|
|
|
return None
|
2026-02-25 13:34:02 +08:00
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
async def create_phone_session(
|
|
|
|
|
self, request: PhoneSessionCreateRequest
|
2026-02-26 14:08:10 +08:00
|
|
|
) -> SessionResponse:
|
2026-02-25 13:34:02 +08:00
|
|
|
if request.token == "000000":
|
|
|
|
|
raise HTTPException(status_code=401, detail="Invalid verification code")
|
2026-02-05 15:13:06 +08:00
|
|
|
return self._token_response
|
|
|
|
|
|
2026-02-26 14:08:10 +08:00
|
|
|
async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse:
|
2026-02-05 15:13:06 +08:00
|
|
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
|
|
|
|
2026-02-26 14:08:10 +08:00
|
|
|
async def delete_session(self, refresh_token: str | None) -> None:
|
2026-02-05 15:13:06 +08:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _override_auth_service(service: AuthService) -> Callable[[], AuthService]:
|
|
|
|
|
def _get_service() -> AuthService:
|
|
|
|
|
return service
|
|
|
|
|
|
|
|
|
|
return _get_service
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
def _token_response() -> SessionResponse:
|
|
|
|
|
user = AuthUser(id="user-1", phone="+8613812345678")
|
|
|
|
|
return SessionResponse(
|
2026-02-05 15:13:06 +08:00
|
|
|
access_token="access",
|
|
|
|
|
refresh_token="refresh",
|
|
|
|
|
expires_in=3600,
|
|
|
|
|
token_type="bearer",
|
|
|
|
|
user=user,
|
|
|
|
|
)
|
2026-03-19 18:42:59 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_send_otp_returns_204() -> None:
|
2026-02-05 15:13:06 +08:00
|
|
|
app.dependency_overrides[get_auth_service] = _override_auth_service(
|
2026-03-19 18:42:59 +08:00
|
|
|
FakeAuthService(_token_response())
|
2026-02-05 15:13:06 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
|
|
|
|
response = client.post(
|
2026-03-19 18:42:59 +08:00
|
|
|
"/api/v1/auth/otp/send",
|
|
|
|
|
json={"phone": "+8613812345678"},
|
2026-02-05 15:13:06 +08:00
|
|
|
)
|
2026-03-19 18:42:59 +08:00
|
|
|
assert response.status_code == 204
|
2026-02-25 13:34:02 +08:00
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
def test_phone_session_returns_token_response() -> None:
|
2026-02-25 13:34:02 +08:00
|
|
|
app.dependency_overrides[get_auth_service] = _override_auth_service(
|
2026-03-19 18:42:59 +08:00
|
|
|
FakeAuthService(_token_response())
|
2026-02-25 13:34:02 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
|
|
|
|
response = client.post(
|
2026-03-19 18:42:59 +08:00
|
|
|
"/api/v1/auth/phone-session",
|
|
|
|
|
json={"phone": "+8613812345678", "token": "123456"},
|
2026-02-25 13:34:02 +08:00
|
|
|
)
|
2026-02-05 15:13:06 +08:00
|
|
|
assert response.status_code == 200
|
|
|
|
|
body = response.json()
|
|
|
|
|
assert body["access_token"] == "access"
|
|
|
|
|
assert body["refresh_token"] == "refresh"
|
2026-03-19 18:42:59 +08:00
|
|
|
assert body["user"]["phone"] == "+8613812345678"
|
2026-02-25 13:34:02 +08:00
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
def test_phone_session_invalid_token_returns_problem_details() -> None:
|
2026-02-25 13:34:02 +08:00
|
|
|
app.dependency_overrides[get_auth_service] = _override_auth_service(
|
2026-03-19 18:42:59 +08:00
|
|
|
FakeAuthService(_token_response())
|
2026-02-25 13:34:02 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
|
|
|
|
response = client.post(
|
2026-03-19 18:42:59 +08:00
|
|
|
"/api/v1/auth/phone-session",
|
|
|
|
|
json={"phone": "+8613812345678", "token": "000000"},
|
2026-02-25 13:34:02 +08:00
|
|
|
)
|
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
assert response.headers["content-type"].startswith("application/problem+json")
|
|
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
def test_legacy_routes_are_removed() -> None:
|
2026-02-25 13:34:02 +08:00
|
|
|
app.dependency_overrides[get_auth_service] = _override_auth_service(
|
2026-03-19 18:42:59 +08:00
|
|
|
FakeAuthService(_token_response())
|
2026-02-25 13:34:02 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
2026-03-19 18:42:59 +08:00
|
|
|
assert client.post("/api/v1/auth/verifications", json={}).status_code == 404
|
|
|
|
|
assert client.post("/api/v1/auth/verify", json={}).status_code == 404
|
|
|
|
|
assert client.post("/api/v1/auth/resend", json={}).status_code == 404
|
|
|
|
|
assert client.post("/api/v1/auth/sessions", json={}).status_code == 405
|
2026-02-25 13:34:02 +08:00
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
def test_send_otp_phone_rate_limited_after_too_many_attempts() -> None:
|
2026-02-25 13:34:02 +08:00
|
|
|
app.dependency_overrides[get_auth_service] = _override_auth_service(
|
2026-03-19 18:42:59 +08:00
|
|
|
FakeAuthService(_token_response())
|
2026-02-25 13:34:02 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
2026-03-19 18:42:59 +08:00
|
|
|
for _ in range(3):
|
2026-02-25 13:34:02 +08:00
|
|
|
ok = client.post(
|
2026-03-19 18:42:59 +08:00
|
|
|
"/api/v1/auth/otp/send",
|
|
|
|
|
json={"phone": "+8613812345678"},
|
2026-02-25 13:34:02 +08:00
|
|
|
)
|
2026-02-26 14:08:10 +08:00
|
|
|
assert ok.status_code == 204
|
2026-02-25 13:34:02 +08:00
|
|
|
|
|
|
|
|
blocked = client.post(
|
2026-03-19 18:42:59 +08:00
|
|
|
"/api/v1/auth/otp/send",
|
|
|
|
|
json={"phone": "+8613812345678"},
|
2026-02-25 13:34:02 +08:00
|
|
|
)
|
|
|
|
|
assert blocked.status_code == 429
|
|
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 18:42:59 +08:00
|
|
|
def test_phone_session_rate_limited_after_too_many_attempts() -> None:
|
2026-02-25 13:34:02 +08:00
|
|
|
app.dependency_overrides[get_auth_service] = _override_auth_service(
|
2026-03-19 18:42:59 +08:00
|
|
|
FakeAuthService(_token_response())
|
2026-02-25 13:34:02 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
try:
|
2026-03-19 18:42:59 +08:00
|
|
|
for _ in range(6):
|
2026-02-25 16:51:12 +08:00
|
|
|
blocked = client.post(
|
2026-03-19 18:42:59 +08:00
|
|
|
"/api/v1/auth/phone-session",
|
|
|
|
|
json={"phone": "+8613812345678", "token": "000000"},
|
2026-02-25 16:51:12 +08:00
|
|
|
)
|
|
|
|
|
assert blocked.status_code == 401
|
|
|
|
|
|
|
|
|
|
blocked = client.post(
|
2026-03-19 18:42:59 +08:00
|
|
|
"/api/v1/auth/phone-session",
|
|
|
|
|
json={"phone": "+8613812345678", "token": "000000"},
|
2026-02-25 16:51:12 +08:00
|
|
|
)
|
|
|
|
|
assert blocked.status_code == 429
|
|
|
|
|
finally:
|
|
|
|
|
app.dependency_overrides = {}
|