权限:移除业务物理删除入口

This commit is contained in:
焦龙言
2026-09-24 08:13:16 +08:00
parent 4706c4afeb
commit ae8c4c8c38
35 changed files with 668 additions and 445 deletions
@@ -24,11 +24,13 @@ CONTROLLER_NAMES = (
'usage',
'return',
'report',
'test_batch',
)
TARGET_PREFIXES = (
'/master',
'/business',
'/report',
'/system/test-batch',
)
ROUTE_HTTP_METHODS = frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE'})
WRITE_METHODS = frozenset({'DELETE', 'PATCH', 'POST', 'PUT'})
@@ -52,6 +54,7 @@ EXPECTED_PAGE_PERMISSIONS = frozenset(
'report:consumption:list',
'report:return:list',
'report:pallettrace:list',
'system:testbatch:list',
}
)
EXPECTED_BUTTON_PERMISSIONS = frozenset(
@@ -59,50 +62,54 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
'master:strain:query',
'master:strain:add',
'master:strain:edit',
'master:strain:remove',
'master:customer:query',
'master:customer:add',
'master:customer:edit',
'master:customer:remove',
'master:site:query',
'master:site:add',
'master:site:edit',
'master:site:remove',
'master:site:assign',
'master:line:query',
'master:line:add',
'master:line:edit',
'master:line:remove',
'master:warehouse:query',
'master:warehouse:add',
'master:warehouse:edit',
'master:warehouse:remove',
'master:warehouse:assign',
'master:pallet:query',
'master:pallet:add',
'master:pallet:edit',
'master:pallet:remove',
'master:pallet:print',
'business:inbound:query',
'business:inbound:add',
'business:inbound:edit',
'business:inbound:remove',
'business:inbound:confirm',
'business:filling:query',
'business:filling:add',
'business:shipment:query',
'business:shipment:add',
'business:shipment:edit',
'business:shipment:remove',
'business:shipment:confirm',
'business:usage:query',
'business:usage:add',
'business:return:query',
'business:return:request',
'business:return:confirm',
'system:testbatch:query',
'system:testbatch:add',
'system:testbatch:edit',
'system:testbatch:assign',
'system:testbatch:close',
'system:testbatch:adopt',
'system:testbatch:purge',
}
)
EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
EXPECTED_WEB_PERMISSIONS = frozenset(
permission
for permission in EXPECTED_PERMISSIONS
if not permission.startswith('system:testbatch:') or permission == 'system:testbatch:adopt'
)
VUE_PAGE_PATHS = (
'src/views/master/strain/index.vue',
'src/views/master/customer/index.vue',
@@ -122,7 +129,30 @@ VUE_PAGE_PATHS = (
'src/views/report/return/index.vue',
'src/views/report/palletTrace/index.vue',
)
PERMISSION_LITERAL_PATTERN = re.compile(r'(?:master|business|report):[a-z]+:[a-z]+')
PERMISSION_LITERAL_PATTERN = re.compile(r'(?:master|business|report|system):[a-z]+:[a-z]+')
UNGUARDED_AUTHENTICATED_ROUTES = {('/system/test-batch/options', 'GET')}
REMOVED_DELETE_SYMBOLS = (
'delStrain',
'delCustomer',
'delSite',
'delLine',
'delWarehouse',
'delPallet',
'delInbound',
'delShipment',
)
REMOVED_DELETE_PERMISSIONS = frozenset(
{
'master:strain:remove',
'master:customer:remove',
'master:site:remove',
'master:line:remove',
'master:warehouse:remove',
'master:pallet:remove',
'business:inbound:remove',
'business:shipment:remove',
}
)
def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
@@ -136,7 +166,7 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
(f'{root}/{{{resource}_id}}', 'GET'): f'{permission_root}:query',
(root, 'POST'): f'{permission_root}:add',
(root, 'PUT'): f'{permission_root}:edit',
(f'{root}/{{{resource}_ids}}', 'DELETE'): f'{permission_root}:remove',
(f'{root}/{{{resource}_ids}}', 'DELETE'): f'{permission_root}:list',
}
)
@@ -153,7 +183,7 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query',
('/business/inbound', 'POST'): 'business:inbound:add',
('/business/inbound', 'PUT'): 'business:inbound:edit',
('/business/inbound/{inbound_ids}', 'DELETE'): 'business:inbound:remove',
('/business/inbound/{inbound_ids}', 'DELETE'): 'business:inbound:list',
('/business/inbound/{inbound_id}/confirm', 'PUT'): 'business:inbound:confirm',
('/business/filling/list', 'GET'): 'business:filling:list',
('/business/filling/strain-options', 'GET'): 'business:filling:query',
@@ -167,7 +197,7 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query',
('/business/shipment', 'POST'): 'business:shipment:add',
('/business/shipment', 'PUT'): 'business:shipment:edit',
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:remove',
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list',
('/business/shipment/{shipment_id}/confirm', 'PUT'): 'business:shipment:confirm',
('/business/usage/list', 'GET'): 'business:usage:list',
('/business/usage/line-options', 'GET'): 'business:usage:query',
@@ -186,6 +216,17 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
('/report/consumption/list', 'GET'): 'report:consumption:list',
('/report/return/list', 'GET'): 'report:return:list',
('/report/pallet-trace/list', 'GET'): 'report:pallettrace:list',
('/system/test-batch/list', 'GET'): 'system:testbatch:list',
('/system/test-batch', 'POST'): 'system:testbatch:add',
('/system/test-batch/{batch_id}', 'PUT'): 'system:testbatch:edit',
('/system/test-batch/{batch_id}/users', 'PUT'): 'system:testbatch:assign',
('/system/test-batch/{batch_id}/close', 'PUT'): 'system:testbatch:close',
('/system/test-batch/{batch_id}/reopen', 'PUT'): 'system:testbatch:close',
('/system/test-batch/{batch_id}/adoption/preview', 'POST'): 'system:testbatch:adopt',
('/system/test-batch/{batch_id}/adoption/execute', 'POST'): 'system:testbatch:adopt',
('/system/test-batch/{batch_id}/purge/preview', 'POST'): 'system:testbatch:purge',
('/system/test-batch/{batch_id}/purge', 'POST'): 'system:testbatch:purge',
('/system/test-batch/{batch_id}', 'GET'): 'system:testbatch:query',
}
)
return expected
@@ -214,6 +255,10 @@ STATIC_READS_BEFORE_DETAIL = {
'/business/return/warehouse-options',
'/business/return/scan/{qr_token}',
),
'/system/test-batch/{batch_id}': (
'/system/test-batch/options',
'/system/test-batch/list',
),
}
EXACT_QUERY_PARAMETERS = {
('/business/inbound/scan/{qr_token}', 'GET'): {'warehouseId'},
@@ -241,7 +286,7 @@ def _auth_dependencies(route: APIRoute) -> list[CheckUserInterfaceAuth]:
def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None:
expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values())
test_case.assertEqual(74, expected.total())
test_case.assertEqual(85, expected.total())
test_case.assertEqual(expected, Counter(literals))
test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected))
@@ -368,10 +413,18 @@ class ApiPermissionContractTest(unittest.TestCase):
]
def test_target_path_selector_uses_namespace_boundaries(self) -> None:
for path in ('/master', '/master/foo', '/business', '/business/foo', '/report/foo'):
for path in (
'/master',
'/master/foo',
'/business',
'/business/foo',
'/report/foo',
'/system/test-batch',
'/system/test-batch/options',
):
with self.subTest(path=path):
self.assertTrue(_is_target_path(path))
for path in ('/masterish', '/business-other', '/reporting'):
for path in ('/masterish', '/business-other', '/reporting', '/system/test-batches'):
with self.subTest(path=path):
self.assertFalse(_is_target_path(path))
@@ -384,8 +437,8 @@ class ApiPermissionContractTest(unittest.TestCase):
if method.upper() in ROUTE_HTTP_METHODS
}
self.assertEqual(set(EXPECTED_ROUTE_PERMISSIONS), actual_operations)
self.assertEqual(74, len(actual_operations))
self.assertEqual(set(EXPECTED_ROUTE_PERMISSIONS) | UNGUARDED_AUTHENTICATED_ROUTES, actual_operations)
self.assertEqual(86, len(actual_operations))
openapi_ai_paths = sorted(path for path in self.schema['paths'] if path == '/ai' or path.startswith('/ai/'))
app_ai_paths = sorted(
@@ -406,8 +459,11 @@ class ApiPermissionContractTest(unittest.TestCase):
operation_keys.append(key)
auth_dependencies = _auth_dependencies(route)
self.assertEqual(1, len(auth_dependencies), key)
actual_permissions[key] = auth_dependencies[0].perm
if key in UNGUARDED_AUTHENTICATED_ROUTES:
self.assertEqual([], auth_dependencies, key)
else:
self.assertEqual(1, len(auth_dependencies), key)
actual_permissions[key] = auth_dependencies[0].perm
self.assertEqual(len(operation_keys), len(set(operation_keys)))
self.assertEqual(EXPECTED_ROUTE_PERMISSIONS, actual_permissions)
@@ -435,19 +491,19 @@ class ApiPermissionContractTest(unittest.TestCase):
literals.append(node.args[0].value)
self.assertEqual([], malformed_calls)
self.assertEqual(17, len(EXPECTED_PAGE_PERMISSIONS))
self.assertEqual(44, len(EXPECTED_BUTTON_PERMISSIONS))
self.assertEqual(18, len(EXPECTED_PAGE_PERMISSIONS))
self.assertEqual(43, len(EXPECTED_BUTTON_PERMISSIONS))
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
self.assertEqual(61, len(EXPECTED_PERMISSIONS))
_assert_controller_permission_literals(self, literals)
self.assertEqual(
{'add', 'assign', 'confirm', 'edit', 'list', 'print', 'query', 'remove', 'request'},
{'add', 'adopt', 'assign', 'close', 'confirm', 'edit', 'list', 'print', 'purge', 'query', 'request'},
{permission.rsplit(':', maxsplit=1)[1] for permission in literals},
)
def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None:
literals = list(EXPECTED_ROUTE_PERMISSIONS.values())
self.assertEqual(74, len(literals))
self.assertEqual(85, len(literals))
literals.append(literals[0])
with self.assertRaises(AssertionError):
@@ -473,7 +529,66 @@ class ApiPermissionContractTest(unittest.TestCase):
self.assertTrue(guarded, relative_path)
actual_permissions.update(guarded)
_assert_exact_permission_layers(self, {'vue': actual_permissions})
self.assertEqual(EXPECTED_WEB_PERMISSIONS, frozenset(actual_permissions))
def test_frontend_has_no_physical_delete_api_or_page_contract(self) -> None:
relative_paths = (
'src/api/master/strain.js',
'src/api/master/customer.js',
'src/api/master/site.js',
'src/api/master/line.js',
'src/api/master/warehouse.js',
'src/api/master/pallet.js',
'src/api/business/inbound.js',
'src/api/business/shipment.js',
*VUE_PAGE_PATHS,
)
for relative_path in relative_paths:
source = (FRONTEND_ROOT / relative_path).read_text(encoding='utf-8')
code, structure = _sanitize_vue_source(source)
with self.subTest(relative_path=relative_path):
for symbol in REMOVED_DELETE_SYMBOLS:
self.assertIsNone(re.search(rf'\b{symbol}\b', structure), symbol)
self.assertFalse(REMOVED_DELETE_PERMISSIONS & set(PERMISSION_LITERAL_PATTERN.findall(code)))
def test_sensitive_test_batch_logs_only_include_batch_id_and_response_summary(self) -> None:
source_path = BACKEND_ROOT / 'module_business/controller/test_batch_controller.py'
tree = ast.parse(source_path.read_text(encoding='utf-8'), filename=str(source_path))
functions = {node.name: node for node in tree.body if isinstance(node, ast.AsyncFunctionDef)}
sensitive_handlers = {
'preview_test_batch_adoption',
'execute_test_batch_adoption',
'preview_test_batch_purge',
'purge_test_batch',
}
self.assertEqual(sensitive_handlers, sensitive_handlers & functions.keys())
for handler_name in sensitive_handlers:
log_calls = [
decorator
for decorator in functions[handler_name].decorator_list
if isinstance(decorator, ast.Call)
and isinstance(decorator.func, ast.Name)
and decorator.func.id == 'Log'
]
with self.subTest(handler=handler_name):
self.assertEqual(1, len(log_calls))
keywords = {keyword.arg: keyword.value for keyword in log_calls[0].keywords}
self.assertEqual('include', ast.literal_eval(keywords['request_log_mode']))
self.assertEqual('summary', ast.literal_eval(keywords['response_log_mode']))
include_fields = keywords['request_include_fields']
self.assertIsInstance(include_fields, ast.Tuple)
self.assertEqual(1, len(include_fields.elts))
field_call = include_fields.elts[0]
self.assertIsInstance(field_call, ast.Call)
self.assertEqual('batch_id', ast.literal_eval(field_call.args[0]))
self.assertIsInstance(field_call.func, ast.Attribute)
self.assertEqual('field', field_call.func.attr)
root = field_call.func.value
self.assertIsInstance(root, ast.Attribute)
self.assertEqual('PATH_PARAMS', root.attr)
self.assertIsInstance(root.value, ast.Name)
self.assertEqual('RequestLogFieldRoot', root.value.id)
def test_vue_guard_parser_rejects_dynamic_arrays_and_has_permi_or(self) -> None:
dynamic_sources = (
@@ -561,7 +676,7 @@ class ApiPermissionContractTest(unittest.TestCase):
self.assertLess(route_order[static_path], route_order[detail_path])
def test_openapi_parameter_names_are_exact(self) -> None:
for path, method in EXPECTED_ROUTE_PERMISSIONS:
for path, method in set(EXPECTED_ROUTE_PERMISSIONS) | UNGUARDED_AUTHENTICATED_ROUTES:
parameters = self.schema['paths'][path][method.lower()].get('parameters', [])
actual_path_parameters = {parameter['name'] for parameter in parameters if parameter['in'] == 'path'}
self.assertEqual(set(re.findall(r'{([^{}]+)}', path)), actual_path_parameters, (path, method))
@@ -593,7 +708,7 @@ class ApiPermissionContractTest(unittest.TestCase):
for method in route.methods & WRITE_METHODS:
actual_write_routes[(route.path, method)] = route
self.assertEqual(32, len(expected_write_permissions))
self.assertEqual(41, len(expected_write_permissions))
self.assertEqual(set(expected_write_permissions), set(actual_write_routes))
for key, expected_permission in expected_write_permissions.items():
with self.subTest(operation=key):