release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -246,7 +246,7 @@ async def test_project_member_can_read_own_effective_permissions(db_session: Asy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session: AsyncSession):
|
||||
async def test_project_pm_cannot_access_system_monitoring(db_session: AsyncSession):
|
||||
pm_id = uuid.uuid4()
|
||||
own_study_id = uuid.uuid4()
|
||||
other_study_id = uuid.uuid4()
|
||||
@@ -256,12 +256,10 @@ async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session
|
||||
await _seed_member(db_session, own_study_id, pm_id, "PM")
|
||||
await db_session.commit()
|
||||
|
||||
scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id))
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await resolve_monitoring_scope(db_session, UserStub(id=pm_id))
|
||||
|
||||
assert scope.is_admin is False
|
||||
assert scope.study_ids == {own_study_id}
|
||||
assert scope.can_access_study(own_study_id)
|
||||
assert not scope.can_access_study(other_study_id)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -272,6 +272,18 @@ def test_faq_and_precautions_are_shared_library_sibling_sections():
|
||||
assert API_ENDPOINT_PERMISSIONS["precautions:read"]["module"] == "shared_library"
|
||||
|
||||
|
||||
def test_collaboration_is_an_independent_shared_library_section():
|
||||
shared_library_keys = OPERATION_TO_ENDPOINTS["shared_library"]
|
||||
assert "collaboration:read" in shared_library_keys["read"]
|
||||
assert "collaboration:create" in shared_library_keys["write"]
|
||||
assert "collaboration:edit" in shared_library_keys["write"]
|
||||
assert "collaboration:manage" in shared_library_keys["write"]
|
||||
assert "collaboration:export" in shared_library_keys["write"]
|
||||
assert "collaboration:delete" in shared_library_keys["write"]
|
||||
assert API_ENDPOINT_PERMISSIONS["collaboration:read"]["module"] == "shared_library"
|
||||
assert API_ENDPOINT_PERMISSIONS["collaboration:read"]["default_roles"] == ["PM", "CRA", "PV", "QA", "CTA"]
|
||||
|
||||
|
||||
def test_precautions_runtime_code_uses_business_entity_names():
|
||||
"""运行时代码不应继续使用 knowledge_note 作为注意事项实体命名。"""
|
||||
backend_root = Path(__file__).resolve().parents[1] / "app"
|
||||
@@ -565,6 +577,16 @@ def test_project_permission_config_is_system_level_permission():
|
||||
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"]
|
||||
|
||||
|
||||
def test_pm_system_management_navigation_matches_backend_permission_contract():
|
||||
"""PM 系统管理导航中的审计日志和权限管理应与后端角色权限保持一致。"""
|
||||
assert "PM" in API_ENDPOINT_PERMISSIONS["audit_logs:read"]["default_roles"]
|
||||
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:read"]["roles"]
|
||||
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"]
|
||||
|
||||
audit_route = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
|
||||
assert 'require_api_permission("audit_logs:read")' in audit_route.read_text()
|
||||
|
||||
|
||||
def test_project_api_permission_routes_use_system_project_config_permission():
|
||||
"""项目权限矩阵 API 应明确依赖 system:permissions:project_config。"""
|
||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "api_permissions.py"
|
||||
@@ -652,24 +674,30 @@ def test_subject_audit_uses_structured_business_snapshot():
|
||||
assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source
|
||||
|
||||
|
||||
def test_faq_audit_uses_business_descriptions():
|
||||
"""医学咨询审计详情应使用业务描述,避免 FAQ 技术文案。"""
|
||||
def test_shared_library_does_not_write_audits():
|
||||
"""共享库各模块及其附件不应写入业务审计。"""
|
||||
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
|
||||
category_source = (api_dir / "faq_categories.py").read_text()
|
||||
item_source = (api_dir / "faqs.py").read_text()
|
||||
services_dir = Path(__file__).resolve().parents[1] / "app" / "services"
|
||||
non_audited_sources = [
|
||||
(api_dir / "faq_categories.py").read_text(),
|
||||
(api_dir / "faqs.py").read_text(),
|
||||
(api_dir / "precautions.py").read_text(),
|
||||
(services_dir / "collaboration_service.py").read_text(),
|
||||
(services_dir / "collaboration_share_service.py").read_text(),
|
||||
(services_dir / "onlyoffice_collaboration_service.py").read_text(),
|
||||
]
|
||||
|
||||
assert 'description": f"创建“{category.name}”分类"' in category_source
|
||||
assert 'description": f"更新“{updated.name}”分类"' in category_source
|
||||
assert 'description": f"删除“{category_name}”分类"' in category_source
|
||||
assert 'f"创建医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"更新医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"回复医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"删除医学咨询问题“{question_name}”"' in item_source
|
||||
assert 'f"删除医学咨询问题“{question_name}”的回复"' in item_source
|
||||
assert "FAQ 分类 {category.name} 已创建" not in category_source
|
||||
assert 'detail="FAQ 已创建"' not in item_source
|
||||
assert 'detail = "FAQ updated"' not in item_source
|
||||
assert '"description": "创建医学咨询问题"' not in item_source
|
||||
for source in non_audited_sources:
|
||||
assert "audit_crud" not in source
|
||||
assert "._audit(" not in source
|
||||
|
||||
attachment_source = (api_dir / "attachments.py").read_text()
|
||||
assert 'SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}' in attachment_source
|
||||
assert "if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
|
||||
assert "if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
|
||||
|
||||
onlyoffice_source = (api_dir / "onlyoffice.py").read_text()
|
||||
assert "OFFICE_PREVIEW_OPEN" not in onlyoffice_source
|
||||
|
||||
|
||||
def test_domain_audits_use_business_object_names():
|
||||
@@ -682,7 +710,6 @@ def test_domain_audits_use_business_object_names():
|
||||
"material_equipments.py",
|
||||
"visits.py",
|
||||
"aes.py",
|
||||
"precautions.py",
|
||||
"fees_contracts.py",
|
||||
"startup.py",
|
||||
"subject_histories.py",
|
||||
@@ -693,7 +720,6 @@ def test_domain_audits_use_business_object_names():
|
||||
assert "_equipment_audit_detail" in sources["material_equipments.py"]
|
||||
assert "_visit_audit_detail" in sources["visits.py"]
|
||||
assert "_ae_audit_detail" in sources["aes.py"]
|
||||
assert "_precaution_audit_detail" in sources["precautions.py"]
|
||||
assert "_contract_audit_detail" in sources["fees_contracts.py"]
|
||||
assert "_payment_audit_detail" in sources["fees_contracts.py"]
|
||||
assert "_feasibility_audit_detail" in sources["startup.py"]
|
||||
|
||||
@@ -54,6 +54,9 @@ class _DummyTask:
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
|
||||
def done(self):
|
||||
return False
|
||||
|
||||
def __await__(self):
|
||||
async def _wait():
|
||||
self.awaited = True
|
||||
@@ -106,6 +109,7 @@ async def test_development_startup_does_not_seed_business_data(monkeypatch):
|
||||
monkeypatch.setattr(main.settings, "ENV", "development")
|
||||
monkeypatch.setattr(main, "ensure_admin_exists", ensure_admin_exists)
|
||||
monkeypatch.setattr(main, "run_daily_lost_visit_job", fake_scheduler)
|
||||
monkeypatch.setattr(main, "resolve_monitoring_server_location", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(main.asyncio, "create_task", fake_create_task)
|
||||
monkeypatch.setattr(main, "engine", _DummyEngine())
|
||||
monkeypatch.setattr(main, "SessionLocal", fake_session_local)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.request_context import (
|
||||
RequestAuditContext,
|
||||
build_request_audit_context,
|
||||
reset_request_audit_context,
|
||||
set_request_audit_context,
|
||||
)
|
||||
from app.crud.audit import log_action
|
||||
|
||||
|
||||
class FakeAsyncSession:
|
||||
def __init__(self) -> None:
|
||||
self.added = None
|
||||
self.flushed = False
|
||||
|
||||
def add(self, value) -> None:
|
||||
self.added = value
|
||||
|
||||
async def flush(self) -> None:
|
||||
self.flushed = True
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host: str = "10.0.0.8") -> None:
|
||||
self.host = host
|
||||
self.port = 54321
|
||||
|
||||
|
||||
class FakeUrl:
|
||||
scheme = "https"
|
||||
path = "/api/v1/audit"
|
||||
query = "token=secret-token&view=all&subject_name=Alice"
|
||||
|
||||
|
||||
class FakeQueryParams:
|
||||
def __init__(self) -> None:
|
||||
self._items = [
|
||||
("token", "secret-token"),
|
||||
("view", "all"),
|
||||
("subject_name", "Alice"),
|
||||
]
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return True
|
||||
|
||||
def multi_items(self):
|
||||
return list(self._items)
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self, headers: dict[str, str], *, client_host: str = "10.0.0.8") -> None:
|
||||
self.headers = headers
|
||||
self.client = FakeClient(client_host)
|
||||
self.method = "GET"
|
||||
self.url = FakeUrl()
|
||||
self.query_params = FakeQueryParams()
|
||||
self.scope = {
|
||||
"http_version": "1.1",
|
||||
"scheme": "https",
|
||||
"path": "/api/v1/audit",
|
||||
"method": "GET",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_log_action_captures_request_access_context():
|
||||
context_token = set_request_audit_context(
|
||||
RequestAuditContext(
|
||||
client_ip="203.0.113.10",
|
||||
user_agent="curl/8.1.2",
|
||||
client_type="desktop",
|
||||
client_version="1.2.3",
|
||||
client_platform="macos",
|
||||
build_channel="release",
|
||||
build_commit="abcdef1",
|
||||
)
|
||||
)
|
||||
db = FakeAsyncSession()
|
||||
try:
|
||||
log = await log_action(
|
||||
db,
|
||||
study_id=uuid.uuid4(),
|
||||
entity_type="subject",
|
||||
entity_id=uuid.uuid4(),
|
||||
action="UPDATE_SUBJECT",
|
||||
detail=None,
|
||||
operator_id=uuid.uuid4(),
|
||||
operator_role="PM",
|
||||
auto_commit=False,
|
||||
)
|
||||
finally:
|
||||
reset_request_audit_context(context_token)
|
||||
|
||||
assert db.added is log
|
||||
assert db.flushed is True
|
||||
assert log.client_ip == "203.0.113.10"
|
||||
assert log.user_agent == "curl/8.1.2"
|
||||
assert log.client_type == "desktop"
|
||||
assert log.client_version == "1.2.3"
|
||||
assert log.client_platform == "macos"
|
||||
assert log.build_channel == "release"
|
||||
assert log.build_commit == "abcdef1"
|
||||
|
||||
|
||||
def test_request_audit_context_captures_sanitized_headers():
|
||||
context = build_request_audit_context(
|
||||
FakeRequest(
|
||||
{
|
||||
"user-agent": "Chrome",
|
||||
"referer": "https://ctms.local/audit?token=secret-token",
|
||||
"authorization": "Bearer secret-token",
|
||||
"cookie": "sid=secret",
|
||||
"x-ctms-client-source": "ctms-web",
|
||||
"x-ctms-client-type": "web",
|
||||
"x-ctms-client-version": "0.1.0",
|
||||
"x-request-id": "req-123",
|
||||
"x-custom-secret": "hidden",
|
||||
"x-random-debug": "skip-me",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert context.user_agent == "Chrome"
|
||||
assert context.client_type == "web"
|
||||
assert context.client_ip == "10.0.0.8"
|
||||
assert uuid.UUID(context.request_id)
|
||||
assert context.request_headers == {
|
||||
"authorization": "[redacted]",
|
||||
"cookie": "[redacted]",
|
||||
"user-agent": "Chrome",
|
||||
"x-ctms-client-source": "ctms-web",
|
||||
"x-ctms-client-type": "web",
|
||||
"x-ctms-client-version": "0.1.0",
|
||||
"x-custom-secret": "[redacted]",
|
||||
"x-request-id": "req-123",
|
||||
}
|
||||
assert context.request_snapshot is not None
|
||||
assert context.request_snapshot["method"] == "GET"
|
||||
assert context.request_snapshot["request_id"] == context.request_id
|
||||
assert context.request_snapshot["path"] == "/api/v1/audit"
|
||||
assert context.request_snapshot["query_string"] == (
|
||||
"token=[redacted]&view=all&subject_name=[redacted]"
|
||||
)
|
||||
assert context.request_snapshot["query_params"] == [
|
||||
{"name": "token", "value": "[redacted]"},
|
||||
{"name": "view", "value": "all"},
|
||||
{"name": "subject_name", "value": "[redacted]"},
|
||||
]
|
||||
assert context.request_snapshot["headers"]["authorization"] == "[redacted]"
|
||||
assert context.request_snapshot["headers"]["cookie"] == "[redacted]"
|
||||
assert context.request_snapshot["headers"]["x-ctms-client-source"] == "ctms-web"
|
||||
assert "referer" not in context.request_snapshot["headers"]
|
||||
assert "x-random-debug" not in context.request_snapshot["headers"]
|
||||
assert context.request_snapshot["client"] == {"host": "10.0.0.8", "port": 54321}
|
||||
assert context.request_snapshot["body"] == {
|
||||
"captured": False,
|
||||
"reason": "body_not_captured_by_audit_policy",
|
||||
}
|
||||
|
||||
|
||||
def test_request_audit_context_prefers_explicit_ctms_client_source():
|
||||
context = build_request_audit_context(
|
||||
FakeRequest(
|
||||
{
|
||||
"x-ctms-client-source": "ctms-desktop",
|
||||
"x-ctms-client-type": "web",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert context.client_type == "desktop"
|
||||
assert context.request_headers == {
|
||||
"x-ctms-client-source": "ctms-desktop",
|
||||
"x-ctms-client-type": "web",
|
||||
}
|
||||
|
||||
|
||||
def test_request_audit_context_uses_proxy_observed_ip_instead_of_spoofed_forwarded_prefix(monkeypatch):
|
||||
monkeypatch.setattr("app.core.request_context.settings.TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
|
||||
context = build_request_audit_context(
|
||||
FakeRequest(
|
||||
{
|
||||
"x-real-ip": "203.0.113.20",
|
||||
"x-forwarded-for": "1.2.3.4, 203.0.113.20",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert context.client_ip == "203.0.113.20"
|
||||
|
||||
|
||||
def test_request_audit_context_ignores_forwarded_headers_from_untrusted_peer(monkeypatch):
|
||||
monkeypatch.setattr("app.core.request_context.settings.TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
|
||||
context = build_request_audit_context(
|
||||
FakeRequest(
|
||||
{
|
||||
"x-real-ip": "203.0.113.20",
|
||||
"x-forwarded-for": "1.2.3.4",
|
||||
},
|
||||
client_host="198.51.100.8",
|
||||
)
|
||||
)
|
||||
|
||||
assert context.client_ip == "198.51.100.8"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.document_service import _content_disposition, _legacy_download_filename, _safe_original_filename
|
||||
|
||||
|
||||
def test_safe_original_filename_strips_paths_and_control_characters():
|
||||
assert _safe_original_filename(r"C:\fakepath\研究方案.xlsx") == "研究方案.xlsx"
|
||||
assert _safe_original_filename("../研究\n方案.xlsx") == "研究方案.xlsx"
|
||||
|
||||
|
||||
def test_content_disposition_preserves_unicode_filename():
|
||||
header = _content_disposition("研究方案 V1.0.xlsx")
|
||||
|
||||
assert header.startswith('attachment; filename="')
|
||||
assert "filename*=UTF-8''%E7%A0%94%E7%A9%B6%E6%96%B9%E6%A1%88%20V1.0.xlsx" in header
|
||||
|
||||
|
||||
def test_legacy_download_filename_uses_document_title_instead_of_storage_uuid():
|
||||
version = SimpleNamespace(file_uri="/uploads/fa6ab7ab-f4f5-4d4a-a052-693f780010fd.pdf")
|
||||
|
||||
assert _legacy_download_filename(version, SimpleNamespace(title="研究方案")) == "研究方案.pdf"
|
||||
assert _legacy_download_filename(version, SimpleNamespace(title="研究方案.PDF")) == "研究方案.PDF"
|
||||
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import ip_geolocation_fallback
|
||||
from app.services.ip_geolocation_fallback import ExternalIpLocation
|
||||
|
||||
|
||||
def test_ip2location_response_parser_requires_matching_public_ip_and_coordinates():
|
||||
result = ip_geolocation_fallback._parse_response(
|
||||
"8.8.8.8",
|
||||
{
|
||||
"ip": "8.8.8.8",
|
||||
"country_code": "US",
|
||||
"country_name": "United States of America",
|
||||
"region_name": "California",
|
||||
"city_name": "Mountain View",
|
||||
"latitude": 37.38605,
|
||||
"longitude": -122.08385,
|
||||
"isp": "Google LLC",
|
||||
},
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.country_code == "US"
|
||||
assert result.longitude == -122.08385
|
||||
assert ip_geolocation_fallback._parse_response("8.8.8.8", {"ip": "1.1.1.1"}) is None
|
||||
assert ip_geolocation_fallback._global_ip("192.168.1.8") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_fallback_caches_public_ip_results(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_fetch(_client, _semaphore, ip_address):
|
||||
calls.append(ip_address)
|
||||
return ExternalIpLocation(
|
||||
ip_address=ip_address,
|
||||
country="United States of America",
|
||||
country_code="US",
|
||||
region="California",
|
||||
city="Mountain View",
|
||||
isp="Google LLC",
|
||||
longitude=-122.08385,
|
||||
latitude=37.38605,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(settings, "ENV", "development")
|
||||
monkeypatch.setattr(settings, "MONITORING_IP_GEO_FALLBACK_ENABLED", True)
|
||||
monkeypatch.setattr(ip_geolocation_fallback, "_fetch_one", fake_fetch)
|
||||
ip_geolocation_fallback.reset_ip_geolocation_fallback_cache()
|
||||
|
||||
first = await ip_geolocation_fallback.resolve_external_ip_locations(["8.8.8.8", "192.168.1.8"])
|
||||
second = await ip_geolocation_fallback.resolve_external_ip_locations(["8.8.8.8"])
|
||||
|
||||
assert first["8.8.8.8"].city == "Mountain View"
|
||||
assert second["8.8.8.8"].latitude == 37.38605
|
||||
assert calls == ["8.8.8.8"]
|
||||
ip_geolocation_fallback.reset_ip_geolocation_fallback_cache()
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
from app.models.study import Study
|
||||
from app.models.user import User, UserStatus
|
||||
from app.services import monitoring_retention
|
||||
from app.services import permission_log_writer, security_access_log_writer
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[object] = []
|
||||
self.committed = False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def add(self, value: object) -> None:
|
||||
self.added.append(value)
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_writer_gracefully_flushes_and_reports_stats(monkeypatch):
|
||||
session = _FakeSession()
|
||||
monkeypatch.setattr(permission_log_writer, "SessionLocal", lambda: session)
|
||||
writer = permission_log_writer.PermissionLogWriter()
|
||||
await writer.start()
|
||||
writer.enqueue(
|
||||
{
|
||||
"study_id": uuid.uuid4(),
|
||||
"user_id": uuid.uuid4(),
|
||||
"endpoint_key": "subjects.read",
|
||||
"role": "PM",
|
||||
"allowed": True,
|
||||
"elapsed_ms": 2.5,
|
||||
}
|
||||
)
|
||||
|
||||
await writer.stop()
|
||||
|
||||
stats = writer.stats()
|
||||
assert session.committed is True
|
||||
assert len(session.added) == 1
|
||||
assert stats["running"] is False
|
||||
assert stats["accepted_entries"] == 1
|
||||
assert stats["written_entries"] == 1
|
||||
assert stats["dropped_entries"] == 0
|
||||
assert stats["queue_size"] == 0
|
||||
assert stats["last_success_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_writer_retries_and_exposes_permanent_failure(monkeypatch):
|
||||
class _FailingSession:
|
||||
async def __aenter__(self):
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(permission_log_writer, "SessionLocal", _FailingSession)
|
||||
writer = permission_log_writer.PermissionLogWriter()
|
||||
batch = [
|
||||
{
|
||||
"study_id": uuid.uuid4(),
|
||||
"user_id": uuid.uuid4(),
|
||||
"endpoint_key": "subjects.read",
|
||||
"role": "PM",
|
||||
"allowed": True,
|
||||
"elapsed_ms": 2.5,
|
||||
}
|
||||
]
|
||||
|
||||
assert await writer._write_batch(batch) is False
|
||||
|
||||
stats = writer.stats()
|
||||
assert stats["retry_count"] == 1
|
||||
assert stats["failed_batches"] == 1
|
||||
assert stats["failed_entries"] == 1
|
||||
assert stats["last_error_type"] == "RuntimeError"
|
||||
|
||||
|
||||
def test_writer_queue_overflow_is_counted():
|
||||
writer = security_access_log_writer.SecurityAccessLogWriter()
|
||||
writer._queue = asyncio.Queue(maxsize=1)
|
||||
|
||||
writer.enqueue({"path": "/first"})
|
||||
writer.enqueue({"path": "/dropped"})
|
||||
|
||||
stats = writer.stats()
|
||||
assert stats["accepted_entries"] == 1
|
||||
assert stats["dropped_entries"] == 1
|
||||
assert stats["queue_size"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retention_deletes_expired_rows_and_preserves_recent_rows(
|
||||
db_session, test_engine, monkeypatch
|
||||
):
|
||||
now = datetime(2026, 7, 10, tzinfo=timezone.utc)
|
||||
study_id = (await db_session.execute(select(Study.id).limit(1))).scalar_one()
|
||||
user = User(
|
||||
email=f"retention-{uuid.uuid4()}@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Retention Test",
|
||||
clinical_department="QA",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
|
||||
old_permission = PermissionAccessLog(
|
||||
study_id=study_id,
|
||||
user_id=user.id,
|
||||
endpoint_key="old",
|
||||
role="PM",
|
||||
allowed=True,
|
||||
elapsed_ms=1,
|
||||
created_at=now - timedelta(days=91),
|
||||
)
|
||||
recent_permission = PermissionAccessLog(
|
||||
study_id=study_id,
|
||||
user_id=user.id,
|
||||
endpoint_key="recent",
|
||||
role="PM",
|
||||
allowed=True,
|
||||
elapsed_ms=1,
|
||||
created_at=now - timedelta(days=89),
|
||||
)
|
||||
old_security = SecurityAccessLog(
|
||||
method="GET",
|
||||
path="/old",
|
||||
status_code=404,
|
||||
elapsed_ms=1,
|
||||
auth_status="ANONYMOUS",
|
||||
created_at=now - timedelta(days=91),
|
||||
)
|
||||
recent_security = SecurityAccessLog(
|
||||
method="GET",
|
||||
path="/recent",
|
||||
status_code=200,
|
||||
elapsed_ms=1,
|
||||
auth_status="AUTHENTICATED",
|
||||
created_at=now - timedelta(days=89),
|
||||
)
|
||||
old_metric = PermissionMetricSnapshot(
|
||||
bucket_time=now - timedelta(days=401)
|
||||
)
|
||||
recent_metric = PermissionMetricSnapshot(
|
||||
bucket_time=now - timedelta(days=399)
|
||||
)
|
||||
db_session.add_all(
|
||||
[
|
||||
old_permission,
|
||||
recent_permission,
|
||||
old_security,
|
||||
recent_security,
|
||||
old_metric,
|
||||
recent_metric,
|
||||
]
|
||||
)
|
||||
await db_session.commit()
|
||||
old_permission_id = old_permission.id
|
||||
recent_permission_id = recent_permission.id
|
||||
old_security_id = old_security.id
|
||||
recent_security_id = recent_security.id
|
||||
old_metric_id = old_metric.id
|
||||
recent_metric_id = recent_metric.id
|
||||
|
||||
session_factory = async_sessionmaker(
|
||||
bind=test_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
monkeypatch.setattr(monitoring_retention, "SessionLocal", session_factory)
|
||||
monkeypatch.setattr(
|
||||
monitoring_retention.settings, "MONITORING_ACCESS_LOG_RETENTION_DAYS", 90
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
monitoring_retention.settings, "MONITORING_METRIC_RETENTION_DAYS", 400
|
||||
)
|
||||
|
||||
deleted = await monitoring_retention.purge_expired_monitoring_data(now=now)
|
||||
|
||||
db_session.expire_all()
|
||||
assert deleted["permission_access_logs"] >= 1
|
||||
assert deleted["security_access_logs"] >= 1
|
||||
assert deleted["permission_metric_snapshots"] >= 1
|
||||
assert await db_session.get(PermissionAccessLog, old_permission_id) is None
|
||||
assert await db_session.get(SecurityAccessLog, old_security_id) is None
|
||||
assert await db_session.get(PermissionMetricSnapshot, old_metric_id) is None
|
||||
assert await db_session.get(PermissionAccessLog, recent_permission_id) is not None
|
||||
assert await db_session.get(SecurityAccessLog, recent_security_id) is not None
|
||||
assert await db_session.get(PermissionMetricSnapshot, recent_metric_id) is not None
|
||||
@@ -0,0 +1,96 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import monitoring_server_location
|
||||
from app.services.ip_geolocation_fallback import ExternalIpLocation
|
||||
|
||||
|
||||
def test_public_ip_normalization_rejects_private_addresses():
|
||||
assert monitoring_server_location._normalize_public_ip("8.8.8.8\n") == "8.8.8.8"
|
||||
assert monitoring_server_location._normalize_public_ip("10.0.0.8") is None
|
||||
assert monitoring_server_location._normalize_public_ip("127.0.0.1") is None
|
||||
assert monitoring_server_location._normalize_public_ip("not-an-ip") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_location_is_derived_from_configured_public_ip(monkeypatch):
|
||||
monkeypatch.setattr(settings, "MONITORING_SERVER_PUBLIC_IP", "8.8.8.8")
|
||||
monkeypatch.setattr(
|
||||
monitoring_server_location,
|
||||
"resolve_ip_location",
|
||||
lambda ip: SimpleNamespace(
|
||||
location="United States / California / San Jose",
|
||||
country="United States",
|
||||
province="California",
|
||||
city="San Jose",
|
||||
isp="Google",
|
||||
),
|
||||
)
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
location = await monitoring_server_location.resolve_monitoring_server_location()
|
||||
|
||||
assert location is not None
|
||||
assert location.name == "United States / California / San Jose"
|
||||
assert location.longitude == -121.8863
|
||||
assert location.latitude == 37.3382
|
||||
assert location.resolution_source == "configured_public_ip"
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_location_returns_none_instead_of_a_hardcoded_fallback(monkeypatch):
|
||||
async def no_public_ip():
|
||||
return None, "unavailable"
|
||||
|
||||
monkeypatch.setattr(monitoring_server_location, "_discover_public_ip", no_public_ip)
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
location = await monitoring_server_location.resolve_monitoring_server_location()
|
||||
|
||||
assert location is None
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_location_uses_external_coordinates_when_local_coordinates_are_missing(monkeypatch):
|
||||
monkeypatch.setattr(settings, "MONITORING_SERVER_PUBLIC_IP", "5.34.216.210")
|
||||
monkeypatch.setattr(
|
||||
monitoring_server_location,
|
||||
"resolve_ip_location",
|
||||
lambda _ip: SimpleNamespace(
|
||||
location="公网",
|
||||
country="",
|
||||
province="",
|
||||
city="",
|
||||
isp="",
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_external_lookup(ip_addresses):
|
||||
assert ip_addresses == ["5.34.216.210"]
|
||||
return {
|
||||
"5.34.216.210": ExternalIpLocation(
|
||||
ip_address="5.34.216.210",
|
||||
country="United States of America",
|
||||
country_code="US",
|
||||
region="California",
|
||||
city="Los Angeles",
|
||||
isp="Example ISP",
|
||||
longitude=-118.2439,
|
||||
latitude=34.05257,
|
||||
)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(monitoring_server_location, "resolve_external_ip_locations", fake_external_lookup)
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
location = await monitoring_server_location.resolve_monitoring_server_location()
|
||||
|
||||
assert location is not None
|
||||
assert location.name == "United States of America / California / Los Angeles"
|
||||
assert location.longitude == -118.2439
|
||||
assert location.latitude == 34.05257
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
@@ -0,0 +1,373 @@
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import visits as visits_api
|
||||
from app.models.notification import Notification
|
||||
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||
from app.models.collaboration import CollaborationEditRequest
|
||||
from app.services import collaboration_service, desktop_notification_service, notification_service, project_reminder_service
|
||||
|
||||
|
||||
def test_generic_notification_table_has_recipient_dedupe_and_state_indexes():
|
||||
table = Notification.__table__
|
||||
assert table.name == "notifications"
|
||||
assert {
|
||||
"study_id", "recipient_id", "category", "action_path", "source_type", "source_id",
|
||||
"requires_action", "due_at", "read_at", "resolved_at",
|
||||
} <= set(table.columns.keys())
|
||||
assert any(constraint.name == "uq_notifications_recipient_dedupe" for constraint in table.constraints)
|
||||
assert {index.name for index in table.indexes} >= {
|
||||
"ix_notifications_recipient_study_state",
|
||||
"ix_notifications_source",
|
||||
}
|
||||
|
||||
|
||||
def test_desktop_delivery_targets_the_generic_notification_feed():
|
||||
table = DesktopNotificationDelivery.__table__
|
||||
assert table.columns.notification_id.nullable is True
|
||||
assert any(
|
||||
constraint.name == "uq_desktop_notification_user_notification"
|
||||
for constraint in table.constraints
|
||||
)
|
||||
statement = str(desktop_notification_service._eligible_query(
|
||||
uuid.uuid4(),
|
||||
datetime.now(timezone.utc),
|
||||
datetime.now(timezone.utc),
|
||||
))
|
||||
assert "notifications" in statement
|
||||
assert "notification_id" in statement
|
||||
assert "resolved_at" in statement
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recipient_notification_creation_is_deduplicated_per_recipient():
|
||||
existing_id = uuid.uuid4()
|
||||
new_id = uuid.uuid4()
|
||||
result = SimpleNamespace(all=lambda: [existing_id])
|
||||
db = SimpleNamespace(scalars=AsyncMock(return_value=result), add=Mock())
|
||||
|
||||
await notification_service.create_recipient_notifications(
|
||||
db,
|
||||
study_id=uuid.uuid4(),
|
||||
recipient_ids=[existing_id, new_id, new_id],
|
||||
category="COLLABORATION_EDIT_REQUEST",
|
||||
priority="NORMAL",
|
||||
title="新的编辑权限申请",
|
||||
message="申请编辑文件",
|
||||
action_path="/knowledge/collaboration?editRequestFile=file-id",
|
||||
source_type="COLLABORATION_EDIT_REQUEST",
|
||||
source_id="request-id",
|
||||
dedupe_key="collaboration-edit-request:request-id",
|
||||
)
|
||||
|
||||
db.add.assert_called_once()
|
||||
created = db.add.call_args.args[0]
|
||||
assert created.recipient_id == new_id
|
||||
assert created.source_id == "request-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolving_a_source_closes_every_recipient_notification():
|
||||
db = SimpleNamespace(execute=AsyncMock())
|
||||
await notification_service.resolve_source_notifications(
|
||||
db,
|
||||
source_type="COLLABORATION_EDIT_REQUEST",
|
||||
source_id="request-id",
|
||||
)
|
||||
db.execute.assert_awaited_once()
|
||||
statement = str(db.execute.await_args.args[0])
|
||||
assert "UPDATE notifications" in statement
|
||||
assert "source_type" in statement
|
||||
assert "source_id" in statement
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_reminder_sync_runs_every_supported_rule_group(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
|
||||
membership = SimpleNamespace(is_active=True, role_in_study="PM")
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
risk_sync = AsyncMock()
|
||||
distribution_sync = AsyncMock()
|
||||
milestone_sync = AsyncMock()
|
||||
visit_sync = AsyncMock()
|
||||
collaboration_sync = AsyncMock()
|
||||
monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=membership))
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_risk_reminders", risk_sync)
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_document_distribution_reminders", distribution_sync)
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_milestone_reminders", milestone_sync)
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_visit_window_reminders", visit_sync)
|
||||
monkeypatch.setattr(project_reminder_service, "_sync_collaboration_edit_request_reminders", collaboration_sync)
|
||||
|
||||
await project_reminder_service.sync_project_reminders(db, study_id, user)
|
||||
|
||||
risk_sync.assert_awaited_once()
|
||||
distribution_sync.assert_awaited_once()
|
||||
milestone_sync.assert_awaited_once()
|
||||
visit_sync.assert_awaited_once()
|
||||
collaboration_sync.assert_awaited_once()
|
||||
assert collaboration_sync.await_args.kwargs["role"] == "PM"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inactive_membership_resolves_stale_project_notifications(monkeypatch):
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
resolve = AsyncMock()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=True)
|
||||
study_id = uuid.uuid4()
|
||||
monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service.notification_service,
|
||||
"resolve_recipient_study_notifications",
|
||||
resolve,
|
||||
)
|
||||
|
||||
await project_reminder_service.sync_project_reminders(db, study_id, user)
|
||||
|
||||
resolve.assert_awaited_once_with(db, study_id=study_id, recipient_id=user.id)
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_risks_include_due_soon_and_overdue_aggregates(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
|
||||
now = datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc)
|
||||
db = SimpleNamespace()
|
||||
sync = AsyncMock()
|
||||
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service,
|
||||
"_count_and_earliest",
|
||||
AsyncMock(side_effect=[
|
||||
(2, date(2026, 7, 15)),
|
||||
(1, date(2026, 7, 18)),
|
||||
(3, now - timedelta(hours=2)),
|
||||
(4, now + timedelta(days=1)),
|
||||
]),
|
||||
)
|
||||
monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync)
|
||||
|
||||
await project_reminder_service._sync_risk_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role="PM",
|
||||
current_time=now,
|
||||
)
|
||||
|
||||
assert [call.kwargs["category"] for call in sync.await_args_list] == [
|
||||
"RISK_OVERDUE_AE",
|
||||
"RISK_AE_DUE_SOON",
|
||||
"RISK_OVERDUE_MONITORING",
|
||||
"RISK_MONITORING_DUE_SOON",
|
||||
]
|
||||
assert [call.kwargs["count"] for call in sync.await_args_list] == [2, 1, 3, 4]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visit_window_reminders_are_actionable_scoped_and_privacy_safe(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
|
||||
site_id = uuid.uuid4()
|
||||
due_visit_id = uuid.uuid4()
|
||||
missed_visit_id = uuid.uuid4()
|
||||
due_subject_id = uuid.uuid4()
|
||||
missed_subject_id = uuid.uuid4()
|
||||
due_visit = SimpleNamespace(
|
||||
id=due_visit_id,
|
||||
planned_date=date(2026, 7, 19),
|
||||
window_start=date(2026, 7, 18),
|
||||
window_end=date(2026, 7, 20),
|
||||
)
|
||||
missed_visit = SimpleNamespace(
|
||||
id=missed_visit_id,
|
||||
planned_date=date(2026, 7, 14),
|
||||
window_start=date(2026, 7, 13),
|
||||
window_end=date(2026, 7, 15),
|
||||
)
|
||||
due_subject = SimpleNamespace(id=due_subject_id, subject_no="S-PRIVACY-001")
|
||||
missed_subject = SimpleNamespace(id=missed_subject_id, subject_no="S-PRIVACY-002")
|
||||
rows = SimpleNamespace(all=lambda: [
|
||||
(due_visit, due_subject),
|
||||
(missed_visit, missed_subject),
|
||||
])
|
||||
db = SimpleNamespace(execute=AsyncMock(return_value=rows))
|
||||
sync = AsyncMock()
|
||||
resolve = AsyncMock()
|
||||
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service,
|
||||
"get_cra_site_scope",
|
||||
AsyncMock(return_value=({site_id}, {"研究中心"})),
|
||||
)
|
||||
monkeypatch.setattr(project_reminder_service.notification_service, "sync_state_notification", sync)
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service.notification_service,
|
||||
"resolve_missing_recipient_sources",
|
||||
resolve,
|
||||
)
|
||||
|
||||
await project_reminder_service._sync_visit_window_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role="CRA",
|
||||
current_time=datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert [call.kwargs["category"] for call in sync.await_args_list] == [
|
||||
"VISIT_WINDOW_DUE_SOON",
|
||||
"VISIT_WINDOW_MISSED",
|
||||
]
|
||||
assert [call.kwargs["priority"] for call in sync.await_args_list] == ["NORMAL", "HIGH"]
|
||||
assert sync.await_args_list[0].kwargs["action_path"] == f"/subjects/{due_subject_id}"
|
||||
assert sync.await_args_list[1].kwargs["action_path"] == f"/subjects/{missed_subject_id}"
|
||||
messages = " ".join(call.kwargs["message"] for call in sync.await_args_list)
|
||||
assert "S-PRIVACY-001" not in messages
|
||||
assert "S-PRIVACY-002" not in messages
|
||||
resolve.assert_awaited_once_with(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="SUBJECT_VISIT_WINDOW",
|
||||
active_source_ids={str(due_visit_id), str(missed_visit_id)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visit_window_reminders_resolve_when_recipient_cannot_manage_visits(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
|
||||
db = SimpleNamespace(execute=AsyncMock())
|
||||
resolve = AsyncMock()
|
||||
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
project_reminder_service.notification_service,
|
||||
"resolve_missing_recipient_sources",
|
||||
resolve,
|
||||
)
|
||||
|
||||
await project_reminder_service._sync_visit_window_reminders(
|
||||
db,
|
||||
study_id=study_id,
|
||||
user=user,
|
||||
role="QA",
|
||||
current_time=datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
db.execute.assert_not_awaited()
|
||||
resolve.assert_awaited_once_with(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=user.id,
|
||||
source_type="SUBJECT_VISIT_WINDOW",
|
||||
active_source_ids=set(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_visit_closes_every_recipient_reminder_immediately(monkeypatch):
|
||||
first_id = uuid.uuid4()
|
||||
second_id = uuid.uuid4()
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
resolve = AsyncMock()
|
||||
monkeypatch.setattr(visits_api.notification_service, "resolve_source_notifications", resolve)
|
||||
|
||||
await visits_api._resolve_visit_reminders(db, [first_id, second_id, first_id])
|
||||
|
||||
assert resolve.await_count == 2
|
||||
assert {call.kwargs["source_id"] for call in resolve.await_args_list} == {
|
||||
str(first_id),
|
||||
str(second_id),
|
||||
}
|
||||
assert all(
|
||||
call.kwargs["source_type"] == "SUBJECT_VISIT_WINDOW"
|
||||
for call in resolve.await_args_list
|
||||
)
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reading_an_informational_notification_archives_it():
|
||||
item = SimpleNamespace(
|
||||
id=uuid.uuid4(),
|
||||
read_at=None,
|
||||
resolved_at=None,
|
||||
requires_action=False,
|
||||
)
|
||||
db = SimpleNamespace(
|
||||
scalar=AsyncMock(return_value=item),
|
||||
commit=AsyncMock(),
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
|
||||
result = await notification_service.mark_read(
|
||||
db,
|
||||
study_id=uuid.uuid4(),
|
||||
recipient_id=uuid.uuid4(),
|
||||
notification_id=item.id,
|
||||
)
|
||||
|
||||
assert result.read_at is not None
|
||||
assert result.resolved_at == result.read_at
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypatch):
|
||||
study_id = uuid.uuid4()
|
||||
file_id = uuid.uuid4()
|
||||
owner_id = uuid.uuid4()
|
||||
manager_id = uuid.uuid4()
|
||||
request_id = uuid.uuid4()
|
||||
item = SimpleNamespace(
|
||||
id=file_id,
|
||||
study_id=study_id,
|
||||
owner_id=owner_id,
|
||||
title="PK_示例.xlsx",
|
||||
allow_edit_request=True,
|
||||
)
|
||||
user = SimpleNamespace(id=uuid.uuid4(), full_name="周成成", email="member@example.com")
|
||||
added = []
|
||||
|
||||
def add(value):
|
||||
added.append(value)
|
||||
|
||||
async def flush():
|
||||
request = next(value for value in added if isinstance(value, CollaborationEditRequest))
|
||||
request.id = request_id
|
||||
request.status = "PENDING"
|
||||
request.resolved_by = None
|
||||
request.resolved_at = None
|
||||
request.created_at = datetime.now(timezone.utc)
|
||||
|
||||
db = SimpleNamespace(
|
||||
scalar=AsyncMock(return_value=None),
|
||||
scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: [owner_id, manager_id])),
|
||||
add=Mock(side_effect=add),
|
||||
flush=AsyncMock(side_effect=flush),
|
||||
commit=AsyncMock(),
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
notify = AsyncMock()
|
||||
monkeypatch.setattr(collaboration_service, "can_edit_file", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
collaboration_service.member_crud,
|
||||
"get_member",
|
||||
AsyncMock(return_value=SimpleNamespace(is_active=True)),
|
||||
)
|
||||
monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify)
|
||||
|
||||
result = await collaboration_service.create_edit_request(db, item, user)
|
||||
|
||||
assert result.id == request_id
|
||||
assert set(notify.await_args.kwargs["recipient_ids"]) == {owner_id, manager_id}
|
||||
assert notify.await_args.kwargs["action_path"] == f"/knowledge/collaboration?editRequestFile={file_id}"
|
||||
assert notify.await_args.kwargs["source_id"] == str(request_id)
|
||||
@@ -0,0 +1,132 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import Response
|
||||
from jose import jwt
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api.v1 import onlyoffice as onlyoffice_api
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import AppException
|
||||
from app.services import onlyoffice_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def onlyoffice_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "ctms-test")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_STORAGE_BASE_URL", "http://backend:8000")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_config_reuses_permission_and_preserves_original_name(monkeypatch, tmp_path):
|
||||
resource_id = uuid.uuid4()
|
||||
study_id = uuid.uuid4()
|
||||
entity_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), full_name="预览用户")
|
||||
source_file = tmp_path / "stored-uuid"
|
||||
source_file.write_bytes(b"office")
|
||||
attachment = SimpleNamespace(
|
||||
id=resource_id,
|
||||
study_id=study_id,
|
||||
entity_type="startup_initiation",
|
||||
entity_id=entity_id,
|
||||
filename="研究方案最终版.DOCX",
|
||||
file_path=str(source_file),
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
permission = AsyncMock()
|
||||
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission)
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
||||
response = Response()
|
||||
db = object()
|
||||
|
||||
result = await onlyoffice_api.get_attachment_preview_config(resource_id, response, db, user)
|
||||
|
||||
permission.assert_awaited_once_with(db, study_id, "startup_initiation", entity_id, "read", user)
|
||||
assert result.file_name == "研究方案最终版.DOCX"
|
||||
assert result.config["document"]["title"] == "研究方案最终版.DOCX"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_permission_failure_does_not_issue_config(monkeypatch, tmp_path):
|
||||
source_file = tmp_path / "source.xlsx"
|
||||
source_file.write_bytes(b"office")
|
||||
attachment = SimpleNamespace(
|
||||
id=uuid.uuid4(), study_id=uuid.uuid4(), entity_type="ethics", entity_id=uuid.uuid4(),
|
||||
filename="数据.xlsx", file_path=str(source_file),
|
||||
)
|
||||
denied = AppException(code="FORBIDDEN", message="权限不足", status_code=403)
|
||||
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", AsyncMock(side_effect=denied))
|
||||
health = AsyncMock()
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", health)
|
||||
|
||||
with pytest.raises(AppException) as error:
|
||||
await onlyoffice_api.get_attachment_preview_config(
|
||||
attachment.id, Response(), object(), SimpleNamespace(id=uuid.uuid4(), full_name="无权限用户")
|
||||
)
|
||||
|
||||
assert error.value.status_code == 403
|
||||
health.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_config_reuses_document_access_and_version_hash(monkeypatch, tmp_path):
|
||||
source_file = tmp_path / "uuid-storage"
|
||||
source_file.write_bytes(b"office")
|
||||
version = SimpleNamespace(
|
||||
id=uuid.uuid4(), document_id=uuid.uuid4(), file_uri=str(source_file), original_filename="统计表.xlsx",
|
||||
file_hash="sha256-current", mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
document = SimpleNamespace(id=version.document_id, trial_id=uuid.uuid4())
|
||||
user = SimpleNamespace(id=uuid.uuid4(), full_name="管理员")
|
||||
access = AsyncMock()
|
||||
monkeypatch.setattr(onlyoffice_api.version_crud, "get", AsyncMock(return_value=version))
|
||||
monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document))
|
||||
monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access)
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
||||
|
||||
result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user)
|
||||
|
||||
assert access.await_args.args[1:] == (document.trial_id, user)
|
||||
assert access.await_args.kwargs == {"action": "view"}
|
||||
assert result.file_name == "统计表.xlsx"
|
||||
assert result.config["document"]["key"] == onlyoffice_service.onlyoffice_document_key(
|
||||
"version", version.id, file_hash="sha256-current"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_attachment_requires_url_bound_jwt_and_returns_original_name(monkeypatch, tmp_path):
|
||||
source_file = tmp_path / "uuid-storage-name"
|
||||
source_file.write_bytes(b"office")
|
||||
attachment = SimpleNamespace(
|
||||
id=uuid.uuid4(), filename="中文原始文件名.xlsx", file_path=str(source_file),
|
||||
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment.id)
|
||||
token = jwt.encode({"payload": {"url": expected_url}}, settings.ONLYOFFICE_JWT_SECRET, algorithm="HS256")
|
||||
request = Request({
|
||||
"type": "http", "method": "GET", "path": f"/internal/onlyoffice/attachments/{attachment.id}/content",
|
||||
"headers": [(b"authorizationjwt", token.encode())],
|
||||
})
|
||||
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
|
||||
|
||||
response = await onlyoffice_api.get_internal_attachment_content(attachment.id, request, object())
|
||||
|
||||
assert "uuid-storage-name" not in response.headers["content-disposition"]
|
||||
assert "UTF-8''" in response.headers["content-disposition"]
|
||||
assert response.media_type == attachment.content_type
|
||||
|
||||
missing_token_request = Request({"type": "http", "method": "GET", "path": "/", "headers": []})
|
||||
with pytest.raises(AppException) as error:
|
||||
await onlyoffice_api.get_internal_attachment_content(attachment.id, missing_token_request, object())
|
||||
assert error.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED"
|
||||
@@ -0,0 +1,194 @@
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.core.config import settings, validate_onlyoffice_configuration
|
||||
from app.core.exceptions import AppException
|
||||
from app.services import onlyoffice_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def onlyoffice_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "ctms-test")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://onlyoffice")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_STORAGE_BASE_URL", "http://backend:8000")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_CONFIG_TTL_SECONDS", 300)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
("方案.DOCX", ("docx", "word")),
|
||||
("数据.xlsx", ("xlsx", "cell")),
|
||||
("培训.PPTX", ("pptx", "slide")),
|
||||
("国产格式.wps", ("wps", "word")),
|
||||
("国产格式.et", ("et", "cell")),
|
||||
("国产格式.dps", ("dps", "slide")),
|
||||
("扫描件.pdf", None),
|
||||
("图片.png", None),
|
||||
],
|
||||
)
|
||||
def test_office_format_contract(filename, expected):
|
||||
assert onlyoffice_service.office_format_for_filename(filename) == expected
|
||||
|
||||
|
||||
def test_all_supported_format_groups_match_the_public_contract():
|
||||
expected = {
|
||||
**{extension: "word" for extension in ("doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt")},
|
||||
**{extension: "cell" for extension in ("xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm", "ods", "ots", "csv", "et", "ett")},
|
||||
**{extension: "slide" for extension in ("ppt", "pptx", "pptm", "pps", "ppsx", "ppsm", "pot", "potx", "potm", "odp", "otp", "dps", "dpt")},
|
||||
}
|
||||
for extension, document_type in expected.items():
|
||||
assert onlyoffice_service.office_format_for_filename(f"原始文件名.{extension.upper()}") == (
|
||||
extension,
|
||||
document_type,
|
||||
)
|
||||
|
||||
|
||||
def test_preview_config_is_read_only_and_uses_original_filename():
|
||||
resource_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
result = onlyoffice_service.build_preview_config(
|
||||
resource_type="attachment",
|
||||
resource_id=resource_id,
|
||||
file_name="研究方案 V1.0.docx",
|
||||
user_id=user_id,
|
||||
user_name="测试用户",
|
||||
)
|
||||
|
||||
document = result.config["document"]
|
||||
permissions = document["permissions"]
|
||||
assert result.file_name == "研究方案 V1.0.docx"
|
||||
assert document["title"] == "研究方案 V1.0.docx"
|
||||
assert document["fileType"] == "docx"
|
||||
assert result.config["documentType"] == "word"
|
||||
assert result.config["editorConfig"]["mode"] == "view"
|
||||
assert all(permissions[key] is False for key in ("copy", "comment", "download", "edit", "fillForms", "print", "review"))
|
||||
assert "token=" not in document["url"]
|
||||
assert "access_token=" not in document["url"]
|
||||
assert document["url"].endswith(f"/internal/onlyoffice/attachments/{resource_id}/content")
|
||||
|
||||
payload = jwt.decode(
|
||||
result.config["token"],
|
||||
settings.ONLYOFFICE_JWT_SECRET,
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
assert payload["document"]["title"] == "研究方案 V1.0.docx"
|
||||
assert payload["editorConfig"]["user"] == {"id": str(user_id), "name": "测试用户"}
|
||||
assert payload["exp"] - payload["iat"] == settings.ONLYOFFICE_CONFIG_TTL_SECONDS
|
||||
|
||||
|
||||
def test_document_key_is_stable_and_version_hash_changes_it():
|
||||
resource_id = uuid.uuid4()
|
||||
first = onlyoffice_service.onlyoffice_document_key("version", resource_id, file_hash="hash-a")
|
||||
repeated = onlyoffice_service.onlyoffice_document_key("version", resource_id, file_hash="hash-a")
|
||||
changed = onlyoffice_service.onlyoffice_document_key("version", resource_id, file_hash="hash-b")
|
||||
|
||||
assert first == repeated
|
||||
assert first != changed
|
||||
assert first.startswith("ctms-")
|
||||
assert len(first) <= 128
|
||||
|
||||
|
||||
def test_outbox_token_must_be_hs256_and_bound_to_exact_url():
|
||||
resource_id = uuid.uuid4()
|
||||
expected_url = onlyoffice_service.onlyoffice_content_url("attachment", resource_id)
|
||||
token = jwt.encode({"payload": {"url": expected_url}}, settings.ONLYOFFICE_JWT_SECRET, algorithm="HS256")
|
||||
|
||||
assert onlyoffice_service.validate_outbox_token(token, expected_url)["payload"]["url"] == expected_url
|
||||
assert onlyoffice_service.validate_outbox_token(f"Bearer {token}", expected_url)["payload"]["url"] == expected_url
|
||||
|
||||
with pytest.raises(AppException) as mismatch:
|
||||
onlyoffice_service.validate_outbox_token(token, f"{expected_url}-other")
|
||||
assert mismatch.value.code == "ONLYOFFICE_SOURCE_URL_MISMATCH"
|
||||
|
||||
config_token = jwt.encode(
|
||||
{"document": {"url": expected_url}},
|
||||
settings.ONLYOFFICE_JWT_SECRET,
|
||||
algorithm="HS256",
|
||||
)
|
||||
with pytest.raises(AppException) as wrong_purpose:
|
||||
onlyoffice_service.validate_outbox_token(config_token, expected_url)
|
||||
assert wrong_purpose.value.code == "ONLYOFFICE_SOURCE_URL_MISMATCH"
|
||||
|
||||
legacy_top_level_url = jwt.encode(
|
||||
{"url": expected_url},
|
||||
settings.ONLYOFFICE_JWT_SECRET,
|
||||
algorithm="HS256",
|
||||
)
|
||||
with pytest.raises(AppException) as wrong_shape:
|
||||
onlyoffice_service.validate_outbox_token(legacy_top_level_url, expected_url)
|
||||
assert wrong_shape.value.code == "ONLYOFFICE_SOURCE_URL_MISMATCH"
|
||||
|
||||
substituted_algorithm = jwt.encode(
|
||||
{"payload": {"url": expected_url}},
|
||||
settings.ONLYOFFICE_JWT_SECRET,
|
||||
algorithm="HS512",
|
||||
)
|
||||
with pytest.raises(AppException) as invalid_algorithm:
|
||||
onlyoffice_service.validate_outbox_token(substituted_algorithm, expected_url)
|
||||
assert invalid_algorithm.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED"
|
||||
|
||||
with pytest.raises(AppException) as missing:
|
||||
onlyoffice_service.validate_outbox_token(None, expected_url)
|
||||
assert missing.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED"
|
||||
|
||||
with pytest.raises(AppException) as wrong_scheme:
|
||||
onlyoffice_service.validate_outbox_token(f"Basic {token}", expected_url)
|
||||
assert wrong_scheme.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_probe_is_cached_for_fifteen_seconds(monkeypatch):
|
||||
onlyoffice_service._health_checked_at = 0.0
|
||||
onlyoffice_service._health_available = False
|
||||
request = type("Response", (), {"status_code": 200})()
|
||||
get = AsyncMock(return_value=request)
|
||||
client = MagicMock()
|
||||
client.__aenter__ = AsyncMock(return_value=type("Client", (), {"get": get})())
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
monkeypatch.setattr(onlyoffice_service.httpx, "AsyncClient", MagicMock(return_value=client))
|
||||
|
||||
await onlyoffice_service.ensure_onlyoffice_available()
|
||||
await onlyoffice_service.ensure_onlyoffice_available()
|
||||
|
||||
get.assert_awaited_once()
|
||||
|
||||
|
||||
def test_enabled_configuration_requires_a_distinct_secret_and_instance(monkeypatch):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", settings.JWT_SECRET_KEY)
|
||||
with pytest.raises(RuntimeError, match="must not reuse"):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "")
|
||||
with pytest.raises(RuntimeError, match="INSTANCE_ID"):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
|
||||
def test_enabled_configuration_rejects_unsafe_service_urls(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://user:password@onlyoffice")
|
||||
with pytest.raises(RuntimeError, match="credentials"):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://onlyoffice/internal-path")
|
||||
with pytest.raises(RuntimeError, match="must not contain a path"):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_service_returns_stable_error(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", False)
|
||||
|
||||
with pytest.raises(AppException) as error:
|
||||
await onlyoffice_service.ensure_onlyoffice_available()
|
||||
|
||||
assert error.value.code == "ONLYOFFICE_DISABLED"
|
||||
assert error.value.status_code == 503
|
||||
@@ -1,7 +1,9 @@
|
||||
"""监控API测试:权限系统监控API端点验证。"""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -10,6 +12,8 @@ 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:
|
||||
@@ -30,6 +34,18 @@ class ProjectPmUserStub:
|
||||
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 不再具备监测范围。"""
|
||||
@@ -54,7 +70,19 @@ async def test_system_permission_monitoring_definitions_are_admin_only():
|
||||
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:
|
||||
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()
|
||||
@@ -103,20 +131,26 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
|
||||
text(
|
||||
"""
|
||||
INSERT INTO permission_access_logs
|
||||
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
|
||||
(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, CURRENT_TIMESTAMP)
|
||||
(: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": "admin.permissions.read",
|
||||
"role": "PM",
|
||||
"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()
|
||||
@@ -141,6 +175,53 @@ async def test_get_permission_metrics(db_session):
|
||||
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):
|
||||
"""测试获取缓存统计"""
|
||||
@@ -262,8 +343,8 @@ async def test_permission_system_health_degraded(db_session):
|
||||
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"] == 30
|
||||
assert data["score_breakdown"]["performance"]["deduction"] == 25
|
||||
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
|
||||
|
||||
@@ -279,6 +360,10 @@ async def test_permission_system_health_includes_metrics(db_session):
|
||||
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"]
|
||||
|
||||
@@ -304,6 +389,41 @@ async def test_permission_system_health_ignores_small_cache_sample(db_session):
|
||||
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 数和访问用户数。"""
|
||||
@@ -381,10 +501,26 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
)
|
||||
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_ip_location",
|
||||
lambda ip: FakeIpInfo("广东省", "深圳市"),
|
||||
"resolve_monitoring_server_location",
|
||||
fake_resolve_monitoring_server_location,
|
||||
)
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
|
||||
@@ -396,6 +532,107 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
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
|
||||
@@ -530,9 +767,11 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
text(
|
||||
"""
|
||||
INSERT INTO permission_access_logs
|
||||
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
|
||||
(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, CURRENT_TIMESTAMP)
|
||||
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
|
||||
:request_snapshot, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -544,6 +783,7 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
"allowed": True,
|
||||
"elapsed_ms": 3.2,
|
||||
"ip_address": "192.168.1.10",
|
||||
"request_snapshot": None,
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
@@ -556,8 +796,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypatch):
|
||||
"""来源分析应同时统计安全事件中的异常来源 IP。"""
|
||||
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()
|
||||
@@ -603,9 +843,11 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
|
||||
text(
|
||||
"""
|
||||
INSERT INTO permission_access_logs
|
||||
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
|
||||
(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, CURRENT_TIMESTAMP)
|
||||
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
|
||||
:request_snapshot, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -617,9 +859,10 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
|
||||
"allowed": True,
|
||||
"elapsed_ms": 3.2,
|
||||
"ip_address": "10.3.1.1",
|
||||
"request_snapshot": None,
|
||||
},
|
||||
)
|
||||
for ip_address in ["8.8.8.8", "1.1.1.1"]:
|
||||
for ip_address, status_code in [("8.8.8.8", 403), ("1.1.1.1", 200)]:
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -633,7 +876,7 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
|
||||
"id": str(uuid.uuid4()),
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin",
|
||||
"status_code": 403,
|
||||
"status_code": status_code,
|
||||
"elapsed_ms": 8.5,
|
||||
"client_ip": ip_address,
|
||||
"user_agent": "pytest",
|
||||
@@ -658,18 +901,23 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
|
||||
locations = {item["country"]: item for item in result["items"]}
|
||||
|
||||
assert result["summary"]["total_count"] == 3
|
||||
assert result["summary"]["denied_count"] == 2
|
||||
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"
|
||||
@@ -784,10 +1032,320 @@ def test_access_logs_user_stats_query_avoids_postgresql_uuid_max():
|
||||
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"
|
||||
@@ -896,10 +1454,13 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session, mo
|
||||
text(
|
||||
"""
|
||||
INSERT INTO security_access_logs
|
||||
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
||||
(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, CURRENT_TIMESTAMP)
|
||||
'203.0.113.10', 'attack-bot/1.0', 'ANONYMOUS', NULL,
|
||||
'{"method":"POST","path":"/api/v1/auth/login","headers":{"authorization":"[redacted]"}}',
|
||||
CURRENT_TIMESTAMP)
|
||||
"""
|
||||
)
|
||||
)
|
||||
@@ -925,6 +1486,7 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session, mo
|
||||
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
|
||||
@@ -993,8 +1555,8 @@ async def test_security_access_logs_prioritize_sensitive_probe_over_foreign_ip(d
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session, monkeypatch):
|
||||
"""安全事件明细应把非中国公网 IP 归类为异常 IP。"""
|
||||
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,
|
||||
@@ -1024,8 +1586,8 @@ async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session
|
||||
)
|
||||
|
||||
item = result["items"][0]
|
||||
assert item["category"] == "ABNORMAL_IP"
|
||||
assert item["severity"] == "HIGH"
|
||||
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"
|
||||
|
||||
@@ -1062,4 +1624,108 @@ async def test_security_access_logs_resolve_public_ip_with_packaged_ip2region(db
|
||||
assert item["ip_country"] == "United States"
|
||||
assert item["ip_province"] == "California"
|
||||
assert item["ip_city"] == "San Jose"
|
||||
assert item["category"] == "ABNORMAL_IP"
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ from datetime import datetime, timedelta, timezone
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from app.main import create_app
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_db_session
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.core.security import create_access_token, decode_token_allow_expired, hash_password, verify_password
|
||||
from app.crud import user as user_crud
|
||||
from app.db.base_class import Base
|
||||
from app.models.audit_log import AuditLog
|
||||
@@ -397,6 +398,7 @@ async def test_root_returns_service_metadata(client_and_db):
|
||||
client, _ = client_and_db
|
||||
resp = await client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert uuid.UUID(resp.headers["x-request-id"])
|
||||
assert resp.json() == {
|
||||
"service": "ctms-backend",
|
||||
"status": "ok",
|
||||
@@ -406,6 +408,17 @@ async def test_root_returns_service_metadata(client_and_db):
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_checks_database(client_and_db):
|
||||
client, _ = client_and_db
|
||||
resp = await client.get("/readyz")
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert payload["status"] == "ready"
|
||||
assert payload["database"]["status"] == "healthy"
|
||||
assert payload["database"]["latency_ms"] >= 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registered_user_can_login_after_email_verification(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
@@ -422,6 +435,74 @@ async def test_registered_user_can_login_after_email_verification(client_and_db)
|
||||
assert resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_desktop_login_uses_30_day_token_without_changing_web_login(client_and_db):
|
||||
client, _ = client_and_db
|
||||
|
||||
web_resp = await encrypted_login(client, "admin@test.com", "admin123")
|
||||
desktop_resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json=await encrypted_auth_payload(client, "admin@test.com", "admin123"),
|
||||
headers={"X-CTMS-Client-Type": "desktop"},
|
||||
)
|
||||
|
||||
assert web_resp.status_code == 200
|
||||
assert desktop_resp.status_code == 200
|
||||
web_payload = decode_token_allow_expired(web_resp.json()["access_token"])
|
||||
desktop_payload = decode_token_allow_expired(desktop_resp.json()["access_token"])
|
||||
assert web_payload["client_type"] == "web"
|
||||
assert desktop_payload["client_type"] == "desktop"
|
||||
assert web_payload["exp"] - web_payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60
|
||||
assert desktop_payload["exp"] - desktop_payload["iat"] == settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_token_extension_cannot_be_upgraded_with_desktop_header(client_and_db):
|
||||
client, _ = client_and_db
|
||||
web_resp = await encrypted_login(client, "admin@test.com", "admin123")
|
||||
token = web_resp.json()["access_token"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/extend",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-CTMS-Client-Type": "desktop",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
payload = decode_token_allow_expired(resp.json()["accessToken"])
|
||||
assert payload["client_type"] == "web"
|
||||
assert payload["exp"] - payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_desktop_token_extension_rejects_sessions_after_30_days(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
async with SessionLocal() as session:
|
||||
admin = await user_crud.get_by_email(session, "admin@test.com")
|
||||
|
||||
session_start = datetime.now(timezone.utc) - timedelta(days=settings.DESKTOP_SESSION_MAX_DAYS, seconds=1)
|
||||
token = create_access_token(
|
||||
user_id=str(admin.id),
|
||||
expires_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
|
||||
session_start=session_start,
|
||||
max_age_seconds=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600,
|
||||
client_type="desktop",
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/extend",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-CTMS-Client-Type": "desktop",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert "会话已到期" in resp.json().get("detail", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_created_user_is_active_by_default(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.source_location_snapshot import SourceLocationSnapshot
|
||||
from app.services.source_location_aggregator import _identity_hash, get_source_location_timeline
|
||||
|
||||
|
||||
def _snapshot(bucket_time: datetime, *, allowed: int, denied: int) -> SourceLocationSnapshot:
|
||||
return SourceLocationSnapshot(
|
||||
bucket_time=bucket_time,
|
||||
ip_hash=_identity_hash("ip", "203.0.113.10"),
|
||||
user_hash=_identity_hash("user", "user-1"),
|
||||
country="United States",
|
||||
country_code="US",
|
||||
province="California",
|
||||
region_code="",
|
||||
city="San Jose",
|
||||
isp="Example ISP",
|
||||
location="United States / California / San Jose",
|
||||
longitude=-121.8863,
|
||||
latitude=37.3382,
|
||||
accuracy_level="city",
|
||||
allowed_count=allowed,
|
||||
denied_count=denied,
|
||||
security_event_count=denied,
|
||||
high_risk_count=0,
|
||||
auth_failure_count=denied,
|
||||
first_seen_at=bucket_time,
|
||||
last_seen_at=bucket_time + timedelta(minutes=30),
|
||||
)
|
||||
|
||||
|
||||
def test_source_location_identity_hash_is_stable_and_does_not_expose_ip():
|
||||
first = _identity_hash("ip", "203.0.113.10")
|
||||
second = _identity_hash("ip", "203.0.113.10")
|
||||
|
||||
assert first == second
|
||||
assert len(first) == 64
|
||||
assert "203.0.113.10" not in first
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_location_timeline_supports_hour_and_day_rollups(db_session):
|
||||
start = datetime(2026, 7, 10, 8, tzinfo=timezone.utc)
|
||||
db_session.add(_snapshot(start, allowed=4, denied=1))
|
||||
db_session.add(_snapshot(start + timedelta(hours=1), allowed=3, denied=2))
|
||||
await db_session.commit()
|
||||
|
||||
hourly = await get_source_location_timeline(
|
||||
db_session,
|
||||
start_at=start,
|
||||
end_at=start + timedelta(hours=2),
|
||||
granularity="hour",
|
||||
)
|
||||
daily = await get_source_location_timeline(
|
||||
db_session,
|
||||
start_at=start,
|
||||
end_at=start + timedelta(hours=2),
|
||||
granularity="day",
|
||||
)
|
||||
|
||||
assert [item["total_count"] for item in hourly] == [5, 5]
|
||||
assert len(daily) == 1
|
||||
assert daily[0]["total_count"] == 10
|
||||
assert daily[0]["denied_count"] == 3
|
||||
assert daily[0]["unique_ip_count"] == 1
|
||||
assert daily[0]["unique_user_count"] == 1
|
||||
@@ -0,0 +1,224 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import User, UserStatus
|
||||
from app.models.user_login_session import UserLoginSession
|
||||
from app.schemas.user import UserLoginActivityRead
|
||||
from app.services.user_login_sessions import (
|
||||
create_login_session,
|
||||
get_login_summaries,
|
||||
login_activity_payload,
|
||||
session_id_from_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_session_id_is_stable_without_storing_a_token():
|
||||
payload = {"sub": "00000000-0000-0000-0000-000000000101", "orig_iat": 1_700_000_000, "client_type": "web"}
|
||||
|
||||
first = session_id_from_payload(payload)
|
||||
second = session_id_from_payload(payload)
|
||||
|
||||
assert first == second
|
||||
assert isinstance(first, uuid.UUID)
|
||||
|
||||
|
||||
def test_session_heartbeat_returns_server_observed_client_ip():
|
||||
auth_source = (Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "auth.py").read_text()
|
||||
|
||||
assert "resolve_client_ip(request)" in auth_source
|
||||
assert '"client_ip": resolve_client_ip(request)' in auth_source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_session_stores_server_observed_login_ip(db_session):
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="login-ip@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Login IP",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
request = SimpleNamespace(
|
||||
headers={
|
||||
"x-ctms-client-type": "desktop",
|
||||
"x-ctms-client-platform": "macos",
|
||||
"x-ctms-client-version": "0.1.0",
|
||||
},
|
||||
client=SimpleNamespace(host="203.0.113.18"),
|
||||
)
|
||||
|
||||
session = await create_login_session(
|
||||
db_session,
|
||||
session_id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert session.login_ip == "203.0.113.18"
|
||||
assert session.client_type == "desktop"
|
||||
|
||||
|
||||
def test_login_activity_payload_uses_server_authoritative_status_and_local_ip_location(monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
|
||||
session = UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
client_type="web",
|
||||
login_ip="127.0.0.1",
|
||||
login_at=now - timedelta(minutes=10),
|
||||
last_seen_at=now - timedelta(seconds=30),
|
||||
)
|
||||
|
||||
online = login_activity_payload(session, reference_time=now)
|
||||
assert online["activity_status"] == "ONLINE"
|
||||
assert online["login_ip"] == "127.0.0.1"
|
||||
assert online["ip_location"] == "本机"
|
||||
assert UserLoginActivityRead.model_validate(online).activity_status == "ONLINE"
|
||||
|
||||
session.last_seen_at = now - timedelta(minutes=6)
|
||||
assert login_activity_payload(session, reference_time=now)["activity_status"] == "OFFLINE"
|
||||
|
||||
session.ended_at = now - timedelta(minutes=1)
|
||||
assert login_activity_payload(session, reference_time=now)["activity_status"] == "ENDED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_summary_marks_recent_unended_sessions_online(db_session, monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="login-summary@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Login Summary",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(user)
|
||||
db_session.add_all(
|
||||
[
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
client_type="desktop",
|
||||
login_at=now - timedelta(minutes=10),
|
||||
last_seen_at=now - timedelta(seconds=20),
|
||||
),
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
client_type="web",
|
||||
login_at=now - timedelta(days=1),
|
||||
last_seen_at=now - timedelta(days=1),
|
||||
ended_at=now - timedelta(days=1),
|
||||
end_reason="logout",
|
||||
),
|
||||
]
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
summaries = await get_login_summaries(db_session, [user.id])
|
||||
|
||||
assert summaries[user.id].status == "ONLINE"
|
||||
assert summaries[user.id].active_session_count == 1
|
||||
assert summaries[user.id].client_type == "desktop"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_summary_counts_same_client_and_ip_as_one_active_source(db_session, monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="deduplicated-login-source@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Deduplicated Login Source",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(user)
|
||||
db_session.add_all(
|
||||
[
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
client_type="web",
|
||||
login_ip="192.168.97.1",
|
||||
login_at=now - timedelta(minutes=2),
|
||||
last_seen_at=now - timedelta(seconds=20),
|
||||
),
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
client_type="web",
|
||||
login_ip="192.168.97.1",
|
||||
login_at=now - timedelta(minutes=1),
|
||||
last_seen_at=now - timedelta(seconds=10),
|
||||
),
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
client_type="desktop",
|
||||
login_ip="192.168.97.1",
|
||||
login_at=now - timedelta(minutes=1),
|
||||
last_seen_at=now - timedelta(seconds=5),
|
||||
),
|
||||
]
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
summaries = await get_login_summaries(db_session, [user.id])
|
||||
|
||||
assert summaries[user.id].status == "ONLINE"
|
||||
assert summaries[user.id].active_session_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_list_filters_online_and_offline_accounts(db_session, monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
|
||||
online_user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="online-filter@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Online Filter",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
offline_user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="offline-filter@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Offline Filter",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add_all([online_user, offline_user])
|
||||
db_session.add(
|
||||
UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=online_user.id,
|
||||
client_type="web",
|
||||
login_at=now - timedelta(minutes=1),
|
||||
last_seen_at=now - timedelta(seconds=10),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
online = await user_crud.list_users(db_session, login_status="ONLINE")
|
||||
offline = await user_crud.list_users(db_session, login_status="OFFLINE")
|
||||
|
||||
assert online_user.id in {user.id for user in online}
|
||||
assert offline_user.id not in {user.id for user in online}
|
||||
assert offline_user.id in {user.id for user in offline}
|
||||
assert await user_crud.count_users(db_session, login_status="ONLINE") == len(online)
|
||||
Reference in New Issue
Block a user