test_role_workflow_semantics_restoration.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. """Restore actual full-save/metadata semantics; UI handler and SQLite persistence."""
  2. import json
  3. from datetime import date, datetime
  4. from pathlib import Path
  5. import subprocess
  6. import pymysql
  7. import pytest
  8. from sqlalchemy import create_engine
  9. from sqlalchemy.orm import Session
  10. from app.models.models import SysRole, SysRoleDataScopeDetail, SysDept, SysUser
  11. from app.models.bx_models import BxWorkflowDef, BxWorkflowNode, BxReimburseApply, BxAdvanceApply
  12. from app.core.bx_auth import BxUserContext
  13. from app.services.bx.workflow import WorkflowService
  14. USER = BxUserContext('u', 'User', 'd', 'super_admin', 't')
  15. @pytest.fixture
  16. def db(monkeypatch):
  17. def forbidden(*args, **kwargs):
  18. raise AssertionError('Business MySQL connections are forbidden')
  19. monkeypatch.setattr(pymysql, 'connect', forbidden)
  20. monkeypatch.setattr(pymysql.connections.Connection, 'connect', forbidden)
  21. engine = create_engine('sqlite://')
  22. for model in (SysRole, SysRoleDataScopeDetail, SysDept, SysUser,
  23. BxWorkflowDef, BxWorkflowNode, BxReimburseApply, BxAdvanceApply):
  24. model.__table__.create(engine)
  25. with Session(engine, autoflush=False) as session:
  26. yield session
  27. engine.dispose()
  28. def actual_scope_payload(departments, users=()):
  29. root = Path(__file__).parents[2]
  30. program = r'''
  31. const fs = require('node:fs')
  32. const ts = require(process.argv[1])
  33. const source = fs.readFileSync(process.argv[2], 'utf8').match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
  34. const ast = ts.createSourceFile('dialog.ts', source, ts.ScriptTarget.Latest, true)
  35. const fn = ast.statements.find(n => ts.isFunctionDeclaration(n) && n.name.text === 'handleDataScopeSubmit')
  36. if (!fn) throw Error('handler missing')
  37. const js = ts.transpileModule(fn.getText(ast), {compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
  38. let payload
  39. const selections = JSON.parse(process.argv[3])
  40. const context = {
  41. dataScopeSubmitLoading: {value:false}, dataScopeForm:{dataScope:'5'},
  42. deptTreeRef:{value:{getCheckedKeys:()=>selections.departments}},
  43. scopeUserCheckedKeys:{value:selections.users}, dataScopeEditingRoleId:{value:'role'},
  44. fetchUpdateRoleDataScope:async (_id, data)=>{payload=data},
  45. dataScopeDialogVisible:{value:true}, emit:()=>{}, refreshViewIfOpen:async()=>{}
  46. }
  47. const run = new Function(...Object.keys(context), js+';return handleDataScopeSubmit')(...Object.values(context))
  48. run().then(()=>process.stdout.write(JSON.stringify(payload))).catch(e=>{console.error(e);process.exit(1)})
  49. '''
  50. result = subprocess.run(['node', '-e', program, str(root/'web-admin/node_modules/typescript'),
  51. str(root/'web-admin/src/components/business/role-manage-dialogs/index.vue'),
  52. json.dumps({'departments': departments, 'users': list(users)})],
  53. check=True, text=True, capture_output=True)
  54. return json.loads(result.stdout)
  55. def role_scope(db):
  56. db.add(SysRole(id='role', tenant_no='t', role_name='Role', data_scope='5'))
  57. db.add(SysDept(id='dept', tenant_no='t', dept_name='Department'))
  58. old = SysRoleDataScopeDetail(id='scope', tenant_no='t', role_id='role', scope_type='dept',
  59. scope_value='dept', create_by='creator', create_time=datetime(2020, 1, 1))
  60. foreign = SysRoleDataScopeDetail(id='foreign', tenant_no='other', role_id='role', scope_type='dept', scope_value='other-dept')
  61. db.add_all([old, foreign]); db.commit()
  62. return old, foreign
  63. def scope_route(db, monkeypatch, payload):
  64. from app.api import admin
  65. monkeypatch.setattr(admin, '_get_tenant_no', lambda *args: 't')
  66. return admin.update_role_data_scope('role', admin.RoleDataScopeBody.model_validate(payload),
  67. user={'user_id':'u'}, db=db)
  68. def test_actual_ui_clear_to_schema_route_removes_custom_scope(db, monkeypatch):
  69. old, foreign = role_scope(db)
  70. payload = actual_scope_payload([])
  71. assert payload == {'dataScope': '5'} # The current complete-save UI intentionally omits an empty list.
  72. result = scope_route(db, monkeypatch, payload)
  73. db.refresh(old); db.refresh(foreign)
  74. assert result['code'] == 200 and old.del_flag == 1 and foreign.del_flag == 0
  75. def test_actual_ui_unchanged_and_clear_restore_keep_relation_identity(db, monkeypatch):
  76. old, foreign = role_scope(db)
  77. for selected in (['dept'], [], ['dept'], ['dept']):
  78. result = scope_route(db, monkeypatch, actual_scope_payload(selected))
  79. assert result['code'] == 200
  80. db.refresh(old)
  81. assert old.del_flag == (0 if selected else 1)
  82. assert old.create_by == 'creator' and old.create_time == datetime(2020, 1, 1)
  83. assert db.query(SysRoleDataScopeDetail).filter_by(tenant_no='t').count() == 1
  84. assert foreign.del_flag == 0
  85. def test_explicit_empty_and_noncustom_scope_clear_still_supported(db, monkeypatch):
  86. old, _ = role_scope(db)
  87. assert scope_route(db, monkeypatch, {'dataScope':'5', 'scopeDetails':[]})['code'] == 200
  88. assert old.del_flag == 1
  89. assert scope_route(db, monkeypatch, {'dataScope':'1'})['code'] == 200
  90. assert old.del_flag == 1
  91. def test_foreign_scope_target_is_rejected_before_mutation(db, monkeypatch):
  92. old, foreign = role_scope(db)
  93. db.add(SysDept(id='foreign-dept', tenant_no='other', dept_name='Foreign')); db.commit()
  94. response = scope_route(db, monkeypatch, {'dataScope':'5', 'scopeDetails':[{'scopeType':'dept','scopeValue':'foreign-dept'}]})
  95. assert response['code'] == 400 and old.del_flag == foreign.del_flag == 0
  96. assert not db.dirty and not db.new
  97. def pending_workflow(db):
  98. workflow = BxWorkflowDef(id='wf', tenant_no='t', biz_type='reimburse', wf_name='Workflow', status=1)
  99. node = BxWorkflowNode(id='node', tenant_no='t', wf_id='wf', node_name='Review', node_order=1,
  100. action_type='approve', approver_id='u', approver_name='User', create_time=datetime(2020, 1, 1))
  101. application = BxReimburseApply(id='a', tenant_no='t', apply_code='A', title='Apply', applicant_id='u',
  102. applicant_name='User', expense_id='expense', expense_name='Expense', amount=10,
  103. reimburse_date=date(2026, 1, 1), status='pending', wf_id='wf', current_node='Review')
  104. db.add_all([workflow, node, application]); db.commit()
  105. return workflow, node, application
  106. def test_pending_workflow_name_update_through_real_schema_route_keeps_nodes(db, monkeypatch):
  107. from app.api.bx import workflow as api
  108. from app.schemas.bx.workflow import WorkflowDefUpdate
  109. workflow, node, application = pending_workflow(db)
  110. monkeypatch.setattr(api, 'get_tenant_no', lambda *args: 't')
  111. api.update_workflow('wf', WorkflowDefUpdate(wfName='Renamed'), db=db, user=USER)
  112. assert workflow.wf_name == 'Renamed'
  113. assert node.id == 'node' and node.node_name == 'Review' and node.create_time == datetime(2020, 1, 1)
  114. assert application.status == 'pending' and application.current_node == 'Review'
  115. assert db.query(BxWorkflowNode).count() == 1
  116. def test_pending_workflow_remark_and_unchanged_status_are_metadata_only(db):
  117. workflow, node, application = pending_workflow(db)
  118. result = WorkflowService.update(db, 'wf', {'remark':'Note', 'status':1, 'biz_type':'reimburse'}, user=USER)
  119. assert result.remark == 'Note' and node.node_name == 'Review' and application.status == 'pending'
  120. @pytest.mark.parametrize('patch', [{'wf_name':'Rename', 'status':0}, {'wf_name':'Rename', 'biz_type':'advance'}])
  121. def test_pending_structural_header_changes_still_rejected_atomically(db, patch):
  122. workflow, node, application = pending_workflow(db)
  123. with pytest.raises(ValueError, match='审批'):
  124. WorkflowService.update(db, 'wf', patch, user=USER)
  125. assert workflow.wf_name == 'Workflow' and workflow.biz_type == 'reimburse' and workflow.status == 1
  126. assert not db.dirty and not db.new
  127. def test_pending_node_change_and_delete_guards_remain(db):
  128. workflow, node, application = pending_workflow(db)
  129. with pytest.raises(ValueError, match='审批'):
  130. WorkflowService.save_nodes(db, 'wf', [{'id':'node', 'node_name':'Changed', 'node_order':1}], user=USER)
  131. with pytest.raises(ValueError, match='审批'):
  132. WorkflowService.soft_delete(db, 'wf', user=USER)
  133. assert node.node_name == 'Review' and workflow.del_flag == 0 and not db.dirty and not db.new