feat(server): add project RBAC and guarded workflows
This commit is contained in:
@@ -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"]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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