打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
322 lines
14 KiB
Python
322 lines
14 KiB
Python
"""隔离 MySQL 验证角色配置、用户分配、旧会话即时生效与工资操作解耦。"""
|
|
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from tests.integration.test_tplus_workbench import workspace as workspace
|
|
|
|
BASE = "/api/v1/system"
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def current(client, headers):
|
|
response = client.get("/api/v1/auth/me", headers=headers)
|
|
assert response.status_code == 200, response.text
|
|
return response.json()["data"]
|
|
|
|
|
|
def authorization(client, admin, user_id):
|
|
response = client.get(f"{BASE}/users/{user_id}/authorization", headers=admin["headers"])
|
|
assert response.status_code == 200, response.text
|
|
return response.json()["data"]
|
|
|
|
|
|
def save(client, admin, user_id, **changes):
|
|
before = authorization(client, admin, user_id)
|
|
data = {
|
|
"role_codes": before["role_codes"],
|
|
"expected_revision": before["revision"],
|
|
"reason": "隔离角色分配验收",
|
|
} | changes
|
|
return client.put(f"{BASE}/users/{user_id}/authorization", headers=admin["headers"], json=data)
|
|
|
|
|
|
def role(client, admin, codes):
|
|
response = client.post(
|
|
BASE + "/roles",
|
|
headers=admin["headers"],
|
|
json={"role_code": "t_rbac_" + uuid4().hex[:8], "role_name": "t_权限验收角色"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
item = response.json()["data"]
|
|
before = client.get(f"{BASE}/roles/{item['id']}/permissions", headers=admin["headers"]).json()[
|
|
"data"
|
|
]
|
|
response = client.put(
|
|
f"{BASE}/roles/{item['id']}/permissions",
|
|
headers=admin["headers"],
|
|
json={
|
|
"permission_codes": codes,
|
|
"expected_revision": before["revision"],
|
|
"reason": "配置测试角色",
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
return item
|
|
|
|
|
|
def test_管理员无需绑定制作人即可维护资料和获得工资权限(client, admin):
|
|
from sqlalchemy import select
|
|
|
|
from app.core.db import get_session_factory
|
|
from app.models import AuthorizationSetting, BusinessRole, User
|
|
from app.services.authorization_service import require_permission
|
|
|
|
with get_session_factory()() as session:
|
|
session.get(AuthorizationSetting, 1).maker_user_id = None
|
|
session.commit()
|
|
require_permission(session, session.get(User, admin["id"]), "settlements:generate")
|
|
business_role_id = session.scalar(
|
|
select(BusinessRole.id).where(BusinessRole.role_code == "salesperson")
|
|
)
|
|
assert business_role_id is not None
|
|
info = current(client, admin["headers"])
|
|
assert info["can_write_payroll"] and info["maker_required_permissions"] == []
|
|
response = client.post(
|
|
"/api/v1/base/personnel",
|
|
headers=admin["headers"],
|
|
json={"personnel_name": "t_角色授权人员", "business_role_id": business_role_id},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
person = response.json()["data"]["id"]
|
|
for path, data in (("regions", {"regions": []}), ("plans", {"plans": []})):
|
|
response = client.put(
|
|
f"/api/v1/base/personnel/{person}/{path}", headers=admin["headers"], json=data
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
def test_角色配置后分配给用户并在原会话即时生效(client, admin, normal):
|
|
assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"]
|
|
item = role(client, admin, ["base:personnel:view", "base:personnel:regions"])
|
|
assert (
|
|
save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200
|
|
)
|
|
assert "base:personnel:regions" in current(client, normal["headers"])["permissions"]
|
|
assert save(client, admin, normal["id"], role_codes=["user"]).status_code == 200
|
|
assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"]
|
|
|
|
|
|
def test_来源销售关系核对需要业务更新和核算准备双权限(client, admin, normal):
|
|
business_only = role(client, admin, ["business:businesses:view", "business:businesses:update"])
|
|
payroll_only = role(client, admin, ["payroll:view", "payroll:prepare"])
|
|
payload = {"item_id": 1, "expected_version": 1, "evidence": "权限回归"}
|
|
url = "/api/v1/business/businesses/1/sales-relation/release"
|
|
for item in (business_only, payroll_only):
|
|
assert save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200
|
|
assert client.put(url, headers=normal["headers"], json=payload).status_code == 403
|
|
assert save(client, admin, normal["id"], role_codes=["user", business_only["role_code"], payroll_only["role_code"]]).status_code == 200
|
|
response = client.put(url, headers=normal["headers"], json=payload)
|
|
assert response.status_code == 400
|
|
assert "来源业务" in response.json()["message"] or "核对结论" in response.json()["message"]
|
|
assert save(client, admin, normal["id"], role_codes=["user"]).status_code == 200
|
|
|
|
|
|
def test_旧个人覆盖写入拒绝且不影响既有角色(client, admin, normal):
|
|
response = save(
|
|
client,
|
|
admin,
|
|
normal["id"],
|
|
overrides=[{"permission_code": "base:personnel:regions", "effect": "ALLOW"}],
|
|
)
|
|
assert response.status_code == 400
|
|
assert current(client, normal["headers"])["roles"] == ["user"]
|
|
assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"]
|
|
|
|
|
|
def test_普通用户不能配置角色或读取权限目录(client, normal):
|
|
assert client.get(BASE + "/permissions/catalog", headers=normal["headers"]).status_code == 403
|
|
assert client.get(BASE + "/roles", headers=normal["headers"]).status_code == 403
|
|
|
|
|
|
def test_过期角色分配版本返回409且不覆盖前次结果(client, admin, normal):
|
|
before = authorization(client, admin, normal["id"])
|
|
assert save(client, admin, normal["id"]).status_code == 200
|
|
response = save(
|
|
client, admin, normal["id"], expected_revision=before["revision"], role_codes=[]
|
|
)
|
|
assert response.status_code == 409
|
|
assert "user" in current(client, normal["headers"])["roles"]
|
|
|
|
|
|
def test_工资角色不再需要绑定且旧绑定接口不再写入(client, admin, normal):
|
|
assert save(client, admin, normal["id"], role_codes=["payroll_maker"]).status_code == 200
|
|
info = current(client, normal["headers"])
|
|
assert info["can_write_payroll"] and "payroll:calculate" in info["permissions"]
|
|
settings = client.get(BASE + "/authorization-settings", headers=admin["headers"]).json()["data"]
|
|
assert settings["mode"] == "roles" and settings["maker_user_id"] is None
|
|
response = client.put(
|
|
BASE + "/authorization-settings",
|
|
headers=admin["headers"],
|
|
json={
|
|
"maker_user_id": normal["id"],
|
|
"expected_revision": settings["revision"],
|
|
"reason": "旧客户端兼容验收",
|
|
},
|
|
)
|
|
assert response.status_code == 400 and "角色" in response.json()["message"]
|
|
|
|
|
|
def test_不能撤销当前管理员自身管理身份(client, admin):
|
|
assert save(client, admin, admin["id"], role_codes=["user"]).status_code == 403
|
|
assert "admin" in current(client, admin["headers"])["roles"]
|
|
|
|
|
|
def test_角色停用撤销旧会话授权且恢复后重新生效(client, admin, normal):
|
|
item = role(client, admin, ["base:personnel:view", "base:personnel:regions"])
|
|
assert (
|
|
save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200
|
|
)
|
|
for status in (0, 1):
|
|
response = client.put(
|
|
f"{BASE}/roles/{item['id']}/status", headers=admin["headers"], json={"status": status}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert (
|
|
"base:personnel:regions" in current(client, normal["headers"])["permissions"]
|
|
) == bool(status)
|
|
|
|
|
|
def test_编辑角色拒绝停用权限且保留原授权(client, admin):
|
|
from sqlalchemy import select
|
|
|
|
from app.core.db import get_session_factory
|
|
from app.models import Permission
|
|
|
|
item = role(client, admin, ["base:personnel:view"])
|
|
url = f"{BASE}/roles/{item['id']}/permissions"
|
|
before = client.get(url, headers=admin["headers"]).json()["data"]
|
|
with get_session_factory()() as session:
|
|
permission = session.scalar(select(Permission).where(
|
|
Permission.perm_code == "base:personnel:regions"
|
|
))
|
|
old_status = permission.status
|
|
permission.status = 0
|
|
session.commit()
|
|
try:
|
|
response = client.put(url, headers=admin["headers"], json={
|
|
"permission_codes": ["base:personnel:view", "base:personnel:regions"],
|
|
"expected_revision": before["revision"], "reason": "验证停用权限",
|
|
})
|
|
assert response.status_code == 400
|
|
after = client.get(url, headers=admin["headers"]).json()["data"]
|
|
assert after["permission_codes"] == before["permission_codes"]
|
|
assert after["revision"] == before["revision"]
|
|
finally:
|
|
with get_session_factory()() as session:
|
|
permission = session.scalar(select(Permission).where(
|
|
Permission.perm_code == "base:personnel:regions"
|
|
))
|
|
permission.status = old_status
|
|
session.commit()
|
|
|
|
|
|
def test_编辑角色拒绝缺失权限且保留原授权(client, admin, monkeypatch):
|
|
from app.services.permission_catalog import CATALOG
|
|
|
|
item = role(client, admin, ["base:personnel:view"])
|
|
url = f"{BASE}/roles/{item['id']}/permissions"
|
|
before = client.get(url, headers=admin["headers"]).json()["data"]
|
|
code = "test:missing:view"
|
|
monkeypatch.setitem(CATALOG, code, {"admin_only": False, "view_code": code})
|
|
response = client.put(url, headers=admin["headers"], json={
|
|
"permission_codes": [code], "expected_revision": before["revision"], "reason": "验证缺失权限",
|
|
})
|
|
assert response.status_code == 400
|
|
after = client.get(url, headers=admin["headers"]).json()["data"]
|
|
assert after["permission_codes"] == before["permission_codes"]
|
|
assert after["revision"] == before["revision"]
|
|
|
|
|
|
def test_角色权限编辑自动影响全部绑定用户(client, admin, normal, make_user):
|
|
other = make_user(["user"])
|
|
item = role(client, admin, ["base:personnel:view"])
|
|
for user_id in (normal["id"], other["id"]):
|
|
assert save(client, admin, user_id, role_codes=[item["role_code"]]).status_code == 200
|
|
before = client.get(f"{BASE}/roles/{item['id']}/permissions", headers=admin["headers"]).json()[
|
|
"data"
|
|
]
|
|
response = client.put(
|
|
f"{BASE}/roles/{item['id']}/permissions",
|
|
headers=admin["headers"],
|
|
json={
|
|
"permission_codes": ["base:personnel:view", "base:personnel:regions"],
|
|
"expected_revision": before["revision"],
|
|
"reason": "角色同步授权验收",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
from app.core.db import get_session_factory
|
|
from app.models import User
|
|
from app.services.authorization_service import calculate
|
|
|
|
with get_session_factory()() as session:
|
|
for user_id in (normal["id"], other["id"]):
|
|
assert (
|
|
"base:personnel:regions"
|
|
in calculate(session, session.get(User, user_id))["permissions"]
|
|
)
|
|
|
|
|
|
def test_表单参照可使用但不能借此读取完整资料(client, admin, normal):
|
|
item = role(client, admin, ["business:businesses:view"])
|
|
assert save(client, admin, normal["id"], role_codes=[item["role_code"]]).status_code == 200
|
|
response = client.get("/api/v1/meta/options?resource=personnel", headers=normal["headers"])
|
|
assert response.status_code == 200, response.text
|
|
assert all(
|
|
not {"salary_standard", "phone", "password"} & row.keys() for row in response.json()["data"]
|
|
)
|
|
assert client.get("/api/v1/base/personnel", headers=normal["headers"]).status_code == 403
|
|
|
|
|
|
def test_排队后撤销角色任务执行前重新检查(client, admin, normal, workspace):
|
|
from sqlalchemy import select
|
|
|
|
from app.models import TPlusWorkRun
|
|
from app.services.tplus import workbench
|
|
|
|
session, _ = workspace
|
|
item = role(client, admin, ["tplus:view", "tplus:preview"])
|
|
assert (
|
|
save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200
|
|
)
|
|
response = client.post(
|
|
BASE + "/tplus/work-runs",
|
|
headers=normal["headers"],
|
|
json={"kind": "MASTER_PREVIEW", "request_key": str(uuid4()), "entity_types": ["personnel"]},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
number = response.json()["data"]["run_no"]
|
|
assert save(client, admin, normal["id"], role_codes=["user"]).status_code == 200
|
|
assert workbench.execute_next(session) == number
|
|
run = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == number))
|
|
assert run.status == "FAILED" and run.error_code == "TASK_AUTHORIZATION_REVOKED"
|
|
|
|
|
|
def test_并发角色分配仅一个版本成功(client, admin, normal):
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
choices = [
|
|
role(client, admin, ["base:personnel:view", code])
|
|
for code in ("base:personnel:regions", "base:personnel:plans")
|
|
]
|
|
before = authorization(client, admin, normal["id"])
|
|
|
|
def change(item):
|
|
return save(
|
|
client,
|
|
admin,
|
|
normal["id"],
|
|
role_codes=["user", item["role_code"]],
|
|
expected_revision=before["revision"],
|
|
).status_code
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
result = list(pool.map(change, choices))
|
|
assert sorted(result) == [200, 409]
|
|
effective = current(client, normal["headers"])["permissions"]
|
|
assert (
|
|
sum(code in effective for code in ("base:personnel:regions", "base:personnel:plans")) == 1
|
|
)
|