test_migration.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. from __future__ import annotations
  2. import importlib.util
  3. from pathlib import Path
  4. from alembic.config import Config
  5. from alembic.script import ScriptDirectory
  6. BACKEND_ROOT = Path(__file__).resolve().parents[2]
  7. MIGRATIONS_ROOT = BACKEND_ROOT / "migrations"
  8. REVISION_PATH = MIGRATIONS_ROOT / "versions" / "0001_initial_schema.py"
  9. RESTORE_REVISION_PATH = (
  10. MIGRATIONS_ROOT / "versions" / "0002_add_restore_audit_action.py"
  11. )
  12. Q3_REVISION_PATH = (
  13. MIGRATIONS_ROOT
  14. / "versions"
  15. / "0003_q3_business_alignment_and_content_search.py"
  16. )
  17. REORDER_REVISION_PATH = (
  18. MIGRATIONS_ROOT / "versions" / "0009_reorder_audit_action.py"
  19. )
  20. ATTACHMENT_TYPE_REVISION_PATH = (
  21. MIGRATIONS_ROOT / "versions" / "0010_attachment_type_redefine.py"
  22. )
  23. REORDER_CATEGORY_REVISION_PATH = (
  24. MIGRATIONS_ROOT
  25. / "versions"
  26. / "0011_reorder_category_audit_action.py"
  27. )
  28. ROLE_MANAGEMENT_REVISION_PATH = (
  29. MIGRATIONS_ROOT / "versions" / "0012_role_management.py"
  30. )
  31. DOCUMENT_PASSWORD_LOCK_REVISION_PATH = (
  32. MIGRATIONS_ROOT / "versions" / "0013_document_password_lock.py"
  33. )
  34. DOCUMENT_RELATIONS_REVISION_PATH = (
  35. MIGRATIONS_ROOT / "versions" / "0014_document_relations.py"
  36. )
  37. LOGIN_IP_REVISION_PATH = (
  38. MIGRATIONS_ROOT / "versions" / "0015_login_ip_restriction.py"
  39. )
  40. PORTAL_USER_PROVISION_REVISION_PATH = (
  41. MIGRATIONS_ROOT / "versions" / "0016_portal_user_provision.py"
  42. )
  43. TYPE_DISPLAY_NAME_REVISION_PATH = (
  44. MIGRATIONS_ROOT / "versions" / "0017_category_type_display_name.py"
  45. )
  46. SUB_PLAN_BINDING_REVISION_PATH = (
  47. MIGRATIONS_ROOT / "versions" / "0018_sub_plan_binding.py"
  48. )
  49. CONTENT_EXTRACT_ERROR_REVISION_PATH = (
  50. MIGRATIONS_ROOT / "versions" / "0019_content_extract_error.py"
  51. )
  52. REMOVE_ASSIGNED_SCOPE_REVISION_PATH = (
  53. MIGRATIONS_ROOT / "versions" / "0021_remove_assigned_scope.py"
  54. )
  55. LOGIN_LOCKOUT_REVISION_PATH = (
  56. MIGRATIONS_ROOT / "versions" / "0022_login_lockout.py"
  57. )
  58. ADMIN_PERMISSIONS_REVISION_PATH = (
  59. MIGRATIONS_ROOT / "versions" / "0023_admin_permissions.py"
  60. )
  61. MANAGE_PERMISSION_MATRIX_REVISION_PATH = (
  62. MIGRATIONS_ROOT / "versions" / "0024_manage_permission_matrix.py"
  63. )
  64. MOVE_AUDIT_ACTIONS_REVISION_PATH = (
  65. MIGRATIONS_ROOT / "versions" / "0025_move_audit_actions.py"
  66. )
  67. def _load_revision_module():
  68. spec = importlib.util.spec_from_file_location("dms_initial_migration", REVISION_PATH)
  69. assert spec is not None and spec.loader is not None
  70. module = importlib.util.module_from_spec(spec)
  71. spec.loader.exec_module(module)
  72. return module
  73. def test_alembic_config_and_head_are_valid():
  74. config = Config(str(MIGRATIONS_ROOT / "alembic.ini"))
  75. script = ScriptDirectory.from_config(config)
  76. assert script.get_current_head() == "0027_ui_theme"
  77. def test_initial_migration_exports_upgrade_and_downgrade():
  78. module = _load_revision_module()
  79. assert callable(module.upgrade)
  80. assert callable(module.downgrade)
  81. def test_initial_migration_contains_seven_tables_and_generated_columns():
  82. source = REVISION_PATH.read_text(encoding="utf-8")
  83. for table_name in (
  84. "sys_organization",
  85. "sys_user",
  86. "doc_category",
  87. "doc_document",
  88. "doc_attachment_binding",
  89. "doc_permission",
  90. "sys_audit_log",
  91. ):
  92. assert f'"{table_name}"' in source
  93. assert source.count('sa.Computed("IF(is_deleted = 0, 1, NULL)"') == 2
  94. assert "uq_doc_attachment_binding_active" in source
  95. assert "uq_doc_permission_active" in source
  96. def test_initial_migration_has_no_cascade():
  97. source = REVISION_PATH.read_text(encoding="utf-8")
  98. assert 'ondelete="CASCADE"' not in source
  99. assert source.count('ondelete="RESTRICT"') >= 8
  100. def test_downgrade_uses_dependency_safe_order():
  101. source = REVISION_PATH.read_text(encoding="utf-8")
  102. positions = [
  103. source.index(f'op.drop_table("{name}")')
  104. for name in (
  105. "sys_audit_log",
  106. "doc_permission",
  107. "doc_attachment_binding",
  108. "doc_document",
  109. "doc_category",
  110. "sys_user",
  111. "sys_organization",
  112. )
  113. ]
  114. assert positions == sorted(positions)
  115. def test_restore_migration_is_scoped_and_reversible():
  116. source = RESTORE_REVISION_PATH.read_text(encoding="utf-8")
  117. assert 'down_revision: str | None = "0001_initial_schema"' in source
  118. assert "RESTORE_DOCUMENT" in source
  119. assert "ix_doc_document_recycle_deleted" in source
  120. assert '["is_deleted", "deleted_at", "id"]' in source
  121. assert "drop_index" in source
  122. assert source.count("create_check_constraint") == 2
  123. assert "create_table" not in source
  124. assert "add_column" not in source
  125. def test_q3_migration_is_single_scoped_revision_with_safe_downgrade():
  126. source = Q3_REVISION_PATH.read_text(encoding="utf-8")
  127. assert (
  128. 'down_revision: str | None = "0002_add_restore_audit_action"'
  129. in source
  130. )
  131. assert "content_text" in source
  132. assert "content_extract_status" in source
  133. assert "content_extracted_at" in source
  134. assert "LONGTEXT" in source
  135. assert "auth_version = auth_version + 1" in source
  136. assert "role_code = 'USER'" in source
  137. assert "role_code IN ('USER', 'ADMIN')" in source
  138. assert "RuntimeError" in source
  139. assert "create_table" not in source
  140. def test_reorder_audit_migration_is_scoped_and_reversible():
  141. source = REORDER_REVISION_PATH.read_text(encoding="utf-8")
  142. assert 'down_revision: str | None = "0008_document_sort_no"' in source
  143. assert "REORDER_DOCUMENT" in source
  144. assert "REORDER_ATTACHMENT" in source
  145. assert "audit_action_type" in source
  146. assert "RuntimeError" in source
  147. assert "create_table" not in source
  148. assert "add_column" not in source
  149. def test_attachment_type_redefine_migration_maps_legacy_values():
  150. source = ATTACHMENT_TYPE_REVISION_PATH.read_text(encoding="utf-8")
  151. assert 'down_revision: str | None = "0009_reorder_audit_action"' in source
  152. assert "SUPPORT_ACHIEVEMENT" in source
  153. assert "TRAINING_MATERIAL" in source
  154. assert "TYPICAL_CASE" in source
  155. assert "document_attachment_type" in source
  156. assert "UPDATE doc_document SET attachment_type = 'SUPPORT_ACHIEVEMENT'" in source
  157. assert "RuntimeError" in source
  158. assert "create_table" not in source
  159. assert "add_column" not in source
  160. def test_reorder_category_audit_migration_is_scoped_and_reversible():
  161. source = REORDER_CATEGORY_REVISION_PATH.read_text(encoding="utf-8")
  162. assert (
  163. 'down_revision: str | None = "0010_attachment_type_redefine"'
  164. in source
  165. )
  166. assert "REORDER_CATEGORY" in source
  167. assert "audit_action_type" in source
  168. assert "RuntimeError" in source
  169. assert "create_table" not in source
  170. assert "add_column" not in source
  171. def test_role_management_migration_is_scoped_and_reversible():
  172. source = ROLE_MANAGEMENT_REVISION_PATH.read_text(encoding="utf-8")
  173. assert (
  174. 'down_revision: str | None = "0011_reorder_category_audit_action"'
  175. in source
  176. )
  177. for table_name in ("sys_role", "sys_role_dept", "sys_role_permission"):
  178. assert f'"{table_name}"' in source
  179. assert "uq_sys_role_default_active" in source
  180. assert "IF(is_default = 1 AND is_deleted = 0, 1, NULL)" in source
  181. assert "can_config_permission" in source
  182. assert "can_bind_attachment" in source
  183. assert "can_unbind_attachment" in source
  184. assert "fk_sys_user_role_id_sys_role" in source
  185. assert "CHANGE_ROLE_PERMISSION" in source
  186. assert "'ROLE'" in source
  187. assert "RuntimeError" in source
  188. assert source.count("op.create_table") == 3
  189. # 降级按依赖序先删矩阵/部门,再删角色本体
  190. positions = [
  191. source.index(f'op.drop_table("{name}")')
  192. for name in ("sys_role_permission", "sys_role_dept", "sys_role")
  193. ]
  194. assert positions == sorted(positions)
  195. def test_document_password_lock_migration_is_scoped_and_reversible():
  196. source = DOCUMENT_PASSWORD_LOCK_REVISION_PATH.read_text(encoding="utf-8")
  197. assert (
  198. 'down_revision: str | None = "0012_role_management"' in source
  199. )
  200. assert '"password_hash"' in source
  201. for column in (
  202. "protect_view",
  203. "protect_download",
  204. "protect_edit",
  205. "protect_delete",
  206. "protect_config_permission",
  207. "protect_bind_attachment",
  208. "protect_unbind_attachment",
  209. ):
  210. assert f'"{column}"' in source
  211. assert "document_password_main_only" in source
  212. assert "(password_hash IS NULL) OR (document_type = 'MAIN')" in source
  213. assert "DOCUMENT_PASSWORD_SET" in source
  214. assert "DOCUMENT_PASSWORD_REMOVE" in source
  215. assert "RuntimeError" in source
  216. assert "create_table" not in source
  217. def test_document_relations_migration_is_scoped_and_reversible():
  218. source = DOCUMENT_RELATIONS_REVISION_PATH.read_text(encoding="utf-8")
  219. assert (
  220. 'down_revision: str | None = "0013_document_password_lock"' in source
  221. )
  222. assert 'op.create_table(\n "doc_document_relation"' in source
  223. assert '"relation_type"' in source
  224. assert "sa.String(length=64)" in source
  225. assert "uq_doc_document_relation_active" in source
  226. assert "IF(is_deleted = 0, 1, NULL)" in source
  227. assert "document_relation_no_self" in source
  228. assert "document_id <> related_document_id" in source
  229. assert source.count("ondelete=\"RESTRICT\"") == 2
  230. # 审计复用EDIT_DOCUMENT,不扩展sys_audit_log的CHECK约束
  231. assert "audit_action_type" not in source
  232. assert "RuntimeError" in source
  233. assert source.count("op.create_table") == 1
  234. assert "add_column" not in source
  235. def test_login_ip_restriction_migration_is_scoped_and_reversible():
  236. source = LOGIN_IP_REVISION_PATH.read_text(encoding="utf-8")
  237. assert (
  238. 'down_revision: str | None = "0014_document_relations"' in source
  239. )
  240. for table_name in ("sys_ip_entry", "sys_org_ip_assignment"):
  241. assert f'"{table_name}"' in source
  242. assert '"ip_value"' in source
  243. assert "uq_sys_ip_entry_ip_value_active" in source
  244. assert "uq_sys_org_ip_assignment_active" in source
  245. assert "IF(is_deleted = 0, 1, NULL)" in source
  246. assert "fk_sys_org_ip_assignment_organization_id_sys_organization" in source
  247. assert "fk_sys_org_ip_assignment_ip_entry_id_sys_ip_entry" in source
  248. assert "CREATE_IP_ENTRY" in source
  249. assert "ASSIGN_ORG_IP" in source
  250. assert '"IP_ENTRY"' in source
  251. assert "RuntimeError" in source
  252. assert source.count("op.create_table") == 2
  253. # 降级按依赖序先删分配表,再删IP条目表
  254. positions = [
  255. source.index(f'op.drop_table("{name}")')
  256. for name in ("sys_org_ip_assignment", "sys_ip_entry")
  257. ]
  258. assert positions == sorted(positions)
  259. def test_portal_user_provision_migration_is_scoped_and_reversible():
  260. source = PORTAL_USER_PROVISION_REVISION_PATH.read_text(encoding="utf-8")
  261. assert (
  262. 'down_revision: str | None = "0015_login_ip_restriction"' in source
  263. )
  264. assert '"external_id"' in source
  265. assert "sa.String(length=128)" in source
  266. assert "uq_sys_user_external_id_active" in source
  267. assert "ix_sys_user_external_active" in source
  268. assert "IF(is_deleted = 0, 1, NULL)" in source
  269. # 降级保护:存在门户用户时拒绝有损降级
  270. assert "external_id IS NOT NULL" in source
  271. assert "RuntimeError" in source
  272. assert "create_table" not in source
  273. def test_category_type_display_name_migration_is_scoped_and_idempotent():
  274. source = TYPE_DISPLAY_NAME_REVISION_PATH.read_text(encoding="utf-8")
  275. assert (
  276. 'down_revision: str | None = "0016_portal_user_provision"' in source
  277. )
  278. assert '_COLUMN = "type_display_name"' in source
  279. assert "sa.String(length=128)" in source
  280. # 幂等:按现存列集合判断,重复执行不重复加列
  281. assert "_existing_columns" in source
  282. assert "drop_column" in source
  283. assert "create_table" not in source
  284. def test_sub_plan_binding_migration_is_scoped_and_reversible():
  285. source = SUB_PLAN_BINDING_REVISION_PATH.read_text(encoding="utf-8")
  286. assert (
  287. 'down_revision: str | None = "0017_category_type_display_name"'
  288. in source
  289. )
  290. assert '"doc_sub_plan_binding"' in source
  291. assert "uq_doc_sub_plan_binding_active" in source
  292. assert "IF(is_deleted = 0, 1, NULL)" in source
  293. assert "ix_doc_sub_plan_binding_main_active_sort" in source
  294. assert "ix_doc_sub_plan_binding_sub_active" in source
  295. assert source.count('ondelete="RESTRICT"') == 2
  296. # 审计复用 BIND/UNBIND_ATTACHMENT,不扩展 sys_audit_log 的 CHECK 约束
  297. assert "audit_action_type" not in source
  298. # 降级保护:已产生挂载数据时拒绝有损降级
  299. assert "RuntimeError" in source
  300. assert source.count("op.create_table") == 1
  301. assert "add_column" not in source
  302. def test_content_extract_error_migration_is_scoped_and_idempotent():
  303. source = CONTENT_EXTRACT_ERROR_REVISION_PATH.read_text(encoding="utf-8")
  304. assert (
  305. 'down_revision: str | None = "0018_sub_plan_binding"' in source
  306. )
  307. assert '_TABLE = "doc_document"' in source
  308. assert '_COLUMN = "content_extract_error"' in source
  309. assert "sa.String(512)" in source
  310. # 幂等:按现存列集合判断,重复执行不重复加列
  311. assert "_existing_columns" in source
  312. assert "add_column" in source
  313. assert "drop_column" in source
  314. assert "create_table" not in source
  315. # 降级保护:已产生失败记录时拒绝有损降级
  316. assert "RuntimeError" in source
  317. assert "IS NOT NULL" in source
  318. def test_remove_assigned_scope_migration_is_scoped_and_idempotent():
  319. source = REMOVE_ASSIGNED_SCOPE_REVISION_PATH.read_text(encoding="utf-8")
  320. assert (
  321. 'down_revision: str | None = "0020_sub_plan_to_attachment"' in source
  322. )
  323. # 存量 ASSIGNED 行物理删除(含软删行)
  324. assert "DELETE FROM sys_role_permission WHERE dept_scope = 'ASSIGNED'" in source
  325. # CHECK 约束按名幂等换名收敛:旧删新加
  326. assert "ck_sys_role_permission_role_permission_dept_scope" in source
  327. assert "ck_sys_role_permission_dept_scope_v2" in source
  328. assert "dept_scope IN ('OWN', 'OTHER')" in source
  329. # 角色可访问部门表下线
  330. assert "DROP TABLE IF EXISTS sys_role_dept" in source
  331. # 降级:重建 sys_role_dept 空表并补种 ASSIGNED 默认全否行
  332. assert 'op.create_table(\n "sys_role_dept"' in source
  333. assert "'ASSIGNED'" in source
  334. def test_login_lockout_migration_is_scoped_and_idempotent():
  335. source = LOGIN_LOCKOUT_REVISION_PATH.read_text(encoding="utf-8")
  336. assert (
  337. 'down_revision: str | None = "0021_remove_assigned_scope"' in source
  338. )
  339. assert '_TABLE = "sys_user"' in source
  340. assert "failed_login_count" in source
  341. assert "locked_until" in source
  342. assert "sa.Integer()" in source
  343. assert "sa.DateTime()" in source
  344. assert 'server_default=sa.text("0")' in source
  345. # 幂等:按现存列集合判断,重复执行不重复加列
  346. assert "_existing_columns" in source
  347. assert "add_column" in source
  348. assert "drop_column" in source
  349. assert "create_table" not in source
  350. def test_admin_permissions_migration_is_scoped_and_idempotent():
  351. source = ADMIN_PERMISSIONS_REVISION_PATH.read_text(encoding="utf-8")
  352. assert 'down_revision: str | None = "0022_login_lockout"' in source
  353. assert '_TABLE = "sys_user"' in source
  354. # 三权限位 + 管理范围锚点
  355. assert "perm_category_edit" in source
  356. assert "perm_org_edit" in source
  357. assert "perm_user_edit" in source
  358. assert '"managed_org_id"' in source
  359. assert "sa.Boolean()" in source
  360. assert "sa.BigInteger()" in source
  361. assert 'server_default=sa.text("0")' in source
  362. # 存量初始化:ADMIN 三权限开启;admin 锚点保持 NULL,其余锚点=所在部门
  363. assert "role_code = 'ADMIN'" in source
  364. assert "username <> 'admin'" in source
  365. assert "managed_org_id = organization_id" in source
  366. # 幂等:按现存列集合判断,重复执行不重复加列
  367. assert "_existing_columns" in source
  368. assert "add_column" in source
  369. assert "drop_column" in source
  370. assert "create_table" not in source
  371. def test_manage_permission_matrix_migration_moves_flags_to_roles():
  372. source = MANAGE_PERMISSION_MATRIX_REVISION_PATH.read_text(encoding="utf-8")
  373. assert 'revision: str = "0024_manage_permission_matrix"' in source
  374. assert 'down_revision: str | None = "0023_admin_permissions"' in source
  375. # 角色侧新增管理位三布尔 + 锚点;用户侧四个旧列下线
  376. assert '"manage_category_edit"' in source
  377. assert '"manage_org_edit"' in source
  378. assert '"manage_user_edit"' in source
  379. assert '"managed_org_id"' in source
  380. assert '"perm_category_edit"' in source
  381. # 存量收敛:唯一占用直接置位、多组占用克隆角色并复制权限矩阵
  382. assert "row_version = row_version + 1" in source
  383. assert "INSERT INTO sys_role_permission" in source
  384. assert "-M{suffix}" in source
  385. # 幂等:列探测 + 以 sys_user 旧列存在为数据迁移前提
  386. assert "_columns_of" in source
  387. assert 'if "perm_category_edit" in user_columns' in source
  388. assert "drop_column" in source
  389. def test_move_audit_actions_migration_extends_action_check():
  390. source = MOVE_AUDIT_ACTIONS_REVISION_PATH.read_text(encoding="utf-8")
  391. assert (
  392. 'down_revision: str | None = "0024_manage_permission_matrix"' in source
  393. )
  394. # 0015 全集 + 跨页移动两个动作 = 38 项,V7 为降级目标集
  395. assert "MOVE_DOCUMENT" in source
  396. assert "MOVE_ATTACHMENT" in source
  397. assert "AUDIT_ACTIONS_V7 = AUDIT_ACTIONS_V8[:-2]" in source
  398. assert "audit_action_type" in source
  399. # 达梦兼容:约束名统一小写比较后按原始逻辑名删除
  400. assert ".lower()" in source
  401. # 降级保护:已存在 MOVE 审计记录时拒绝有损降级
  402. assert "RuntimeError" in source
  403. assert "create_table" not in source
  404. assert "add_column" not in source