feat(server): add project RBAC and guarded workflows

This commit is contained in:
2026-07-30 16:45:10 +08:00
parent cad8527ab6
commit 9bff6a556a
27 changed files with 1431 additions and 395 deletions
+75
View File
@@ -0,0 +1,75 @@
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():
client = _build_client(_user(role="admin"), SimpleNamespace())
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"]
+1 -1
View File
@@ -56,7 +56,7 @@ async def test_meta_project_returns_map_extent(monkeypatch):
response = await module.get_project_metadata(
ctx=SimpleNamespace(
project_id=project_id,
project_role="editor",
project_role="member",
),
metadata_repo=repo,
)
+68
View File
@@ -0,0 +1,68 @@
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()
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(
return_value=SimpleNamespace(id=project_id, code="demo", status="active")
),
)
monkeypatch.setattr(
model_import, "_run_uploaded_inp", AsyncMock(return_value="imported")
)
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.inp", VALID_INP)},
)
assert response.status_code == 200
assert response.json()["result"] == "imported"
repo.get_project_by_id.assert_awaited_once_with(project_id)
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
+19 -14
View File
@@ -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):
-21
View File
@@ -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 = []