"""监控API测试:权限系统监控API端点验证。""" import inspect import json import uuid from types import SimpleNamespace import pytest from fastapi import HTTPException from sqlalchemy import text from app.core.permission_monitor import set_permission_monitor, PermissionMonitor from app.api.v1 import permission_monitoring from app.api.v1.system_permissions import list_system_permissions from app.services.monitoring_server_location import MonitoringServerLocation class FakeIpInfo: def __init__(self, province: str, city: str, isp: str = "电信", country: str = "中国") -> None: self.country = country self.province = province self.city = city self.isp = isp self.location = " / ".join(part for part in [country, province, city, isp] if part) class AdminUserStub: is_admin = True class ProjectPmUserStub: id = uuid.uuid4() is_admin = False @pytest.fixture(autouse=True) def disable_server_public_ip_discovery(monkeypatch): async def no_server_location(): return None monkeypatch.setattr( permission_monitoring, "resolve_monitoring_server_location", no_server_location, ) @pytest.mark.asyncio async def test_resolve_monitoring_scope_rejects_project_pm(db_session): """系统监测模块仅允许系统管理员访问,项目 PM 不再具备监测范围。""" with pytest.raises(HTTPException) as exc_info: await permission_monitoring.resolve_monitoring_scope(db_session, ProjectPmUserStub()) assert exc_info.value.status_code == 403 @pytest.mark.asyncio async def test_system_permission_monitoring_definitions_are_admin_only(): """系统级权限定义中的系统监测模块不应再标注 PM 角色。""" data = await list_system_permissions() monitoring_items = [ item for item in data["permissions"] if item["module"] == "system_monitoring" ] assert monitoring_items assert data["module_labels"]["system_monitoring"] == "系统监测" assert all(item["roles"] == ["ADMIN"] for item in monitoring_items) assert all("PM" not in item["roles"] for item in monitoring_items) async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None: study_exists = ( await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)}) ).scalar_one_or_none() if not study_exists: await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": str(study_id), "code": f"PERM-MON-{study_id.hex[:8]}", "name": "Permission Monitoring Study", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": "[]", }, ) user_exists = ( await db_session.execute(text("SELECT id FROM users WHERE id = :id"), {"id": str(user_id)}) ).scalar_one_or_none() if not user_exists: await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": str(user_id), "email": f"{user_id.hex}@example.com", "password_hash": "hash", "full_name": "Permission Monitoring User", "clinical_department": "临床运营", "is_admin": False, "status": "ACTIVE", }, ) await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, request_snapshot, created_at) VALUES (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, :request_snapshot, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "study_id": str(study_id), "user_id": str(user_id), "endpoint_key": "admin.permissions.read", "role": "PM", "allowed": allowed, "elapsed_ms": elapsed_ms, "ip_address": "127.0.0.1", "request_snapshot": None, }, ) await db_session.commit() @pytest.mark.asyncio async def test_get_permission_metrics(db_session): """测试获取权限系统指标""" monitor = PermissionMonitor() set_permission_monitor(monitor) study_id = uuid.uuid4() user_id = uuid.uuid4() await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=5) await _seed_permission_log(db_session, study_id, user_id, allowed=False, elapsed_ms=3) data = await permission_monitoring.get_permission_metrics(db=db_session, _=AdminUserStub(), hours=24) assert "check_metrics" in data assert "cache_metrics" in data assert data["check_metrics"]["total_checks"] == 2 assert data["check_metrics"]["allowed_checks"] == 1 assert data["check_metrics"]["denied_checks"] == 1 @pytest.mark.asyncio async def test_get_cache_statistics(db_session): """测试获取缓存统计""" monitor = PermissionMonitor() set_permission_monitor(monitor) monitor.record_cache_hit() monitor.record_cache_hit() monitor.record_cache_miss() data = await permission_monitoring.get_cache_statistics(_=AdminUserStub(), db=db_session) assert "cache_metrics" in data assert data["cache_metrics"]["total_accesses"] == 3 assert data["cache_metrics"]["cache_hits"] == 2 @pytest.mark.asyncio async def test_get_alerts(db_session): """测试获取告警列表""" monitor = PermissionMonitor() set_permission_monitor(monitor) monitor.record_slow_check_alert(100) data = await permission_monitoring.get_alerts(_=AdminUserStub(), db=db_session) assert "alerts" in data assert data["total"] > 0 @pytest.mark.asyncio async def test_get_alerts_with_level_filter(db_session): """测试按级别过滤告警""" monitor = PermissionMonitor() set_permission_monitor(monitor) monitor.record_slow_check_alert(100) monitor.record_error_alert(ValueError("test error")) data = await permission_monitoring.get_alerts(level="warning", _=AdminUserStub(), db=db_session) assert "alerts" in data assert all(alert["level"] == "warning" for alert in data["alerts"]) @pytest.mark.asyncio async def test_get_alerts_with_limit(db_session): """测试限制告警数量""" monitor = PermissionMonitor() set_permission_monitor(monitor) for _ in range(20): monitor.record_slow_check_alert(100) data = await permission_monitoring.get_alerts(limit=5, _=AdminUserStub(), db=db_session) assert len(data["alerts"]) <= 5 @pytest.mark.asyncio async def test_reset_metrics(db_session): """测试重置指标""" monitor = PermissionMonitor() set_permission_monitor(monitor) monitor.record_cache_hit() assert monitor.metrics.cache_metrics.total_accesses == 1 result = await permission_monitoring.reset_metrics(_=AdminUserStub(), db=db_session) assert result["message"] == "指标已重置" assert monitor.metrics.cache_metrics.total_accesses == 0 @pytest.mark.asyncio async def test_clear_alerts(db_session): """测试清除告警""" monitor = PermissionMonitor() set_permission_monitor(monitor) monitor.record_slow_check_alert(100) assert len(monitor.get_alerts()) > 0 result = await permission_monitoring.clear_alerts(_=AdminUserStub(), db=db_session) assert result["message"] == "告警已清除" assert len(monitor.get_alerts()) == 0 @pytest.mark.asyncio async def test_permission_system_health_healthy(db_session): """测试权限系统健康检查(健康状态)""" monitor = PermissionMonitor() set_permission_monitor(monitor) for _ in range(100): monitor.record_cache_hit() data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub()) assert data["status"] == "healthy" assert data["health_score"] >= 80 @pytest.mark.asyncio async def test_permission_system_health_degraded(db_session): """测试权限系统健康检查(降级状态)""" monitor = PermissionMonitor() set_permission_monitor(monitor) study_id = uuid.uuid4() user_id = uuid.uuid4() for _ in range(10): await _seed_permission_log(db_session, study_id, user_id, allowed=False, elapsed_ms=100) for _ in range(100): monitor.record_cache_miss() data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub()) assert "status" in data assert "health_score" in data assert "issues" in data assert any("权限检查平均响应时间过长" in issue for issue in data["issues"]) assert any("权限拒绝率偏高" in issue for issue in data["issues"]) assert any("缓存命中率偏低" in issue for issue in data["issues"]) assert data["status"] == "unhealthy" assert data["health_score"] == 35 assert data["score_breakdown"]["performance"]["deduction"] == 20 assert data["score_breakdown"]["deny_rate"]["deduction"] == 20 assert data["score_breakdown"]["cache"]["deduction"] == 25 @pytest.mark.asyncio async def test_permission_system_health_includes_metrics(db_session): """测试健康检查包含详细指标""" monitor = PermissionMonitor() set_permission_monitor(monitor) data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub()) assert "last_hour" in data assert "cache_stats" in data assert "score_breakdown" in data assert "sample_sufficient" in data assert "components" in data assert "storage" in data assert "runtime" in data["score_breakdown"] assert data["components"]["database"]["status"] == "healthy" assert "total_checks" in data["last_hour"] assert "cache_metrics" in data["cache_stats"] @pytest.mark.asyncio async def test_permission_system_health_ignores_small_cache_sample(db_session): """缓存样本不足时不应因为低命中率扣分""" monitor = PermissionMonitor() set_permission_monitor(monitor) for _ in range(3): monitor.record_cache_miss() data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub()) assert data["score_breakdown"]["cache"]["sample_sufficient"] is False assert data["score_breakdown"]["cache"]["deduction"] == 0 non_cache_deductions = sum( item["deduction"] for name, item in data["score_breakdown"].items() if name != "cache" ) assert data["health_score"] == 100 - non_cache_deductions @pytest.mark.asyncio async def test_permission_system_health_reports_writer_degradation(db_session, monkeypatch): monitor = PermissionMonitor() set_permission_monitor(monitor) writer = SimpleNamespace( stats=lambda: { "running": True, "queue_size": 3, "queue_capacity": 100, "failed_batches": 0, "dropped_entries": 2, } ) monkeypatch.setattr(permission_monitoring, "get_log_writer", lambda: writer) monkeypatch.setattr(permission_monitoring, "get_security_log_writer", lambda: None) monkeypatch.setattr( permission_monitoring, "get_monitoring_retention_status", lambda: { "running": False, "last_run_started_at": None, "last_success_at": None, "last_error_at": None, }, ) data = await permission_monitoring.permission_system_health( db=db_session, _=AdminUserStub() ) assert data["components"]["permission_log_writer"]["status"] == "degraded" assert data["score_breakdown"]["runtime"]["deduction"] == 10 assert any("权限日志写入器" in issue for issue in data["issues"]) @pytest.mark.asyncio async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch): """IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。""" await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.commit() study_id = "00000000-0000-0000-0000-000000000001" user_a = "00000000-0000-0000-0000-000000000101" user_b = "00000000-0000-0000-0000-000000000102" await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": study_id, "code": "IP-LOCATION-STUDY", "name": "IP Location Study", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": "[]", }, ) for user_id, email in [(user_a, "user-a@example.com"), (user_b, "user-b@example.com")]: await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": user_id, "email": email, "password_hash": "hash", "full_name": email, "clinical_department": "临床运营", "is_admin": False, "status": "ACTIVE", }, ) rows = [ (study_id, user_a, "10.1.1.1", True), (study_id, user_a, "10.1.1.1", False), (study_id, user_b, "10.1.1.2", True), ] for study, user, ip_address, allowed in rows: await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) VALUES (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) """ ), { "study_id": study, "user_id": user, "endpoint_key": "admin.permissions.read", "role": "PM", "allowed": allowed, "elapsed_ms": 3.2, "ip_address": ip_address, }, ) await db_session.commit() resolved_ips = [] def fake_resolve_ip_location(ip): resolved_ips.append(ip) return FakeIpInfo("广东省", "深圳市") async def fake_resolve_monitoring_server_location(): return MonitoringServerLocation( name="China / 重庆 / 重庆市", longitude=106.5516, latitude=29.563, accuracy_level="city", resolution_source="public_ip_discovery", ) monkeypatch.setattr(permission_monitoring, "resolve_ip_location", fake_resolve_ip_location) monkeypatch.setattr( permission_monitoring, "resolve_monitoring_server_location", fake_resolve_monitoring_server_location, ) result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10) assert result["items"][0]["province"] == "广东省" assert result["items"][0]["total_count"] == 3 assert result["items"][0]["unique_ip_count"] == 2 assert result["items"][0]["unique_user_count"] == 2 assert result["summary"]["total_count"] == 3 assert result["summary"]["unique_ip_count"] == 2 assert result["summary"]["unique_user_count"] == 2 assert result["items"][0]["country_code"] == "CN" assert result["items"][0]["region_code"] == "CN-GD" assert result["items"][0]["longitude"] is not None assert result["items"][0]["risk_level"] == "attention" assert result["period"]["mode"] == "rolling" assert result["period"]["retention_days"] >= 7 assert result["data_quality"]["located_ip_count"] == 2 assert result["server_location"]["longitude"] == 106.5516 assert result["server_location"]["resolution_source"] == "public_ip_discovery" assert sorted(resolved_ips) == ["10.1.1.1", "10.1.1.2"] await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) VALUES (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, datetime('now', '-120 days')) """ ), { "study_id": study_id, "user_id": user_a, "endpoint_key": "admin.permissions.read", "role": "PM", "allowed": True, "elapsed_ms": 2.4, "ip_address": "10.1.1.3", }, ) await db_session.commit() resolved_ips.clear() all_result = await permission_monitoring.get_ip_locations( db=db_session, _=AdminUserStub(), days=7, limit=10, all_time=True, ) assert all_result["days"] is None assert all_result["summary"]["total_count"] == 4 assert all_result["summary"]["unique_ip_count"] == 3 assert sorted(resolved_ips) == ["10.1.1.1", "10.1.1.2", "10.1.1.3"] @pytest.mark.asyncio async def test_ip_locations_merges_same_region_with_different_isp(db_session, monkeypatch): """同一省市的 IP 属地统计不应因运营商不同拆分。""" await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.commit() study_id = "00000000-0000-0000-0000-000000000401" user_id = "00000000-0000-0000-0000-000000000501" await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": study_id, "code": "IP-LOCATION-MERGE-STUDY", "name": "IP Location Merge Study", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": "[]", }, ) await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": user_id, "email": "region-merge@example.com", "password_hash": "hash", "full_name": "属地合并用户", "clinical_department": "临床运营", "is_admin": False, "status": "ACTIVE", }, ) for ip_address in ["10.2.1.1", "10.2.1.2"]: await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) VALUES (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "study_id": study_id, "user_id": user_id, "endpoint_key": "admin.permissions.read", "role": "PM", "allowed": True, "elapsed_ms": 3.2, "ip_address": ip_address, }, ) await db_session.commit() ip_info_by_address = { "10.2.1.1": FakeIpInfo("重庆", "重庆市", "电信"), "10.2.1.2": FakeIpInfo("重庆", "重庆市", "联通"), } monkeypatch.setattr( permission_monitoring, "resolve_ip_location", lambda ip: ip_info_by_address[ip], ) result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10) assert len(result["items"]) == 1 assert result["items"][0]["province"] == "重庆" assert result["items"][0]["city"] == "重庆市" assert result["items"][0]["total_count"] == 2 assert result["items"][0]["unique_ip_count"] == 2 @pytest.mark.asyncio async def test_ip_locations_keeps_private_network_location_label(db_session, monkeypatch): """局域网 IP 无省市信息时,属地统计仍应保留可展示标签。""" await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.commit() study_id = "00000000-0000-0000-0000-000000000601" user_id = "00000000-0000-0000-0000-000000000701" await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": study_id, "code": "IP-LOCATION-PRIVATE-STUDY", "name": "IP Location Private Study", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": "[]", }, ) await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": user_id, "email": "private-network@example.com", "password_hash": "hash", "full_name": "局域网用户", "clinical_department": "临床运营", "is_admin": False, "status": "ACTIVE", }, ) await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, request_snapshot, created_at) VALUES (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, :request_snapshot, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "study_id": study_id, "user_id": user_id, "endpoint_key": "admin.permissions.read", "role": "PM", "allowed": True, "elapsed_ms": 3.2, "ip_address": "192.168.1.10", "request_snapshot": None, }, ) await db_session.commit() result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10) assert result["items"][0]["location"] == "局域网" assert result["items"][0]["province"] == "" assert result["items"][0]["city"] == "" @pytest.mark.asyncio async def test_ip_locations_scores_security_sources_by_behavior(db_session, monkeypatch): """来源分析应按安全行为而非地域评估来源风险。""" await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.execute(text("DELETE FROM security_access_logs")) await db_session.commit() study_id = "00000000-0000-0000-0000-000000000801" user_id = "00000000-0000-0000-0000-000000000901" await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": study_id, "code": "IP-LOCATION-SECURITY-STUDY", "name": "IP Location Security Study", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": "[]", }, ) await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": user_id, "email": "source-analysis@example.com", "password_hash": "hash", "full_name": "来源分析用户", "clinical_department": "临床运营", "is_admin": False, "status": "ACTIVE", }, ) await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, request_snapshot, created_at) VALUES (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, :request_snapshot, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "study_id": study_id, "user_id": user_id, "endpoint_key": "admin.permissions.read", "role": "PM", "allowed": True, "elapsed_ms": 3.2, "ip_address": "10.3.1.1", "request_snapshot": None, }, ) for ip_address, status_code in [("8.8.8.8", 403), ("1.1.1.1", 200)]: await db_session.execute( text( """ INSERT INTO security_access_logs (id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at) VALUES (:id, :method, :path, :status_code, :elapsed_ms, :client_ip, :user_agent, :auth_status, :user_identifier, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "method": "GET", "path": "/api/v1/admin", "status_code": status_code, "elapsed_ms": 8.5, "client_ip": ip_address, "user_agent": "pytest", "auth_status": "ANONYMOUS", "user_identifier": None, }, ) await db_session.commit() ip_info_by_address = { "10.3.1.1": FakeIpInfo("", "", "", "局域网"), "8.8.8.8": FakeIpInfo("California", "Mountain View", "Google", "United States"), "1.1.1.1": FakeIpInfo("Queensland", "Brisbane", "Cloudflare", "Australia"), } monkeypatch.setattr( permission_monitoring, "resolve_ip_location", lambda ip: ip_info_by_address[ip], ) result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10) locations = {item["country"]: item for item in result["items"]} assert result["summary"]["total_count"] == 3 assert result["summary"]["denied_count"] == 1 assert result["summary"]["unique_ip_count"] == 3 assert result["summary"]["unique_user_count"] == 1 assert locations["United States"]["total_count"] == 1 assert locations["United States"]["denied_count"] == 1 assert locations["United States"]["risk_level"] == "high" assert locations["United States"]["country_code"] == "US" assert locations["Australia"]["total_count"] == 1 assert locations["Australia"]["denied_count"] == 0 assert locations["Australia"]["risk_level"] == "normal" @pytest.mark.asyncio async def test_access_logs_include_user_behavior_summary(db_session): """访问日志应返回用户行为审计汇总和用户排行。""" await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.execute(text("DELETE FROM security_access_logs")) await db_session.commit() study_id = "00000000-0000-0000-0000-000000000201" user_a = "00000000-0000-0000-0000-000000000301" user_b = "00000000-0000-0000-0000-000000000302" await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": study_id, "code": "ACCESS-LOG-STUDY", "name": "Access Log Study", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": "[]", }, ) for user_id, email, name, role in [ (user_a, "audit-a@example.com", "审计用户A", "PM"), (user_b, "audit-b@example.com", "审计用户B", "CRA"), ]: await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": user_id, "email": email, "password_hash": "hash", "full_name": name, "clinical_department": "临床运营", "is_admin": False, "status": "ACTIVE", }, ) rows = [ (study_id, user_a, "admin.permissions.read", "PM", True, 3.0, "10.1.1.1"), (study_id, user_a, "admin.users.delete", "PM", False, 9.0, "10.1.1.2"), (study_id, user_b, "admin.permissions.read", "CRA", True, 6.0, "10.1.1.3"), ] for study, user, endpoint, role, allowed, elapsed_ms, ip_address in rows: await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) VALUES (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) """ ), { "study_id": study, "user_id": user, "endpoint_key": endpoint, "role": role, "allowed": allowed, "elapsed_ms": elapsed_ms, "ip_address": ip_address, }, ) await db_session.commit() result = await permission_monitoring.get_access_logs( db=db_session, _=AdminUserStub(), study_id=None, user_id=None, endpoint_key=None, role=None, allowed=None, start_time=None, end_time=None, page=1, page_size=50, ) assert result["summary"]["total_count"] == 3 assert result["summary"]["unique_user_count"] == 2 assert result["summary"]["denied_count"] == 1 assert result["summary"]["avg_elapsed_ms"] == 6.0 assert result["user_stats"][0]["user_name"] == "审计用户A" assert result["user_stats"][0]["total_count"] == 1 assert result["user_stats"][0]["denied_count"] in {0, 1} assert result["user_stats"][0]["unique_ip_count"] == 1 assert result["user_stats"][0]["sample_ip_address"] in {"10.1.1.1", "10.1.1.2"} user_a_ips = { stat["sample_ip_address"] for stat in result["user_stats"] if stat["user_name"] == "审计用户A" } assert user_a_ips == {"10.1.1.1", "10.1.1.2"} def test_access_logs_user_stats_query_avoids_postgresql_uuid_max(): """访问日志用户统计不能对 UUID 字段使用 max,PostgreSQL 不支持 max(uuid)。""" source = inspect.getsource(permission_monitoring.get_access_logs) assert "func.max(PermissionAccessLog.user_id)" not in source @pytest.mark.asyncio async def test_access_logs_filter_by_account_location_and_client_source(db_session, monkeypatch): """访问审计应支持按账号、IP 属地和客户端来源排查。""" await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.execute(text("DELETE FROM security_access_logs")) await db_session.commit() study_id = "00000000-0000-0000-0000-000000001401" user_a = "00000000-0000-0000-0000-000000001501" user_b = "00000000-0000-0000-0000-000000001502" await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": study_id, "code": "ACCESS-CONTEXT-STUDY", "name": "Access Context Study", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": "[]", }, ) for user_id, email, name in [ (user_a, "audit-shanghai@example.com", "上海审计用户"), (user_b, "audit-beijing@example.com", "北京审计用户"), ]: await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": user_id, "email": email, "password_hash": "hash", "full_name": name, "clinical_department": "临床运营", "is_admin": False, "status": "ACTIVE", }, ) rows = [ (user_a, "203.0.113.10", "web", "Chrome", "macos", {"user-agent": "Chrome", "x-request-id": "req-web"}), (user_b, "198.51.100.20", "desktop", "CTMS Desktop", "windows", {"user-agent": "CTMS Desktop"}), (user_a, "10.10.10.10", None, "curl/8.0", None, {"user-agent": "curl/8.0"}), ] for user, ip_address, client_type, user_agent, platform, request_headers in rows: await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, user_agent, client_type, client_version, client_platform, build_channel, build_commit, request_headers, created_at) VALUES (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, :user_agent, :client_type, :client_version, :client_platform, :build_channel, :build_commit, :request_headers, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "study_id": study_id, "user_id": user, "endpoint_key": "admin.permissions.read", "role": "PM", "allowed": True, "elapsed_ms": 4.0, "ip_address": ip_address, "user_agent": user_agent, "client_type": client_type, "client_version": "0.1.0" if client_type else None, "client_platform": platform, "build_channel": "local" if client_type else None, "build_commit": "abcdef1" if client_type else None, "request_headers": json.dumps(request_headers), }, ) await db_session.commit() ip_info_by_address = { "203.0.113.10": FakeIpInfo("上海市", "上海市", "电信"), "198.51.100.20": FakeIpInfo("北京市", "北京市", "联通"), "10.10.10.10": FakeIpInfo("", "", "", "局域网"), } monkeypatch.setattr( permission_monitoring, "resolve_ip_location", lambda ip: ip_info_by_address[ip], ) result = await permission_monitoring.get_access_logs( db=db_session, _=AdminUserStub(), study_id=None, user_id=None, endpoint_key=None, role=None, allowed=None, start_time=None, end_time=None, client_ip=None, client_type="web", keyword="上海", page=1, page_size=50, ) assert result["total"] == 1 assert result["items"][0]["user_email"] == "audit-shanghai@example.com" assert result["items"][0]["client_type"] == "web" assert result["items"][0]["client_platform"] == "macos" assert result["items"][0]["user_agent"] == "Chrome" assert result["items"][0]["request_headers"]["x-request-id"] == "req-web" assert result["items"][0]["ip_city"] == "上海市" unknown_result = await permission_monitoring.get_access_logs( db=db_session, _=AdminUserStub(), study_id=None, user_id=None, endpoint_key=None, role=None, allowed=None, start_time=None, end_time=None, client_ip=None, client_type="unknown", keyword="curl", page=1, page_size=50, ) assert unknown_result["total"] == 1 assert unknown_result["items"][0]["ip_address"] == "10.10.10.10" @pytest.mark.asyncio async def test_access_logs_include_security_events_for_admin(db_session, monkeypatch): """访问日志应合并业务权限日志和安全访问日志,避免匿名/探测请求审计盲区。""" await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.execute(text("DELETE FROM security_access_logs")) await db_session.commit() study_id = "00000000-0000-0000-0000-000000001601" user_id = "00000000-0000-0000-0000-000000001701" await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": study_id, "code": "ACCESS-SECURITY-FEED", "name": "Access Security Feed", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": "[]", }, ) await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": user_id, "email": "access-feed@example.com", "password_hash": "hash", "full_name": "统一访问用户", "clinical_department": "临床运营", "is_admin": False, "status": "ACTIVE", }, ) await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, request_snapshot, created_at) VALUES (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, :request_snapshot, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "study_id": study_id, "user_id": user_id, "endpoint_key": "project_overview:read", "role": "PM", "allowed": True, "elapsed_ms": 3.0, "ip_address": "192.168.10.2", "request_snapshot": json.dumps( { "method": "GET", "path": "/api/v1/projects/overview", "query_string": "tab=summary", "headers": {"user-agent": "Chrome", "x-request-id": "perm-1"}, "body": {"captured": False, "reason": "body_not_captured_by_audit_policy"}, } ), }, ) await db_session.execute( text( """ INSERT INTO security_access_logs (id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, request_headers, request_snapshot, created_at) VALUES (:id, :method, :path, :status_code, :elapsed_ms, :client_ip, :user_agent, :auth_status, :user_identifier, :request_headers, :request_snapshot, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "method": "GET", "path": "/api/.git/config", "status_code": 404, "elapsed_ms": 1.5, "client_ip": "45.148.10.95", "user_agent": "probe-bot/1.0", "auth_status": "ANONYMOUS", "user_identifier": None, "request_headers": json.dumps({"user-agent": "probe-bot/1.0", "x-request-id": "sec-1"}), "request_snapshot": json.dumps( { "method": "GET", "path": "/api/.git/config", "query_string": "token=[redacted]", "headers": {"user-agent": "probe-bot/1.0", "authorization": "[redacted]"}, "body": {"captured": False, "reason": "body_not_captured_by_audit_policy"}, } ), }, ) await db_session.commit() ip_info_by_address = { "192.168.10.2": FakeIpInfo("", "", "", "局域网"), "45.148.10.95": FakeIpInfo("South Holland", "", "", "Netherlands"), } monkeypatch.setattr(permission_monitoring, "resolve_ip_location", lambda ip: ip_info_by_address[ip]) result = await permission_monitoring.get_access_logs( db=db_session, _=AdminUserStub(), study_id=None, user_id=None, endpoint_key=None, role=None, allowed=None, start_time=None, end_time=None, client_ip=None, client_type=None, keyword=None, page=1, page_size=50, ) assert result["total"] == 2 assert result["summary"]["total_count"] == 2 assert result["summary"]["denied_count"] == 1 items_by_type = {item["event_type"]: item for item in result["items"]} assert items_by_type["permission"]["endpoint_key"] == "project_overview:read" assert items_by_type["security"]["status_code"] == 404 assert items_by_type["security"]["allowed"] is False assert items_by_type["security"]["category"] == "PROBE" assert items_by_type["security"]["severity"] == "CRITICAL" assert items_by_type["security"]["request_headers"]["x-request-id"] == "sec-1" assert items_by_type["permission"]["request_snapshot"]["path"] == "/api/v1/projects/overview" assert items_by_type["security"]["request_snapshot"]["headers"]["authorization"] == "[redacted]" @pytest.mark.asyncio async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session): """IP 访问排行应按同一 IP 的整体访问量聚合排序,而不是按用户/IP/角色拆分。""" await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.execute(text("DELETE FROM security_access_logs")) await db_session.commit() study_id = "00000000-0000-0000-0000-000000001201" user_a = "00000000-0000-0000-0000-000000001301" user_b = "00000000-0000-0000-0000-000000001302" await db_session.execute( text( """ INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) """ ), { "id": study_id, "code": "ACCESS-IP-RANK-STUDY", "name": "Access IP Rank Study", "status": "ACTIVE", "is_locked": False, "visit_schedule": "[]", "active_roles": "[]", }, ) for user_id, email, name in [ (user_a, "ip-rank-a@example.com", "IP排行用户A"), (user_b, "ip-rank-b@example.com", "IP排行用户B"), ]: await db_session.execute( text( """ INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { "id": user_id, "email": email, "password_hash": "hash", "full_name": name, "clinical_department": "临床运营", "is_admin": False, "status": "ACTIVE", }, ) rows = [ (user_a, "183.230.169.20", True), (user_a, "183.230.169.20", True), (user_b, "183.230.169.20", False), (user_a, "45.148.10.95", False), (user_b, "52.53.218.145", False), ] for user, ip_address, allowed in rows: await db_session.execute( text( """ INSERT INTO permission_access_logs (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) VALUES (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "study_id": study_id, "user_id": user, "endpoint_key": "admin.permissions.read", "role": "CRA", "allowed": allowed, "elapsed_ms": 4.0, "ip_address": ip_address, }, ) await db_session.commit() result = await permission_monitoring.get_access_logs( db=db_session, _=AdminUserStub(), study_id=None, user_id=None, endpoint_key=None, role=None, allowed=None, start_time=None, end_time=None, page=1, page_size=50, ) assert result["user_stats"][0]["sample_ip_address"] == "183.230.169.20" assert result["user_stats"][0]["total_count"] == 3 assert result["user_stats"][0]["denied_count"] == 1 assert {stat["sample_ip_address"] for stat in result["user_stats"][1:]} == {"52.53.218.145", "45.148.10.95"} @pytest.mark.asyncio async def test_security_access_logs_include_anonymous_ip_attempts(db_session, monkeypatch): """安全访问日志应覆盖未登录或未知账号的底层访问尝试。""" await db_session.execute(text("DELETE FROM security_access_logs")) monkeypatch.setattr( permission_monitoring, "resolve_ip_location", lambda ip: FakeIpInfo("上海市", "上海市", "联通"), ) await db_session.execute( text( """ INSERT INTO security_access_logs (id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, request_snapshot, created_at) VALUES ('00000000-0000-4000-8000-000000000501', 'POST', '/api/v1/auth/login', 401, 12.5, '203.0.113.10', 'attack-bot/1.0', 'ANONYMOUS', NULL, '{"method":"POST","path":"/api/v1/auth/login","headers":{"authorization":"[redacted]"}}', CURRENT_TIMESTAMP) """ ) ) await db_session.commit() result = await permission_monitoring.get_security_access_logs( db=db_session, _=AdminUserStub(), status_min=400, auth_status=None, page=1, page_size=20, ) assert result["summary"]["total_count"] == 1 assert result["summary"]["anonymous_count"] == 1 assert result["summary"]["error_count"] == 1 assert result["items"][0]["client_ip"] == "203.0.113.10" assert result["items"][0]["ip_location"] == "中国 / 上海市 / 上海市 / 联通" assert result["items"][0]["ip_country"] == "中国" assert result["items"][0]["ip_province"] == "上海市" assert result["items"][0]["ip_city"] == "上海市" assert result["items"][0]["ip_isp"] == "联通" assert result["items"][0]["account_label"] == "未知账号" assert result["items"][0]["status_code"] == 401 assert result["items"][0]["request_snapshot"]["headers"]["authorization"] == "[redacted]" @pytest.mark.asyncio async def test_security_access_logs_classify_sensitive_path_probe(db_session): """安全中心应把敏感路径探测识别为严重安全事件。""" await db_session.execute(text("DELETE FROM security_access_logs")) await db_session.execute( text( """ INSERT INTO security_access_logs (id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at) VALUES ('00000000-0000-4000-8000-000000000502', 'GET', '/api/.git/config', 404, 0.7, '198.51.100.20', 'probe-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP) """ ) ) await db_session.commit() result = await permission_monitoring.get_security_access_logs( db=db_session, _=AdminUserStub(), status_min=400, auth_status=None, page=1, page_size=20, ) assert result["items"][0]["category"] == "PROBE" assert result["items"][0]["severity"] == "CRITICAL" @pytest.mark.asyncio async def test_security_access_logs_prioritize_sensitive_probe_over_foreign_ip(db_session, monkeypatch): """敏感路径探测应优先于海外 IP 分类,避免降低风险等级。""" await db_session.execute(text("DELETE FROM security_access_logs")) monkeypatch.setattr( permission_monitoring, "resolve_ip_location", lambda ip: FakeIpInfo("South Holland", "", "", "Netherlands"), ) await db_session.execute( text( """ INSERT INTO security_access_logs (id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at) VALUES ('00000000-0000-4000-8000-000000000505', 'GET', '/api/.git/config', 404, 0.7, '45.148.10.95', 'probe-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP) """ ) ) await db_session.commit() result = await permission_monitoring.get_security_access_logs( db=db_session, _=AdminUserStub(), status_min=400, auth_status=None, page=1, page_size=20, ) assert result["items"][0]["category"] == "PROBE" assert result["items"][0]["severity"] == "CRITICAL" @pytest.mark.asyncio async def test_security_access_logs_classify_foreign_invalid_token_by_behavior(db_session, monkeypatch): """海外来源不应单凭地域升级风险,仍按认证行为分类。""" await db_session.execute(text("DELETE FROM security_access_logs")) monkeypatch.setattr( permission_monitoring, "resolve_ip_location", lambda ip: FakeIpInfo("California", "San Jose", "Google", "United States"), ) await db_session.execute( text( """ INSERT INTO security_access_logs (id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at) VALUES ('00000000-0000-4000-8000-000000000504', 'GET', '/api/v1/studies/', 401, 8.4, '52.53.218.145', 'foreign-client/1.0', 'INVALID_TOKEN', NULL, CURRENT_TIMESTAMP) """ ) ) await db_session.commit() result = await permission_monitoring.get_security_access_logs( db=db_session, _=AdminUserStub(), status_min=400, auth_status=None, page=1, page_size=20, ) item = result["items"][0] assert item["category"] == "INVALID_TOKEN" assert item["severity"] == "MEDIUM" assert item["ip_location"] == "United States / California / San Jose / Google" assert item["ip_country"] == "United States" @pytest.mark.asyncio async def test_security_access_logs_resolve_public_ip_with_packaged_ip2region(db_session): """安全中心公网 IP 应复用 ip2region 解析结果,避免前端只能显示未知位置。""" await db_session.execute(text("DELETE FROM security_access_logs")) await db_session.execute( text( """ INSERT INTO security_access_logs (id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at) VALUES ('00000000-0000-4000-8000-000000000503', 'GET', '/api/v1/auth/login', 404, 1.2, '52.53.218.145', 'probe-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP) """ ) ) await db_session.commit() result = await permission_monitoring.get_security_access_logs( db=db_session, _=AdminUserStub(), status_min=400, auth_status=None, page=1, page_size=20, ) item = result["items"][0] assert item["client_ip"] == "52.53.218.145" assert item["ip_location"] not in {"", "公网", "未知"} assert item["ip_country"] == "United States" assert item["ip_province"] == "California" assert item["ip_city"] == "San Jose" assert item["category"] == "NOT_FOUND_NOISE" @pytest.mark.asyncio async def test_access_logs_deduplicate_permission_and_security_rows_by_request_id(db_session): await db_session.execute(text("DELETE FROM permission_access_logs")) await db_session.execute(text("DELETE FROM security_access_logs")) request_id = str(uuid.uuid4()) study_id = str(uuid.uuid4()) user_id = str(uuid.uuid4()) await _seed_permission_log(db_session, uuid.UUID(study_id), uuid.UUID(user_id), allowed=False, elapsed_ms=4.0) await db_session.execute( text("UPDATE permission_access_logs SET request_id = :request_id"), {"request_id": request_id}, ) await db_session.execute( text( """ INSERT INTO security_access_logs (id, method, path, status_code, elapsed_ms, client_ip, auth_status, user_identifier, request_id, category, severity, created_at) VALUES (:id, 'GET', '/api/v1/studies/denied', 403, 5.0, '203.0.113.20', 'AUTHENTICATED', :user_id, :request_id, 'OTHER', 'LOW', CURRENT_TIMESTAMP) """ ), {"id": str(uuid.uuid4()), "user_id": user_id, "request_id": request_id}, ) await db_session.commit() result = await permission_monitoring.get_access_logs( db=db_session, _=AdminUserStub(), study_id=None, user_id=None, endpoint_key=None, role=None, allowed=None, start_time=None, end_time=None, client_ip=None, client_type=None, keyword=None, page=1, page_size=50, ) assert result["total"] == 1 assert result["items"][0]["event_type"] == "security" assert result["items"][0]["request_id"] == request_id @pytest.mark.asyncio async def test_security_events_only_includes_persisted_probe(db_session): await db_session.execute(text("DELETE FROM security_access_logs")) for category, ip in [("PROBE", "52.53.218.145"), ("OTHER", "203.0.113.20")]: await db_session.execute( text( """ INSERT INTO security_access_logs (id, method, path, status_code, elapsed_ms, client_ip, auth_status, category, severity, created_at) VALUES (:id, 'GET', '/api/v1/studies/', 200, 3.0, :ip, 'AUTHENTICATED', :category, :severity, CURRENT_TIMESTAMP) """ ), { "id": str(uuid.uuid4()), "ip": ip, "category": category, "severity": "CRITICAL" if category == "PROBE" else "LOW", }, ) await db_session.commit() result = await permission_monitoring.get_security_access_logs( db=db_session, _=AdminUserStub(), status_min=None, auth_status=None, events_only=True, page=1, page_size=20, ) assert result["total"] == 1 assert result["items"][0]["category"] == "PROBE" assert result["summary"]["high_risk_count"] == 1 def test_server_error_classification_takes_priority_over_foreign_ip(): log = SimpleNamespace( path="/api/v1/studies/", status_code=503, auth_status="AUTHENTICATED", category=None, severity=None, ) location = FakeIpInfo("California", "San Jose", "Google", "United States") assert permission_monitoring._classify_security_access_log(log, location) == { "category": "SERVER_ERROR", "severity": "HIGH", }