安全:隔离报表选项扫码与首页数据

This commit is contained in:
焦龙言
2026-09-23 12:37:30 +08:00
parent 5a76fe7521
commit c97f6ed3bd
8 changed files with 429 additions and 62 deletions
@@ -11,6 +11,7 @@ from sqlalchemy.sql.elements import BinaryExpression, BindParameter, BooleanClau
from exceptions.exception import PermissionException
from module_business.dao.access_dao import AccessDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizUserSite, BizUserWarehouse
from module_business.service.access_service import AccessService
@@ -260,32 +261,28 @@ class AccessDaoTest(unittest.IsolatedAsyncioTestCase):
result.scalars.return_value.all.return_value = (11, 13)
db = self._db_with_result(result)
warehouse_ids = await AccessDao.list_user_warehouse_ids(db, user_id=8)
warehouse_ids = await AccessDao.list_user_warehouse_ids(db, user_id=8, scope=TestBatchScope.formal())
self.assertEqual([11, 13], warehouse_ids)
statement = db.execute.await_args.args[0]
self._assert_statement_targets(
statement,
BizUserWarehouse,
BizUserWarehouse.warehouse_id,
((BizUserWarehouse.user_id, 8),),
)
sql = ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split())
self.assertIn('JOIN biz_warehouse', sql)
self.assertIn('biz_user_warehouse.user_id = 8', sql)
self.assertIn('biz_warehouse.test_batch_id IS NULL', sql)
async def test_list_user_site_ids_returns_scalar_ids(self) -> None:
result = MagicMock()
result.scalars.return_value.all.return_value = (3, 5)
db = self._db_with_result(result)
site_ids = await AccessDao.list_user_site_ids(db, user_id=8)
site_ids = await AccessDao.list_user_site_ids(db, user_id=8, scope=TestBatchScope.formal())
self.assertEqual([3, 5], site_ids)
statement = db.execute.await_args.args[0]
self._assert_statement_targets(
statement,
BizUserSite,
BizUserSite.site_id,
((BizUserSite.user_id, 8),),
)
sql = ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split())
self.assertIn('JOIN biz_customer_site', sql)
self.assertIn('biz_user_site.user_id = 8', sql)
self.assertIn('biz_customer_site.test_batch_id IS NULL', sql)
async def test_replace_warehouse_users_replaces_complete_mapping(self) -> None:
db = AsyncMock(spec=AsyncSession)
@@ -22,6 +22,7 @@ from module_business.controller import report_controller as report_controller_mo
from module_business.dao import report_dao as report_dao_module
from module_business.dao.access_dao import AccessDao
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.report_vo import (
ConsumptionReportModel,
ConsumptionReportQueryModel,
@@ -206,12 +207,12 @@ class TestReportPublicContract(unittest.TestCase):
report_dao = getattr(report_dao_module, 'ReportDao', None)
self.assertIsNotNone(report_dao, 'ReportDao 尚未实现')
expected_signatures = {
'get_pallet_inventory_list': ('db', 'query_object', 'warehouse_ids', 'site_ids'),
'get_filling_report_list': ('db', 'query_object', 'warehouse_ids'),
'get_shipment_report_list': ('db', 'query_object', 'warehouse_ids', 'site_ids'),
'get_consumption_report_list': ('db', 'query_object', 'site_ids'),
'get_return_report_list': ('db', 'query_object', 'site_ids', 'warehouse_ids'),
'get_pallet_trace_list': ('db', 'query_object', 'warehouse_ids', 'site_ids'),
'get_pallet_inventory_list': ('db', 'query_object', 'scope', 'warehouse_ids', 'site_ids'),
'get_filling_report_list': ('db', 'query_object', 'scope', 'warehouse_ids'),
'get_shipment_report_list': ('db', 'query_object', 'scope', 'warehouse_ids', 'site_ids'),
'get_consumption_report_list': ('db', 'query_object', 'scope', 'site_ids'),
'get_return_report_list': ('db', 'query_object', 'scope', 'site_ids', 'warehouse_ids'),
'get_pallet_trace_list': ('db', 'query_object', 'scope', 'warehouse_ids', 'site_ids'),
}
for method_name, expected_parameters in expected_signatures.items():
@@ -237,7 +238,7 @@ class TestReportPublicContract(unittest.TestCase):
method = getattr(report_service, method_name, None)
self.assertIsNotNone(method, f'ReportService.{method_name} 尚未实现')
self.assertEqual(
('query_db', 'query_object', 'current_user'),
('query_db', 'query_object', 'current_user', 'test_scope'),
tuple(inspect.signature(method).parameters),
)
@@ -335,6 +336,7 @@ class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase):
hasNext=False,
)
paginate = AsyncMock(return_value=page)
scope.setdefault('scope', TestBatchScope.formal())
with patch.object(PageUtil, 'paginate', new=paginate):
try:
result = await method(self.db, query_object, **scope)
@@ -659,23 +661,24 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
dao_method = AsyncMock(return_value=page)
warehouse_scope = AsyncMock(return_value=[8, 7, 7])
site_scope = AsyncMock(return_value=[10, 9, 9])
test_scope = TestBatchScope.formal()
with (
patch.object(ReportDao, dao_name, new=dao_method),
patch.object(AccessDao, 'list_user_warehouse_ids', new=warehouse_scope),
patch.object(AccessDao, 'list_user_site_ids', new=site_scope),
):
try:
result = await getattr(ReportService, service_name)(self.db, query_object, self.user)
result = await getattr(ReportService, service_name)(self.db, query_object, self.user, test_scope)
except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现数据范围和行转换')
dao_method.assert_awaited_once_with(self.db, query_object, **expected_scope)
dao_method.assert_awaited_once_with(self.db, query_object, test_scope, **expected_scope)
if 'warehouse_ids' in expected_scope:
warehouse_scope.assert_awaited_once_with(self.db, 7)
warehouse_scope.assert_awaited_once_with(self.db, 7, test_scope)
else:
warehouse_scope.assert_not_awaited()
if 'site_ids' in expected_scope:
site_scope.assert_awaited_once_with(self.db, 7)
site_scope.assert_awaited_once_with(self.db, 7, test_scope)
else:
site_scope.assert_not_awaited()
self.assertEqual(17, result.total)
@@ -779,6 +782,7 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
)
for service_name, dao_name, query_object, expected_scope in cases:
with self.subTest(service_name=service_name):
test_scope = TestBatchScope.formal()
dao_method = AsyncMock(
return_value=PageModel(
rows=[],
@@ -796,13 +800,13 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
patch.object(AccessDao, 'list_user_site_ids', new=site_scope),
):
try:
await getattr(ReportService, service_name)(self.db, query_object, make_user(1))
await getattr(ReportService, service_name)(self.db, query_object, make_user(1), test_scope)
except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现管理员范围语义')
warehouse_scope.assert_not_awaited()
site_scope.assert_not_awaited()
dao_method.assert_awaited_once_with(self.db, query_object, **expected_scope)
dao_method.assert_awaited_once_with(self.db, query_object, test_scope, **expected_scope)
async def _assert_empty_page_without_dao(
self,
@@ -813,6 +817,7 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
site_ids: list[int] | None,
) -> None:
dao_method = AsyncMock()
test_scope = TestBatchScope.formal()
with (
patch.object(ReportDao, dao_name, new=dao_method),
patch.object(
@@ -827,7 +832,7 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
),
):
try:
result = await getattr(ReportService, service_name)(self.db, query_object, self.user)
result = await getattr(ReportService, service_name)(self.db, query_object, self.user, test_scope)
except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现空范围短路')
@@ -888,7 +893,12 @@ class TestReportServices(unittest.IsolatedAsyncioTestCase):
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[])),
):
try:
await getattr(ReportService, service_name)(self.db, query_object, self.user)
await getattr(ReportService, service_name)(
self.db,
query_object,
self.user,
TestBatchScope.formal(),
)
except NotImplementedError:
self.fail(f'ReportService.{service_name} 尚未实现单侧 OR 范围可见性')
dao_method.assert_awaited_once()
@@ -0,0 +1,235 @@
import ast
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql, postgresql
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
from module_business.dao import report_dao as report_dao_module
from module_business.dao.access_dao import AccessDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.report_vo import (
ConsumptionReportQueryModel,
FillingReportQueryModel,
PalletInventoryReportQueryModel,
PalletTraceReportQueryModel,
ReturnReportQueryModel,
ShipmentReportQueryModel,
)
from module_business.service.report_service import ReportService
from utils.page_util import PageUtil
BACKEND_ROOT = Path(__file__).resolve().parents[2]
SCOPED_DAO_METHODS = {
'module_business/dao/master_dao.py': {
'list_strain',
'get_strain',
'list_customer',
'get_customer',
'list_site',
'get_site',
'list_line',
'get_line',
'list_warehouse',
'get_warehouse',
'list_pallet',
'get_pallet',
},
'module_business/dao/operation_dao.py': {
'get_inbound_list',
'get_inbound_by_id',
'get_filling_list',
'get_filling_by_id',
'get_shipment_list',
'get_shipment_by_id',
'get_usage_list',
'get_usage_by_id',
'get_return_list',
'get_return_by_id',
},
'module_business/dao/report_dao.py': {
'get_pallet_inventory_list',
'get_filling_report_list',
'get_shipment_report_list',
'get_consumption_report_list',
'get_return_report_list',
'get_pallet_trace_list',
},
}
def make_test_scope(batch_id: int) -> TestBatchScope:
return TestBatchScope(
batch_id=batch_id,
mode='TEST',
status=SimpleNamespace(value='ACTIVE'),
writable=True,
)
def _compile(statement: object) -> str:
return ' '.join(
' '.join(str(statement.compile(dialect=dialect, compile_kwargs={'literal_binds': True})).lower().split())
for dialect in (mysql.dialect(), postgresql.dialect())
)
class TestScopedDaoSignatureContract(unittest.TestCase):
def test_all_public_read_daos_require_test_batch_scope(self) -> None:
for relative_path, method_names in SCOPED_DAO_METHODS.items():
tree = ast.parse((BACKEND_ROOT / relative_path).read_text(encoding='utf-8'))
methods = {
node.name: node for node in ast.walk(tree) if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
}
for method_name in method_names:
with self.subTest(path=relative_path, method=method_name):
method = methods[method_name]
parameters = [*method.args.posonlyargs, *method.args.args, *method.args.kwonlyargs]
scope = next((parameter for parameter in parameters if parameter.arg == 'scope'), None)
self.assertIsNotNone(scope)
self.assertIn('TestBatchScope', ast.unparse(scope.annotation))
class TestAccessDaoBatchScope(unittest.IsolatedAsyncioTestCase):
async def _capture_ids(self, method_name: str, scope: TestBatchScope) -> str:
result = MagicMock()
result.scalars.return_value.all.return_value = [7]
db = AsyncMock(spec=AsyncSession)
db.execute.return_value = result
await getattr(AccessDao, method_name)(db, 8, scope)
statement = db.execute.await_args.args[0]
return _compile(statement)
async def test_authorized_warehouse_ids_join_and_filter_resource_scope(self) -> None:
sql = await self._capture_ids('list_user_warehouse_ids', make_test_scope(7))
self.assertIn('join biz_warehouse', sql)
self.assertIn('biz_warehouse.test_batch_id = 7', sql)
async def test_authorized_site_ids_formal_scope_filters_formal_resource(self) -> None:
sql = await self._capture_ids('list_user_site_ids', TestBatchScope.formal())
self.assertIn('join biz_customer_site', sql)
self.assertIn('biz_customer_site.test_batch_id is null', sql)
class TestReportSqlBatchScope(unittest.IsolatedAsyncioTestCase):
async def _capture_report_sql(self, method_name: str, query_object: object) -> str:
db = MagicMock(spec=AsyncSession)
page = PageModel(
rows=[], pageNum=query_object.page_num, pageSize=query_object.page_size, total=0, hasNext=False
)
paginate = AsyncMock(return_value=page)
scope_arguments = {
'get_pallet_inventory_list': {'warehouse_ids': [8, 7], 'site_ids': [10, 9]},
'get_filling_report_list': {'warehouse_ids': [8, 7]},
'get_shipment_report_list': {'warehouse_ids': [8, 7], 'site_ids': [10, 9]},
'get_consumption_report_list': {'site_ids': [10, 9]},
'get_return_report_list': {'site_ids': [10, 9], 'warehouse_ids': [8, 7]},
'get_pallet_trace_list': {'warehouse_ids': [8, 7], 'site_ids': [10, 9]},
}
with patch.object(PageUtil, 'paginate', new=paginate):
result = await getattr(report_dao_module.ReportDao, method_name)(
db,
query_object,
make_test_scope(7),
**scope_arguments[method_name],
)
self.assertIs(page, result)
return _compile(paginate.await_args.args[1])
async def test_each_report_root_query_contains_test_batch_condition(self) -> None:
cases = (
(
'get_pallet_inventory_list',
PalletInventoryReportQueryModel(),
(
'biz_pallet_inventory.test_batch_id = 7',
'biz_pallet.test_batch_id = 7',
'active_filling.test_batch_id = 7',
),
),
(
'get_filling_report_list',
FillingReportQueryModel(),
(
'biz_filling_record.test_batch_id = 7',
'biz_pallet.test_batch_id = 7',
'biz_warehouse.test_batch_id = 7',
),
),
(
'get_shipment_report_list',
ShipmentReportQueryModel(),
(
'biz_shipment_order.test_batch_id = 7',
'biz_shipment_item.test_batch_id = 7',
'biz_pallet.test_batch_id = 7',
'source_warehouse.test_batch_id = 7',
'target_site.test_batch_id = 7',
'target_customer.test_batch_id = 7',
),
),
(
'get_consumption_report_list',
ConsumptionReportQueryModel(),
(
'biz_usage_record.test_batch_id = 7',
'biz_pallet.test_batch_id = 7',
'biz_filling_record.test_batch_id = 7',
'biz_customer_site.test_batch_id = 7',
'biz_production_line.test_batch_id = 7',
'biz_customer.test_batch_id = 7',
),
),
(
'get_return_report_list',
ReturnReportQueryModel(),
(
'biz_return_order.test_batch_id = 7',
'biz_return_item.test_batch_id = 7',
'biz_pallet.test_batch_id = 7',
'source_site.test_batch_id = 7',
'target_warehouse.test_batch_id = 7',
),
),
(
'get_pallet_trace_list',
PalletTraceReportQueryModel(),
(
'biz_pallet_event.test_batch_id = 7',
'biz_pallet.test_batch_id = 7',
'before_warehouse.test_batch_id = 7',
'after_warehouse.test_batch_id = 7',
'before_site.test_batch_id = 7',
'after_site.test_batch_id = 7',
'before_line.test_batch_id = 7',
'after_line.test_batch_id = 7',
),
),
)
for method_name, query_object, expected_fragments in cases:
with self.subTest(method=method_name):
sql = await self._capture_report_sql(method_name, query_object)
for fragment in expected_fragments:
self.assertIn(fragment, sql)
class TestReportServiceScopeForwarding(unittest.IsolatedAsyncioTestCase):
async def test_formal_scope_is_forwarded_to_report_dao(self) -> None:
db = MagicMock(spec=AsyncSession)
user = SimpleNamespace(user=SimpleNamespace(user_id=1, admin=True))
query = PalletInventoryReportQueryModel()
page = PageModel(rows=[], pageNum=1, pageSize=10, total=0, hasNext=False)
dao = AsyncMock(return_value=page)
with patch.object(report_dao_module.ReportDao, 'get_pallet_inventory_list', new=dao):
await ReportService.get_pallet_inventory_list_services(db, query, user, TestBatchScope.formal())
self.assertEqual(TestBatchScope.formal(), dao.await_args.args[2])
if __name__ == '__main__':
unittest.main()