Files
Aether/tests/api/test_auth_routes.py
fawney19 c4bb6b8161 feat(auth): 重构认证系统,引入 session 会话管理
- 新增 user_sessions 数据库表及 Alembic 迁移
- 实现 SessionService 会话生命周期管理(创建/刷新/撤销/清理)
- 认证流程改用 refresh token cookie + access token 双令牌模式
- 前端实现自动静默刷新、跨标签页同步及设备指纹
- 用户设置页新增会话管理和密码修改功能
- 管理员用户管理新增强制登出和会话查看
- 密码策略增强,支持强度校验和泄露检测
- OAuth 登录流程适配新会话机制
- 新增完整的单元测试和 API 测试覆盖

Closes #232

Co-authored-by: LewisPen <LewisPen@nyadoo.com>
2026-03-17 16:34:09 +08:00

221 lines
7.0 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from src.api.auth.routes import _logout_with_refresh_cookie_fallback
from src.api.auth.routes import router as auth_router
from src.config import config
from src.database import get_db
def _build_auth_app(
db: MagicMock,
monkeypatch: pytest.MonkeyPatch,
pipeline_result: Any = None,
pipeline_exception: Exception | None = None,
) -> TestClient:
app = FastAPI()
app.include_router(auth_router)
app.dependency_overrides[get_db] = lambda: db
async def _fake_pipeline_run(
*, adapter: Any, http_request: object, db: MagicMock, mode: object
) -> Any:
_ = adapter, http_request, db, mode
if pipeline_exception is not None:
raise pipeline_exception
return pipeline_result
monkeypatch.setattr("src.api.auth.routes.pipeline.run", _fake_pipeline_run)
return TestClient(app)
def test_login_route_sets_refresh_cookie_and_hides_token(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_auth_app(
db,
monkeypatch,
pipeline_result={
"access_token": "access-1",
"token_type": "bearer",
"expires_in": 86400,
"user_id": "user-1",
"username": "tester",
"role": "user",
"_refresh_token": "refresh-1",
},
)
response = client.post("/api/auth/login", json={"email": "user@example.com", "password": "pw"})
assert response.status_code == 200
assert response.json() == {
"access_token": "access-1",
"token_type": "bearer",
"expires_in": 86400,
"user_id": "user-1",
"username": "tester",
"role": "user",
}
set_cookie = response.headers.get("set-cookie", "")
assert config.auth_refresh_cookie_name in set_cookie
assert "refresh-1" in set_cookie
assert "HttpOnly" in set_cookie
def test_refresh_route_sets_cookie_and_hides_token(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_auth_app(
db,
monkeypatch,
pipeline_result={
"access_token": "access-2",
"token_type": "bearer",
"expires_in": 86400,
"_refresh_token": "refresh-2",
},
)
response = client.post("/api/auth/refresh")
assert response.status_code == 200
assert response.json() == {
"access_token": "access-2",
"token_type": "bearer",
"expires_in": 86400,
}
set_cookie = response.headers.get("set-cookie", "")
assert config.auth_refresh_cookie_name in set_cookie
assert "refresh-2" in set_cookie
assert "HttpOnly" in set_cookie
def test_refresh_route_clears_cookie_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_auth_app(
db,
monkeypatch,
pipeline_exception=HTTPException(status_code=401, detail="登录会话已失效,请重新登录"),
)
response = client.post("/api/auth/refresh")
assert response.status_code == 401
assert response.json()["error"]["message"] == "登录会话已失效,请重新登录"
set_cookie = response.headers.get("set-cookie", "")
assert config.auth_refresh_cookie_name in set_cookie
assert "Max-Age=0" in set_cookie
def test_logout_route_clears_cookie_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_auth_app(
db,
monkeypatch,
pipeline_exception=HTTPException(status_code=401, detail="缺少认证令牌"),
)
response = client.post("/api/auth/logout")
assert response.status_code == 401
assert response.json()["error"]["message"] == "缺少认证令牌"
set_cookie = response.headers.get("set-cookie", "")
assert config.auth_refresh_cookie_name in set_cookie
assert "Max-Age=0" in set_cookie
def test_logout_route_uses_refresh_cookie_fallback_on_auth_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
client = _build_auth_app(
db,
monkeypatch,
pipeline_exception=HTTPException(status_code=401, detail="Token已过期"),
)
async def _fake_fallback(_request: object, _db: MagicMock) -> dict[str, Any]:
return {"message": "登出成功", "success": True}
monkeypatch.setattr(
"src.api.auth.routes._logout_with_refresh_cookie_fallback",
_fake_fallback,
)
response = client.post(
"/api/auth/logout",
cookies={config.auth_refresh_cookie_name: "refresh-1"},
)
assert response.status_code == 200
assert response.json() == {"message": "登出成功", "success": True}
set_cookie = response.headers.get("set-cookie", "")
assert config.auth_refresh_cookie_name in set_cookie
assert "Max-Age=0" in set_cookie
@pytest.mark.asyncio
async def test_logout_with_refresh_cookie_fallback_revokes_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
user = SimpleNamespace(id="user-1", email="user@example.com")
session = SimpleNamespace(id="session-1", is_revoked=False, is_expired=False)
db.query.return_value.filter.return_value.first.return_value = user
request = SimpleNamespace(
cookies={config.auth_refresh_cookie_name: "refresh-1"},
headers={},
query_params={},
state=SimpleNamespace(),
)
monkeypatch.setattr(
"src.api.auth.routes.AuthService.verify_token",
AsyncMock(
return_value={
"user_id": "user-1",
"session_id": "session-1",
}
),
)
monkeypatch.setattr(
"src.api.auth.routes.SessionService.extract_client_device_id",
lambda _request: "device-1",
)
monkeypatch.setattr(
"src.api.auth.routes.SessionService.get_session_for_user",
lambda *_args, **_kwargs: session,
)
assert_session_device_matches = MagicMock()
revoke_session = MagicMock()
log_event = MagicMock()
monkeypatch.setattr(
"src.api.auth.routes.SessionService.assert_session_device_matches",
assert_session_device_matches,
)
monkeypatch.setattr(
"src.api.auth.routes.SessionService.revoke_session",
revoke_session,
)
monkeypatch.setattr("src.api.auth.routes.AuditService.log_event", log_event)
monkeypatch.setattr("src.api.auth.routes.get_client_ip", lambda _request: "127.0.0.1")
monkeypatch.setattr(
"src.api.auth.routes.get_user_agent",
lambda _request: "pytest-agent",
)
result = await _logout_with_refresh_cookie_fallback(request, db)
assert result == {"message": "登出成功", "success": True}
assert_session_device_matches.assert_called_once_with(session, "device-1")
revoke_session.assert_called_once()
log_event.assert_called_once()
db.commit.assert_called_once()
assert request.state.tx_committed_by_route is True