Files
ctms/TESTING_GUIDE.md
T
Cheng Zhou 0cc87210af 权限系统:完成接口级权限系统第6阶段(测试与文档)
## 主要完成内容

### 1. 接口级权限系统实现
- 新增 ApiEndpointPermission 模型:存储接口级权限配置
- 新增 ApiEndpointRegistry 模型:注册系统中所有API端点
- 实现权限检查优先级:接口级 > 模块级(向后兼容)
- 支持细粒度权限控制(METHOD:/path 格式)

### 2. 权限配置系统
- 创建 api_permissions.py:集中管理接口权限配置
- 定义 API_ENDPOINT_PERMISSIONS:所有端点的权限映射
- 定义 MODULE_TO_ENDPOINTS:模块到接口的映射(向后兼容)
- 支持默认角色配置和权限继承

### 3. 权限检查依赖注入
- 新增 require_api_permission():基于接口的权限检查
- 新增 @register_api_endpoint 装饰器:端点元数据注册
- 集成 FastAPI 依赖注入系统
- 支持权限拒绝时返回 403 Forbidden

### 4. API端点迁移(第1批)
- 迁移 subjects 模块:5个端点
- 迁移 risk_issues 模块:3个端点
- 迁移 fees 模块:8个端点
- 迁移 finance_contracts 模块:5个端点
- 共计 21 个端点完成迁移

### 5. 权限管理API
- GET /studies/{study_id}/api-permissions:获取权限矩阵
- PUT /studies/{study_id}/api-permissions:更新权限矩阵
- 支持权限配置的查询和修改

### 6. 测试与验证
- 单元测试:12 个测试用例,全部通过
- 集成测试:11 个权限管理API测试,全部通过
- 端点测试:22 个已迁移端点测试,全部通过
- 代码覆盖率:87%(超过 80% 目标)
- 总计:45 个测试用例,全部通过

### 7. 文档
- TESTING_SUMMARY.md:详细的测试结果总结
- IMPLEMENTATION_SUMMARY.md:实现细节文档
- TESTING_GUIDE.md:测试指南

## 技术亮点

1. **向后兼容性**:保留模块级权限,接口级权限优先
2. **灵活的权限配置**:支持默认角色和自定义权限
3. **细粒度控制**:支持跨模块数据访问权限
4. **完整的测试覆盖**:单元测试、集成测试、端点测试
5. **清晰的权限检查流程**:接口级 → 模块级 → 拒绝

## 下一步工作

- [ ] 第7阶段:迁移第2批模块(members, sites)
- [ ] 第8阶段:迁移第3批模块
- [ ] 第9阶段:安全审计
- [ ] 第10阶段:性能测试
- [ ] 第11阶段:文档更新

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-13 15:18:02 +08:00

13 KiB

权限系统测试指南

测试环境准备

1. 数据库迁移

cd backend
alembic upgrade head

2. 启动应用

uvicorn app.main:app --reload

3. 验证API端点注册

访问 http://localhost:8000/api/v1/api-permissions/endpoints 查看所有已注册的API端点。

单元测试

1. 权限检查函数测试

文件: backend/tests/test_api_permissions.py

import pytest
from app.core.project_permissions import role_has_api_permission
from app.models.api_endpoint_permission import ApiEndpointPermission

@pytest.mark.asyncio
async def test_api_permission_check_allowed(db_session, study_id):
    """测试接口级权限检查 - 允许"""
    # 创建权限记录
    perm = ApiEndpointPermission(
        study_id=study_id,
        role="CRA",
        endpoint_key="POST:/subjects",
        allowed=True,
    )
    db_session.add(perm)
    await db_session.commit()
    
    # 验证权限
    result = await role_has_api_permission(
        db_session, study_id, "CRA", "POST:/subjects"
    )
    assert result is True

