feat(admin): add project metadata config

This commit is contained in:
2026-06-12 15:08:37 +08:00
parent 23c008f602
commit a6e7a2e75c
15 changed files with 2460 additions and 10 deletions
+646
View File
@@ -0,0 +1,646 @@
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()
+83
View File
@@ -0,0 +1,83 @@
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 app.auth import metadata_dependencies
@pytest.fixture
def anyio_backend():
return "asyncio"
def _user(**overrides):
data = {
"id": uuid4(),
"keycloak_id": uuid4(),
"username": "old-name",
"email": "old@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)
@pytest.mark.anyio
async def test_current_metadata_user_refreshes_keycloak_claim_snapshot():
keycloak_id = uuid4()
user = _user(keycloak_id=keycloak_id)
refreshed = _user(
id=user.id,
keycloak_id=keycloak_id,
username="alice",
email="alice@example.com",
last_login_at=datetime(2026, 6, 12, tzinfo=timezone.utc),
)
repo = SimpleNamespace(
get_user_by_keycloak_id=AsyncMock(return_value=user),
refresh_user_keycloak_snapshot=AsyncMock(return_value=refreshed),
)
response = await metadata_dependencies.get_current_metadata_user(
{
"sub": str(keycloak_id),
"preferred_username": "alice",
"email": "alice@example.com",
},
metadata_repo=repo,
)
assert response.username == "alice"
repo.get_user_by_keycloak_id.assert_awaited_once_with(keycloak_id)
repo.refresh_user_keycloak_snapshot.assert_awaited_once_with(
user,
username="alice",
email="alice@example.com",
)
@pytest.mark.anyio
async def test_current_metadata_user_rejects_invalid_keycloak_sub():
repo = SimpleNamespace(
get_user_by_keycloak_id=AsyncMock(),
refresh_user_keycloak_snapshot=AsyncMock(),
)
with pytest.raises(HTTPException) as exc:
await metadata_dependencies.get_current_metadata_user(
{"sub": "not-a-uuid"},
metadata_repo=repo,
)
assert exc.value.status_code == 401
repo.get_user_by_keycloak_id.assert_not_called()
repo.refresh_user_keycloak_snapshot.assert_not_called()
+12
View File
@@ -0,0 +1,12 @@
from app.infra.db.dynamic_manager import ProjectConnectionManager
def test_normalize_pg_url_preserves_password():
manager = ProjectConnectionManager()
url = manager._normalize_pg_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
@@ -23,6 +23,11 @@ class _DummyEncryptor:
self._raise_invalid_token = raise_invalid_token
self.encrypted_values = []
def encrypt(self, value):
encrypted = f"encrypted::{value}"
self.encrypted_values.append(value)
return encrypted
def decrypt(self, _value):
if self._raise_invalid_token:
raise InvalidToken()
@@ -117,3 +122,46 @@ def test_encrypted_dsn_decrypts_without_migration(monkeypatch):
assert routing.dsn == "postgresql://u:p%40ss@host/db"
session.commit.assert_not_awaited()
def test_upsert_project_database_config_encrypts_plaintext_dsn(monkeypatch):
project_id = uuid4()
session = SimpleNamespace(
execute=None,
add=None,
commit=None,
refresh=None,
)
added = []
session.execute = AsyncMock(return_value=_DummyResult(None))
session.add = lambda item: added.append(item)
session.commit = AsyncMock()
session.refresh = AsyncMock()
encryptor = _DummyEncryptor()
repo = MetadataRepository(session)
monkeypatch.setattr(
"app.infra.db.metadb.repositories.metadata_repository.is_database_encryption_configured",
lambda: True,
)
monkeypatch.setattr(
"app.infra.db.metadb.repositories.metadata_repository.get_database_encryptor",
lambda: encryptor,
)
record = asyncio.run(
repo.upsert_project_database_config(
project_id,
db_role="biz_data",
db_type="postgresql",
dsn="postgresql://user:secret@localhost/db",
pool_min_size=1,
pool_max_size=5,
)
)
assert encryptor.encrypted_values == ["postgresql://user:secret@localhost/db"]
assert record.dsn_encrypted == "encrypted::postgresql://user:secret@localhost/db"
assert added == [record]
session.commit.assert_awaited_once()
session.refresh.assert_awaited_once_with(record)