ab59476d10
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
1732 lines
62 KiB
Python
1732 lines
62 KiB
Python
"""监控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
|
||
from app.services.ip_geolocation_fallback import ExternalIpLocation
|
||
|
||
|
||
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,
|
||
endpoint_key: str = "admin.permissions.read",
|
||
role: str = "PM",
|
||
client_type: str | None = None,
|
||
client_version: str | None = None,
|
||
client_platform: str | None = None,
|
||
) -> 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,
|
||
client_type, client_version, client_platform, request_snapshot, created_at)
|
||
VALUES
|
||
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
|
||
:client_type, :client_version, :client_platform, :request_snapshot, CURRENT_TIMESTAMP)
|
||
"""
|
||
),
|
||
{
|
||
"id": str(uuid.uuid4()),
|
||
"study_id": str(study_id),
|
||
"user_id": str(user_id),
|
||
"endpoint_key": endpoint_key,
|
||
"role": role,
|
||
"allowed": allowed,
|
||
"elapsed_ms": elapsed_ms,
|
||
"ip_address": "127.0.0.1",
|
||
"client_type": client_type,
|
||
"client_version": client_version,
|
||
"client_platform": client_platform,
|
||
"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_trends_returns_dense_summary_comparison_and_attribution(db_session):
|
||
baseline = await permission_monitoring.get_trends(db=db_session, _=AdminUserStub(), period="24h")
|
||
study_id = uuid.uuid4()
|
||
user_id = uuid.uuid4()
|
||
await _seed_permission_log(
|
||
db_session,
|
||
study_id,
|
||
user_id,
|
||
allowed=True,
|
||
elapsed_ms=5,
|
||
endpoint_key="subjects.read",
|
||
client_type="web",
|
||
client_version="0.1.0",
|
||
client_platform="macos",
|
||
)
|
||
await _seed_permission_log(
|
||
db_session,
|
||
study_id,
|
||
user_id,
|
||
allowed=False,
|
||
elapsed_ms=80,
|
||
endpoint_key="subjects.export",
|
||
client_type="web",
|
||
client_version="0.1.0",
|
||
client_platform="macos",
|
||
)
|
||
|
||
data = await permission_monitoring.get_trends(db=db_session, _=AdminUserStub(), period="24h")
|
||
|
||
assert data["period"] == "24h"
|
||
assert data["range"]["bucket_seconds"] == 3600
|
||
assert len(data["data_points"]) == 24
|
||
assert data["data_points"][-1]["sample_state"] == "partial"
|
||
assert data["summary"]["total_checks"] == baseline["summary"]["total_checks"] + 2
|
||
assert data["summary"]["denied_checks"] == baseline["summary"]["denied_checks"] + 1
|
||
assert data["summary"]["slow_check_count"] == baseline["summary"]["slow_check_count"] + 1
|
||
assert data["summary"]["active_study_count"] == baseline["summary"]["active_study_count"] + 1
|
||
assert data["summary"]["active_user_count"] == baseline["summary"]["active_user_count"] + 1
|
||
assert data["summary"]["active_endpoint_count"] >= 2
|
||
assert data["summary"]["max_elapsed_ms"] >= data["summary"]["p95_elapsed_ms"]
|
||
assert data["previous_summary"]["total_checks"] == 0
|
||
assert any(item["endpoint_key"] == "subjects.export" for item in data["attribution"]["top_denied"])
|
||
assert data["attribution"]["top_slow"][0]["endpoint_key"] == "subjects.export"
|
||
assert any(item["client_type"] == "web" for item in data["attribution"]["client_breakdown"])
|
||
|
||
|
||
@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_uses_external_coordinates_only_when_local_coordinates_are_missing(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 = uuid.uuid4()
|
||
user_id = uuid.uuid4()
|
||
await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=3.2)
|
||
await db_session.execute(
|
||
text("UPDATE permission_access_logs SET ip_address = :ip_address"),
|
||
{"ip_address": "8.8.8.8"},
|
||
)
|
||
await db_session.commit()
|
||
|
||
monkeypatch.setattr(
|
||
permission_monitoring,
|
||
"resolve_ip_location",
|
||
lambda _ip: FakeIpInfo("", "", isp="", country="未知国家"),
|
||
)
|
||
|
||
async def fake_external_lookup(ip_addresses):
|
||
assert ip_addresses == ["8.8.8.8"]
|
||
return {
|
||
"8.8.8.8": ExternalIpLocation(
|
||
ip_address="8.8.8.8",
|
||
country="United States of America",
|
||
country_code="US",
|
||
region="California",
|
||
city="Mountain View",
|
||
isp="Google LLC",
|
||
longitude=-122.08385,
|
||
latitude=37.38605,
|
||
)
|
||
}
|
||
|
||
monkeypatch.setattr(permission_monitoring, "resolve_external_ip_locations", fake_external_lookup)
|
||
|
||
result = await permission_monitoring.get_ip_locations(
|
||
db=db_session,
|
||
_=AdminUserStub(),
|
||
days=7,
|
||
limit=10,
|
||
)
|
||
|
||
assert result["items"][0]["longitude"] == -122.08385
|
||
assert result["items"][0]["latitude"] == 37.38605
|
||
assert result["items"][0]["city"] == "Mountain View"
|
||
assert result["data_quality"]["external_fallback_ip_count"] == 1
|
||
assert result["data_quality"]["resolver"] == "ip2region+ip2location.io"
|
||
|
||
|
||
@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]"
|
||
|
||
permission_only = 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,
|
||
event_type="permission",
|
||
keyword=None,
|
||
page=1,
|
||
page_size=50,
|
||
)
|
||
assert permission_only["total"] == 1
|
||
assert permission_only["items"][0]["event_type"] == "permission"
|
||
|
||
|
||
@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",
|
||
}
|