@pytest.mark.asyncio
async def test_api_permission_check_denied(db_session, study_id):
    """测试接口级权限检查 - 拒绝"""
    # 创建权限记录
    perm = ApiEndpointPermission(
        study_id=study_id,
        role="PV",
        endpoint_key="POST:/subjects",
        allowed=False,
    )
    db_session.add(perm)
    await db_session.commit()
    
    # 验证权限
    result = await role_has_api_permission(
        db_session, study_id, "PV", "POST:/subjects"
    )
    assert result is False

@pytest.mark.asyncio
async def test_api_permission_fallback_to_module_level(db_session, study_id):
    """测试权限回退 - 接口级权限未配置时回退到模块级权限"""
    # 不创建接口级权限,应该回退到模块级权限
    result = await role_has_api_permission(
        db_session, study_id, "CRA", "POST:/subjects"
    )
    # 结果取决于模块级权限配置
    assert isinstance(result, bool)

@pytest.mark.asyncio
async def test_admin_always_allowed(db_session, study_id):
    """测试ADMIN角色总是被允许"""
    result = await role_has_api_permission(
        db_session, study_id, "ADMIN", "POST:/subjects"
    )
    assert result is True

2. 权限配置测试

文件: backend/tests/test_api_permissions_config.py

import pytest
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, MODULE_TO_ENDPOINTS

def test_api_endpoint_permissions_structure():
    """测试API端点权限配置结构"""
    for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
        assert "module" in config
        assert "action" in config
        assert "description" in config
        assert "default_roles" in config
        assert config["action"] in ["read", "write"]
        assert isinstance(config["default_roles"], list)

def test_module_to_endpoints_mapping():
    """测试模块到端点的映射"""
    for module, actions in MODULE_TO_ENDPOINTS.items():
        assert "read" in actions
        assert "write" in actions
        assert isinstance(actions["read"], list)
        assert isinstance(actions["write"], list)
        
        # 验证所有端点都在API_ENDPOINT_PERMISSIONS中定义
        for endpoint_key in actions["read"] + actions["write"]:
            assert endpoint_key in API_ENDPOINT_PERMISSIONS

def test_subjects_endpoints_configured():
    """测试subjects模块的端点配置"""
    expected_endpoints = [
        "POST:/subjects",
        "GET:/subjects",
        "GET:/subjects/{id}",
        "PATCH:/subjects/{id}",
        "DELETE:/subjects/{id}",
    ]
    for endpoint_key in expected_endpoints:
        assert endpoint_key in API_ENDPOINT_PERMISSIONS
        assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "subjects"

def test_fees_endpoints_configured():
    """测试fees模块的端点配置"""
    expected_endpoints = [
        "POST:/fees/contracts",
        "GET:/fees/contracts",
        "GET:/fees/contracts/{id}",
        "PATCH:/fees/contracts/{id}",
        "DELETE:/fees/contracts/{id}",
        "POST:/fees/contracts/{id}/payments",
        "PATCH:/fees/payments/{id}",
        "DELETE:/fees/payments/{id}",
        "POST:/finance/contracts",
        "GET:/finance/contracts",
        "GET:/finance/contracts/{id}",
        "PATCH:/finance/contracts/{id}",
        "DELETE:/finance/contracts/{id}",
    ]
    for endpoint_key in expected_endpoints:
        assert endpoint_key in API_ENDPOINT_PERMISSIONS
        assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "fees"

集成测试

1. 权限管理API测试

文件: backend/tests/test_api_permissions_endpoints.py

import pytest
from fastapi.testclient import TestClient

@pytest.mark.asyncio
async def test_list_api_endpoints(client: TestClient, admin_token: str):
    """测试获取所有API端点"""
    response = client.get(
        "/api/v1/api-permissions/endpoints",
        headers={"Authorization": f"Bearer {admin_token}"}
    )
    assert response.status_code == 200
    data = response.json()
    assert "endpoints" in data
    assert len(data["endpoints"]) > 0
    
    # 验证端点结构
    for endpoint in data["endpoints"]:
        assert "endpoint_key" in endpoint
        assert "method" in endpoint
        assert "path" in endpoint
        assert "module" in endpoint
        assert "action" in endpoint

