Files
TJWaterServerBinary/tests/api/test_admin_metadata_endpoints.py
T

647 lines
21 KiB
Python

from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from fastapi import Response
from app.auth.metadata_dependencies import get_current_metadata_admin
from app.api.v1.endpoints import admin_metadata
from app.domain.schemas.admin_metadata import (
AdminProjectCreateRequest,
MetadataUsersBatchSyncRequest,
MetadataUserSyncRequest,
MetadataUserUpdateRequest,
ProjectDatabaseUpsertRequest,
ProjectGeoServerConfigUpsertRequest,
ProjectMemberCreateRequest,
ProjectMemberUpdateRequest,
)
from app.infra.db.metadb.repositories.metadata_repository import ProjectDbRouting
@pytest.fixture
def anyio_backend():
return "asyncio"
def _user(**overrides):
data = {
"id": uuid4(),
"keycloak_id": uuid4(),
"username": "alice",
"email": "alice@example.com",
"role": "user",
"is_active": True,
"is_superuser": False,
"created_at": datetime(2026, 1, 1, tzinfo=timezone.utc),
"updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc),
"last_login_at": None,
}
data.update(overrides)
return SimpleNamespace(**data)
def _project(**overrides):
data = {"id": uuid4(), "name": "Demo"}
data.update(overrides)
return SimpleNamespace(**data)
def _membership(**overrides):
data = {
"id": uuid4(),
"user_id": uuid4(),
"project_id": uuid4(),
"project_role": "viewer",
}
data.update(overrides)
return SimpleNamespace(**data)
def _database_config(**overrides):
data = {
"id": uuid4(),
"project_id": uuid4(),
"db_role": "biz_data",
"db_type": "postgresql",
"dsn_encrypted": "encrypted-dsn",
"pool_min_size": 1,
"pool_max_size": 5,
}
data.update(overrides)
return SimpleNamespace(**data)
def _geoserver_config(**overrides):
data = {
"id": uuid4(),
"project_id": uuid4(),
"gs_base_url": "http://geoserver",
"gs_admin_user": "admin",
"gs_admin_password_encrypted": "encrypted-password",
"gs_datastore_name": "ds_postgis",
"default_extent": {"bbox": [1, 2, 3, 4]},
"srid": 4326,
"updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc),
}
data.update(overrides)
return SimpleNamespace(**data)
def test_to_async_sqlalchemy_url_preserves_password():
url = admin_metadata._to_async_sqlalchemy_url(
"postgresql://tjwater:secret@192.168.1.114:5433/tjwater"
)
assert url == "postgresql+psycopg://tjwater:secret@192.168.1.114:5433/tjwater"
assert "***" not in url
@pytest.mark.anyio
async def test_sync_metadata_user_upserts_without_password(monkeypatch):
keycloak_id = uuid4()
synced_user = _user(keycloak_id=keycloak_id, username="new-user")
admin = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
upsert_user_from_keycloak=AsyncMock(return_value=synced_user),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.sync_metadata_user(
MetadataUserSyncRequest(
keycloak_id=keycloak_id,
username="new-user",
email="new-user@example.com",
role="user",
is_active=True,
),
current_user=admin,
metadata_repo=repo,
)
repo.upsert_user_from_keycloak.assert_awaited_once()
kwargs = repo.upsert_user_from_keycloak.await_args.kwargs
assert kwargs["keycloak_id"] == keycloak_id
assert "password" not in kwargs
assert response.username == "new-user"
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch):
users = [_user(username="alice"), _user(username="bob")]
admin = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=SimpleNamespace(rollback=AsyncMock()),
upsert_user_from_keycloak=AsyncMock(side_effect=users),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.sync_metadata_users_batch(
MetadataUsersBatchSyncRequest(
users=[
MetadataUserSyncRequest(
keycloak_id=users[0].keycloak_id,
username="alice",
email="alice@example.com",
role="user",
is_active=True,
),
MetadataUserSyncRequest(
keycloak_id=users[1].keycloak_id,
username="bob",
email="bob@example.com",
role="viewer",
is_active=True,
),
]
),
current_user=admin,
metadata_repo=repo,
)
assert [item.success for item in response] == [True, True]
assert [item.user.username for item in response] == ["alice", "bob"]
assert repo.upsert_user_from_keycloak.await_count == 2
assert admin_metadata.log_audit_event.await_count == 2
@pytest.mark.anyio
async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
user_id = uuid4()
updated = _user(id=user_id, role="operator", is_active=False)
repo = SimpleNamespace(
session=object(),
update_user_admin=AsyncMock(return_value=updated),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.update_metadata_user(
MetadataUserUpdateRequest(
role="operator",
is_active=False,
),
user_id=user_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
repo.update_user_admin.assert_awaited_once_with(
user_id,
updates={"role": "operator", "is_active": False},
)
assert response.role == "operator"
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_update_metadata_user_rejects_self_update(monkeypatch):
current_user = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
update_user_admin=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.update_metadata_user(
MetadataUserUpdateRequest(role="viewer"),
user_id=current_user.id,
current_user=current_user,
metadata_repo=repo,
)
assert exc.value.status_code == 403
repo.update_user_admin.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
@pytest.mark.anyio
async def test_create_project_audits_metadata_admin_change(monkeypatch):
project = SimpleNamespace(
id=uuid4(),
name="Demo Project",
code="demo",
description="desc",
gs_workspace="demo_ws",
map_extent={"bbox": [1, 2, 3, 4]},
status="active",
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
repo = SimpleNamespace(
session=object(),
create_project=AsyncMock(return_value=project),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.create_admin_project(
AdminProjectCreateRequest(
name="Demo Project",
code="demo",
description="desc",
gs_workspace="demo_ws",
map_extent={"bbox": [1, 2, 3, 4]},
status="active",
),
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.project_id == project.id
repo.create_project.assert_awaited_once()
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_upsert_project_database_hides_dsn_and_audits_without_plaintext(monkeypatch):
project_id = uuid4()
record = _database_config(project_id=project_id)
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
upsert_project_database_config=AsyncMock(return_value=record),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
monkeypatch.setattr(admin_metadata, "_check_database_connection", AsyncMock())
response = await admin_metadata.upsert_project_database(
ProjectDatabaseUpsertRequest(
db_role="biz_data",
dsn="postgresql://user:secret@localhost/db",
pool_min_size=1,
pool_max_size=5,
),
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.has_dsn is True
assert "dsn" not in response.model_dump()
admin_metadata._check_database_connection.assert_awaited_once()
repo.upsert_project_database_config.assert_awaited_once()
assert repo.upsert_project_database_config.await_args.kwargs["db_type"] == "postgresql"
request_data = admin_metadata.log_audit_event.await_args.kwargs["request_data"]
assert request_data["dsn_updated"] is True
assert request_data["db_type"] == "postgresql"
assert "dsn" not in request_data
assert "postgresql://user:secret@localhost/db" not in str(request_data)
@pytest.mark.anyio
async def test_upsert_project_database_rejects_unhealthy_connection(monkeypatch):
project_id = uuid4()
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
upsert_project_database_config=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
monkeypatch.setattr(
admin_metadata,
"_check_database_connection",
AsyncMock(
side_effect=Exception(
'FATAL: password authentication failed for user "tjwater"'
)
),
)
with pytest.raises(HTTPException) as exc:
await admin_metadata.upsert_project_database(
ProjectDatabaseUpsertRequest(
db_role="iot_data",
dsn="postgresql://tjwater:bad@192.168.1.114:5433/tjwater",
pool_min_size=1,
pool_max_size=5,
),
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert exc.value.status_code == 400
assert exc.value.detail == "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"
repo.upsert_project_database_config.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
@pytest.mark.anyio
async def test_project_database_health_returns_ok(monkeypatch):
project_id = uuid4()
repo = SimpleNamespace(
get_project_db_routing=AsyncMock(
return_value=ProjectDbRouting(
project_id=project_id,
db_role="biz_data",
db_type="postgresql",
dsn="postgresql://user:secret@localhost/db",
pool_min_size=1,
pool_max_size=5,
)
)
)
monkeypatch.setattr(admin_metadata, "_check_database_connection", AsyncMock())
response = await admin_metadata.check_project_database_health(
project_id=project_id,
db_role="biz_data",
response=Response(),
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.ok is True
assert response.detail == "连通性测试通过"
admin_metadata._check_database_connection.assert_awaited_once()
@pytest.mark.anyio
async def test_project_database_health_can_test_unsaved_plaintext_dsn(monkeypatch):
project_id = uuid4()
repo = SimpleNamespace(
get_project_db_routing=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "_check_database_connection", AsyncMock())
response = await admin_metadata.check_project_database_health(
project_id=project_id,
db_role="iot_data",
payload=admin_metadata.ProjectDatabaseHealthRequest(
dsn="postgresql://tjwater:secret@192.168.1.114:5433/tjwater"
),
response=Response(),
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.ok is True
assert response.db_type == "timescaledb"
routing = admin_metadata._check_database_connection.await_args.args[0]
assert routing.dsn == "postgresql://tjwater:secret@192.168.1.114:5433/tjwater"
repo.get_project_db_routing.assert_not_called()
@pytest.mark.anyio
async def test_project_database_health_sanitizes_password_failures(monkeypatch):
project_id = uuid4()
repo = SimpleNamespace(
get_project_db_routing=AsyncMock(
return_value=ProjectDbRouting(
project_id=project_id,
db_role="iot_data",
db_type="timescaledb",
dsn="postgresql://tjwater:bad-password@192.168.1.114:5433/db",
pool_min_size=1,
pool_max_size=5,
)
)
)
monkeypatch.setattr(
admin_metadata,
"_check_database_connection",
AsyncMock(
side_effect=Exception(
'(psycopg.OperationalError) connection failed: FATAL: '
'password authentication failed for user "tjwater"'
)
),
)
fastapi_response = Response()
response = await admin_metadata.check_project_database_health(
project_id=project_id,
db_role="iot_data",
response=fastapi_response,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert fastapi_response.status_code == 503
assert response.ok is False
assert response.db_type == "timescaledb"
assert response.detail == "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"
assert "psycopg" not in response.detail
@pytest.mark.anyio
async def test_upsert_geoserver_config_hides_password_and_audits_without_plaintext(monkeypatch):
project_id = uuid4()
record = _geoserver_config(project_id=project_id)
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
upsert_geoserver_config=AsyncMock(return_value=record),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.upsert_project_geoserver_config(
ProjectGeoServerConfigUpsertRequest(
gs_base_url="http://geoserver",
gs_admin_user="admin",
gs_admin_password="secret-password",
gs_datastore_name="ds_postgis",
default_extent={"bbox": [1, 2, 3, 4]},
srid=4326,
),
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.has_password is True
assert "password" not in response.model_dump()
request_data = admin_metadata.log_audit_event.await_args.kwargs["request_data"]
assert request_data["password_updated"] is True
assert "secret-password" not in str(request_data)
@pytest.mark.anyio
async def test_get_geoserver_config_returns_empty_state_when_unconfigured():
project_id = uuid4()
repo = SimpleNamespace(
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
get_geoserver_config_record=AsyncMock(return_value=None),
)
response = await admin_metadata.get_project_geoserver_config(
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.project_id == project_id
assert response.configured is False
assert response.has_password is False
assert response.gs_datastore_name == "ds_postgis"
@pytest.mark.anyio
async def test_metadata_admin_dependency_rejects_non_admin_user():
with pytest.raises(HTTPException) as exc:
await get_current_metadata_admin(_user(role="user", is_superuser=False))
assert exc.value.status_code == 403
assert exc.value.detail == "Admin access required"
@pytest.mark.anyio
async def test_add_project_member_rejects_duplicate(monkeypatch):
project_id = uuid4()
user_id = uuid4()
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=_project(id=project_id)),
get_user_by_id=AsyncMock(return_value=_user(id=user_id)),
get_project_membership=AsyncMock(return_value=_membership()),
add_project_member=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.add_project_member(
ProjectMemberCreateRequest(user_id=user_id, project_role="viewer"),
project_id=project_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert exc.value.status_code == 409
repo.add_project_member.assert_not_called()
@pytest.mark.anyio
async def test_add_project_member_rejects_self_membership_change(monkeypatch):
project_id = uuid4()
current_user = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(),
add_project_member=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.add_project_member(
ProjectMemberCreateRequest(
user_id=current_user.id,
project_role="viewer",
),
project_id=project_id,
current_user=current_user,
metadata_repo=repo,
)
assert exc.value.status_code == 403
repo.get_project_by_id.assert_not_called()
repo.add_project_member.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
@pytest.mark.anyio
async def test_update_project_member_role_audits_change(monkeypatch):
project_id = uuid4()
user_id = uuid4()
user = _user(id=user_id, username="bob", email="bob@example.com")
membership = _membership(
user_id=user_id,
project_id=project_id,
project_role="admin",
)
repo = SimpleNamespace(
session=object(),
get_user_by_id=AsyncMock(return_value=user),
update_project_member_role=AsyncMock(return_value=membership),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.update_project_member(
ProjectMemberUpdateRequest(project_role="admin"),
project_id=project_id,
user_id=user_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response.project_role == "admin"
repo.update_project_member_role.assert_awaited_once_with(
project_id, user_id, "admin"
)
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_update_project_member_rejects_self_membership_change(monkeypatch):
project_id = uuid4()
current_user = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
get_user_by_id=AsyncMock(),
update_project_member_role=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.update_project_member(
ProjectMemberUpdateRequest(project_role="admin"),
project_id=project_id,
user_id=current_user.id,
current_user=current_user,
metadata_repo=repo,
)
assert exc.value.status_code == 403
repo.get_user_by_id.assert_not_called()
repo.update_project_member_role.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
@pytest.mark.anyio
async def test_remove_project_member_audits_change(monkeypatch):
project_id = uuid4()
user_id = uuid4()
repo = SimpleNamespace(
session=object(),
remove_project_member=AsyncMock(return_value=True),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
response = await admin_metadata.remove_project_member(
project_id=project_id,
user_id=user_id,
current_user=_user(role="admin", is_superuser=True),
metadata_repo=repo,
)
assert response is None
repo.remove_project_member.assert_awaited_once_with(project_id, user_id)
admin_metadata.log_audit_event.assert_awaited_once()
@pytest.mark.anyio
async def test_remove_project_member_rejects_self_membership_change(monkeypatch):
project_id = uuid4()
current_user = _user(role="admin", is_superuser=True)
repo = SimpleNamespace(
session=object(),
remove_project_member=AsyncMock(),
)
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
with pytest.raises(HTTPException) as exc:
await admin_metadata.remove_project_member(
project_id=project_id,
user_id=current_user.id,
current_user=current_user,
metadata_repo=repo,
)
assert exc.value.status_code == 403
repo.remove_project_member.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()