feat(server): add project RBAC and guarded workflows
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import access as access_endpoint
|
||||
from app.auth.metadata_dependencies import (
|
||||
get_current_metadata_user,
|
||||
get_metadata_repository,
|
||||
)
|
||||
from tests.conftest import build_test_app
|
||||
|
||||
|
||||
def _user(**overrides):
|
||||
data = {
|
||||
"id": uuid4(),
|
||||
"username": "alice",
|
||||
"role": "user",
|
||||
"is_superuser": False,
|
||||
}
|
||||
data.update(overrides)
|
||||
return SimpleNamespace(**data)
|
||||
|
||||
|
||||
def _build_client(user, repo) -> TestClient:
|
||||
app = build_test_app(access_endpoint.router, "/api/v1")
|
||||
app.dependency_overrides[get_current_metadata_user] = lambda: user
|
||||
app.dependency_overrides[get_metadata_repository] = lambda: repo
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_access_context_returns_global_admin_permissions_without_project():
|
||||
user = _user(role="admin")
|
||||
repo = SimpleNamespace()
|
||||
client = _build_client(user, repo)
|
||||
|
||||
response = client.get("/api/v1/access/context")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["is_system_admin"] is True
|
||||
assert payload["project_id"] is None
|
||||
assert "environment.manage" in payload["permissions"]
|
||||
assert "webgis.view" not in payload["permissions"]
|
||||
|
||||
|
||||
def test_access_context_returns_project_member_permissions():
|
||||
project_id = uuid4()
|
||||
user = _user()
|
||||
|
||||
async def get_project_by_id(value):
|
||||
assert value == project_id
|
||||
return SimpleNamespace(id=project_id, code="demo", status="active")
|
||||
|
||||
async def get_membership_role(value, user_id):
|
||||
assert value == project_id
|
||||
assert user_id == user.id
|
||||
return "member"
|
||||
|
||||
repo = SimpleNamespace(
|
||||
get_project_by_id=get_project_by_id,
|
||||
get_membership_role=get_membership_role,
|
||||
)
|
||||
client = _build_client(user, repo)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/access/context",
|
||||
headers={"X-Project-Id": str(project_id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["project_id"] == str(project_id)
|
||||
assert payload["project_role"] == "member"
|
||||
assert "scada.clean" in payload["permissions"]
|
||||
assert "optimization.run" in payload["permissions"]
|
||||
assert "model.import" not in payload["permissions"]
|
||||
@@ -138,7 +138,7 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch):
|
||||
keycloak_id=users[1].keycloak_id,
|
||||
username="bob",
|
||||
email="bob@example.com",
|
||||
role="viewer",
|
||||
role="user",
|
||||
is_active=True,
|
||||
),
|
||||
]
|
||||
@@ -156,7 +156,7 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch):
|
||||
@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)
|
||||
updated = _user(id=user_id, role="user", is_active=False)
|
||||
repo = SimpleNamespace(
|
||||
session=object(),
|
||||
update_user_admin=AsyncMock(return_value=updated),
|
||||
@@ -165,7 +165,7 @@ async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
|
||||
|
||||
response = await admin_metadata.update_metadata_user(
|
||||
MetadataUserUpdateRequest(
|
||||
role="operator",
|
||||
role="user",
|
||||
is_active=False,
|
||||
),
|
||||
user_id=user_id,
|
||||
@@ -175,9 +175,9 @@ async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
|
||||
|
||||
repo.update_user_admin.assert_awaited_once_with(
|
||||
user_id,
|
||||
updates={"role": "operator", "is_active": False},
|
||||
updates={"role": "user", "is_active": False},
|
||||
)
|
||||
assert response.role == "operator"
|
||||
assert response.role == "user"
|
||||
admin_metadata.log_audit_event.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ async def test_update_metadata_user_rejects_self_update(monkeypatch):
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await admin_metadata.update_metadata_user(
|
||||
MetadataUserUpdateRequest(role="viewer"),
|
||||
MetadataUserUpdateRequest(role="user"),
|
||||
user_id=current_user.id,
|
||||
current_user=current_user,
|
||||
metadata_repo=repo,
|
||||
@@ -221,6 +221,7 @@ async def test_create_project_audits_metadata_admin_change(monkeypatch):
|
||||
create_project=AsyncMock(return_value=project),
|
||||
)
|
||||
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
|
||||
current_user = _user(role="admin", is_superuser=True)
|
||||
|
||||
response = await admin_metadata.create_admin_project(
|
||||
AdminProjectCreateRequest(
|
||||
@@ -231,12 +232,16 @@ async def test_create_project_audits_metadata_admin_change(monkeypatch):
|
||||
map_extent={"bbox": [1, 2, 3, 4]},
|
||||
status="active",
|
||||
),
|
||||
current_user=_user(role="admin", is_superuser=True),
|
||||
current_user=current_user,
|
||||
metadata_repo=repo,
|
||||
)
|
||||
|
||||
assert response.project_id == project.id
|
||||
repo.create_project.assert_awaited_once()
|
||||
assert (
|
||||
repo.create_project.await_args.kwargs["creator_user_id"]
|
||||
== current_user.id
|
||||
)
|
||||
admin_metadata.log_audit_event.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -482,7 +487,7 @@ async def test_update_project_member_role_audits_change(monkeypatch):
|
||||
membership = _membership(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
project_role="admin",
|
||||
project_role="member",
|
||||
)
|
||||
repo = SimpleNamespace(
|
||||
session=object(),
|
||||
@@ -492,16 +497,16 @@ async def test_update_project_member_role_audits_change(monkeypatch):
|
||||
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
|
||||
|
||||
response = await admin_metadata.update_project_member(
|
||||
ProjectMemberUpdateRequest(project_role="admin"),
|
||||
ProjectMemberUpdateRequest(project_role="member"),
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
current_user=_user(role="admin", is_superuser=True),
|
||||
metadata_repo=repo,
|
||||
)
|
||||
|
||||
assert response.project_role == "admin"
|
||||
assert response.project_role == "member"
|
||||
repo.update_project_member_role.assert_awaited_once_with(
|
||||
project_id, user_id, "admin"
|
||||
project_id, user_id, "member"
|
||||
)
|
||||
admin_metadata.log_audit_event.assert_awaited_once()
|
||||
|
||||
@@ -519,7 +524,7 @@ async def test_update_project_member_rejects_self_membership_change(monkeypatch)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await admin_metadata.update_project_member(
|
||||
ProjectMemberUpdateRequest(project_role="admin"),
|
||||
ProjectMemberUpdateRequest(project_role="member"),
|
||||
project_id=project_id,
|
||||
user_id=current_user.id,
|
||||
current_user=current_user,
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
|
||||
project_id=project_id,
|
||||
project_code="fengyang",
|
||||
user_id=user_id,
|
||||
project_role="editor",
|
||||
project_role="member",
|
||||
),
|
||||
current_user=SimpleNamespace(
|
||||
id=user_id,
|
||||
@@ -52,7 +52,21 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
|
||||
"is_superuser": False,
|
||||
"project_id": str(project_id),
|
||||
"network": "fengyang",
|
||||
"project_role": "editor",
|
||||
"project_role": "member",
|
||||
"permissions": [
|
||||
"burst.run",
|
||||
"burst.view",
|
||||
"optimization.run",
|
||||
"optimization.view",
|
||||
"risk.run",
|
||||
"risk.view",
|
||||
"scada.clean",
|
||||
"scada.view",
|
||||
"simulation.run",
|
||||
"simulation.view",
|
||||
"webgis.edit",
|
||||
"webgis.view",
|
||||
],
|
||||
"token_expires_at": "2026-06-11T13:10:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -10,7 +11,12 @@ from app.auth.metadata_dependencies import (
|
||||
from tests.conftest import build_test_app, make_audit_log
|
||||
|
||||
|
||||
def _build_client(repo, *, metadata_admin=None, metadata_user=None) -> TestClient:
|
||||
def _build_client(
|
||||
repo,
|
||||
*,
|
||||
metadata_admin=None,
|
||||
metadata_user=None,
|
||||
) -> TestClient:
|
||||
app = build_test_app(audit_endpoint.router, "/audit")
|
||||
app.dependency_overrides[audit_endpoint.get_audit_repository] = lambda: repo
|
||||
if metadata_admin is not None:
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_meta_project_returns_map_extent(monkeypatch):
|
||||
app = build_test_app(module.router, "/api/v1")
|
||||
app.dependency_overrides[module.get_project_context] = lambda: SimpleNamespace(
|
||||
project_id=project_id,
|
||||
project_role="editor",
|
||||
project_role="member",
|
||||
)
|
||||
app.dependency_overrides[module.get_metadata_repository] = lambda: repo
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import model_import
|
||||
from app.auth.metadata_dependencies import (
|
||||
get_current_metadata_admin,
|
||||
get_metadata_repository,
|
||||
)
|
||||
from tests.conftest import build_test_app
|
||||
|
||||
|
||||
VALID_INP = b"[TITLE]\nDesktop model\n[JUNCTIONS]\n;ID Elev Demand\n"
|
||||
|
||||
|
||||
def _client(*, admin=None, repo=None) -> TestClient:
|
||||
app = build_test_app(model_import.router, "/api/v1")
|
||||
if admin is not None:
|
||||
app.dependency_overrides[get_current_metadata_admin] = lambda: admin
|
||||
if repo is not None:
|
||||
app.dependency_overrides[get_metadata_repository] = lambda: repo
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_system_admin_can_import_model_without_project_membership(
|
||||
monkeypatch,
|
||||
):
|
||||
project_id = uuid4()
|
||||
project = SimpleNamespace(id=project_id, code="demo", status="active")
|
||||
repo = SimpleNamespace(
|
||||
session=object(),
|
||||
get_project_by_id=AsyncMock(return_value=project),
|
||||
)
|
||||
admin = SimpleNamespace(id=uuid4(), role="admin", is_superuser=False)
|
||||
monkeypatch.setattr(
|
||||
model_import,
|
||||
"_run_uploaded_inp",
|
||||
AsyncMock(return_value="imported"),
|
||||
)
|
||||
monkeypatch.setattr(model_import, "log_audit_event", AsyncMock())
|
||||
client = _client(admin=admin, repo=repo)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/admin/projects/{project_id}/model/import",
|
||||
files={"file": ("desktop-model.inp", VALID_INP)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["project_id"] == str(project_id)
|
||||
assert response.json()["result"] == "imported"
|
||||
repo.get_project_by_id.assert_awaited_once_with(project_id)
|
||||
model_import.log_audit_event.assert_awaited_once()
|
||||
|
||||
|
||||
def test_non_admin_is_denied_model_import():
|
||||
def deny_admin():
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
app = build_test_app(model_import.router, "/api/v1")
|
||||
app.dependency_overrides[get_current_metadata_admin] = deny_admin
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/admin/projects/{uuid4()}/model/import",
|
||||
files={"file": ("desktop-model.inp", VALID_INP)},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Admin access required"
|
||||
|
||||
|
||||
def test_model_import_rejects_non_inp_file(monkeypatch):
|
||||
project_id = uuid4()
|
||||
repo = SimpleNamespace(
|
||||
session=object(),
|
||||
get_project_by_id=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
id=project_id,
|
||||
code="demo",
|
||||
status="active",
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(model_import, "log_audit_event", AsyncMock())
|
||||
client = _client(
|
||||
admin=SimpleNamespace(id=uuid4(), role="admin", is_superuser=False),
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/admin/projects/{project_id}/model/import",
|
||||
files={"file": ("desktop-model.txt", VALID_INP)},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Only .inp model files are accepted"
|
||||
model_import.log_audit_event.assert_not_awaited()
|
||||
@@ -2,6 +2,7 @@ from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import build_test_app, install_stub, load_module_from_path
|
||||
@@ -254,21 +255,25 @@ def test_optimize_rejects_viewer_project_role(monkeypatch):
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_project_owner_and_admin_can_optimize(monkeypatch):
|
||||
@pytest.mark.parametrize(
|
||||
"project_role",
|
||||
["owner", "admin", "modeler", "dispatcher", "auditor"],
|
||||
)
|
||||
def test_legacy_project_roles_cannot_optimize(monkeypatch, project_role):
|
||||
module = _load_module(monkeypatch)
|
||||
for project_role in ("owner", "admin"):
|
||||
response = _client(module, project_role=project_role).post(
|
||||
"/api/v1/sensor-placement-schemes/optimize",
|
||||
json={
|
||||
"network": "tjwater",
|
||||
"scheme_name": f"{project_role}方案",
|
||||
"sensor_type": "pressure",
|
||||
"method": "kmeans",
|
||||
"sensor_count": 2,
|
||||
"min_diameter": 300,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response = _client(module, project_role=project_role).post(
|
||||
"/api/v1/sensor-placement-schemes/optimize",
|
||||
json={
|
||||
"network": "tjwater",
|
||||
"scheme_name": f"{project_role}方案",
|
||||
"sensor_type": "pressure",
|
||||
"method": "kmeans",
|
||||
"sensor_count": 2,
|
||||
"min_diameter": 300,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_optimize_maps_running_project_job_to_409(monkeypatch):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -199,26 +198,6 @@ def test_project_management_maps_named_arguments(monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def test_network_update_surfaces_service_error(monkeypatch, tmp_path):
|
||||
module = _load_simulation_module(monkeypatch)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
def boom(_path):
|
||||
raise RuntimeError("write failed")
|
||||
|
||||
monkeypatch.setattr(module, "network_update", boom)
|
||||
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/network_update/",
|
||||
files={"file": ("update.txt", b"payload")},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "数据库操作失败: write failed" in response.json()["detail"]
|
||||
assert list(Path(tmp_path).glob("network_update_*"))
|
||||
|
||||
|
||||
def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
|
||||
module = _load_simulation_module(monkeypatch)
|
||||
captured_calls = []
|
||||
|
||||
Reference in New Issue
Block a user