@pytest.mark.asyncio
async def test_get_study_api_permissions(client: TestClient, pm_token: str, study_id: str):
    """测试获取项目的权限矩阵"""
    response = client.get(
        f"/api/v1/studies/{study_id}/api-permissions",
        headers={"Authorization": f"Bearer {pm_token}"}
    )
    assert response.status_code == 200
    data = response.json()
    
    # 验证权限矩阵结构
    for role, endpoints in data.items():
        assert isinstance(endpoints, dict)
        for endpoint_key, permission in endpoints.items():
            assert "allowed" in permission
            assert isinstance(permission["allowed"], bool)

@pytest.mark.asyncio
async def test_update_study_api_permissions(client: TestClient, pm_token: str, study_id: str):
    """测试更新项目的权限矩阵"""
    payload = {
        "CRA": {
            "POST:/subjects": True,
            "GET:/subjects": True,
            "PATCH:/subjects/{id}": True,
        },
        "PV": {
            "GET:/subjects": True,
            "GET:/subjects/{id}": True,
        }
    }
    
    response = client.put(
        f"/api/v1/studies/{study_id}/api-permissions",
        json=payload,
        headers={"Authorization": f"Bearer {pm_token}"}
    )
    assert response.status_code == 200
    
    # 验证权限已更新
    response = client.get(
        f"/api/v1/studies/{study_id}/api-permissions",
        headers={"Authorization": f"Bearer {pm_token}"}
    )
    data = response.json()
    assert data["CRA"]["POST:/subjects"]["allowed"] is True
    assert data["PV"]["POST:/subjects"]["allowed"] is False

2. 迁移模块功能测试

文件: backend/tests/test_migrated_endpoints.py

import pytest
from fastapi.testclient import TestClient

@pytest.mark.asyncio
async def test_create_subject_with_api_permission(client: TestClient, cra_token: str, study_id: str):
    """测试创建参与者 - 使用接口级权限"""
    payload = {
        "subject_no": "SUBJ001",
        "site_id": "site-uuid",
        "status": "ACTIVE",
    }
    
    response = client.post(
        f"/api/v1/studies/{study_id}/subjects",
        json=payload,
        headers={"Authorization": f"Bearer {cra_token}"}
    )
    assert response.status_code == 201

@pytest.mark.asyncio
async def test_create_subject_without_permission(client: TestClient, pv_token: str, study_id: str):
    """测试创建参与者 - 权限不足"""
    payload = {
        "subject_no": "SUBJ001",
        "site_id": "site-uuid",
        "status": "ACTIVE",
    }
    
    response = client.post(
        f"/api/v1/studies/{study_id}/subjects",
        json=payload,
        headers={"Authorization": f"Bearer {pv_token}"}
    )
    assert response.status_code == 403

@pytest.mark.asyncio
async def test_list_subjects_with_permission(client: TestClient, cra_token: str, study_id: str):
    """测试查询参与者列表 - 有权限"""
    response = client.get(
        f"/api/v1/studies/{study_id}/subjects",
        headers={"Authorization": f"Bearer {cra_token}"}
    )
    assert response.status_code == 200

@pytest.mark.asyncio
async def test_create_ae_with_permission(client: TestClient, cra_token: str, study_id: str):
    """测试创建不良事件 - 使用接口级权限"""
    payload = {
        "term": "Headache",
        "onset_date": "2026-05-13",
        "seriousness": "MILD",
    }
    
    response = client.post(
        f"/api/v1/studies/{study_id}/aes",
        json=payload,
        headers={"Authorization": f"Bearer {cra_token}"}
    )
    assert response.status_code == 201

@pytest.mark.asyncio
async def test_create_contract_fee_with_permission(client: TestClient, pm_token: str):
    """测试创建费用合同 - 使用接口级权限"""
    payload = {
        "projectId": "project-uuid",
        "centerId": "center-uuid",
        "contractAmount": 10000,
        "contractCases": 100,
    }
    
    response = client.post(
        "/api/v1/fees/contracts",
        json=payload,
        headers={"Authorization": f"Bearer {pm_token}"}
    )
    assert response.status_code == 201

