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()