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

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):
@@ -446,6 +446,7 @@ EXPECTED_CHECKS.update(
)
EXPECTED_PAGES = {
519: ('测试数据批次', 1, 7, 'testBatch', 'system/testBatch/index', 'system:testbatch:list'),
502: ('菌种资料', 121, 1, 'strain', 'master/strain/index', 'master:strain:list'),
503: ('客户资料', 121, 2, 'customer', 'master/customer/index', 'master:customer:list'),
504: ('客户现场', 121, 3, 'site', 'master/site/index', 'master:site:list'),
@@ -476,47 +477,46 @@ BUTTON_SPECS = """
1065 502 1 菌种查询 master:strain:query
1066 502 2 菌种新增 master:strain:add
1067 502 3 菌种修改 master:strain:edit
1068 502 4 菌种删除 master:strain:remove
1069 503 1 客户查询 master:customer:query
1070 503 2 客户新增 master:customer:add
1071 503 3 客户修改 master:customer:edit
1072 503 4 客户删除 master:customer:remove
1073 504 1 现场查询 master:site:query
1074 504 2 现场新增 master:site:add
1075 504 3 现场修改 master:site:edit
1076 504 4 现场删除 master:site:remove
1077 504 5 分配用户 master:site:assign
1078 505 1 产线查询 master:line:query
1079 505 2 产线新增 master:line:add
1080 505 3 产线修改 master:line:edit
1081 505 4 产线删除 master:line:remove
1082 506 1 仓库查询 master:warehouse:query
1083 506 2 仓库新增 master:warehouse:add
1084 506 3 仓库修改 master:warehouse:edit
1085 506 4 仓库删除 master:warehouse:remove
1086 506 5 分配用户 master:warehouse:assign
1087 507 1 托盘查询 master:pallet:query
1088 507 2 托盘新增 master:pallet:add
1089 507 3 托盘修改 master:pallet:edit
1090 507 4 托盘删除 master:pallet:remove
1091 507 5 托盘打印 master:pallet:print
1092 508 1 入库查询 business:inbound:query
1093 508 2 入库新增 business:inbound:add
1094 508 3 入库修改 business:inbound:edit
1095 508 4 入库删除 business:inbound:remove
1096 508 5 入库确认 business:inbound:confirm
1097 509 1 装填查询 business:filling:query
1098 509 2 装填新增 business:filling:add
1099 510 1 发货查询 business:shipment:query
1100 510 2 发货新增 business:shipment:add
1101 510 3 发货修改 business:shipment:edit
1102 510 4 发货删除 business:shipment:remove
1103 510 5 发货确认 business:shipment:confirm
1104 511 1 使用查询 business:usage:query
1105 511 2 使用新增 business:usage:add
1106 512 1 退回查询 business:return:query
1107 512 2 退回申请 business:return:request
1108 512 3 退回确认 business:return:confirm
1109 519 1 批次查询 system:testbatch:query
1110 519 2 批次新增 system:testbatch:add
1111 519 3 批次修改 system:testbatch:edit
1112 519 4 批次授权 system:testbatch:assign
1113 519 5 批次关闭 system:testbatch:close
1114 519 6 数据纳入 system:testbatch:adopt
1115 519 7 批次清理 system:testbatch:purge
"""
EXPECTED_BUTTONS = {
int(menu_id): (name, int(parent_id), int(order_num), permission)
@@ -526,6 +526,7 @@ EXPECTED_BUTTONS = {
}
EXPECTED_PAGE_PERMISSIONS = frozenset(
[
'system:testbatch:list',
'master:strain:list',
'master:customer:list',
'master:site:list',
@@ -550,41 +551,40 @@ 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',
'system:testbatch:query',
'system:testbatch:add',
'system:testbatch:edit',
'system:testbatch:assign',
'system:testbatch:close',
'system:testbatch:adopt',
'system:testbatch:purge',
'business:shipment:confirm',
'business:usage:query',
'business:usage:add',
@@ -595,6 +595,7 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
)
EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
BUSINESS_MENU_SPECS = """
519|1|测试数据批次|testBatch||7|system:testbatch:list|system/testBatch/index
121|0|基础资料维护|businessMaster||1||<NULL>
122|0|业务管理|businessOperation||2||<NULL>
123|0|报表查询|businessReport||3||<NULL>
@@ -618,47 +619,46 @@ BUSINESS_MENU_SPECS = """
1065|502|菌种查询|||1|master:strain:query|
1066|502|菌种新增|||2|master:strain:add|
1067|502|菌种修改|||3|master:strain:edit|
1068|502|菌种删除|||4|master:strain:remove|
1069|503|客户查询|||1|master:customer:query|
1070|503|客户新增|||2|master:customer:add|
1071|503|客户修改|||3|master:customer:edit|
1072|503|客户删除|||4|master:customer:remove|
1073|504|现场查询|||1|master:site:query|
1074|504|现场新增|||2|master:site:add|
1075|504|现场修改|||3|master:site:edit|
1076|504|现场删除|||4|master:site:remove|
1077|504|分配用户|||5|master:site:assign|
1078|505|产线查询|||1|master:line:query|
1079|505|产线新增|||2|master:line:add|
1080|505|产线修改|||3|master:line:edit|
1081|505|产线删除|||4|master:line:remove|
1082|506|仓库查询|||1|master:warehouse:query|
1083|506|仓库新增|||2|master:warehouse:add|
1084|506|仓库修改|||3|master:warehouse:edit|
1085|506|仓库删除|||4|master:warehouse:remove|
1086|506|分配用户|||5|master:warehouse:assign|
1087|507|托盘查询|||1|master:pallet:query|
1088|507|托盘新增|||2|master:pallet:add|
1089|507|托盘修改|||3|master:pallet:edit|
1090|507|托盘删除|||4|master:pallet:remove|
1091|507|托盘打印|||5|master:pallet:print|
1092|508|入库查询|||1|business:inbound:query|
1093|508|入库新增|||2|business:inbound:add|
1094|508|入库修改|||3|business:inbound:edit|
1095|508|入库删除|||4|business:inbound:remove|
1096|508|入库确认|||5|business:inbound:confirm|
1097|509|装填查询|||1|business:filling:query|
1098|509|装填新增|||2|business:filling:add|
1099|510|发货查询|||1|business:shipment:query|
1100|510|发货新增|||2|business:shipment:add|
1101|510|发货修改|||3|business:shipment:edit|
1102|510|发货删除|||4|business:shipment:remove|
1103|510|发货确认|||5|business:shipment:confirm|
1104|511|使用查询|||1|business:usage:query|
1105|511|使用新增|||2|business:usage:add|
1106|512|退回查询|||1|business:return:query|
1107|512|退回申请|||2|business:return:request|
1108|512|退回确认|||3|business:return:confirm|
1109|519|批次查询|||1|system:testbatch:query|
1110|519|批次新增|||2|system:testbatch:add|
1111|519|批次修改|||3|system:testbatch:edit|
1112|519|批次授权|||4|system:testbatch:assign|
1113|519|批次关闭|||5|system:testbatch:close|
1114|519|数据纳入|||6|system:testbatch:adopt|
1115|519|批次清理|||7|system:testbatch:purge|
""".strip()
@@ -679,7 +679,33 @@ def _expected_business_menu_fields() -> dict[int, tuple]:
EXPECTED_BUSINESS_MENU_FIELDS = _expected_business_menu_fields()
LEGACY_PATCH_BUSINESS_MENU_FIELDS = EXPECTED_BUSINESS_MENU_FIELDS | {
LEGACY_REMOVED_BUTTON_SPECS = {
1068: ('菌种删除', 502, 4, 'master:strain:remove'),
1072: ('客户删除', 503, 4, 'master:customer:remove'),
1076: ('现场删除', 504, 4, 'master:site:remove'),
1081: ('产线删除', 505, 4, 'master:line:remove'),
1085: ('仓库删除', 506, 4, 'master:warehouse:remove'),
1090: ('托盘删除', 507, 4, 'master:pallet:remove'),
1095: ('入库删除', 508, 4, 'business:inbound:remove'),
1102: ('发货删除', 510, 4, 'business:shipment:remove'),
}
LEGACY_BUSINESS_MENU_IDS = set(range(121, 124)) | set(range(502, 519)) | set(range(1065, 1109))
LEGACY_EXPECTED_BUSINESS_MENU_FIELDS = {
menu_id: fields for menu_id, fields in EXPECTED_BUSINESS_MENU_FIELDS.items() if menu_id in LEGACY_BUSINESS_MENU_IDS
}
LEGACY_EXPECTED_BUSINESS_MENU_FIELDS.update(
{
menu_id: (parent_id, name, '', '', order_num, permission, '')
for menu_id, (name, parent_id, order_num, permission) in LEGACY_REMOVED_BUTTON_SPECS.items()
}
)
LEGACY_EXPECTED_PAGE_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS - {'system:testbatch:list'}
LEGACY_EXPECTED_BUTTON_PERMISSIONS = EXPECTED_BUTTON_PERMISSIONS - {
permission for permission in EXPECTED_BUTTON_PERMISSIONS if permission.startswith('system:testbatch:')
} | {spec[3] for spec in LEGACY_REMOVED_BUTTON_SPECS.values()}
LEGACY_EXPECTED_PERMISSIONS = LEGACY_EXPECTED_PAGE_PERMISSIONS | LEGACY_EXPECTED_BUTTON_PERMISSIONS
LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | {
121: (1, '基础资料维护', 'businessMaster', '', 10, '', None),
122: (1, '业务管理', 'businessOperation', '', 11, '', None),
123: (1, '报表查询', 'businessReport', '', 12, '', None),
@@ -706,7 +732,28 @@ EXPECTED_TOP_LEVEL_MENU_STABLE_FIELDS = {
3: ('系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool'),
}
MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS)
BUSINESS_MENU_IDS = set(range(121, 124)) | set(range(502, 519)) | set(range(1065, 1109))
BUSINESS_MENU_IDS = (
set(range(121, 124))
| set(range(502, 519))
| {519}
| set(range(1065, 1068))
| set(range(1069, 1072))
| set(range(1073, 1076))
| {1077}
| set(range(1078, 1081))
| set(range(1082, 1085))
| {1086}
| set(range(1087, 1090))
| {1091}
| set(range(1092, 1095))
| {1096}
| set(range(1097, 1099))
| set(range(1099, 1102))
| {1103}
| set(range(1104, 1109))
| set(range(1109, 1116))
)
TEST_BATCH_MENU_IDS = {519, 1109, 1110, 1111, 1112, 1113, 1114, 1115}
MYSQL_MENU_STABLE_COLUMNS = (
'menu_name',
'parent_id',
@@ -1022,13 +1069,11 @@ def _assert_test_batch_sql_contract(
test_case.assertRegex(normalized, index_pattern)
test_case.assertIn("comment '测试数据批次id;null表示正式数据'", normalized)
else:
index_pattern = (
rf'\bcreate\s+index\s+{index_name}\s+on\s+{table_name}\s*\(\s*test_batch_id\s*\)'
)
index_pattern = rf'\bcreate\s+index\s+{index_name}\s+on\s+{table_name}\s*\(\s*test_batch_id\s*\)'
test_case.assertRegex(normalized, index_pattern)
test_case.assertRegex(
normalized,
rf"comment\s+on\s+column\s+{table_name}\.test_batch_id\s+is\s+"
rf'comment\s+on\s+column\s+{table_name}\.test_batch_id\s+is\s+'
r"'测试数据批次id;null表示正式数据'",
)
@@ -1049,8 +1094,8 @@ def _menu_rows(sql: str, menu_ids: set[int]) -> dict[int, list]:
return {int(row[0]): row for row in selected}
def _business_menu_rows(sql: str) -> dict[int, list]:
return _menu_rows(sql, BUSINESS_MENU_IDS)
def _business_menu_rows(sql: str, menu_ids: set[int] | None = None) -> dict[int, list]:
return _menu_rows(sql, BUSINESS_MENU_IDS if menu_ids is None else menu_ids)
def _assert_top_level_menu_fields(test_case: unittest.TestCase, sql: str) -> None:
@@ -1067,7 +1112,7 @@ def _assert_business_menu_fields(
sql: str,
expected_fields: dict[int, tuple] = EXPECTED_BUSINESS_MENU_FIELDS,
) -> None:
rows = _business_menu_rows(sql)
rows = _business_menu_rows(sql, set(expected_fields))
invalid_widths = {menu_id: len(row) for menu_id, row in rows.items() if len(row) != SYS_MENU_VALUE_COUNT}
test_case.assertEqual({}, invalid_widths)
actual = {
@@ -1085,17 +1130,21 @@ def _assert_business_menu_fields(
test_case.assertEqual(expected_fields, actual)
def _assert_business_permission_inventory(test_case: unittest.TestCase, sql: str) -> None:
def _assert_business_permission_inventory(
test_case: unittest.TestCase,
sql: str,
expected_permissions: frozenset[str] = EXPECTED_PERMISSIONS,
) -> None:
permission_rows = [
row
for row in _insert_rows(sql, 'sys_menu')
if len(row) > SYS_MENU_PERMS_INDEX
and row[SYS_MENU_PERMS_INDEX]
and re.match(r'^(?:master|business|report):', row[SYS_MENU_PERMS_INDEX])
and re.match(r'^(?:master|business|report|system:testbatch):', row[SYS_MENU_PERMS_INDEX])
]
test_case.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in permission_rows))
actual_permissions = Counter(row[SYS_MENU_PERMS_INDEX] for row in permission_rows)
test_case.assertEqual(Counter(EXPECTED_PERMISSIONS), actual_permissions)
test_case.assertEqual(Counter(expected_permissions), actual_permissions)
MENU_PROMOTION_LOCK_STATEMENT = (
@@ -1310,6 +1359,57 @@ class SqlContractTest(unittest.TestCase):
is_patch=is_patch,
)
def test_test_batch_patches_migrate_legacy_menu_permissions_without_role_grants(self) -> None:
legacy_removed_ids = set(LEGACY_REMOVED_BUTTON_SPECS)
for path in (TEST_BATCH_MYSQL_PATCH_PATH, TEST_BATCH_PG_PATCH_PATH):
with self.subTest(path=path.name):
raw = path.read_text(encoding='utf-8')
statements = _sql_statements(raw)
normalized = [_normalized_sql_statement(statement) for statement in statements]
role_delete_indexes = [
index
for index, statement in enumerate(normalized)
if re.fullmatch(r'delete from sys_role_menu where menu_id in \([^)]+\)', statement)
]
menu_delete_indexes = [
index
for index, statement in enumerate(normalized)
if re.fullmatch(r'delete from sys_menu where menu_id in \([^)]+\)', statement)
]
menu_insert_indexes = [
index
for index, statement in enumerate(normalized)
if statement.startswith('insert into sys_menu values ')
]
self.assertEqual(1, len(role_delete_indexes))
self.assertEqual(1, len(menu_delete_indexes))
self.assertEqual(1, len(menu_insert_indexes))
role_delete = normalized[role_delete_indexes[0]]
menu_delete = normalized[menu_delete_indexes[0]]
role_delete_ids = {
int(value.strip()) for value in re.search(r'\(([^)]+)\)', role_delete).group(1).split(',')
}
menu_delete_ids = {
int(value.strip()) for value in re.search(r'\(([^)]+)\)', menu_delete).group(1).split(',')
}
self.assertEqual(legacy_removed_ids, role_delete_ids)
self.assertEqual(legacy_removed_ids, menu_delete_ids)
self.assertLess(role_delete_indexes[0], menu_delete_indexes[0])
self.assertLess(menu_delete_indexes[0], menu_insert_indexes[0])
inserted_rows = _insert_rows(raw, 'sys_menu')
inserted_by_id = {int(row[0]): row for row in inserted_rows}
self.assertEqual(TEST_BATCH_MENU_IDS, set(inserted_by_id))
self.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in inserted_by_id.values()))
self.assertEqual('测试数据批次', inserted_by_id[519][1])
self.assertEqual('1', inserted_by_id[519][2])
self.assertEqual('7', inserted_by_id[519][3])
self.assertEqual('system:testbatch:list', inserted_by_id[519][13])
self.assertEqual(519, int(inserted_by_id[1109][2]))
self.assertEqual('system:testbatch:purge', inserted_by_id[1115][13])
self.assertNotRegex('\n'.join(normalized), r'\binsert into sys_role_menu\b')
def test_initializers_define_exact_business_table_set(self) -> None:
for filename in INITIALIZERS:
with self.subTest(filename=filename):
@@ -1357,8 +1457,8 @@ class SqlContractTest(unittest.TestCase):
self.assertFalse(_is_postgres_naive_timestamp_with_default_precision('occurred_at timestamp with time zone'))
def test_initializers_define_fixed_business_menus_and_permissions(self) -> None:
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))
self.assertEqual(64, len(EXPECTED_BUSINESS_MENU_FIELDS))
@@ -1969,20 +2069,26 @@ class SqlContractTest(unittest.TestCase):
self.assertEqual(LEGACY_EXPECTED_FOREIGN_KEYS[table], foreign_keys)
self.assertEqual(LEGACY_EXPECTED_CHECKS[table], checks)
_assert_business_menu_fields(self, raw, LEGACY_PATCH_BUSINESS_MENU_FIELDS)
_assert_business_permission_inventory(self, raw)
patch_rows = _business_menu_rows(raw)
_assert_business_permission_inventory(self, raw, LEGACY_EXPECTED_PERMISSIONS)
patch_rows = _business_menu_rows(raw, LEGACY_BUSINESS_MENU_IDS)
mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8'))
compared_indexes = (0, 1, *range(4, 15))
common_menu_ids = LEGACY_BUSINESS_MENU_IDS & BUSINESS_MENU_IDS
expected_legacy_rows = {
menu_id: tuple(
'#' if index == SYS_MENU_ICON_INDEX and menu_id in EXPECTED_BUSINESS_ROOT_ICONS else row[index]
for index in compared_indexes
)
for menu_id, row in mysql_rows.items()
if menu_id in common_menu_ids
}
self.assertEqual(
expected_legacy_rows,
{menu_id: tuple(row[index] for index in compared_indexes) for menu_id, row in patch_rows.items()},
{
menu_id: tuple(row[index] for index in compared_indexes)
for menu_id, row in patch_rows.items()
if menu_id in common_menu_ids
},
)
self.assertRegex(lower, r'\bon\s+duplicate\s+key\s+update\b')
self.assertNotRegex(lower, r'\binsert\s+ignore\b')
@@ -2017,7 +2123,7 @@ class SqlContractTest(unittest.TestCase):
flags=re.S,
)
self.assertIsNotNone(menu_insert, 'sys_menu seed must use a fail-closed upsert')
self.assertEqual(len(BUSINESS_MENU_IDS), len(_insert_rows(menu_insert_statement, 'sys_menu')))
self.assertEqual(len(LEGACY_BUSINESS_MENU_IDS), len(_insert_rows(menu_insert_statement, 'sys_menu')))
expected_guard = ' and '.join(f'{column} <=> values({column})' for column in MYSQL_MENU_STABLE_COLUMNS)
actual_guard = re.sub(r'\s+', ' ', menu_insert.group('guard')).strip()
self.assertEqual(expected_guard, actual_guard)
@@ -2039,8 +2145,8 @@ class SqlContractTest(unittest.TestCase):
verification_columns = tuple(column.strip() for column in verification.group('columns').split(','))
self.assertEqual(('menu_id', *MYSQL_MENU_STABLE_COLUMNS), verification_columns)
verification_ids = [int(value.strip()) for value in verification.group('ids').split(',')]
self.assertEqual(len(BUSINESS_MENU_IDS), len(verification_ids))
self.assertEqual(BUSINESS_MENU_IDS, set(verification_ids))
self.assertEqual(len(LEGACY_BUSINESS_MENU_IDS), len(verification_ids))
self.assertEqual(LEGACY_BUSINESS_MENU_IDS, set(verification_ids))
self.assertRegex(raw, r'查询结果必须为\s*64')
normalized = [_normalized_sql_statement(statement) for statement in statements]
@@ -2149,11 +2255,12 @@ class SqlContractTest(unittest.TestCase):
self.assertEqual(('menu_id', *MYSQL_MENU_STABLE_COLUMNS), expected_columns)
expected_rows = _insert_rows(statements[expected_inserts[0]], expected_table)
self.assertEqual(len(BUSINESS_MENU_IDS), len(expected_rows))
self.assertEqual(len(LEGACY_BUSINESS_MENU_IDS), len(expected_rows))
expected_rows_by_id = {int(row[0]): tuple(row) for row in expected_rows}
self.assertEqual(BUSINESS_MENU_IDS, set(expected_rows_by_id))
self.assertEqual(LEGACY_BUSINESS_MENU_IDS, set(expected_rows_by_id))
seed_rows_by_id = {
menu_id: tuple(row[: len(expected_columns)]) for menu_id, row in _business_menu_rows(raw).items()
menu_id: tuple(row[: len(expected_columns)])
for menu_id, row in _business_menu_rows(raw, LEGACY_BUSINESS_MENU_IDS).items()
}
self.assertEqual(seed_rows_by_id, expected_rows_by_id)