feat(auth): migrate to Keycloak metadata auth
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import agent_auth as agent_auth_endpoint
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_payload
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||
from tests.conftest import build_test_app
|
||||
|
||||
|
||||
def _build_client(*, project_context=None, current_user=None) -> TestClient:
|
||||
app = build_test_app(agent_auth_endpoint.router, "/api/v1")
|
||||
if project_context is not None:
|
||||
app.dependency_overrides[get_project_context] = lambda: project_context
|
||||
if current_user is not None:
|
||||
app.dependency_overrides[get_current_metadata_user] = lambda: current_user
|
||||
app.dependency_overrides[get_current_keycloak_payload] = lambda: {"exp": 1781183400}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_agent_auth_context_returns_metadata_user_and_project_context():
|
||||
user_id = uuid4()
|
||||
keycloak_sub = uuid4()
|
||||
project_id = uuid4()
|
||||
client = _build_client(
|
||||
project_context=ProjectContext(
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
project_role="editor",
|
||||
),
|
||||
current_user=SimpleNamespace(
|
||||
id=user_id,
|
||||
keycloak_id=keycloak_sub,
|
||||
username="alice",
|
||||
role="user",
|
||||
is_superuser=False,
|
||||
),
|
||||
)
|
||||
|
||||
response = client.get("/api/v1/agent/auth/context")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"user_id": str(user_id),
|
||||
"keycloak_sub": str(keycloak_sub),
|
||||
"username": "alice",
|
||||
"role": "user",
|
||||
"is_superuser": False,
|
||||
"project_id": str(project_id),
|
||||
"project_role": "editor",
|
||||
"token_expires_at": "2026-06-11T13:10:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
def test_agent_auth_context_propagates_project_auth_failures():
|
||||
def reject_project():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No access to project",
|
||||
)
|
||||
|
||||
app = build_test_app(agent_auth_endpoint.router, "/api/v1")
|
||||
app.dependency_overrides[get_project_context] = reject_project
|
||||
app.dependency_overrides[get_current_metadata_user] = lambda: SimpleNamespace(
|
||||
id=uuid4(),
|
||||
keycloak_id=uuid4(),
|
||||
username="alice",
|
||||
role="user",
|
||||
is_superuser=False,
|
||||
)
|
||||
app.dependency_overrides[get_current_keycloak_payload] = lambda: {"exp": 1781183400}
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/agent/auth/context")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "No access to project"
|
||||
@@ -2,7 +2,7 @@
|
||||
"""
|
||||
测试新增 API 集成
|
||||
|
||||
验证新的认证、用户管理和审计日志接口是否正确集成
|
||||
验证 Keycloak/metadata 认证和审计日志接口是否正确集成
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -17,16 +17,15 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.
|
||||
"module_name, desc",
|
||||
[
|
||||
("app.core.encryption", "加密模块"),
|
||||
("app.core.security", "安全模块"),
|
||||
("app.core.audit", "审计模块"),
|
||||
("app.domain.models.role", "角色模型"),
|
||||
("app.domain.schemas.user", "用户Schema"),
|
||||
("app.domain.schemas.audit", "审计Schema"),
|
||||
("app.auth.permissions", "权限控制"),
|
||||
("app.api.v1.endpoints.auth", "认证接口"),
|
||||
("app.api.v1.endpoints.user_management", "用户管理接口"),
|
||||
("app.auth.keycloak_dependencies", "Keycloak Token 校验"),
|
||||
("app.auth.metadata_dependencies", "Metadata 用户解析"),
|
||||
("app.auth.project_dependencies", "项目权限控制"),
|
||||
("app.api.v1.endpoints.agent_auth", "Agent 认证上下文接口"),
|
||||
("app.api.v1.endpoints.meta", "Metadata 接口"),
|
||||
("app.api.v1.endpoints.audit", "审计日志接口"),
|
||||
("app.infra.db.metadb.repositories.user_repository", "用户仓储"),
|
||||
("app.infra.db.metadb.repositories.metadata_repository", "Metadata 仓储"),
|
||||
("app.infra.db.metadb.repositories.audit_repository", "审计仓储"),
|
||||
("app.infra.audit.middleware", "审计中间件"),
|
||||
],
|
||||
@@ -49,8 +48,8 @@ def test_router_configuration():
|
||||
routes = [r.path for r in api_router.routes if hasattr(r, "path")]
|
||||
|
||||
# 验证基础路径是否存在
|
||||
assert any("/auth" in r for r in routes), "缺少认证相关路由 (/auth)"
|
||||
assert any("/users" in r for r in routes), "缺少用户管理路由 (/users)"
|
||||
assert any("/agent/auth/context" in r for r in routes), "缺少 Agent 认证上下文路由"
|
||||
assert any("/meta" in r for r in routes), "缺少 Metadata 路由"
|
||||
assert any("/audit" in r for r in routes), "缺少审计日志路由 (/audit)"
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import auth as auth_endpoint
|
||||
from app.auth.dependencies import get_current_active_user, get_user_repository
|
||||
from app.core.security import create_access_token, create_refresh_token, get_password_hash
|
||||
from tests.conftest import build_test_app, make_user
|
||||
|
||||
|
||||
def _build_client(repo, current_user=None) -> TestClient:
|
||||
app = build_test_app(auth_endpoint.router, "/api/v1/auth")
|
||||
app.dependency_overrides[get_user_repository] = lambda: repo
|
||||
if current_user is not None:
|
||||
app.dependency_overrides[get_current_active_user] = lambda: current_user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_register_success():
|
||||
repo = SimpleNamespace(
|
||||
user_exists=AsyncMock(side_effect=[False, False]),
|
||||
create_user=AsyncMock(return_value=make_user()),
|
||||
)
|
||||
client = _build_client(repo)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"username": "tester",
|
||||
"email": "tester@example.com",
|
||||
"password": "secret123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["username"] == "tester"
|
||||
|
||||
|
||||
def test_register_rejects_duplicate_username():
|
||||
repo = SimpleNamespace(
|
||||
user_exists=AsyncMock(side_effect=[True]),
|
||||
create_user=AsyncMock(),
|
||||
)
|
||||
client = _build_client(repo)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"username": "tester",
|
||||
"email": "tester@example.com",
|
||||
"password": "secret123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Username already registered"
|
||||
repo.create_user.assert_not_awaited()
|
||||
|
||||
|
||||
def test_login_supports_email_lookup():
|
||||
hashed_password = get_password_hash("secret123")
|
||||
repo = SimpleNamespace(
|
||||
get_user_by_username=AsyncMock(return_value=None),
|
||||
get_user_by_email=AsyncMock(
|
||||
return_value=make_user(
|
||||
email="tester@example.com",
|
||||
hashed_password=hashed_password,
|
||||
)
|
||||
),
|
||||
)
|
||||
client = _build_client(repo)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "tester@example.com", "password": "secret123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["token_type"] == "bearer"
|
||||
repo.get_user_by_email.assert_awaited_once_with("tester@example.com")
|
||||
|
||||
|
||||
def test_login_simple_uses_query_params():
|
||||
hashed_password = get_password_hash("secret123")
|
||||
repo = SimpleNamespace(
|
||||
get_user_by_username=AsyncMock(
|
||||
return_value=make_user(hashed_password=hashed_password)
|
||||
),
|
||||
get_user_by_email=AsyncMock(),
|
||||
)
|
||||
client = _build_client(repo)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login/simple",
|
||||
params={"username": "tester", "password": "secret123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["token_type"] == "bearer"
|
||||
|
||||
|
||||
def test_me_returns_current_user_info():
|
||||
client = _build_client(SimpleNamespace(), current_user=make_user(username="alice"))
|
||||
|
||||
response = client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "alice"
|
||||
|
||||
|
||||
def test_refresh_rejects_access_token():
|
||||
repo = SimpleNamespace(get_user_by_username=AsyncMock())
|
||||
client = _build_client(repo)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
params={"refresh_token": create_access_token("tester")},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_refresh_success_returns_new_access_token():
|
||||
repo = SimpleNamespace(
|
||||
get_user_by_username=AsyncMock(return_value=make_user()),
|
||||
)
|
||||
client = _build_client(repo)
|
||||
refresh_token = create_refresh_token("tester")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
params={"refresh_token": refresh_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["refresh_token"] == refresh_token
|
||||
assert payload["token_type"] == "bearer"
|
||||
@@ -1,95 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import user_management as user_management_endpoint
|
||||
from app.auth.dependencies import get_current_active_user, get_user_repository
|
||||
from app.auth.permissions import get_current_admin
|
||||
from app.domain.models.role import UserRole
|
||||
from tests.conftest import build_test_app, make_user
|
||||
|
||||
|
||||
def _build_client(repo, *, current_user=None, admin_user=None) -> TestClient:
|
||||
app = build_test_app(user_management_endpoint.router, "/users")
|
||||
app.dependency_overrides[get_user_repository] = lambda: repo
|
||||
if current_user is not None:
|
||||
app.dependency_overrides[get_current_active_user] = lambda: current_user
|
||||
if admin_user is not None:
|
||||
app.dependency_overrides[get_current_admin] = lambda: admin_user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_list_users_requires_admin_role():
|
||||
repo = SimpleNamespace(
|
||||
get_all_users=AsyncMock(
|
||||
return_value=[
|
||||
make_user(id=1, username="admin", role=UserRole.ADMIN),
|
||||
make_user(id=2, username="user2"),
|
||||
]
|
||||
)
|
||||
)
|
||||
client = _build_client(
|
||||
repo,
|
||||
current_user=make_user(id=1, role=UserRole.ADMIN),
|
||||
)
|
||||
|
||||
response = client.get("/users/", params={"skip": 5, "limit": 2})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
repo.get_all_users.assert_awaited_once_with(skip=5, limit=2)
|
||||
|
||||
|
||||
def test_get_user_rejects_non_owner_non_admin():
|
||||
repo = SimpleNamespace(get_user_by_id=AsyncMock())
|
||||
client = _build_client(repo, current_user=make_user(id=2, role=UserRole.USER))
|
||||
|
||||
response = client.get("/users/3")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "You don't have permission to view this user"
|
||||
repo.get_user_by_id.assert_not_awaited()
|
||||
|
||||
|
||||
def test_update_user_blocks_role_change_for_non_admin():
|
||||
repo = SimpleNamespace(
|
||||
get_user_by_id=AsyncMock(return_value=make_user(id=1)),
|
||||
update_user=AsyncMock(),
|
||||
)
|
||||
client = _build_client(repo, current_user=make_user(id=1, role=UserRole.USER))
|
||||
|
||||
response = client.put("/users/1", json={"role": "ADMIN"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Only admins can change user roles"
|
||||
repo.update_user.assert_not_awaited()
|
||||
|
||||
|
||||
def test_delete_user_blocks_self_delete_for_admin():
|
||||
admin_user = make_user(id=1, role=UserRole.ADMIN, is_superuser=True)
|
||||
repo = SimpleNamespace(delete_user=AsyncMock())
|
||||
client = _build_client(repo, admin_user=admin_user)
|
||||
|
||||
response = client.delete("/users/1")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "You cannot delete your own account"
|
||||
repo.delete_user.assert_not_awaited()
|
||||
|
||||
|
||||
def test_activate_user_updates_active_flag():
|
||||
repo = SimpleNamespace(
|
||||
update_user=AsyncMock(return_value=make_user(id=2, is_active=True)),
|
||||
)
|
||||
client = _build_client(
|
||||
repo,
|
||||
admin_user=make_user(id=1, role=UserRole.ADMIN, is_superuser=True),
|
||||
)
|
||||
|
||||
response = client.post("/users/2/activate")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_active"] is True
|
||||
user_update = repo.update_user.await_args.args[1]
|
||||
assert user_update.is_active is True
|
||||
Reference in New Issue
Block a user