Files
zkxw/backend/tests/test_web_sensitive_permissions.py
T
zlwuandCursor ac8e471aca fix: align sample registration, freight scope, and OA datetime display
Let customer-service quick register write storage, certificate, and insurance separately without extra menu or data-scope checks. List freight by document data scope like the web admin, allow protocol staff outside the collection region, and pad OA datetimes to seconds so PC date pickers can show historical minute values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 17:30:30 +08:00

762 lines
22 KiB
Python

import asyncio
from pathlib import Path
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.core import menu_guard
from app.core.auth import get_current_user
from app.core.database import get_db
from app.models.base import Base
from app.models.models import (
SysMenu,
SysPost,
SysPostRole,
SysRole,
SysRoleMenu,
SysUser,
SysUserPost,
SysUserRole,
)
PROTOCOL_LIST = "biz:cellStorageProtocol:list"
PROTOCOL_AUDIT = "biz:cellStorageProtocolAudit:list"
PAYMENT_CONFIRM = "biz:cellStoragePaymentConfirm:list"
REFUND_CONFIRM = "biz:cellStorageRefundConfirm:list"
STORAGE_CERT = "cell-storage:storage-cert:list"
QC_MENUS = {"biz:qcWorkOrder:list", "biz:myQcTask:list"}
def _session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
SysMenu.__table__,
SysPost.__table__,
SysPostRole.__table__,
SysRole.__table__,
SysRoleMenu.__table__,
SysUser.__table__,
SysUserPost.__table__,
SysUserRole.__table__,
],
)
return sessionmaker(bind=engine)()
def _thread_session():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(
engine,
tables=[
SysMenu.__table__,
SysPost.__table__,
SysPostRole.__table__,
SysRole.__table__,
SysRoleMenu.__table__,
SysUser.__table__,
SysUserPost.__table__,
SysUserRole.__table__,
],
)
return sessionmaker(bind=engine)()
def _add_user(db, *, user_id="u1", tenant_no="tenant-a"):
db.add(
SysUser(
id=user_id,
user_no=user_id,
nick_name=user_id,
password="unused",
status="1",
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
)
)
def _add_role(db, *, role_id, tenant_no, role_name="普通角色"):
db.add(
SysRole(
id=role_id,
role_name=role_name,
status="1",
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
)
)
def _add_menu_grant(db, *, role_id, tenant_no, code, suffix):
menu_id = f"menu-{suffix}"
db.add_all(
[
SysMenu(
id=menu_id,
menu_name=code,
parent_id="root",
menu_type="C",
path=f"/{suffix}",
perms=code,
status="1",
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
),
SysRoleMenu(
id=f"rm-{role_id}-{suffix}",
role_id=role_id,
menu_id=menu_id,
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
),
]
)
def _bind_direct(db, *, user_id, role_id, tenant_no, suffix):
db.add(
SysUserRole(
id=f"ur-{suffix}",
user_id=user_id,
role_id=role_id,
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
)
)
def _bind_post(db, *, user_id, role_id, tenant_no, suffix):
post_id = f"post-{suffix}"
db.add_all(
[
SysPost(
id=post_id,
post_name="授权职位",
post_code=post_id,
status="1",
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
),
SysUserPost(
id=f"up-{suffix}",
user_id=user_id,
post_id=post_id,
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
),
SysPostRole(
id=f"pr-{suffix}",
post_id=post_id,
role_id=role_id,
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
),
]
)
def _strict_checker():
checker = getattr(menu_guard, "strict_user_has_menu_perm", None)
assert callable(checker), "menu_guard must expose strict_user_has_menu_perm"
return checker
def _strict_dependency(code):
factory = getattr(menu_guard, "require_strict_menu_code", None)
assert callable(factory), "menu_guard must expose require_strict_menu_code"
return factory(code)
def test_strict_permission_uses_direct_and_post_role_union():
db = _session()
_add_user(db)
_add_role(db, role_id="market", tenant_no="tenant-a", role_name="市场专员")
_add_role(db, role_id="finance", tenant_no="tenant-a", role_name="财务人员")
_add_menu_grant(
db,
role_id="market",
tenant_no="tenant-a",
code="mini:market:specialist:payment",
suffix="mini",
)
_add_menu_grant(
db,
role_id="finance",
tenant_no="tenant-a",
code=PAYMENT_CONFIRM,
suffix="finance",
)
_bind_direct(
db, user_id="u1", role_id="market", tenant_no="tenant-a", suffix="market"
)
_bind_post(
db, user_id="u1", role_id="finance", tenant_no="tenant-a", suffix="finance"
)
db.commit()
checker = _strict_checker()
user = {"user_id": "u1", "tenant_no": "forged", "role": "admin"}
assert checker(db, user, PAYMENT_CONFIRM)
assert not checker(db, user, REFUND_CONFIRM)
assert asyncio.run(_strict_dependency(PAYMENT_CONFIRM)(user=user, db=db)) is user
def test_strict_permission_ignores_cross_tenant_grants():
db = _session()
_add_user(db, tenant_no="tenant-a")
_add_role(db, role_id="market", tenant_no="tenant-a", role_name="市场专员")
_bind_direct(
db, user_id="u1", role_id="market", tenant_no="tenant-a", suffix="market"
)
_add_role(db, role_id="finance-b", tenant_no="tenant-b", role_name="财务人员")
_add_menu_grant(
db,
role_id="finance-b",
tenant_no="tenant-b",
code=PAYMENT_CONFIRM,
suffix="finance-b",
)
_bind_direct(
db,
user_id="u1",
role_id="finance-b",
tenant_no="tenant-b",
suffix="finance-b",
)
db.commit()
assert not _strict_checker()(
db,
{"user_id": "u1", "tenant_no": "tenant-b", "role": "super_admin"},
PAYMENT_CONFIRM,
)
@pytest.mark.parametrize("role_name", ["超级市场专员", "超级管理员"])
def test_strict_permission_rejects_privileged_role_name_without_menu(role_name):
db = _session()
_add_user(db)
_add_role(
db,
role_id="super",
tenant_no="tenant-a",
role_name=role_name,
)
_bind_direct(
db, user_id="u1", role_id="super", tenant_no="tenant-a", suffix="super"
)
db.commit()
user = {"user_id": "u1", "tenant_no": "forged", "role": "admin"}
assert not _strict_checker()(db, user, REFUND_CONFIRM)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(_strict_dependency(REFUND_CONFIRM)(user=user, db=db))
assert exc_info.value.status_code == 403
def test_strict_permission_allows_super_admin_only_with_explicit_menu_grant():
db = _session()
_add_user(db)
_add_role(db, role_id="super", tenant_no="tenant-a", role_name="超级管理员")
_add_menu_grant(
db,
role_id="super",
tenant_no="tenant-a",
code=REFUND_CONFIRM,
suffix="refund",
)
_bind_direct(
db, user_id="u1", role_id="super", tenant_no="tenant-a", suffix="super"
)
db.commit()
user = {"user_id": "u1", "tenant_no": "forged", "role": "super_admin"}
assert _strict_checker()(db, user, REFUND_CONFIRM)
assert asyncio.run(_strict_dependency(REFUND_CONFIRM)(user=user, db=db)) is user
def test_strict_dependency_rejects_market_only_role():
db = _session()
_add_user(db)
_add_role(db, role_id="market", tenant_no="tenant-a", role_name="地区经理")
_add_menu_grant(
db,
role_id="market",
tenant_no="tenant-a",
code="mini:market:manager:protocol",
suffix="mini",
)
_bind_direct(
db, user_id="u1", role_id="market", tenant_no="tenant-a", suffix="market"
)
db.commit()
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
_strict_dependency(PROTOCOL_AUDIT)(
user={"user_id": "u1", "tenant_no": "tenant-a", "role": "admin"},
db=db,
)
)
assert exc_info.value.status_code == 403
def _route_strict_codes(router, endpoint_name):
route = next(r for r in router.routes if r.endpoint.__name__ == endpoint_name)
codes = set()
for dependency in route.dependant.dependencies:
codes.update(getattr(dependency.call, "strict_menu_codes", ()))
return codes
@pytest.mark.parametrize(
("module_name", "endpoint_names", "expected_codes"),
[
(
"research_donation",
[
"create_research_donation",
"update_research_donation",
],
{PROTOCOL_LIST},
),
(
"protocol_sample_payment",
["confirm_payment_transfer", "reject_payment_transfer"],
{PAYMENT_CONFIRM},
),
(
"presign_order",
["confirm_presign_transfer", "reject_presign_transfer"],
{PAYMENT_CONFIRM},
),
(
"sample_cert_insurance",
["put_sample_storage_cert", "put_sample_insurance"],
{STORAGE_CERT},
),
(
"sample_storage_cert_qc",
["put_sample_storage_cert_qc"],
QC_MENUS,
),
],
)
def test_sensitive_write_routes_declare_strict_menu_dependencies(
module_name, endpoint_names, expected_codes
):
module = __import__(f"app.api.{module_name}", fromlist=["router"])
for endpoint_name in endpoint_names:
assert _route_strict_codes(module.router, endpoint_name) == expected_codes
def test_research_donation_audit_route_has_no_strict_menu_dependency():
"""科研捐赠审核只保留运营端登录,不再要求审核菜单权限。"""
from app.api import research_donation
assert _route_strict_codes(research_donation.router, "submit_donation_audit") == set()
def test_collection_protocol_write_routes_have_no_strict_menu_dependencies():
"""采集协议单接口仅保留运营端登录守卫,不再挂菜单权限码。"""
from app.api import collection_protocol
write_endpoints = [
"create_collection_protocol",
"update_collection_protocol",
"delete_collection_protocol",
"submit_collection_protocol_audit",
"post_receipt_confirm",
"post_receipt_reject",
"post_protocol_invoice_request",
"complete_protocol_invoice_request",
"post_submit_refund_finance",
"post_submit_refund_finance_batch",
"post_refund_confirm",
"post_refund_reject",
"update_fullmoon_todo_insurance",
"update_storage_cert_handle",
"update_fullmoon_todo_handle",
]
for endpoint_name in write_endpoints:
assert _route_strict_codes(collection_protocol.router, endpoint_name) == set()
def test_protocol_refund_record_routes_have_no_strict_menu_dependencies():
"""采集协议单上的退费登记只保留运营端登录,不再要求退费确认菜单。"""
from app.api import protocol_refund_record
for endpoint_name in (
"list_protocol_refund_records",
"create_protocol_refund_record",
"update_protocol_refund_record",
"delete_protocol_refund_record",
):
assert _route_strict_codes(protocol_refund_record.router, endpoint_name) == set()
def test_collection_protocol_write_paths_keep_payment_detail_guard_only():
source = Path("app/api/collection_protocol.py").read_text()
assert "assert_protocol_sensitive_fields" in source
assert "require_strict_menu_code" not in source
assert "assert_strict_menu_code" not in source
def test_research_donation_write_paths_assert_sensitive_field_permissions():
source = Path("app/api/research_donation.py").read_text()
assert "assert_donation_sensitive_fields" in source
assert PROTOCOL_AUDIT in source
def _permission_user(db, *codes):
_add_user(db)
_add_role(db, role_id="protocol", tenant_no="tenant-a", role_name="协议录入")
_add_menu_grant(
db,
role_id="protocol",
tenant_no="tenant-a",
code=PROTOCOL_LIST,
suffix="protocol",
)
_bind_direct(
db,
user_id="u1",
role_id="protocol",
tenant_no="tenant-a",
suffix="protocol",
)
for index, code in enumerate(codes):
role_id = f"extra-{index}"
_add_role(db, role_id=role_id, tenant_no="tenant-a")
_add_menu_grant(
db,
role_id=role_id,
tenant_no="tenant-a",
code=code,
suffix=role_id,
)
_bind_direct(
db,
user_id="u1",
role_id=role_id,
tenant_no="tenant-a",
suffix=role_id,
)
db.commit()
return {"user_id": "u1", "tenant_no": "tenant-a", "role": "admin"}
def test_protocol_sensitive_fields_no_longer_require_menu_permissions():
from app.api.collection_protocol import (
ProtocolCreateBody,
ProtocolUpdateBody,
assert_protocol_sensitive_fields,
)
db = _session()
user = _permission_user(db)
assert_protocol_sensitive_fields(db, user, ProtocolCreateBody(), creating=True)
assert_protocol_sensitive_fields(
db,
user,
ProtocolCreateBody(protocolStatus="sxz"),
creating=True,
)
assert_protocol_sensitive_fields(
db,
user,
ProtocolUpdateBody(storageCert={}),
creating=False,
)
def test_donation_sensitive_fields_require_audit_permission():
from app.api.research_donation import (
DonationCreateBody,
DonationUpdateBody,
assert_donation_sensitive_fields,
)
db = _session()
user = _permission_user(db)
assert_donation_sensitive_fields(db, user, DonationCreateBody(), creating=True)
for body, creating in (
(DonationCreateBody(donationStatus="sxz"), True),
(DonationCreateBody(sourceType="cell_storage"), True),
(DonationUpdateBody(sourceProtocolId="p1"), False),
):
with pytest.raises(HTTPException) as exc_info:
assert_donation_sensitive_fields(db, user, body, creating=creating)
assert exc_info.value.status_code == 403
def test_market_only_user_is_stopped_before_sensitive_crud(monkeypatch):
"""其它仍挂菜单码的写接口:仅有小程序市场权限时仍应 403。
采集协议单接口已按方案去掉菜单码,不在本用例断言范围内。
"""
from app.api import (
protocol_sample_payment,
sample_cert_insurance,
sample_storage_cert_qc,
)
db = _thread_session()
_add_user(db)
_add_role(db, role_id="market", tenant_no="tenant-a", role_name="市场专员")
_add_menu_grant(
db,
role_id="market",
tenant_no="tenant-a",
code="mini:market:specialist:protocol",
suffix="mini-protocol",
)
_bind_direct(
db, user_id="u1", role_id="market", tenant_no="tenant-a", suffix="market"
)
db.commit()
called = []
def forbidden_call(*args, **kwargs):
called.append((args, kwargs))
raise AssertionError("sensitive CRUD must not be called")
monkeypatch.setattr(
protocol_sample_payment.psp_svc, "confirm_receipt", forbidden_call
)
monkeypatch.setattr(
sample_cert_insurance.cert_crud, "upsert_handle", forbidden_call
)
monkeypatch.setattr(sample_storage_cert_qc.svc, "upsert", forbidden_call)
app = FastAPI()
for router in (
protocol_sample_payment.router,
sample_cert_insurance.router,
sample_storage_cert_qc.router,
):
app.include_router(router)
user = {"user_id": "u1", "tenant_no": "tenant-a", "role": "admin"}
def override_db():
yield db
app.dependency_overrides[get_current_user] = lambda: user
app.dependency_overrides[get_db] = override_db
requests = [
("post", "/web/admin/payment-transfers/t1/receipt-confirm", {}),
("put", "/web/admin/sample-storage-certs/S1", {}),
("put", "/web/admin/sample-storage-cert-qc/S1", {"payload": {}}),
]
client = TestClient(app)
for method, url, body in requests:
response = client.request(method, url, json=body)
assert response.status_code == 403, (method, url, response.text)
assert called == []
def test_quick_register_writes_skip_menu_and_data_scope():
"""客户服务七类登记不再校验菜单码,也不按数据权限拦截样本。"""
api_source = Path("app/api/protocol_sample_collection.py").read_text()
crud_source = Path("app/crud/sample_info_quick_register.py").read_text()
assert "assert_strict_menu_code" not in api_source
assert "apply_data_scope" not in crud_source
@pytest.mark.parametrize("kind", ["storage_cert", "insurance", "all"])
def test_quick_register_does_not_require_storage_cert_menu(monkeypatch, kind):
"""客户服务管理登记储存证/保险不再要求储存证菜单权限。"""
from app.api import protocol_sample_collection
db = _session()
user = _permission_user(db)
called = []
def allowed_call(*args, **kwargs):
called.append((args, kwargs))
return {"success": 1, "fail": 0}
monkeypatch.setattr(
protocol_sample_collection.quick_svc,
"batch_handle_storage_certs",
allowed_call,
)
monkeypatch.setattr(
protocol_sample_collection.quick_svc,
"batch_handle_insurance",
allowed_call,
)
body = protocol_sample_collection.QuickRegisterBody(
kind=kind,
receptionSource="cell_storage",
items=[{"sampleCode": "S1", "protocolId": "P1"}],
handleTime="2026-10-14 10:00:00",
)
result = asyncio.run(
protocol_sample_collection.quick_register_sample_info(
body=body,
db=db,
user=user,
)
)
assert result["code"] == 200
assert called
@pytest.mark.parametrize(
"payment_detail",
[
{"installmentNo": 1, "payStatus": "yjf"},
{"installmentNo": 1, "PayStatus": "yjf"},
{"installmentNo": 1, "financeConfirmAttachments": "secret.pdf"},
{
"installmentNo": 1,
"financeConfirmAttachmentFiles": [{"url": "secret.pdf"}],
},
{"installmentNo": 1, "financeRemark": "confirmed"},
{"installmentNo": 1, "refundAmount": 100},
{"installmentNo": 1, "refundRemark": "refund"},
{"installmentNo": 1, "refundAttachments": "refund.pdf"},
{
"installmentNo": 1,
"refundAttachmentFiles": [{"url": "refund.pdf"}],
},
{
"installmentNo": 1,
"refundFinanceConfirmAttachments": "finance.pdf",
},
{
"installmentNo": 1,
"refundFinanceConfirmAttachmentFiles": [{"url": "finance.pdf"}],
},
{"installmentNo": 1, "refundFinanceRemark": "confirmed"},
],
)
def test_protocol_create_rejects_nested_finance_and_refund_fields_before_crud(
monkeypatch, payment_detail
):
from app.api import collection_protocol
called = []
def forbidden_call(*args, **kwargs):
called.append((args, kwargs))
raise AssertionError("protocol CRUD must not be called")
monkeypatch.setattr(
collection_protocol.cp_svc,
"create_collection_protocol",
forbidden_call,
)
body = collection_protocol.ProtocolCreateBody(
paymentModelVersion=1,
paymentDetails=[payment_detail],
)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
collection_protocol.create_collection_protocol(
body=body,
user={"user_id": "u1", "role": "admin"},
db=None,
)
)
assert exc_info.value.status_code == 400
assert called == []
def test_protocol_update_rejects_snake_case_nested_sensitive_fields_before_crud(
monkeypatch,
):
from app.api import collection_protocol
called = []
def forbidden_call(*args, **kwargs):
called.append((args, kwargs))
raise AssertionError("protocol CRUD must not be called")
monkeypatch.setattr(
collection_protocol.cp_svc,
"update_collection_protocol",
forbidden_call,
)
body = collection_protocol.ProtocolUpdateBody(
payment_details=[
{
"installment_no": 1,
"finance_confirm_attachment_files": [{"url": "secret.pdf"}],
}
]
)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
collection_protocol.update_collection_protocol(
protocol_id="p1",
body=body,
user={"user_id": "u1", "role": "admin"},
db=None,
)
)
assert exc_info.value.status_code == 400
assert called == []
def test_payment_detail_sensitive_field_inventory_matches_crud_write_fields():
from app.api.collection_protocol import _PAYMENT_DETAIL_SENSITIVE_FIELDS
assert _PAYMENT_DETAIL_SENSITIVE_FIELDS == {
"pay_status",
"payStatus",
"finance_confirm_attachments",
"financeConfirmAttachments",
"finance_confirm_attachment_files",
"financeConfirmAttachmentFiles",
"finance_remark",
"financeRemark",
"refund_amount",
"refundAmount",
"refund_remark",
"refundRemark",
"refund_attachments",
"refundAttachments",
"refund_attachment_files",
"refundAttachmentFiles",
"refund_finance_confirm_attachments",
"refundFinanceConfirmAttachments",
"refund_finance_confirm_attachment_files",
"refundFinanceConfirmAttachmentFiles",
"refund_finance_remark",
"refundFinanceRemark",
}