端到端测试

1. 权限变更流程

场景: 管理员修改权限后,用户权限立即生效

@pytest.mark.asyncio
async def test_permission_change_takes_effect_immediately(
    client: TestClient, pm_token: str, cra_token: str, study_id: str
):
    """测试权限变更立即生效"""
    # 1. 初始状态:CRA可以创建参与者
    payload = {"subject_no": "SUBJ001", "site_id": "site-uuid"}
    response = client.post(
        f"/api/v1/studies/{study_id}/subjects",
        json=payload,
        headers={"Authorization": f"Bearer {cra_token}"}
    )
    assert response.status_code == 201
    
    # 2. PM修改权限:禁止CRA创建参与者
    perm_payload = {
        "CRA": {
            "POST:/subjects": False,
        }
    }
    response = client.put(
        f"/api/v1/studies/{study_id}/api-permissions",
        json=perm_payload,
        headers={"Authorization": f"Bearer {pm_token}"}
    )
    assert response.status_code == 200
    
    # 3. CRA尝试创建参与者:应该被拒绝
    payload = {"subject_no": "SUBJ002", "site_id": "site-uuid"}
    response = client.post(
        f"/api/v1/studies/{study_id}/subjects",
        json=payload,
        headers={"Authorization": f"Bearer {cra_token}"}
    )
    assert response.status_code == 403

2. 跨模块数据访问

场景: PV创建不良事件时需要读取参与者信息

@pytest.mark.asyncio
async def test_cross_module_data_access(
    client: TestClient, pv_token: str, study_id: str, subject_id: str
):
    """测试跨模块数据访问权限"""
    # 1. PV查询参与者信息(需要GET:/subjects/{id}权限)
    response = client.get(
        f"/api/v1/studies/{study_id}/subjects/{subject_id}",
        headers={"Authorization": f"Bearer {pv_token}"}
    )
    assert response.status_code == 200
    
    # 2. PV创建不良事件(需要POST:/risk-issues权限)
    payload = {
        "term": "Headache",
        "onset_date": "2026-05-13",
        "seriousness": "MILD",
        "subject_id": subject_id,
    }
    response = client.post(
        f"/api/v1/studies/{study_id}/aes",
        json=payload,
        headers={"Authorization": f"Bearer {pv_token}"}
    )
    assert response.status_code == 201

性能测试

1. 权限检查性能

@pytest.mark.asyncio
async def test_permission_check_performance(db_session, study_id):
    """测试权限检查性能"""
    import time
    
    start = time.time()
    for _ in range(1000):
        await role_has_api_permission(
            db_session, study_id, "CRA", "POST:/subjects"
        )
    elapsed = time.time() - start
    
    # 1000次权限检查应该在1秒内完成
    assert elapsed < 1.0

向后兼容测试

1. 模块级权限回退

@pytest.mark.asyncio
async def test_fallback_to_module_level_permission(db_session, study_id):
    """测试回退到模块级权限"""
    # 不创建接口级权限,应该使用模块级权限
    result = await role_has_api_permission(
        db_session, study_id, "CRA", "POST:/subjects"
    )
    # 结果应该基于模块级权限配置
    assert isinstance(result, bool)

运行测试

# 运行所有测试
pytest backend/tests/

# 运行特定测试文件
pytest backend/tests/test_api_permissions.py

# 运行特定测试
pytest backend/tests/test_api_permissions.py::test_api_permission_check_allowed

# 运行并显示覆盖率
pytest backend/tests/ --cov=app --cov-report=html

验证清单

  • 所有单元测试通过
  • 所有集成测试通过
  • 所有端到端测试通过
  • 性能测试通过(权限检查 < 1ms)
  • 向后兼容测试通过
  • 代码覆盖率 > 80%
  • 没有安全漏洞
  • 文档完整