feat: add tenant-scoped rule and permission management
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Release acceptance tests."""
|
||||
@@ -0,0 +1,479 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from .helpers import ReleaseApiClient, ReleaseTestConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantSeed:
|
||||
tenant_code: str
|
||||
tenant_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SeededUser:
|
||||
user_id: int
|
||||
sub: str
|
||||
username: str
|
||||
nickname: str
|
||||
role_key: str
|
||||
tenant_code: str
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocumentTypeSeed:
|
||||
type_id: int
|
||||
type_code: str
|
||||
type_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseDocumentSeed:
|
||||
document_id: int
|
||||
file_id: int | None
|
||||
type_id: int
|
||||
type_code: str
|
||||
tenant_code: str
|
||||
tenant_name: str
|
||||
file_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseDatasetSeed:
|
||||
dataset_id: int
|
||||
dataset_name: str
|
||||
tenant_code: str
|
||||
tenant_name: str
|
||||
app_id: int | None
|
||||
app_name: str | None
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
return str(os.getenv(name, default)).strip()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def release_config() -> ReleaseTestConfig:
|
||||
return ReleaseTestConfig(
|
||||
base_url=_env("LEAUDIT_TEST_BASE_URL", "http://127.0.0.1:8096").rstrip("/"),
|
||||
admin_username=_env("LEAUDIT_TEST_ADMIN_USERNAME", "000"),
|
||||
admin_password=_env("LEAUDIT_TEST_ADMIN_PASSWORD", "admin06111"),
|
||||
timeout_seconds=float(_env("LEAUDIT_TEST_TIMEOUT_SECONDS", "30")),
|
||||
tenant_a_code=_env("LEAUDIT_TEST_TENANT_A_CODE", "PTA01"),
|
||||
tenant_a_name=_env("LEAUDIT_TEST_TENANT_A_NAME", "Pytest租户A"),
|
||||
tenant_b_code=_env("LEAUDIT_TEST_TENANT_B_CODE", "PTB01"),
|
||||
tenant_b_name=_env("LEAUDIT_TEST_TENANT_B_NAME", "Pytest租户B"),
|
||||
module_name=_env("LEAUDIT_TEST_MODULE_NAME", f"Pytest发布验收入口模块-{time.time_ns()}"),
|
||||
module_path=_env("LEAUDIT_TEST_MODULE_PATH", "/documents"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def anonymous_client(release_config: ReleaseTestConfig) -> ReleaseApiClient:
|
||||
client = ReleaseApiClient(release_config)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def admin_auth(release_config: ReleaseTestConfig, anonymous_client: ReleaseApiClient) -> dict[str, Any]:
|
||||
return anonymous_client.login_password(release_config.admin_username, release_config.admin_password)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def admin_client(release_config: ReleaseTestConfig, admin_auth: dict[str, Any]) -> ReleaseApiClient:
|
||||
client = ReleaseApiClient(release_config, token=str(admin_auth["access_token"]))
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def role_map(admin_client: ReleaseApiClient) -> dict[str, int]:
|
||||
response = admin_client.get("/api/v3/rbac/roles?page=1&page_size=200")
|
||||
items = ReleaseApiClient.json_data(response)["items"]
|
||||
mapping = {str(item["role_key"]): int(item["id"]) for item in items}
|
||||
for role_key in ("super_admin", "provincial_admin", "admin", "common"):
|
||||
assert role_key in mapping, f"缺少角色种子: {role_key}"
|
||||
return mapping
|
||||
|
||||
|
||||
def _ensure_tenant(admin_client: ReleaseApiClient, tenant_code: str, tenant_name: str) -> TenantSeed:
|
||||
detail_response = admin_client.get(f"/api/v3/tenants/{tenant_code}", expected_status=None)
|
||||
body = detail_response.json()
|
||||
desired_payload = {
|
||||
"tenant_name": tenant_name,
|
||||
"tenant_short_name": tenant_name,
|
||||
"tenant_type": "CUSTOM",
|
||||
"parent_tenant_code": None,
|
||||
"display_order": 500,
|
||||
"is_public": False,
|
||||
"can_host_entry_module": True,
|
||||
"can_host_documents": True,
|
||||
"can_host_rag": True,
|
||||
"can_host_templates": True,
|
||||
"feature_keys": ["home.entry_module", "documents.upload", "rag.dataset"],
|
||||
"alias_values": [tenant_name],
|
||||
"ext": {"created_by": "pytest-release"},
|
||||
}
|
||||
if detail_response.status_code == 404:
|
||||
create_payload = {
|
||||
"tenant_code": tenant_code,
|
||||
"is_enabled": True,
|
||||
**desired_payload,
|
||||
}
|
||||
admin_client.post("/api/v3/tenants", json=create_payload, expected_status=200)
|
||||
else:
|
||||
assert detail_response.status_code == 200, body
|
||||
admin_client.put(f"/api/v3/tenants/{tenant_code}", json=desired_payload, expected_status=200)
|
||||
admin_client.patch(f"/api/v3/tenants/{tenant_code}/status", json={"is_enabled": True}, expected_status=200)
|
||||
return TenantSeed(tenant_code=tenant_code, tenant_name=tenant_name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tenant_a(admin_client: ReleaseApiClient, release_config: ReleaseTestConfig) -> TenantSeed:
|
||||
return _ensure_tenant(admin_client, release_config.tenant_a_code, release_config.tenant_a_name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tenant_b(admin_client: ReleaseApiClient, release_config: ReleaseTestConfig) -> TenantSeed:
|
||||
return _ensure_tenant(admin_client, release_config.tenant_b_code, release_config.tenant_b_name)
|
||||
|
||||
|
||||
def _ensure_user(
|
||||
*,
|
||||
anonymous_client: ReleaseApiClient,
|
||||
admin_client: ReleaseApiClient,
|
||||
role_map: dict[str, int],
|
||||
sub: str,
|
||||
username: str,
|
||||
nickname: str,
|
||||
role_key: str,
|
||||
tenant: TenantSeed,
|
||||
) -> SeededUser:
|
||||
login_data = anonymous_client.login_oauth(
|
||||
sub=sub,
|
||||
username=username,
|
||||
nickname=nickname,
|
||||
ou_id=f"pytest-{tenant.tenant_code.lower()}",
|
||||
ou_name=f"{tenant.tenant_name}测试组织",
|
||||
area=tenant.tenant_name,
|
||||
)
|
||||
user_info = login_data["user_info"]
|
||||
user_id = int(user_info["user_id"])
|
||||
admin_client.post(
|
||||
f"/api/v3/rbac/users/{user_id}/roles",
|
||||
json={"role_ids": [role_map[role_key]]},
|
||||
expected_status=200,
|
||||
)
|
||||
admin_client.put(
|
||||
f"/api/v3/rbac/users/{user_id}/tenant",
|
||||
json={"tenant_code": tenant.tenant_code},
|
||||
expected_status=200,
|
||||
)
|
||||
refreshed = anonymous_client.login_oauth(
|
||||
sub=sub,
|
||||
username=username,
|
||||
nickname=nickname,
|
||||
ou_id=f"pytest-{tenant.tenant_code.lower()}",
|
||||
ou_name=f"{tenant.tenant_name}测试组织",
|
||||
area=tenant.tenant_name,
|
||||
)
|
||||
refreshed_user = refreshed["user_info"]
|
||||
assert str(refreshed_user.get("tenant_code") or "") == tenant.tenant_code
|
||||
return SeededUser(
|
||||
user_id=user_id,
|
||||
sub=sub,
|
||||
username=username,
|
||||
nickname=nickname,
|
||||
role_key=role_key,
|
||||
tenant_code=tenant.tenant_code,
|
||||
token=str(refreshed["access_token"]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tenant_admin_user(
|
||||
anonymous_client: ReleaseApiClient,
|
||||
admin_client: ReleaseApiClient,
|
||||
role_map: dict[str, int],
|
||||
tenant_a: TenantSeed,
|
||||
) -> SeededUser:
|
||||
return _ensure_user(
|
||||
anonymous_client=anonymous_client,
|
||||
admin_client=admin_client,
|
||||
role_map=role_map,
|
||||
sub="pytest-admin-pta01",
|
||||
username="pytest_admin_pta01",
|
||||
nickname="Pytest租户A管理员",
|
||||
role_key="admin",
|
||||
tenant=tenant_a,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tenant_admin_user_b(
|
||||
anonymous_client: ReleaseApiClient,
|
||||
admin_client: ReleaseApiClient,
|
||||
role_map: dict[str, int],
|
||||
tenant_b: TenantSeed,
|
||||
) -> SeededUser:
|
||||
return _ensure_user(
|
||||
anonymous_client=anonymous_client,
|
||||
admin_client=admin_client,
|
||||
role_map=role_map,
|
||||
sub="pytest-admin-ptb01",
|
||||
username="pytest_admin_ptb01",
|
||||
nickname="Pytest租户B管理员",
|
||||
role_key="admin",
|
||||
tenant=tenant_b,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tenant_common_user_a(
|
||||
anonymous_client: ReleaseApiClient,
|
||||
admin_client: ReleaseApiClient,
|
||||
role_map: dict[str, int],
|
||||
tenant_a: TenantSeed,
|
||||
) -> SeededUser:
|
||||
return _ensure_user(
|
||||
anonymous_client=anonymous_client,
|
||||
admin_client=admin_client,
|
||||
role_map=role_map,
|
||||
sub="pytest-common-pta01",
|
||||
username="pytest_common_pta01",
|
||||
nickname="Pytest租户A普通用户",
|
||||
role_key="common",
|
||||
tenant=tenant_a,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tenant_common_user_b(
|
||||
anonymous_client: ReleaseApiClient,
|
||||
admin_client: ReleaseApiClient,
|
||||
role_map: dict[str, int],
|
||||
tenant_b: TenantSeed,
|
||||
) -> SeededUser:
|
||||
return _ensure_user(
|
||||
anonymous_client=anonymous_client,
|
||||
admin_client=admin_client,
|
||||
role_map=role_map,
|
||||
sub="pytest-common-ptb01",
|
||||
username="pytest_common_ptb01",
|
||||
nickname="Pytest租户B普通用户",
|
||||
role_key="common",
|
||||
tenant=tenant_b,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_admin_api(release_config: ReleaseTestConfig, tenant_admin_user: SeededUser) -> ReleaseApiClient:
|
||||
client = ReleaseApiClient(release_config, token=tenant_admin_user.token)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_admin_api_b(release_config: ReleaseTestConfig, tenant_admin_user_b: SeededUser) -> ReleaseApiClient:
|
||||
client = ReleaseApiClient(release_config, token=tenant_admin_user_b.token)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def common_api_a(release_config: ReleaseTestConfig, tenant_common_user_a: SeededUser) -> ReleaseApiClient:
|
||||
client = ReleaseApiClient(release_config, token=tenant_common_user_a.token)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def common_api_b(release_config: ReleaseTestConfig, tenant_common_user_b: SeededUser) -> ReleaseApiClient:
|
||||
client = ReleaseApiClient(release_config, token=tenant_common_user_b.token)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
def _find_module_by_name(admin_client: ReleaseApiClient, module_name: str) -> dict[str, Any] | None:
|
||||
response = admin_client.get(f"/api/v3/entry-modules?page=1&page_size=200&name={module_name}".replace("page_size", "page_size"), expected_status=None)
|
||||
items = ReleaseApiClient.json_data(response)["items"]
|
||||
for item in items:
|
||||
if str(item.get("name") or "") == module_name:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release_entry_module(
|
||||
admin_client: ReleaseApiClient,
|
||||
release_config: ReleaseTestConfig,
|
||||
tenant_b: TenantSeed,
|
||||
) -> dict[str, Any]:
|
||||
module_name = release_config.module_name
|
||||
existing = _find_module_by_name(admin_client, module_name)
|
||||
payload = {
|
||||
"name": module_name,
|
||||
"description": "pytest release acceptance only",
|
||||
"path": release_config.module_path,
|
||||
"route_path": release_config.module_path,
|
||||
"tenants": [
|
||||
{
|
||||
"tenant_code": tenant_b.tenant_code,
|
||||
"tenant_name": tenant_b.tenant_name,
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
created = False
|
||||
if existing:
|
||||
module_id = int(existing["id"])
|
||||
response = admin_client.put(f"/api/v3/entry-modules/{module_id}", json=payload, expected_status=200)
|
||||
else:
|
||||
response = admin_client.post("/api/v3/entry-modules", json=payload, expected_status=None)
|
||||
if response.status_code == 200:
|
||||
created = True
|
||||
else:
|
||||
retried = False
|
||||
for attempt in range(1, 4):
|
||||
module_name = f"{release_config.module_name}-{attempt}"
|
||||
payload["name"] = module_name
|
||||
existing = _find_module_by_name(admin_client, module_name)
|
||||
if existing:
|
||||
module_id = int(existing["id"])
|
||||
response = admin_client.put(f"/api/v3/entry-modules/{module_id}", json=payload, expected_status=200)
|
||||
retried = True
|
||||
break
|
||||
response = admin_client.post("/api/v3/entry-modules", json=payload, expected_status=None)
|
||||
if response.status_code == 200:
|
||||
created = True
|
||||
retried = True
|
||||
break
|
||||
assert retried, response.text
|
||||
module = ReleaseApiClient.json_data(response)
|
||||
try:
|
||||
yield module
|
||||
finally:
|
||||
if created:
|
||||
admin_client.delete(f"/api/v3/entry-modules/{int(module['id'])}", expected_status=200)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def release_document_type(admin_client: ReleaseApiClient) -> DocumentTypeSeed:
|
||||
response = admin_client.get("/api/document-types")
|
||||
items = ReleaseApiClient.json_data(response)
|
||||
assert isinstance(items, list), items
|
||||
for item in items:
|
||||
type_id = int(item.get("id") or 0)
|
||||
type_code = str(item.get("code") or "").strip()
|
||||
type_name = str(item.get("name") or "").strip()
|
||||
if type_id > 0 and type_code:
|
||||
return DocumentTypeSeed(type_id=type_id, type_code=type_code, type_name=type_name or type_code)
|
||||
pytest.skip("当前环境没有可用于发布验收的文档类型")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_release_document(
|
||||
admin_client: ReleaseApiClient,
|
||||
release_document_type: DocumentTypeSeed,
|
||||
) -> Callable[..., ReleaseDocumentSeed]:
|
||||
created_document_ids: list[int] = []
|
||||
|
||||
def _make_document(
|
||||
*,
|
||||
client: ReleaseApiClient,
|
||||
tenant: TenantSeed,
|
||||
file_name: str,
|
||||
content: bytes | None = None,
|
||||
content_type: str = "text/plain",
|
||||
) -> ReleaseDocumentSeed:
|
||||
payload_bytes = content or f"pytest release document for {tenant.tenant_code} / {file_name}\n".encode("utf-8")
|
||||
response = client.post(
|
||||
"/api/upload",
|
||||
data={
|
||||
"typeId": str(release_document_type.type_id),
|
||||
"typeCode": release_document_type.type_code,
|
||||
"tenant_code": tenant.tenant_code,
|
||||
"region": tenant.tenant_name,
|
||||
"fileRole": "primary",
|
||||
"autoRun": "false",
|
||||
"speed": "normal",
|
||||
},
|
||||
files={
|
||||
"file": (file_name, payload_bytes, content_type),
|
||||
},
|
||||
expected_status=200,
|
||||
)
|
||||
data = ReleaseApiClient.json_data(response)
|
||||
document_id = int(data["documentId"])
|
||||
created_document_ids.append(document_id)
|
||||
return ReleaseDocumentSeed(
|
||||
document_id=document_id,
|
||||
file_id=int(data["fileId"]) if data.get("fileId") is not None else None,
|
||||
type_id=release_document_type.type_id,
|
||||
type_code=release_document_type.type_code,
|
||||
tenant_code=tenant.tenant_code,
|
||||
tenant_name=tenant.tenant_name,
|
||||
file_name=file_name,
|
||||
)
|
||||
|
||||
try:
|
||||
yield _make_document
|
||||
finally:
|
||||
for document_id in reversed(created_document_ids):
|
||||
admin_client.delete(f"/api/documents/{document_id}", expected_status=None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_release_dataset(
|
||||
admin_client: ReleaseApiClient,
|
||||
) -> Callable[..., ReleaseDatasetSeed]:
|
||||
created_dataset_ids: list[int] = []
|
||||
|
||||
def _make_dataset(
|
||||
*,
|
||||
tenant: TenantSeed,
|
||||
dataset_name: str,
|
||||
is_public: bool = False,
|
||||
is_default: bool = False,
|
||||
) -> ReleaseDatasetSeed:
|
||||
response = admin_client.post(
|
||||
"/api/v3/rag/datasets/admin",
|
||||
json={
|
||||
"tenant_code": tenant.tenant_code,
|
||||
"tenant_name": tenant.tenant_name,
|
||||
"area": tenant.tenant_name,
|
||||
"name": dataset_name,
|
||||
"description": f"{dataset_name} - pytest release",
|
||||
"is_public": is_public,
|
||||
"is_default": is_default,
|
||||
"status": 1,
|
||||
"sort_order": 0,
|
||||
},
|
||||
expected_status=200,
|
||||
)
|
||||
data = ReleaseApiClient.json_data(response)
|
||||
dataset_id = int(data["id"])
|
||||
created_dataset_ids.append(dataset_id)
|
||||
return ReleaseDatasetSeed(
|
||||
dataset_id=dataset_id,
|
||||
dataset_name=str(data.get("name") or dataset_name),
|
||||
tenant_code=str(data.get("tenantCode") or tenant.tenant_code),
|
||||
tenant_name=str(data.get("tenantName") or tenant.tenant_name),
|
||||
app_id=int(data["appId"]) if data.get("appId") is not None else None,
|
||||
app_name=str(data.get("appName") or "") or None,
|
||||
)
|
||||
|
||||
try:
|
||||
yield _make_dataset
|
||||
finally:
|
||||
for dataset_id in reversed(created_dataset_ids):
|
||||
admin_client.delete(f"/api/v3/rag/datasets/admin/{dataset_id}", expected_status=None)
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseTestConfig:
|
||||
base_url: str
|
||||
admin_username: str
|
||||
admin_password: str
|
||||
timeout_seconds: float
|
||||
tenant_a_code: str
|
||||
tenant_a_name: str
|
||||
tenant_b_code: str
|
||||
tenant_b_name: str
|
||||
module_name: str
|
||||
module_path: str
|
||||
|
||||
|
||||
class ReleaseApiClient:
|
||||
def __init__(self, config: ReleaseTestConfig, token: str | None = None) -> None:
|
||||
self.config = config
|
||||
self.token = token
|
||||
self._client = httpx.Client(base_url=config.base_url, timeout=config.timeout_seconds)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def with_token(self, token: str) -> "ReleaseApiClient":
|
||||
return ReleaseApiClient(self.config, token=token)
|
||||
|
||||
def request(self, method: str, path: str, *, expected_status: int | None = 200, **kwargs: Any) -> httpx.Response:
|
||||
headers = dict(kwargs.pop("headers", {}) or {})
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
response = self._client.request(method, path, headers=headers, **kwargs)
|
||||
if expected_status is not None:
|
||||
assert response.status_code == expected_status, self._format_error(response)
|
||||
return response
|
||||
|
||||
def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
||||
return self.request("GET", path, **kwargs)
|
||||
|
||||
def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
||||
return self.request("POST", path, **kwargs)
|
||||
|
||||
def put(self, path: str, **kwargs: Any) -> httpx.Response:
|
||||
return self.request("PUT", path, **kwargs)
|
||||
|
||||
def patch(self, path: str, **kwargs: Any) -> httpx.Response:
|
||||
return self.request("PATCH", path, **kwargs)
|
||||
|
||||
def delete(self, path: str, **kwargs: Any) -> httpx.Response:
|
||||
return self.request("DELETE", path, **kwargs)
|
||||
|
||||
def login_password(self, username: str, password: str) -> dict[str, Any]:
|
||||
response = self.post(
|
||||
"/api/auth/login",
|
||||
json={"username": username, "password": password},
|
||||
expected_status=200,
|
||||
)
|
||||
payload = response.json()
|
||||
assert payload.get("success") is True, payload
|
||||
data = payload.get("data") or {}
|
||||
assert data.get("access_token"), payload
|
||||
return data
|
||||
|
||||
def login_oauth(
|
||||
self,
|
||||
*,
|
||||
sub: str,
|
||||
username: str,
|
||||
nickname: str,
|
||||
ou_id: str,
|
||||
ou_name: str,
|
||||
area: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
response = self.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"userInfo": {
|
||||
"sub": sub,
|
||||
"username": username,
|
||||
"nickname": nickname,
|
||||
"email": f"{username}@pytest.local",
|
||||
"phone_number": "13800000000",
|
||||
"ou_id": ou_id,
|
||||
"ou_name": ou_name,
|
||||
"is_leader": False,
|
||||
},
|
||||
"area": area,
|
||||
"expiresIn": 3600,
|
||||
},
|
||||
expected_status=200,
|
||||
)
|
||||
payload = response.json()
|
||||
assert payload.get("success") is True, payload
|
||||
data = payload.get("data") or {}
|
||||
assert data.get("access_token"), payload
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def json_data(response: httpx.Response) -> Any:
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict) and "data" in payload:
|
||||
return payload["data"]
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _format_error(response: httpx.Response) -> str:
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
body = response.text
|
||||
return f"{response.request.method} {response.request.url} -> {response.status_code}: {body}"
|
||||
|
||||
|
||||
def flatten_route_paths(routes: list[dict[str, Any]]) -> set[str]:
|
||||
paths: set[str] = set()
|
||||
|
||||
def collect(items: list[dict[str, Any]]) -> None:
|
||||
for item in items:
|
||||
route_path = str(item.get("route_path") or "")
|
||||
if route_path:
|
||||
paths.add(route_path)
|
||||
children = item.get("children") or []
|
||||
if isinstance(children, list):
|
||||
collect(children)
|
||||
|
||||
collect(routes)
|
||||
return paths
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .helpers import ReleaseApiClient, flatten_route_paths
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g1_admin_auth_and_rbac_context(admin_client: ReleaseApiClient) -> None:
|
||||
me_response = admin_client.get("/api/auth/me")
|
||||
me = ReleaseApiClient.json_data(me_response)
|
||||
assert int(me["user_id"]) > 0
|
||||
assert isinstance(me.get("roles"), list) and me["roles"]
|
||||
assert isinstance(me.get("permissions"), list)
|
||||
|
||||
routes_response = admin_client.get("/api/rbac/user/routes")
|
||||
routes_data = ReleaseApiClient.json_data(routes_response)
|
||||
route_paths = flatten_route_paths(routes_data["routes"])
|
||||
assert "/home" in route_paths
|
||||
assert "/settings" in route_paths
|
||||
assert "/role-permissions" in route_paths
|
||||
|
||||
users_response = admin_client.get("/api/v3/rbac/users?page=1&page_size=20")
|
||||
users_data = ReleaseApiClient.json_data(users_response)
|
||||
assert users_data["total"] >= 1
|
||||
assert isinstance(users_data["items"], list)
|
||||
|
||||
org_response = admin_client.get("/api/admin/users/organizations/tree?include_users=false")
|
||||
org_data = ReleaseApiClient.json_data(org_response)
|
||||
assert "organizations" in org_data
|
||||
assert org_data["total_organizations"] >= 1
|
||||
|
||||
tenant_option_response = admin_client.get("/api/v3/tenants/options?feature_key=home.entry_module")
|
||||
tenant_option_data = ReleaseApiClient.json_data(tenant_option_response)
|
||||
assert tenant_option_data["total"] >= 1
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import SeededUser, TenantSeed
|
||||
from .helpers import ReleaseApiClient
|
||||
|
||||
|
||||
def _set_module_tenants(admin_client: ReleaseApiClient, module_id: int, module_name: str, tenants: list[TenantSeed], path: str) -> dict:
|
||||
payload = {
|
||||
"name": module_name,
|
||||
"description": "pytest release acceptance only",
|
||||
"path": path,
|
||||
"route_path": path,
|
||||
"tenants": [
|
||||
{
|
||||
"tenant_code": tenant.tenant_code,
|
||||
"tenant_name": tenant.tenant_name,
|
||||
"enabled": True,
|
||||
"sort_order": index,
|
||||
}
|
||||
for index, tenant in enumerate(tenants, start=1)
|
||||
],
|
||||
}
|
||||
response = admin_client.put(f"/api/v3/entry-modules/{module_id}", json=payload, expected_status=200)
|
||||
return ReleaseApiClient.json_data(response)
|
||||
|
||||
|
||||
def _home_module_names(client: ReleaseApiClient) -> list[str]:
|
||||
response = client.get("/api/home/entry-modules")
|
||||
modules = ReleaseApiClient.json_data(response)
|
||||
return [str(item.get("name") or "") for item in modules]
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g2_tenant_detail_and_user_tenant_switch(
|
||||
admin_client: ReleaseApiClient,
|
||||
anonymous_client: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_common_user_a: SeededUser,
|
||||
) -> None:
|
||||
detail_response = admin_client.get(f"/api/v3/tenants/{tenant_a.tenant_code}")
|
||||
detail = ReleaseApiClient.json_data(detail_response)
|
||||
assert detail["tenant_code"] == tenant_a.tenant_code
|
||||
assert detail["tenant_name"] == tenant_a.tenant_name
|
||||
assert "home.entry_module" in detail["feature_keys"]
|
||||
|
||||
switch_response = admin_client.put(
|
||||
f"/api/v3/rbac/users/{tenant_common_user_a.user_id}/tenant",
|
||||
json={"tenant_code": tenant_a.tenant_code},
|
||||
expected_status=200,
|
||||
)
|
||||
switch_data = ReleaseApiClient.json_data(switch_response)
|
||||
assert switch_data["tenant_code"] == tenant_a.tenant_code
|
||||
|
||||
relogin = anonymous_client.login_oauth(
|
||||
sub=tenant_common_user_a.sub,
|
||||
username=tenant_common_user_a.username,
|
||||
nickname=tenant_common_user_a.nickname,
|
||||
ou_id=f"pytest-{tenant_a.tenant_code.lower()}",
|
||||
ou_name=f"{tenant_a.tenant_name}测试组织",
|
||||
area=tenant_a.tenant_name,
|
||||
)
|
||||
assert relogin["user_info"]["tenant_code"] == tenant_a.tenant_code
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g3_entry_module_visibility_follows_tenant_assignment(
|
||||
admin_client: ReleaseApiClient,
|
||||
release_entry_module: dict,
|
||||
release_config,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_b: TenantSeed,
|
||||
common_api_a: ReleaseApiClient,
|
||||
common_api_b: ReleaseApiClient,
|
||||
) -> None:
|
||||
module_id = int(release_entry_module["id"])
|
||||
module_name = str(release_entry_module["name"])
|
||||
|
||||
_set_module_tenants(admin_client, module_id, module_name, [tenant_b], release_config.module_path)
|
||||
names_a = _home_module_names(common_api_a)
|
||||
names_b = _home_module_names(common_api_b)
|
||||
assert module_name not in names_a
|
||||
assert module_name in names_b
|
||||
|
||||
_set_module_tenants(admin_client, module_id, module_name, [tenant_a, tenant_b], release_config.module_path)
|
||||
names_a = _home_module_names(common_api_a)
|
||||
names_b = _home_module_names(common_api_b)
|
||||
assert module_name in names_a
|
||||
assert module_name in names_b
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import fitz
|
||||
import pytest
|
||||
|
||||
from .conftest import TenantSeed
|
||||
from .helpers import ReleaseApiClient
|
||||
|
||||
|
||||
def _document_ids(items: list[dict]) -> set[int]:
|
||||
return {int(item["documentId"]) for item in items if item.get("documentId") is not None}
|
||||
|
||||
|
||||
def _sample_pdf_bytes(text: str) -> bytes:
|
||||
document = fitz.open()
|
||||
page = document.new_page()
|
||||
page.insert_text((72, 72), text)
|
||||
return document.tobytes()
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g4_document_list_and_detail_respect_tenant_boundary(
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
tenant_admin_api_b: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_b: TenantSeed,
|
||||
make_release_document,
|
||||
) -> None:
|
||||
own_doc = make_release_document(
|
||||
client=tenant_admin_api,
|
||||
tenant=tenant_a,
|
||||
file_name=f"pytest-g4-a-{int(time.time())}.txt",
|
||||
)
|
||||
other_doc = make_release_document(
|
||||
client=tenant_admin_api_b,
|
||||
tenant=tenant_b,
|
||||
file_name=f"pytest-g4-b-{int(time.time())}.txt",
|
||||
)
|
||||
|
||||
own_list = ReleaseApiClient.json_data(tenant_admin_api.get("/api/documents/list?page=1&pageSize=100"))
|
||||
own_ids = _document_ids(own_list["documents"])
|
||||
assert own_doc.document_id in own_ids
|
||||
assert other_doc.document_id not in own_ids
|
||||
|
||||
own_detail = ReleaseApiClient.json_data(tenant_admin_api.get(f"/api/documents/{own_doc.document_id}"))
|
||||
assert int(own_detail["documentId"]) == own_doc.document_id
|
||||
assert str(own_detail.get("tenantCode") or "") == tenant_a.tenant_code
|
||||
|
||||
cross_detail = tenant_admin_api.get(f"/api/documents/{other_doc.document_id}", expected_status=404)
|
||||
assert "无权访问" in cross_detail.text or "不存在" in cross_detail.text
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g4_document_update_append_and_delete_reject_cross_tenant_access(
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
tenant_admin_api_b: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
make_release_document,
|
||||
) -> None:
|
||||
own_doc = make_release_document(
|
||||
client=tenant_admin_api,
|
||||
tenant=tenant_a,
|
||||
file_name=f"pytest-g4-own-{int(time.time())}.pdf",
|
||||
content=_sample_pdf_bytes("pytest g4 own document"),
|
||||
content_type="application/pdf",
|
||||
)
|
||||
|
||||
update_response = tenant_admin_api.put(
|
||||
f"/api/documents/{own_doc.document_id}",
|
||||
json={"remark": "pytest g4 own update"},
|
||||
expected_status=200,
|
||||
)
|
||||
update_data = ReleaseApiClient.json_data(update_response)
|
||||
assert int(update_data["documentId"]) == own_doc.document_id
|
||||
|
||||
append_response = tenant_admin_api.post(
|
||||
f"/api/documents/{own_doc.document_id}/attachments",
|
||||
data={"mergeMode": "new", "remark": "pytest attachment"},
|
||||
files=[("files", ("attachment.pdf", _sample_pdf_bytes("pytest attachment"), "application/pdf"))],
|
||||
expected_status=200,
|
||||
)
|
||||
append_data = ReleaseApiClient.json_data(append_response)
|
||||
new_document_id = int(append_data["documentId"])
|
||||
assert new_document_id != own_doc.document_id
|
||||
assert str(append_data.get("tenantCode") or "") == tenant_a.tenant_code
|
||||
assert int(append_data.get("previousVersionId") or 0) == own_doc.document_id
|
||||
|
||||
cross_update = tenant_admin_api_b.put(
|
||||
f"/api/documents/{new_document_id}",
|
||||
json={"remark": "should fail"},
|
||||
expected_status=404,
|
||||
)
|
||||
assert "无权访问" in cross_update.text or "不存在" in cross_update.text
|
||||
|
||||
cross_append = tenant_admin_api_b.post(
|
||||
f"/api/documents/{new_document_id}/attachments",
|
||||
data={"mergeMode": "new", "remark": "should fail"},
|
||||
files=[("files", ("cross.pdf", _sample_pdf_bytes("cross tenant"), "application/pdf"))],
|
||||
expected_status=404,
|
||||
)
|
||||
assert "无权访问" in cross_append.text or "不存在" in cross_append.text
|
||||
|
||||
cross_delete = tenant_admin_api_b.delete(f"/api/documents/{new_document_id}", expected_status=404)
|
||||
assert "无权访问" in cross_delete.text or "不存在" in cross_delete.text
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g4_common_user_only_sees_self_created_documents(
|
||||
common_api_a: ReleaseApiClient,
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
make_release_document,
|
||||
) -> None:
|
||||
user_doc = make_release_document(
|
||||
client=common_api_a,
|
||||
tenant=tenant_a,
|
||||
file_name=f"pytest-g4-common-self-{int(time.time())}.txt",
|
||||
)
|
||||
admin_doc = make_release_document(
|
||||
client=tenant_admin_api,
|
||||
tenant=tenant_a,
|
||||
file_name=f"pytest-g4-common-other-{int(time.time())}.txt",
|
||||
)
|
||||
|
||||
own_list = ReleaseApiClient.json_data(common_api_a.get("/api/documents/list?page=1&pageSize=100"))
|
||||
own_ids = _document_ids(own_list["documents"])
|
||||
assert user_doc.document_id in own_ids
|
||||
assert admin_doc.document_id not in own_ids
|
||||
|
||||
own_detail = ReleaseApiClient.json_data(common_api_a.get(f"/api/documents/{user_doc.document_id}"))
|
||||
assert int(own_detail["documentId"]) == user_doc.document_id
|
||||
|
||||
hidden_detail = common_api_a.get(f"/api/documents/{admin_doc.document_id}", expected_status=404)
|
||||
assert "无权访问" in hidden_detail.text or "不存在" in hidden_detail.text
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g4_govdoc_list_endpoint_does_not_fail_when_backfilling_version_groups(
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
) -> None:
|
||||
response = tenant_admin_api.get("/api/govdoc/documents?page=1&pageSize=10", expected_status=200)
|
||||
payload = response.json()
|
||||
assert payload.get("code") in {0, 200}, payload
|
||||
assert "data" in payload, payload
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ReleaseDatasetSeed, TenantSeed
|
||||
from .helpers import ReleaseApiClient
|
||||
|
||||
|
||||
def _dataset_ids(items: list[dict]) -> set[int]:
|
||||
return {int(item["id"]) for item in items if item.get("id") is not None}
|
||||
|
||||
|
||||
def _app_ids(items: list[dict]) -> set[str]:
|
||||
return {str(item["appId"]) for item in items if item.get("appId") is not None}
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g5_rag_dataset_admin_scope_is_limited_to_own_tenant(
|
||||
admin_client: ReleaseApiClient,
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
tenant_admin_api_b: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_b: TenantSeed,
|
||||
make_release_dataset,
|
||||
) -> None:
|
||||
dataset_a = make_release_dataset(
|
||||
tenant=tenant_a,
|
||||
dataset_name=f"Pytest G5 A {int(time.time())}",
|
||||
)
|
||||
dataset_b = make_release_dataset(
|
||||
tenant=tenant_b,
|
||||
dataset_name=f"Pytest G5 B {int(time.time())}",
|
||||
)
|
||||
|
||||
global_admin_list = ReleaseApiClient.json_data(admin_client.get("/api/v3/rag/datasets/admin?page=1&pageSize=100"))
|
||||
ids_a = _dataset_ids(global_admin_list["data"])
|
||||
assert dataset_a.dataset_id in ids_a
|
||||
assert dataset_b.dataset_id in ids_a
|
||||
|
||||
tenant_admin_list = ReleaseApiClient.json_data(
|
||||
tenant_admin_api.get("/api/v3/rag/datasets/admin?page=1&pageSize=100")
|
||||
)
|
||||
tenant_admin_ids = _dataset_ids(tenant_admin_list["data"])
|
||||
assert dataset_a.dataset_id in tenant_admin_ids
|
||||
assert dataset_b.dataset_id not in tenant_admin_ids
|
||||
|
||||
forbidden_query = tenant_admin_api.get(
|
||||
f"/api/v3/rag/datasets/admin?page=1&pageSize=100&tenant_code={tenant_b.tenant_code}",
|
||||
expected_status=403,
|
||||
)
|
||||
assert "本地区知识库配置" in forbidden_query.text or "本租户知识库" in forbidden_query.text
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g5_rag_dataset_detail_and_update_respect_tenant_boundary(
|
||||
admin_client: ReleaseApiClient,
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_b: TenantSeed,
|
||||
make_release_dataset,
|
||||
) -> None:
|
||||
dataset_a = make_release_dataset(
|
||||
tenant=tenant_a,
|
||||
dataset_name=f"Pytest G5 Detail A {int(time.time())}",
|
||||
)
|
||||
dataset_b = make_release_dataset(
|
||||
tenant=tenant_b,
|
||||
dataset_name=f"Pytest G5 Detail B {int(time.time())}",
|
||||
)
|
||||
|
||||
own_detail = ReleaseApiClient.json_data(tenant_admin_api.get(f"/api/v3/rag/datasets/{dataset_a.dataset_id}"))
|
||||
assert int(own_detail["id"]) == dataset_a.dataset_id
|
||||
assert str(own_detail.get("tenantCode") or "") == tenant_a.tenant_code
|
||||
|
||||
hidden_detail = ReleaseApiClient.json_data(tenant_admin_api.get(f"/api/v3/rag/datasets/{dataset_b.dataset_id}"))
|
||||
assert hidden_detail is None
|
||||
|
||||
own_update = ReleaseApiClient.json_data(
|
||||
tenant_admin_api.patch(
|
||||
f"/api/v3/rag/datasets/{dataset_a.dataset_id}",
|
||||
json={"name": f"{dataset_a.dataset_name}-tenant-updated"},
|
||||
expected_status=200,
|
||||
)
|
||||
)
|
||||
assert str(own_update["name"]).endswith("-tenant-updated")
|
||||
|
||||
hidden_update = ReleaseApiClient.json_data(
|
||||
tenant_admin_api.patch(
|
||||
f"/api/v3/rag/datasets/{dataset_b.dataset_id}",
|
||||
json={"name": "should-not-work"},
|
||||
expected_status=200,
|
||||
)
|
||||
)
|
||||
assert hidden_update is None
|
||||
|
||||
admin_update = ReleaseApiClient.json_data(
|
||||
admin_client.patch(
|
||||
f"/api/v3/rag/datasets/{dataset_a.dataset_id}",
|
||||
json={"name": f"{dataset_a.dataset_name}-updated"},
|
||||
expected_status=200,
|
||||
)
|
||||
)
|
||||
assert str(admin_update["name"]).endswith("-updated")
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g5_rag_apps_and_public_dataset_visibility(
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
tenant_admin_api_b: ReleaseApiClient,
|
||||
common_api_a: ReleaseApiClient,
|
||||
common_api_b: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_b: TenantSeed,
|
||||
make_release_dataset,
|
||||
) -> None:
|
||||
dataset_a = make_release_dataset(
|
||||
tenant=tenant_a,
|
||||
dataset_name=f"Pytest G5 App A {int(time.time())}",
|
||||
is_default=True,
|
||||
)
|
||||
dataset_b = make_release_dataset(
|
||||
tenant=tenant_b,
|
||||
dataset_name=f"Pytest G5 App B {int(time.time())}",
|
||||
is_default=True,
|
||||
)
|
||||
public_dataset = make_release_dataset(
|
||||
tenant=tenant_a,
|
||||
dataset_name=f"Pytest G5 Public {int(time.time())}",
|
||||
is_public=True,
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
apps_a = ReleaseApiClient.json_data(common_api_a.get("/api/v3/rag/apps"))
|
||||
app_ids_a = _app_ids(apps_a["data"])
|
||||
assert dataset_a.app_id is not None
|
||||
assert str(dataset_a.app_id) in app_ids_a
|
||||
assert dataset_b.app_id is not None
|
||||
assert str(dataset_b.app_id) not in app_ids_a
|
||||
assert public_dataset.app_id is not None
|
||||
assert str(public_dataset.app_id) in app_ids_a
|
||||
|
||||
apps_b = ReleaseApiClient.json_data(common_api_b.get("/api/v3/rag/apps"))
|
||||
app_ids_b = _app_ids(apps_b["data"])
|
||||
assert str(dataset_b.app_id) in app_ids_b
|
||||
assert str(dataset_a.app_id) not in app_ids_b
|
||||
assert str(public_dataset.app_id) in app_ids_b
|
||||
|
||||
default_app_a = ReleaseApiClient.json_data(common_api_a.get("/api/v3/rag/apps/default"))
|
||||
assert default_app_a is not None
|
||||
assert str(default_app_a.get("tenantCode") or "") in {tenant_a.tenant_code, public_dataset.tenant_code}
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g5_common_user_cannot_access_rag_admin_endpoints(
|
||||
common_api_a: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
) -> None:
|
||||
forbidden_admin_list = common_api_a.get("/api/v3/rag/datasets/admin?page=1&pageSize=20", expected_status=403)
|
||||
assert "管理知识库权限" in forbidden_admin_list.text
|
||||
|
||||
forbidden_create = common_api_a.post(
|
||||
"/api/v3/rag/datasets/admin",
|
||||
json={
|
||||
"tenant_code": tenant_a.tenant_code,
|
||||
"tenant_name": tenant_a.tenant_name,
|
||||
"area": tenant_a.tenant_name,
|
||||
"name": f"should-forbid-{int(time.time())}",
|
||||
"description": "forbidden",
|
||||
"status": 1,
|
||||
},
|
||||
expected_status=403,
|
||||
)
|
||||
assert "创建知识库权限" in forbidden_create.text
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import SeededUser, TenantSeed
|
||||
from .helpers import ReleaseApiClient
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g5_rule_sets_are_globally_readable_but_bindings_and_groups_follow_tenant_scope(
|
||||
admin_client: ReleaseApiClient,
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
common_api_a: ReleaseApiClient,
|
||||
) -> None:
|
||||
admin_rule_sets = ReleaseApiClient.json_data(admin_client.get("/api/rule-sets"))
|
||||
tenant_rule_sets = ReleaseApiClient.json_data(tenant_admin_api.get("/api/rule-sets"))
|
||||
common_rule_sets = ReleaseApiClient.json_data(common_api_a.get("/api/rule-sets"))
|
||||
|
||||
assert len(admin_rule_sets) > 0
|
||||
assert len(tenant_rule_sets) == len(admin_rule_sets)
|
||||
assert len(common_rule_sets) == len(admin_rule_sets)
|
||||
|
||||
admin_bindings = ReleaseApiClient.json_data(admin_client.get("/api/rule-sets/bindings"))
|
||||
tenant_bindings = ReleaseApiClient.json_data(tenant_admin_api.get("/api/rule-sets/bindings"))
|
||||
common_bindings = ReleaseApiClient.json_data(common_api_a.get("/api/rule-sets/bindings"))
|
||||
assert isinstance(admin_bindings, list)
|
||||
assert len(admin_bindings) > 0
|
||||
assert tenant_bindings == []
|
||||
assert common_bindings == []
|
||||
|
||||
admin_groups = admin_client.get("/api/v3/evaluation-point-groups/all")
|
||||
tenant_groups = tenant_admin_api.get("/api/v3/evaluation-point-groups/all")
|
||||
common_groups = common_api_a.get("/api/v3/evaluation-point-groups/all")
|
||||
assert ReleaseApiClient.json_data(admin_groups) != []
|
||||
assert ReleaseApiClient.json_data(tenant_groups) == []
|
||||
assert ReleaseApiClient.json_data(common_groups) == []
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g5_group_detail_and_children_follow_same_scope_boundary(
|
||||
admin_client: ReleaseApiClient,
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
common_api_a: ReleaseApiClient,
|
||||
) -> None:
|
||||
admin_groups = ReleaseApiClient.json_data(admin_client.get("/api/v3/evaluation-point-groups/all"))
|
||||
assert admin_groups != []
|
||||
|
||||
target_group = admin_groups[0]
|
||||
target_group_id = int(target_group["id"])
|
||||
|
||||
admin_detail = ReleaseApiClient.json_data(
|
||||
admin_client.get(f"/api/v3/evaluation-point-groups/{target_group_id}")
|
||||
)
|
||||
assert int(admin_detail["id"]) == target_group_id
|
||||
|
||||
admin_children = admin_client.get(
|
||||
f"/api/v3/evaluation-point-groups/{target_group_id}/children?page=1&page_size=20"
|
||||
).json()
|
||||
assert isinstance(admin_children, dict)
|
||||
assert "data" in admin_children
|
||||
|
||||
tenant_detail = tenant_admin_api.get(
|
||||
f"/api/v3/evaluation-point-groups/{target_group_id}",
|
||||
expected_status=404,
|
||||
)
|
||||
assert "规则分组不存在" in tenant_detail.text
|
||||
|
||||
tenant_children = tenant_admin_api.get(
|
||||
f"/api/v3/evaluation-point-groups/{target_group_id}/children?page=1&page_size=20",
|
||||
expected_status=404,
|
||||
)
|
||||
assert "规则分组不存在" in tenant_children.text
|
||||
|
||||
common_detail = common_api_a.get(
|
||||
f"/api/v3/evaluation-point-groups/{target_group_id}",
|
||||
expected_status=404,
|
||||
)
|
||||
assert "规则分组不存在" in common_detail.text
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g5_cross_review_rejects_cross_tenant_documents_and_members(
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
common_api_b: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_b: TenantSeed,
|
||||
tenant_common_user_a: SeededUser,
|
||||
tenant_common_user_b: SeededUser,
|
||||
make_release_document,
|
||||
) -> None:
|
||||
doc_a = make_release_document(
|
||||
client=tenant_admin_api,
|
||||
tenant=tenant_a,
|
||||
file_name=f"pytest-cross-a-{int(time.time())}.txt",
|
||||
)
|
||||
doc_b = make_release_document(
|
||||
client=common_api_b,
|
||||
tenant=tenant_b,
|
||||
file_name=f"pytest-cross-b-{int(time.time())}.txt",
|
||||
)
|
||||
|
||||
same_tenant = tenant_admin_api.post(
|
||||
"/api/v3/cross-review/tasks",
|
||||
json={
|
||||
"taskName": f"Pytest Cross Same {int(time.time())}",
|
||||
"taskType": "CITY",
|
||||
"memberUserIds": [tenant_common_user_a.user_id],
|
||||
"principalUserIds": [],
|
||||
"documentIds": [doc_a.document_id],
|
||||
},
|
||||
expected_status=200,
|
||||
)
|
||||
same_tenant_data = ReleaseApiClient.json_data(same_tenant)
|
||||
assert int(same_tenant_data["documentCount"]) == 1
|
||||
|
||||
cross_doc = tenant_admin_api.post(
|
||||
"/api/v3/cross-review/tasks",
|
||||
json={
|
||||
"taskName": f"Pytest Cross Doc {int(time.time())}",
|
||||
"taskType": "CITY",
|
||||
"memberUserIds": [tenant_common_user_a.user_id],
|
||||
"principalUserIds": [],
|
||||
"documentIds": [doc_b.document_id],
|
||||
},
|
||||
expected_status=403,
|
||||
)
|
||||
assert "其他租户文档" in cross_doc.text
|
||||
|
||||
cross_member = tenant_admin_api.post(
|
||||
"/api/v3/cross-review/tasks",
|
||||
json={
|
||||
"taskName": f"Pytest Cross Member {int(time.time())}",
|
||||
"taskType": "CITY",
|
||||
"memberUserIds": [tenant_common_user_b.user_id],
|
||||
"principalUserIds": [],
|
||||
"documentIds": [doc_a.document_id],
|
||||
},
|
||||
expected_status=403,
|
||||
)
|
||||
assert "其他租户用户" in cross_member.text
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g5_cross_review_task_visibility_progress_and_documents_follow_member_scope(
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
tenant_admin_api_b: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_common_user_a: SeededUser,
|
||||
make_release_document,
|
||||
) -> None:
|
||||
doc_a = make_release_document(
|
||||
client=tenant_admin_api,
|
||||
tenant=tenant_a,
|
||||
file_name=f"pytest-cross-query-{int(time.time())}.txt",
|
||||
)
|
||||
created = tenant_admin_api.post(
|
||||
"/api/v3/cross-review/tasks",
|
||||
json={
|
||||
"taskName": f"Pytest Query Task {int(time.time())}",
|
||||
"taskType": "CITY",
|
||||
"memberUserIds": [tenant_common_user_a.user_id],
|
||||
"principalUserIds": [],
|
||||
"documentIds": [doc_a.document_id],
|
||||
},
|
||||
expected_status=200,
|
||||
)
|
||||
created_data = ReleaseApiClient.json_data(created)
|
||||
task_id = int(created_data["taskId"])
|
||||
|
||||
queried = ReleaseApiClient.json_data(
|
||||
tenant_admin_api.post(
|
||||
"/api/v3/cross-review/tasks/query",
|
||||
json={"page": 1, "pageSize": 50, "keyword": "Pytest Query Task"},
|
||||
expected_status=200,
|
||||
)
|
||||
)
|
||||
task_ids = {int(item["taskId"]) for item in queried["items"]}
|
||||
assert task_id in task_ids
|
||||
|
||||
created_item = next(item for item in queried["items"] if int(item["taskId"]) == task_id)
|
||||
evaluation_tenant_codes = {str(item.get("tenantCode") or "") for item in created_item.get("evaluationTenants") or []}
|
||||
assert tenant_a.tenant_code in evaluation_tenant_codes
|
||||
|
||||
progress = ReleaseApiClient.json_data(
|
||||
tenant_admin_api.get(f"/api/v3/cross-review/tasks/{task_id}/progress", expected_status=200)
|
||||
)
|
||||
assert int(progress["taskId"]) == task_id
|
||||
assert int(progress["totalDocuments"]) == 1
|
||||
|
||||
documents = ReleaseApiClient.json_data(
|
||||
tenant_admin_api.get(
|
||||
f"/api/v3/cross-review/tasks/{task_id}/documents?page=1&pageSize=20",
|
||||
expected_status=200,
|
||||
)
|
||||
)
|
||||
assert int(documents["taskId"]) == task_id
|
||||
returned_document_ids = {int(item["documentId"]) for item in documents["items"]}
|
||||
assert doc_a.document_id in returned_document_ids
|
||||
|
||||
cross_tenant_query = ReleaseApiClient.json_data(
|
||||
tenant_admin_api_b.post(
|
||||
"/api/v3/cross-review/tasks/query",
|
||||
json={"page": 1, "pageSize": 50, "keyword": "Pytest Query Task"},
|
||||
expected_status=200,
|
||||
)
|
||||
)
|
||||
cross_tenant_task_ids = {int(item["taskId"]) for item in cross_tenant_query["items"]}
|
||||
assert task_id not in cross_tenant_task_ids
|
||||
|
||||
cross_tenant_progress = tenant_admin_api_b.get(
|
||||
f"/api/v3/cross-review/tasks/{task_id}/progress",
|
||||
expected_status=403,
|
||||
)
|
||||
assert "当前用户不是交叉评查任务成员" in cross_tenant_progress.text
|
||||
|
||||
cross_tenant_documents = tenant_admin_api_b.get(
|
||||
f"/api/v3/cross-review/tasks/{task_id}/documents?page=1&pageSize=20",
|
||||
expected_status=403,
|
||||
)
|
||||
assert "当前用户不是交叉评查任务成员" in cross_tenant_documents.text
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from .helpers import ReleaseApiClient
|
||||
|
||||
|
||||
def _select_rule_set(client: ReleaseApiClient) -> dict[str, Any]:
|
||||
preferred_rule_type = os.getenv("LEAUDIT_TEST_RULE_TYPE", "contract.entrust").strip()
|
||||
rule_sets = ReleaseApiClient.json_data(client.get("/api/rule-sets"))
|
||||
assert isinstance(rule_sets, list)
|
||||
assert rule_sets, "当前环境没有规则集,无法测试版本管理"
|
||||
|
||||
for item in rule_sets:
|
||||
if str(item.get("ruleType") or "") == preferred_rule_type and item.get("currentVersionId"):
|
||||
return item
|
||||
|
||||
for item in rule_sets:
|
||||
if item.get("currentVersionId"):
|
||||
return item
|
||||
|
||||
pytest.skip("当前环境没有带 currentVersionId 的规则集,无法测试发布/回滚闭环")
|
||||
|
||||
|
||||
def _set_metadata_version(yaml_text: str, version_no: str) -> str:
|
||||
lines = yaml_text.splitlines()
|
||||
metadata_index: int | None = None
|
||||
version_pattern = re.compile(r"^(\s*)version\s*:")
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
if line.strip() == "metadata:":
|
||||
metadata_index = index
|
||||
continue
|
||||
if metadata_index is None:
|
||||
continue
|
||||
if line and not line.startswith((" ", "\t")):
|
||||
break
|
||||
match = version_pattern.match(line)
|
||||
if match:
|
||||
lines[index] = f"{match.group(1)}version: '{version_no}'"
|
||||
return "\n".join(lines) + ("\n" if yaml_text.endswith("\n") else "")
|
||||
|
||||
if metadata_index is None:
|
||||
return f"metadata:\n version: '{version_no}'\n{yaml_text}"
|
||||
|
||||
lines.insert(metadata_index + 1, f" version: '{version_no}'")
|
||||
return "\n".join(lines) + ("\n" if yaml_text.endswith("\n") else "")
|
||||
|
||||
|
||||
def _rule_set_by_type(client: ReleaseApiClient, rule_type: str) -> dict[str, Any]:
|
||||
rule_sets = ReleaseApiClient.json_data(client.get("/api/rule-sets"))
|
||||
for item in rule_sets:
|
||||
if str(item.get("ruleType") or "") == rule_type:
|
||||
return item
|
||||
raise AssertionError(f"规则集不存在: {rule_type}")
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g6_rule_detail_version_management_save_publish_and_rollback(admin_client: ReleaseApiClient) -> None:
|
||||
"""覆盖规则配置详情页的版本管理闭环。
|
||||
|
||||
该用例会创建一个真实历史版本并短暂发布,最后回滚到执行前的 currentVersionId。
|
||||
"""
|
||||
|
||||
target_rule_set = _select_rule_set(admin_client)
|
||||
rule_type = str(target_rule_set["ruleType"])
|
||||
original_rule_set_id = int(target_rule_set["id"])
|
||||
original_current_version_id = int(target_rule_set["currentVersionId"])
|
||||
original_tenant_code = str(target_rule_set.get("effectiveTenantCode") or "")
|
||||
|
||||
versions_before = ReleaseApiClient.json_data(admin_client.get(f"/api/rule-sets/{rule_type}/versions"))
|
||||
assert isinstance(versions_before, list)
|
||||
assert {int(item["ruleSetId"]) for item in versions_before} == {original_rule_set_id}
|
||||
assert original_current_version_id in {int(item["id"]) for item in versions_before}
|
||||
|
||||
content = ReleaseApiClient.json_data(admin_client.get(f"/api/rule-sets/versions/{original_current_version_id}/content"))
|
||||
assert int(content["ruleSetId"]) == original_rule_set_id
|
||||
assert str(content["ruleType"]) == rule_type
|
||||
assert str(content["yamlText"]).strip()
|
||||
|
||||
new_version_no = f"pytest-vm-{int(time.time())}"
|
||||
yaml_text = _set_metadata_version(str(content["yamlText"]), new_version_no)
|
||||
created_version_id: int | None = None
|
||||
|
||||
try:
|
||||
created = ReleaseApiClient.json_data(
|
||||
admin_client.post(
|
||||
f"/api/rule-sets/{rule_type}/versions",
|
||||
json={
|
||||
"yamlText": yaml_text,
|
||||
"changeNote": f"pytest rule detail version management smoke {new_version_no}",
|
||||
},
|
||||
expected_status=200,
|
||||
)
|
||||
)
|
||||
created_version_id = int(created["id"])
|
||||
assert int(created["ruleSetId"]) == original_rule_set_id
|
||||
assert str(created["versionNo"]) == new_version_no
|
||||
assert str(created["status"]) == "draft"
|
||||
|
||||
versions_after_create = ReleaseApiClient.json_data(admin_client.get(f"/api/rule-sets/{rule_type}/versions"))
|
||||
assert created_version_id in {int(item["id"]) for item in versions_after_create}
|
||||
assert {int(item["ruleSetId"]) for item in versions_after_create} == {original_rule_set_id}
|
||||
|
||||
published = ReleaseApiClient.json_data(
|
||||
admin_client.post(
|
||||
f"/api/rule-sets/{rule_type}/publish",
|
||||
json={"versionId": created_version_id},
|
||||
expected_status=200,
|
||||
)
|
||||
)
|
||||
assert int(published["id"]) == created_version_id
|
||||
assert int(published["ruleSetId"]) == original_rule_set_id
|
||||
assert str(published["status"]) == "published"
|
||||
|
||||
current_after_publish = _rule_set_by_type(admin_client, rule_type)
|
||||
assert int(current_after_publish["id"]) == original_rule_set_id
|
||||
assert int(current_after_publish["currentVersionId"]) == created_version_id
|
||||
assert str(current_after_publish.get("effectiveTenantCode") or "") == original_tenant_code
|
||||
|
||||
finally:
|
||||
if created_version_id:
|
||||
admin_client.post(
|
||||
f"/api/rule-sets/{rule_type}/rollback",
|
||||
json={"versionId": original_current_version_id},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
restored = _rule_set_by_type(admin_client, rule_type)
|
||||
assert int(restored["id"]) == original_rule_set_id
|
||||
assert int(restored["currentVersionId"]) == original_current_version_id
|
||||
assert str(restored.get("effectiveTenantCode") or "") == original_tenant_code
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_g6_rule_detail_version_management_rejects_cross_rule_set_publish(admin_client: ReleaseApiClient) -> None:
|
||||
"""发布接口必须拒绝把其他规则集的版本发布到当前规则类型。"""
|
||||
|
||||
rule_sets = ReleaseApiClient.json_data(admin_client.get("/api/rule-sets"))
|
||||
candidates = [item for item in rule_sets if item.get("currentVersionId")]
|
||||
if len(candidates) < 2:
|
||||
pytest.skip("当前环境少于两个带 currentVersionId 的规则集,无法测试跨规则集发布拦截")
|
||||
|
||||
left = candidates[0]
|
||||
right = next((item for item in candidates[1:] if str(item["ruleType"]) != str(left["ruleType"])), None)
|
||||
if right is None:
|
||||
pytest.skip("当前环境没有可用于跨规则类型发布拦截的第二个规则集")
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/rule-sets/{left['ruleType']}/publish",
|
||||
json={"versionId": int(right["currentVersionId"])},
|
||||
expected_status=403,
|
||||
)
|
||||
assert "当前租户不能发布或回滚其他租户的规则版本" in response.text
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import SeededUser, TenantSeed
|
||||
from .helpers import ReleaseApiClient
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_global_admin_can_query_cross_tenant_scope(
|
||||
admin_client: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_b: TenantSeed,
|
||||
) -> None:
|
||||
data_a = ReleaseApiClient.json_data(
|
||||
admin_client.get(f"/api/v3/rbac/users?page=1&page_size=100&tenant_code={tenant_a.tenant_code}")
|
||||
)
|
||||
data_b = ReleaseApiClient.json_data(
|
||||
admin_client.get(f"/api/v3/rbac/users?page=1&page_size=100&tenant_code={tenant_b.tenant_code}")
|
||||
)
|
||||
assert isinstance(data_a["items"], list)
|
||||
assert isinstance(data_b["items"], list)
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_tenant_admin_is_limited_to_own_tenant_scope(
|
||||
tenant_admin_api: ReleaseApiClient,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_b: TenantSeed,
|
||||
tenant_common_user_a: SeededUser,
|
||||
tenant_common_user_b: SeededUser,
|
||||
) -> None:
|
||||
own_scope = ReleaseApiClient.json_data(tenant_admin_api.get("/api/v3/rbac/users?page=1&page_size=100"))
|
||||
tenant_codes = {str(item.get("tenant_code") or "") for item in own_scope["items"]}
|
||||
assert tenant_a.tenant_code in tenant_codes
|
||||
assert tenant_b.tenant_code not in tenant_codes
|
||||
|
||||
forbidden_query = tenant_admin_api.get(
|
||||
f"/api/v3/rbac/users?page=1&page_size=20&tenant_code={tenant_b.tenant_code}",
|
||||
expected_status=403,
|
||||
)
|
||||
assert "不能查询其他租户用户" in forbidden_query.text
|
||||
|
||||
same_tenant_update = tenant_admin_api.put(
|
||||
f"/api/v3/rbac/users/{tenant_common_user_a.user_id}/tenant",
|
||||
json={"tenant_code": tenant_a.tenant_code},
|
||||
expected_status=200,
|
||||
)
|
||||
same_tenant_data = ReleaseApiClient.json_data(same_tenant_update)
|
||||
assert same_tenant_data["tenant_code"] == tenant_a.tenant_code
|
||||
|
||||
cross_tenant_update = tenant_admin_api.put(
|
||||
f"/api/v3/rbac/users/{tenant_common_user_b.user_id}/tenant",
|
||||
json={"tenant_code": tenant_a.tenant_code},
|
||||
expected_status=403,
|
||||
)
|
||||
assert "不能修改其他租户用户" in cross_tenant_update.text
|
||||
|
||||
|
||||
@pytest.mark.release
|
||||
def test_common_user_cannot_access_management_but_keeps_business_entry(
|
||||
common_api_a: ReleaseApiClient,
|
||||
release_entry_module: dict,
|
||||
admin_client: ReleaseApiClient,
|
||||
release_config,
|
||||
tenant_a: TenantSeed,
|
||||
tenant_b: TenantSeed,
|
||||
) -> None:
|
||||
module_id = int(release_entry_module["id"])
|
||||
module_name = str(release_entry_module["name"])
|
||||
admin_client.put(
|
||||
f"/api/v3/entry-modules/{module_id}",
|
||||
json={
|
||||
"name": module_name,
|
||||
"description": "pytest release acceptance only",
|
||||
"path": release_config.module_path,
|
||||
"route_path": release_config.module_path,
|
||||
"tenants": [
|
||||
{
|
||||
"tenant_code": tenant_a.tenant_code,
|
||||
"tenant_name": tenant_a.tenant_name,
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"tenant_code": tenant_b.tenant_code,
|
||||
"tenant_name": tenant_b.tenant_name,
|
||||
"enabled": True,
|
||||
"sort_order": 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
users_response = common_api_a.get("/api/v3/rbac/users?page=1&page_size=20", expected_status=403)
|
||||
assert "系统设置管理权限" in users_response.text
|
||||
|
||||
tenants_response = common_api_a.get("/api/v3/tenants", expected_status=403)
|
||||
assert "租户" in tenants_response.text
|
||||
|
||||
home_response = common_api_a.get("/api/home/entry-modules")
|
||||
home_modules = ReleaseApiClient.json_data(home_response)
|
||||
home_names = [str(item.get("name") or "") for item in home_modules]
|
||||
assert module_name in home_names
|
||||
Reference in New Issue
Block a user