按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
256 lines
10 KiB
Python
256 lines
10 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):
|
|
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, 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
|
|
)
|