修复:收紧入库数据范围校验

This commit is contained in:
焦龙言
2026-07-26 16:14:27 +08:00
parent 6f22cf0282
commit 2e616ab6e0
5 changed files with 416 additions and 74 deletions
@@ -7,13 +7,14 @@ from types import SimpleNamespace
from typing import Any, get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.routing import APIRoute
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.interface_auth import CheckUserInterfaceAuth
from common.aspect.pre_auth import PreAuth
from common.context import RequestContext
from common.vo import (
CrudResponseModel,
DataResponseModel,
@@ -23,6 +24,7 @@ from common.vo import (
)
from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
from module_admin.service.log_service import LogQueueService
from module_admin.service.login_service import LoginService
from module_business.controller import inbound_controller as inbound_controller_module
from module_business.dao.access_dao import AccessDao
@@ -161,6 +163,13 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
self.db = MagicMock(spec=AsyncSession)
self.user = make_user()
self.fixed_time = datetime(2026, 7, 26, 10, 30)
self.scope_patcher = patch.object(
AccessDao,
'list_user_warehouse_ids',
new=AsyncMock(return_value=[2, 5, 7, 8, 11]),
)
self.default_scope = self.scope_patcher.start()
self.addCleanup(self.scope_patcher.stop)
async def test_add_validates_and_locks_pallets_before_writing_draft(self) -> None:
page = InboundCreateModel(warehouseId=7, palletIds=[5, 2])
@@ -320,14 +329,38 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_edit_checks_original_and_target_access_then_replaces_items(self) -> None:
async def test_add_checks_warehouse_access_before_revealing_existence(self) -> None:
page = InboundCreateModel(warehouseId=404, palletIds=[2])
get_warehouse = AsyncMock(return_value=None)
access = AsyncMock(
side_effect=PermissionException(data='', message='无权访问该仓库'),
)
pallet_lock = AsyncMock()
with (
patch.object(MasterDao, 'get_warehouse', new=get_warehouse),
patch.object(AccessService, 'assert_warehouse_access', new=access),
patch.object(InventoryDao, 'get_inventories_for_update', new=pallet_lock),
self.assertRaises(Exception) as raised,
):
await InboundService.add_inbound_services(self.db, page, self.user)
self.assertIsInstance(raised.exception, PermissionException)
self.assertEqual('无权访问该仓库', raised.exception.message)
access.assert_awaited_once_with(self.db, self.user, 404)
get_warehouse.assert_not_awaited()
pallet_lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_edit_scopes_original_and_checks_target_access_then_replaces_items(self) -> None:
page = InboundUpdateModel(inboundId=11, warehouseId=8, palletIds=[5, 2])
inbound = make_inbound(warehouse_id=7, pallet_ids=(3,))
lock_order = AsyncMock(return_value=inbound)
access = AsyncMock()
update = AsyncMock()
replace = AsyncMock()
with (
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(return_value=inbound)),
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
patch.object(AccessService, 'assert_warehouse_access', new=access),
patch.object(
MasterDao,
@@ -351,10 +384,13 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
service_datetime.now.return_value = self.fixed_time
result = await InboundService.edit_inbound_services(self.db, page, self.user)
self.assertEqual(
[unittest.mock.call(self.db, self.user, 7), unittest.mock.call(self.db, self.user, 8)],
access.await_args_list,
self.default_scope.assert_awaited_once_with(self.db, 7)
lock_order.assert_awaited_once_with(
self.db,
11,
warehouse_ids=[2, 5, 7, 8, 11],
)
access.assert_awaited_once_with(self.db, self.user, 8)
inventory_lock.assert_awaited_once_with(self.db, [2, 5])
pallet_lock.assert_awaited_once_with(self.db, [2, 5])
self.assertEqual(8, inbound.warehouse_id)
@@ -388,16 +424,56 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
update.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_edit_scopes_parent_lock_and_hides_unmatched_document(self) -> None:
page = InboundUpdateModel(inboundId=404, warehouseId=8, palletIds=[2])
lock_order = AsyncMock(return_value=None)
get_warehouse = AsyncMock()
inventory_lock = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope,
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
patch.object(MasterDao, 'get_warehouse', new=get_warehouse),
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
self.assertRaises(Exception) as raised,
):
await InboundService.edit_inbound_services(self.db, page, self.user)
self.assertIsInstance(raised.exception, PermissionException)
self.assertEqual('无权访问该入库单', raised.exception.message)
scope.assert_awaited_once_with(self.db, 7)
lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=[7])
get_warehouse.assert_not_awaited()
inventory_lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_admin_missing_edit_keeps_precise_not_found(self) -> None:
page = InboundUpdateModel(inboundId=404, warehouseId=8, palletIds=[2])
scope = AsyncMock()
lock_order = AsyncMock(return_value=None)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
self.assertRaises(ServiceException) as raised,
):
await InboundService.edit_inbound_services(self.db, page, make_user(1))
self.assertEqual('入库单不存在', raised.exception.message)
scope.assert_not_awaited()
lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=None)
self.db.rollback.assert_awaited_once()
async def test_batch_delete_locks_and_preflights_all_before_ordered_delete(self) -> None:
actions: list[str] = []
async def lock(_db: AsyncSession, inbound_id: int) -> BizInboundOrder:
async def lock(
_db: AsyncSession,
inbound_id: int,
warehouse_ids: list[int] | None = None,
) -> BizInboundOrder:
self.assertEqual([2, 5, 7, 8, 11], warehouse_ids)
actions.append(f'lock-{inbound_id}')
return make_inbound(inbound_id=inbound_id, warehouse_id=inbound_id)
async def access(_db: AsyncSession, _user: CurrentUserModel, warehouse_id: int) -> None:
actions.append(f'access-{warehouse_id}')
async def delete(_db: AsyncSession, inbound_id: int) -> None:
actions.append(f'delete-{inbound_id}')
@@ -405,17 +481,20 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
actions.append('commit')
self.db.commit.side_effect = commit
access = AsyncMock()
with (
patch.object(OperationDao, 'get_inbound_for_update', new=AsyncMock(side_effect=lock)),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=access)),
patch.object(AccessService, 'assert_warehouse_access', new=access),
patch.object(OperationDao, 'delete_inbound', new=AsyncMock(side_effect=delete)),
):
result = await InboundService.delete_inbound_services(self.db, [5, 2], self.user)
self.assertEqual(
['lock-2', 'access-2', 'lock-5', 'access-5', 'delete-2', 'delete-5', 'commit'],
['lock-2', 'lock-5', 'delete-2', 'delete-5', 'commit'],
actions,
)
self.default_scope.assert_awaited_once_with(self.db, 7)
access.assert_not_awaited()
self.assertTrue(result.is_success)
self.db.rollback.assert_not_awaited()
@@ -429,7 +508,9 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
patch.object(
OperationDao,
'get_inbound_for_update',
new=AsyncMock(side_effect=lambda _db, inbound_id: records[inbound_id]),
new=AsyncMock(
side_effect=lambda _db, inbound_id, warehouse_ids=None: records[inbound_id],
),
),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
patch.object(OperationDao, 'delete_inbound', new=delete),
@@ -441,6 +522,39 @@ class TestInboundDraftService(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_delete_scopes_parent_lock_and_hides_unmatched_document(self) -> None:
lock_order = AsyncMock(return_value=None)
delete = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope,
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
patch.object(OperationDao, 'delete_inbound', new=delete),
self.assertRaises(Exception) as raised,
):
await InboundService.delete_inbound_services(self.db, [404], self.user)
self.assertIsInstance(raised.exception, PermissionException)
self.assertEqual('无权访问该入库单', raised.exception.message)
scope.assert_awaited_once_with(self.db, 7)
lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=[7])
delete.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_admin_missing_delete_keeps_precise_not_found(self) -> None:
scope = AsyncMock()
lock_order = AsyncMock(return_value=None)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
self.assertRaises(ServiceException) as raised,
):
await InboundService.delete_inbound_services(self.db, [404], make_user(1))
self.assertEqual('入库单不存在', raised.exception.message)
scope.assert_not_awaited()
lock_order.assert_awaited_once_with(self.db, 404, warehouse_ids=None)
self.db.rollback.assert_awaited_once()
async def test_all_draft_mutations_convert_integrity_errors(self) -> None:
add_page = InboundCreateModel(warehouseId=7, palletIds=[2])
with (
@@ -543,28 +657,52 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(0, actual.total)
self.assertFalse(actual.has_next)
async def test_detail_loads_then_checks_warehouse_scope(self) -> None:
async def test_detail_pushes_warehouse_scope_into_parent_query(self) -> None:
inbound = make_inbound()
get_detail = AsyncMock(return_value=inbound)
access = AsyncMock()
with (
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
patch.object(
AccessDao,
'list_user_warehouse_ids',
new=AsyncMock(return_value=[7, 3, 7]),
) as scope,
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
patch.object(AccessService, 'assert_warehouse_access', new=access),
):
actual = await InboundService.inbound_detail_services(self.db, 11, self.user)
access.assert_awaited_once_with(self.db, self.user, 7)
scope.assert_awaited_once_with(self.db, 7)
get_detail.assert_awaited_once_with(self.db, 11, warehouse_ids=[3, 7])
access.assert_not_awaited()
self.assertIsInstance(actual, InboundModel)
async def test_missing_detail_fails_stably_without_access_probe(self) -> None:
access = AsyncMock()
async def test_normal_user_detail_hides_missing_or_external_document(self) -> None:
get_detail = AsyncMock(return_value=None)
with (
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=None)),
patch.object(AccessService, 'assert_warehouse_access', new=access),
self.assertRaises(ServiceException) as raised,
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
self.assertRaises(Exception) as raised,
):
await InboundService.inbound_detail_services(self.db, 404, self.user)
self.assertIsInstance(raised.exception, PermissionException)
self.assertEqual('无权访问该入库单', raised.exception.message)
get_detail.assert_awaited_once_with(self.db, 404, warehouse_ids=[7])
async def test_admin_missing_detail_keeps_precise_not_found(self) -> None:
scope = AsyncMock()
get_detail = AsyncMock(return_value=None)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
self.assertRaises(ServiceException) as raised,
):
await InboundService.inbound_detail_services(self.db, 404, make_user(1))
self.assertEqual('入库单不存在', raised.exception.message)
access.assert_not_awaited()
scope.assert_not_awaited()
get_detail.assert_awaited_once_with(self.db, 404, warehouse_ids=None)
async def test_warehouse_options_filter_disabled_and_unauthorized_records(self) -> None:
warehouses = [
@@ -722,6 +860,58 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.command = ConfirmCommandModel(idempotencyKey='confirm-key')
self.fixed_time = datetime(2026, 7, 26, 14, 20)
self.request_hash = InboundService._build_confirm_request_hash(11)
self.scope_patcher = patch.object(
AccessDao,
'list_user_warehouse_ids',
new=AsyncMock(return_value=[7]),
)
self.default_scope = self.scope_patcher.start()
self.addCleanup(self.scope_patcher.stop)
async def test_confirm_scopes_initial_parent_and_hides_unmatched_document(self) -> None:
get_detail = AsyncMock(return_value=None)
get_by_key = AsyncMock()
lock_order = AsyncMock()
inventory_lock = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])) as scope,
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=get_by_key),
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
patch.object(InventoryDao, 'get_inventories_for_update', new=inventory_lock),
self.assertRaises(Exception) as raised,
):
await InboundService.confirm_inbound_services(self.db, 404, self.command, self.user)
self.assertIsInstance(raised.exception, PermissionException)
self.assertEqual('无权访问该入库单', raised.exception.message)
scope.assert_awaited_once_with(self.db, 7)
get_detail.assert_awaited_once_with(self.db, 404, warehouse_ids=[7])
get_by_key.assert_not_awaited()
lock_order.assert_not_awaited()
inventory_lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_admin_missing_confirm_keeps_precise_not_found(self) -> None:
scope = AsyncMock()
get_detail = AsyncMock(return_value=None)
get_by_key = AsyncMock()
lock_order = AsyncMock()
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
patch.object(OperationDao, 'get_inbound_by_confirm_idempotency_key', new=get_by_key),
patch.object(OperationDao, 'get_inbound_for_update', new=lock_order),
self.assertRaises(ServiceException) as raised,
):
await InboundService.confirm_inbound_services(self.db, 404, self.command, make_user(1))
self.assertEqual('入库单不存在', raised.exception.message)
scope.assert_not_awaited()
get_detail.assert_awaited_once_with(self.db, 404, warehouse_ids=None)
get_by_key.assert_not_awaited()
lock_order.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def _run_successful_confirm(self, *, commit_side_effect: Exception | None = None) -> dict[str, Any]:
inbound = make_inbound()
@@ -730,7 +920,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
actions: list[str] = []
events: list[BizPalletEvent] = []
async def detail(*_args: Any) -> BizInboundOrder:
async def detail(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
actions.append('detail')
return inbound
@@ -740,7 +930,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
async def by_key(*_args: Any) -> None:
actions.append('idempotency-read')
async def lock_order(*_args: Any) -> BizInboundOrder:
async def lock_order(*_args: Any, **_kwargs: Any) -> BizInboundOrder:
actions.append('order-lock')
return inbound
@@ -797,10 +987,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
patch.object(inbound_service_module, 'datetime'),
)
with (
patches[0],
patches[1],
patches[0] as detail_read,
patches[1] as access_probe,
patches[2],
patches[3],
patches[3] as order_lock,
patches[4] as inventory_lock,
patches[5] as pallet_lock,
patches[6] as ensure_transition,
@@ -817,6 +1007,9 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
'inventories': inventories,
'events': events,
'actions': actions,
'detail_read': detail_read,
'access_probe': access_probe,
'order_lock': order_lock,
'inventory_lock': inventory_lock,
'pallet_lock': pallet_lock,
'ensure_transition': ensure_transition,
@@ -828,10 +1021,8 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
[
'detail',
'access',
'idempotency-read',
'order-lock',
'access',
'inventory-lock',
'pallet-lock',
'transition-UNSTOCKED-EMPTY_IN_WAREHOUSE',
@@ -843,6 +1034,10 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
],
captured['actions'],
)
self.default_scope.assert_awaited_once_with(self.db, 7)
captured['detail_read'].assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
captured['order_lock'].assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
captured['access_probe'].assert_not_awaited()
captured['inventory_lock'].assert_awaited_once_with(self.db, [2, 5])
captured['pallet_lock'].assert_awaited_once_with(self.db, [2, 5])
captured['ensure_transition'].assert_has_calls(
@@ -962,8 +1157,8 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
):
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
lock.assert_awaited_once_with(self.db, 11)
self.assertEqual(3, access.await_count)
lock.assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
access.assert_awaited_once_with(self.db, self.user, 7)
inventory_lock.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
@@ -992,7 +1187,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
):
await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
self.assertEqual(2, access.await_count)
access.assert_awaited_once_with(self.db, self.user, 8)
lock.assert_not_awaited()
self.db.rollback.assert_awaited_once()
@@ -1035,7 +1230,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
):
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
get_detail.assert_awaited_once_with(self.db, 11)
get_detail.assert_awaited_once_with(self.db, 11, warehouse_ids=[7])
inventory_lock.assert_awaited_once_with(self.db, [5])
self.assertEqual('EMPTY_IN_WAREHOUSE', inventory.flow_state)
self.assertTrue(result.is_success)
@@ -1063,7 +1258,7 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
):
result = await InboundService.confirm_inbound_services(self.db, 11, self.command, self.user)
self.assertEqual(3, access.await_count)
access.assert_awaited_once_with(self.db, self.user, 7)
self.assertEqual(2, key_reads.await_count)
self.assertEqual(2, self.db.rollback.await_count)
self.assertTrue(result.is_success)
@@ -1098,6 +1293,68 @@ class TestInboundConfirmService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(2, self.db.rollback.await_count)
class TestInboundLogPermission(unittest.IsolatedAsyncioTestCase):
async def test_real_inbound_log_wrapper_preserves_permission_code(self) -> None:
async def receive() -> dict[str, Any]:
return {
'type': 'http.request',
'body': b'{"warehouseId":404,"palletIds":[2]}',
'more_body': False,
}
request = Request(
{
'type': 'http',
'method': 'POST',
'scheme': 'http',
'path': '/business/inbound',
'raw_path': b'/business/inbound',
'root_path': '',
'query_string': b'',
'headers': [
(b'content-type', b'application/json'),
(b'user-agent', b'unittest'),
],
'client': ('127.0.0.1', 12345),
'server': ('testserver', 80),
},
receive=receive,
)
user = make_user()
db = MagicMock(spec=AsyncSession)
service = AsyncMock(
side_effect=PermissionException(data='', message='无权访问该仓库'),
)
enqueue_log = AsyncMock()
user_token = RequestContext.set_current_user(user)
try:
with (
patch.object(InboundService, 'add_inbound_services', new=service),
patch(
'common.annotation.log_annotation.Log._get_oper_location',
new=AsyncMock(return_value='内网IP'),
),
patch.object(LogQueueService, 'enqueue_operation_log', new=enqueue_log),
patch('common.annotation.log_annotation.logger.exception') as exception_log,
):
response = await inbound_controller_module.add_business_inbound(
request=request,
add_inbound=InboundCreateModel(warehouseId=404, palletIds=[2]),
query_db=db,
current_user=user,
)
finally:
RequestContext.reset_current_user(user_token)
payload = json.loads(response.body)
self.assertEqual(403, payload['code'])
self.assertEqual('无权访问该仓库', payload['msg'])
self.assertFalse(payload['success'])
service.assert_awaited_once()
exception_log.assert_not_called()
enqueue_log.assert_awaited_once()
class TestInboundController(unittest.TestCase):
@staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]: