feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
@@ -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()
|
||||
@@ -4,6 +4,7 @@ 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():
|
||||
@@ -51,3 +52,45 @@ async def test_server_location_returns_none_instead_of_a_hardcoded_fallback(monk
|
||||
|
||||
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()
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.core.permission_monitor import set_permission_monitor, PermissionMonito
|
||||
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:
|
||||
@@ -583,6 +584,57 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
assert sorted(resolved_ips) == ["10.1.1.1", "10.1.1.2", "10.1.1.3"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_uses_external_coordinates_only_when_local_coordinates_are_missing(db_session, monkeypatch):
|
||||
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
await db_session.commit()
|
||||
study_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=3.2)
|
||||
await db_session.execute(
|
||||
text("UPDATE permission_access_logs SET ip_address = :ip_address"),
|
||||
{"ip_address": "8.8.8.8"},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda _ip: FakeIpInfo("", "", isp="", country="未知国家"),
|
||||
)
|
||||
|
||||
async def fake_external_lookup(ip_addresses):
|
||||
assert ip_addresses == ["8.8.8.8"]
|
||||
return {
|
||||
"8.8.8.8": ExternalIpLocation(
|
||||
ip_address="8.8.8.8",
|
||||
country="United States of America",
|
||||
country_code="US",
|
||||
region="California",
|
||||
city="Mountain View",
|
||||
isp="Google LLC",
|
||||
longitude=-122.08385,
|
||||
latitude=37.38605,
|
||||
)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(permission_monitoring, "resolve_external_ip_locations", fake_external_lookup)
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(
|
||||
db=db_session,
|
||||
_=AdminUserStub(),
|
||||
days=7,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert result["items"][0]["longitude"] == -122.08385
|
||||
assert result["items"][0]["latitude"] == 37.38605
|
||||
assert result["items"][0]["city"] == "Mountain View"
|
||||
assert result["data_quality"]["external_fallback_ip_count"] == 1
|
||||
assert result["data_quality"]["resolver"] == "ip2region+ip2location.io"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_merges_same_region_with_different_isp(db_session, monkeypatch):
|
||||
"""同一省市的 IP 属地统计不应因运营商不同拆分。"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -8,7 +9,13 @@ 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.services.user_login_sessions import get_login_summaries, session_id_from_payload
|
||||
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():
|
||||
@@ -28,6 +35,63 @@ def test_session_heartbeat_returns_server_observed_client_ip():
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user