权限:移除业务物理删除入口
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user