816 lines
23 KiB
Python
816 lines
23 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"),
|
|
[
|
|
(
|
|
"collection_protocol",
|
|
[
|
|
"create_collection_protocol",
|
|
"update_collection_protocol",
|
|
"delete_collection_protocol",
|
|
],
|
|
{PROTOCOL_LIST},
|
|
),
|
|
(
|
|
"research_donation",
|
|
[
|
|
"create_research_donation",
|
|
"update_research_donation",
|
|
"delete_research_donation",
|
|
"batch_delete_research_donations",
|
|
],
|
|
{PROTOCOL_LIST},
|
|
),
|
|
(
|
|
"collection_protocol",
|
|
["submit_collection_protocol_audit"],
|
|
{PROTOCOL_AUDIT},
|
|
),
|
|
(
|
|
"research_donation",
|
|
[
|
|
"submit_donation_audit",
|
|
"change_donation_status",
|
|
"convert_from_storage",
|
|
"convert_to_storage",
|
|
],
|
|
{PROTOCOL_AUDIT},
|
|
),
|
|
(
|
|
"collection_protocol",
|
|
["post_receipt_confirm", "post_receipt_reject"],
|
|
{PAYMENT_CONFIRM},
|
|
),
|
|
(
|
|
"protocol_sample_payment",
|
|
["confirm_payment_transfer", "reject_payment_transfer"],
|
|
{PAYMENT_CONFIRM},
|
|
),
|
|
(
|
|
"presign_order",
|
|
["confirm_presign_transfer", "reject_presign_transfer"],
|
|
{PAYMENT_CONFIRM},
|
|
),
|
|
(
|
|
"collection_protocol",
|
|
["post_protocol_invoice_request", "complete_protocol_invoice_request"],
|
|
{PAYMENT_CONFIRM},
|
|
),
|
|
(
|
|
"collection_protocol",
|
|
[
|
|
"post_submit_refund_finance",
|
|
"post_submit_refund_finance_batch",
|
|
"post_refund_confirm",
|
|
"post_refund_reject",
|
|
],
|
|
{REFUND_CONFIRM},
|
|
),
|
|
(
|
|
"protocol_refund_record",
|
|
[
|
|
"create_protocol_refund_record",
|
|
"update_protocol_refund_record",
|
|
"delete_protocol_refund_record",
|
|
],
|
|
{REFUND_CONFIRM},
|
|
),
|
|
(
|
|
"collection_protocol",
|
|
[
|
|
"update_fullmoon_todo_insurance",
|
|
"update_storage_cert_handle",
|
|
"update_fullmoon_todo_handle",
|
|
],
|
|
{STORAGE_CERT},
|
|
),
|
|
(
|
|
"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_collection_protocol_write_paths_assert_sensitive_field_permissions():
|
|
source = Path("app/api/collection_protocol.py").read_text()
|
|
assert "assert_protocol_sensitive_fields" in source
|
|
assert PROTOCOL_AUDIT in source
|
|
assert STORAGE_CERT 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_require_dedicated_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)
|
|
|
|
with pytest.raises(HTTPException) as status_error:
|
|
assert_protocol_sensitive_fields(
|
|
db,
|
|
user,
|
|
ProtocolCreateBody(protocolStatus="sxz"),
|
|
creating=True,
|
|
)
|
|
assert status_error.value.status_code == 403
|
|
|
|
with pytest.raises(HTTPException) as storage_error:
|
|
assert_protocol_sensitive_fields(
|
|
db,
|
|
user,
|
|
ProtocolUpdateBody(storageCert={}),
|
|
creating=False,
|
|
)
|
|
assert storage_error.value.status_code == 403
|
|
|
|
|
|
def test_protocol_sensitive_fields_allow_union_of_dedicated_permissions():
|
|
from app.api.collection_protocol import (
|
|
ProtocolUpdateBody,
|
|
assert_protocol_sensitive_fields,
|
|
)
|
|
|
|
db = _session()
|
|
user = _permission_user(db, PROTOCOL_AUDIT, STORAGE_CERT)
|
|
|
|
assert_protocol_sensitive_fields(
|
|
db,
|
|
user,
|
|
ProtocolUpdateBody(protocolStatus="sxz", 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):
|
|
from app.api import (
|
|
collection_protocol,
|
|
protocol_refund_record,
|
|
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(
|
|
collection_protocol.cp_svc, "create_collection_protocol", forbidden_call
|
|
)
|
|
monkeypatch.setattr(collection_protocol.cp_svc, "create_audit", forbidden_call)
|
|
monkeypatch.setattr(
|
|
protocol_sample_payment.psp_svc, "confirm_receipt", forbidden_call
|
|
)
|
|
monkeypatch.setattr(
|
|
protocol_refund_record.rr_svc, "create_refund_record", 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 (
|
|
collection_protocol.router,
|
|
protocol_sample_payment.router,
|
|
protocol_refund_record.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/collection-protocols", {}),
|
|
(
|
|
"post",
|
|
"/web/admin/collection-protocols/p1/audit",
|
|
{"auditStatus": "tg"},
|
|
),
|
|
("post", "/web/admin/payment-transfers/t1/receipt-confirm", {}),
|
|
(
|
|
"post",
|
|
"/web/admin/collection-protocols/p1/refund-records",
|
|
{"refundAmount": 1},
|
|
),
|
|
("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 == []
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["storage_cert", "insurance", "all"])
|
|
def test_market_only_user_cannot_use_sensitive_quick_register_branches(
|
|
monkeypatch, kind
|
|
):
|
|
from app.api import protocol_sample_collection
|
|
|
|
db = _session()
|
|
user = _permission_user(db)
|
|
called = []
|
|
|
|
def forbidden_call(*args, **kwargs):
|
|
called.append((args, kwargs))
|
|
return {"success": 1, "fail": 0}
|
|
|
|
monkeypatch.setattr(
|
|
protocol_sample_collection.quick_svc,
|
|
"batch_handle_storage_certs",
|
|
forbidden_call,
|
|
)
|
|
monkeypatch.setattr(
|
|
protocol_sample_collection.quick_svc,
|
|
"batch_handle_insurance",
|
|
forbidden_call,
|
|
)
|
|
body = protocol_sample_collection.QuickRegisterBody(
|
|
kind=kind,
|
|
receptionSource="cell_storage",
|
|
items=[{"sampleCode": "S1", "protocolId": "P1"}],
|
|
handleTime="2026-10-14 10:00:00",
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
protocol_sample_collection.quick_register_sample_info(
|
|
body=body,
|
|
db=db,
|
|
user=user,
|
|
)
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
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",
|
|
}
|