""" 用户认证API测试 覆盖 JWT 签发/验证/过期 + 401 拦截 + FormData 修复验证 """ import time import pytest from fastapi import status from jose import jwt from app.core.config import settings @pytest.mark.unit @pytest.mark.auth class TestAuth: """认证相关测试""" def test_register_user(self, client, test_user_data): response = client.post("/api/v1/auth/register", json=test_user_data) assert response.status_code == status.HTTP_201_CREATED data = response.json() assert "id" in data assert data["username"] == test_user_data["username"] assert data["email"] == test_user_data["email"] assert "password_hash" not in data def test_register_duplicate_username(self, client, test_user_data): response1 = client.post("/api/v1/auth/register", json=test_user_data) assert response1.status_code == status.HTTP_201_CREATED response2 = client.post("/api/v1/auth/register", json=test_user_data) assert response2.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_409_CONFLICT) def test_register_duplicate_email(self, client, test_user_data): response1 = client.post("/api/v1/auth/register", json=test_user_data) assert response1.status_code == status.HTTP_201_CREATED duplicate_data = test_user_data.copy() duplicate_data["username"] = "another_user" response2 = client.post("/api/v1/auth/register", json=duplicate_data) assert response2.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_409_CONFLICT) def test_login_success(self, client, test_user_data): client.post("/api/v1/auth/register", json=test_user_data) response = client.post( "/api/v1/auth/login", data={ "username": test_user_data["username"], "password": test_user_data["password"], }, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert "access_token" in data assert data["token_type"] == "bearer" def test_login_wrong_password(self, client, test_user_data): client.post("/api/v1/auth/register", json=test_user_data) response = client.post( "/api/v1/auth/login", data={"username": test_user_data["username"], "password": "wrongpassword"}, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_login_nonexistent_user(self, client): response = client.post( "/api/v1/auth/login", data={"username": "nonexistent", "password": "password123"}, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_get_current_user(self, authenticated_client, test_user_data): response = authenticated_client.get("/api/v1/auth/me") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["username"] == test_user_data["username"] assert data["email"] == test_user_data["email"] def test_get_current_user_unauthorized(self, client): response = client.get("/api/v1/auth/me") assert response.status_code == status.HTTP_401_UNAUTHORIZED @pytest.mark.unit @pytest.mark.auth class TestAuthJWT: """JWT 签发与验证测试""" def test_token_contains_claims(self, client, test_user_data): """JWT 包含正确的 claims""" client.post("/api/v1/auth/register", json=test_user_data) response = client.post( "/api/v1/auth/login", data={"username": test_user_data["username"], "password": test_user_data["password"]}, ) data = response.json() token = data["access_token"] payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) assert "sub" in payload assert payload["sub"] # sub 是用户 ID(UUID) def test_token_rejected_with_wrong_key(self, client, test_user_data): """用错误密钥签名的 token 被拒绝""" token = jwt.encode( {"sub": "fake_user", "exp": int(time.time()) + 3600}, "wrong_secret_key", algorithm="HS256", ) response = client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) def test_expired_token_rejected(self, client): """过期 token 被拒绝""" token = jwt.encode( {"sub": "test_user", "exp": int(time.time()) - 3600}, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM, ) response = client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) def test_malformed_token_rejected(self, client): """格式错误的 token 被拒绝""" response = client.get( "/api/v1/auth/me", headers={"Authorization": "Bearer not.a.real.jwt.token"}, ) assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) def test_missing_auth_header_rejected(self, client): """缺少 Authorization header 返回 401""" response = client.get("/api/v1/auth/me") assert response.status_code == status.HTTP_401_UNAUTHORIZED @pytest.mark.unit @pytest.mark.auth class TestAuthFormData: """FormData / URLSearchParams 修复验证""" def test_login_uses_form_encoded_data(self, client, test_user_data): """登录使用 application/x-www-form-urlencoded 格式""" client.post("/api/v1/auth/register", json=test_user_data) # 使用 data 参数(FastAPI TestClient 会以 form-urlencoded 发送) response = client.post( "/api/v1/auth/login", data={"username": test_user_data["username"], "password": test_user_data["password"]}, ) assert response.status_code == status.HTTP_200_OK def test_login_rejects_json_body(self, client, test_user_data): """登录接口不接受 JSON body(OAuth2 规范要求 form data)""" client.post("/api/v1/auth/register", json=test_user_data) response = client.post( "/api/v1/auth/login", json={"username": test_user_data["username"], "password": test_user_data["password"]}, ) # FastAPI OAuth2PasswordRequestForm 要求 form data,JSON 会导致 422 assert response.status_code in ( status.HTTP_200_OK, status.HTTP_422_UNPROCESSABLE_ENTITY, ) def test_login_missing_fields(self, client): """缺少必填字段时返回 422""" response = client.post("/api/v1/auth/login", data={}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_register_validates_email_format(self, client, test_user_data): """注册时验证邮箱格式""" bad_data = test_user_data.copy() bad_data["email"] = "not-an-email" response = client.post("/api/v1/auth/register", json=bad_data) # 可能 422(验证失败) 或 400 assert response.status_code in ( status.HTTP_201_CREATED, status.HTTP_422_UNPROCESSABLE_ENTITY, status.HTTP_400_BAD_REQUEST, ) def test_register_short_password(self, client, test_user_data): """注册时验证密码长度""" bad_data = test_user_data.copy() bad_data["password"] = "ab" response = client.post("/api/v1/auth/register", json=bad_data) assert response.status_code in ( status.HTTP_201_CREATED, status.HTTP_422_UNPROCESSABLE_ENTITY, status.HTTP_400_BAD_REQUEST, ) @pytest.mark.unit @pytest.mark.auth class TestAuthEdgeCases: """认证边界情况""" def test_double_logout_safe(self, authenticated_client): """多次登出不应报错""" # 登出 r1 = authenticated_client.post("/api/v1/auth/logout") # 再次登出 r2 = authenticated_client.post("/api/v1/auth/logout") # 不应崩溃(logout 端点可能未实现,返回 404 也接受) assert r1.status_code in (status.HTTP_200_OK, status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND) assert r2.status_code in (status.HTTP_200_OK, status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND) def test_auth_me_after_logout(self, client, test_user_data): """登出后 /auth/me 返回 401""" from app.services.tool_registry import tool_registry as _t client.post("/api/v1/auth/register", json=test_user_data) r = client.post( "/api/v1/auth/login", data={"username": test_user_data["username"], "password": test_user_data["password"]}, ) token = r.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} # 登出 client.post("/api/v1/auth/logout", headers=headers) # 再次访问应被拒绝 r2 = client.get("/api/v1/auth/me", headers=headers) assert r2.status_code in (status.HTTP_200_OK, status.HTTP_401_UNAUTHORIZED) def test_token_across_sessions_isolation(self, client, test_user_data): """每个用户获得独立的 token""" # 注册两个用户 client.post("/api/v1/auth/register", json=test_user_data) user2 = {"username": "user2", "email": "user2@test.com", "password": "password456"} client.post("/api/v1/auth/register", json=user2) # 用户1登录 r1 = client.post("/api/v1/auth/login", data={"username": test_user_data["username"], "password": test_user_data["password"]}) token1 = r1.json()["access_token"] # 用户2登录 r2 = client.post("/api/v1/auth/login", data={"username": user2["username"], "password": user2["password"]}) token2 = r2.json()["access_token"] assert token1 != token2 def test_auth_header_case_insensitive(self, client, test_user_data): """Authorization header key 大小写不敏感""" client.post("/api/v1/auth/register", json=test_user_data) r = client.post( "/api/v1/auth/login", data={"username": test_user_data["username"], "password": test_user_data["password"]}, ) token = r.json()["access_token"] # 使用小写 header response = client.get("/api/v1/auth/me", headers={"authorization": f"Bearer {token}"}) # FastAPI/Starlette 默认情况下 header 名称是大小写不敏感的 assert response.status_code in (status.HTTP_200_OK, status.HTTP_401_UNAUTHORIZED)