@steedos-labs/plugin-workflow 3.0.103 → 3.0.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
  <link rel="shortcut icon" type="image/svg+xml" href="/images/logo.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>designer</title>
8
- <script type="module" crossorigin src="/api/workflow/designer-v2/assets/index-C7za9VGq.js"></script>
8
+ <script type="module" crossorigin src="/api/workflow/designer-v2/assets/index-CTmOPK1Y.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/api/workflow/designer-v2/assets/index-CWhD6d5C.css">
10
10
  </head>
11
11
  <body>
@@ -1,5 +1,5 @@
1
1
  waitForThing(window, 'antd').then(function(){
2
- var v = '3.0.103';
2
+ var v = '3.0.105';
3
3
  loadJs('/amis-renderer/amis-renderer.js?v=' + v);
4
4
  loadCss('/amis-renderer/amis-renderer.css?v=' + v)
5
5
  })
@@ -128,7 +128,7 @@ amis_schema: |-
128
128
  ]
129
129
  }
130
130
  },
131
- "hiddenOn": "(window.Builder && window.Builder.settings && window.Builder.settings.STEEDOS_PUBLIC_WORKFLOW_READONLY === 'true') || !(record.box == 'draft' || (record.box == 'monitor' && context.user.is_space_admin) \n || (_.includes(record.flow.perms.users_can_admin, context.user.userId) \n || _.intersection(record.flow.perms.orgs_can_admin, context.user.organizations_parents) > 0)\n || (record.box == 'inbox' && record.state == 'draft' && record.forward_from_instance))"
131
+ "hiddenOn": "(window.Builder && window.Builder.settings && window.Builder.settings.STEEDOS_PUBLIC_WORKFLOW_READONLY === 'true') || !((record.box == 'draft' && record.state == 'draft') || (record.box == 'monitor' && context.user.is_space_admin) \n || (_.includes(record.flow.perms.users_can_admin, context.user.userId) \n || _.intersection(record.flow.perms.orgs_can_admin, context.user.organizations_parents) > 0)\n || (record.box == 'inbox' && record.state == 'draft' && record.forward_from_instance))"
132
132
  }
133
133
  ],
134
134
  "regions": [
@@ -39,11 +39,11 @@ amis_schema: |-
39
39
  "api": {
40
40
  "url": "${context.rootUrl}/api/workflow/retrieve",
41
41
  "method": "post",
42
+ "silent": true,
42
43
  "requestAdaptor": "api.data = {\n \"_id\": api.body.record._id,\n \"retrieve_comment\": api.body.retrieve_comment\n};\nreturn api;",
43
44
  "adaptor": "if (payload && payload.status === -1) {\n return {...payload, status: 1, msg: payload.msg || '取回失败'};\n}\nreturn {...payload, status: 0, msg: payload && payload.msg ? payload.msg : '取回成功'};",
44
45
  "messages": {
45
- "success": "取回成功",
46
- "failed": "${msg}"
46
+ "success": "取回成功"
47
47
  },
48
48
  "headers": {
49
49
  "Authorization": "Bearer ${context.tenantId},${context.authToken}"
@@ -56,7 +56,34 @@ amis_schema: |-
56
56
  "onEvent": {
57
57
  "submitFail": {
58
58
  "weight": 0,
59
- "actions": []
59
+ "actions": [
60
+ {
61
+ "actionType": "dialog",
62
+ "dialog": {
63
+ "type": "dialog",
64
+ "title": "无法取回",
65
+ "data": {
66
+ "__errorMsg": "${event.data.error.msg || event.data.error.message || event.data.error.response.msg || '取回失败,请稍后重试'}"
67
+ },
68
+ "body": [
69
+ {
70
+ "type": "tpl",
71
+ "tpl": "${__errorMsg}"
72
+ }
73
+ ],
74
+ "actions": [
75
+ {
76
+ "type": "button",
77
+ "actionType": "cancel",
78
+ "label": "确定",
79
+ "primary": true
80
+ }
81
+ ],
82
+ "showCloseButton": true,
83
+ "closeOnEsc": true
84
+ }
85
+ }
86
+ ]
60
87
  },
61
88
  "submitSucc": {
62
89
  "weight": 0,
@@ -127,7 +154,7 @@ amis_schema: |-
127
154
  ]
128
155
  }
129
156
  },
130
- "hiddenOn": "(window.Builder && window.Builder.settings && window.Builder.settings.STEEDOS_PUBLIC_WORKFLOW_READONLY === 'true') || !( (record.box === 'outbox' || record.box === 'pending') && record.state === 'pending')"
157
+ "hiddenOn": "(window.Builder && window.Builder.settings && window.Builder.settings.STEEDOS_PUBLIC_WORKFLOW_READONLY === 'true') || !( (record.box === 'outbox' || record.box === 'pending') && record.state === 'pending') || (function(){ try { var ts = record.traces || []; if (!ts.length) { return false; } var last = ts[ts.length - 1]; var isRead = false; (last.approves || []).forEach(function(a){ if (a.is_read === true) { isRead = true; } }); if (!isRead) { return false; } var pid = (last.previous_trace_ids || [])[0]; var prev = null; var guard = 0; while (pid && guard < ts.length) { guard++; prev = null; for (var j = 0; j < ts.length; j++) { if (ts[j]._id === pid) { prev = ts[j]; break; } } if (!prev || prev.judge !== 'skipped' || !prev.previous_trace_ids || !prev.previous_trace_ids.length) { break; } pid = prev.previous_trace_ids[0]; } var prevIsStart = !!prev && (!prev.previous_trace_ids || prev.previous_trace_ids.length === 0); return !prevIsStart; } catch (e) { return false; } })()"
131
158
  }
132
159
  ],
133
160
  "regions": [
@@ -51,7 +51,7 @@ amis_schema: |-
51
51
  "weight": 0
52
52
  }
53
53
  },
54
- "hiddenOn": "record.box != 'inbox' && record.box != 'draft'"
54
+ "hiddenOn": "record.box != 'inbox' && !(record.box == 'draft' && record.state == 'draft')"
55
55
  }
56
56
  ],
57
57
  "regions": [
@@ -7,7 +7,7 @@ amis_schema: |-
7
7
  "label": "${'CustomAction.instances.instance_submit' | t}",
8
8
  "level": "primary",
9
9
  "id": "u:instance_submit",
10
- "hiddenOn": "!(this.record.box === 'inbox' || this.record.box === 'draft')",
10
+ "hiddenOn": "!(this.record.box === 'inbox' || (this.record.box === 'draft' && this.record.state === 'draft'))",
11
11
  "onEvent": {
12
12
  "click": {
13
13
  "weight": 0,
@@ -62,9 +62,14 @@ router.post('/api/workflow/remove', requireAuthentication, async function (req,
62
62
  $in: space_user.organizations
63
63
  }
64
64
  }).fetch();
65
- if ((instance.submitter !== current_user) && (!space.admins.includes(current_user)) && !WorkflowManager.canAdmin(flow, space_user, spaceUserOrganizations)) {
65
+ const isAdmin = space.admins.includes(current_user) || WorkflowManager.canAdmin(flow, space_user, spaceUserOrganizations);
66
+ if ((instance.submitter !== current_user) && !isAdmin) {
66
67
  throw new Error("您不能删除此申请单。");
67
68
  }
69
+ // 非草稿状态仅管理员可删(监控箱删单):防止申请人绕过「取回/取消申请」直接删掉审批中的单子
70
+ if (instance.state !== "draft" && !isAdmin) {
71
+ throw new Error(instance.state === "pending" ? "申请单正在审批中,不能删除,请先取回或取消申请。" : "申请单已结束,不能删除。");
72
+ }
68
73
  delete_obj = db.instances.findOne(insId);
69
74
  delete_obj.deleted = new Date;
70
75
  delete_obj.deleted_by = current_user;
@@ -55,8 +55,11 @@ router.post('/api/workflow/retrieve', requireAuthentication, async function (req
55
55
  var cc_users, flow, handler_info, i, ins, instance, instance_id, last_trace, last_trace_id, newApprove, newTrace, now, old_inbox_users, org_info, previous_step, previous_trace, previous_trace_approves, previous_trace_id, previous_trace_name, previous_trace_step_id, r, retrieve_approve, retrieve_comment, retrieve_type, setObj, space_id, space_user, the_trace, traces;
56
56
  instance = await UUFlowManager.getInstance(hashData["_id"]);
57
57
  retrieve_comment = hashData['retrieve_comment'];
58
- // 验证instance为审核中状态
59
- // UUFlowManager.isInstancePending(instance)
58
+ // 验证instance为审核中状态:已结束/已终止的申请单不允许取回(含传阅取回),
59
+ // 否则取回会把 state 重置为 pending,导致已结束的申请单被重新打开
60
+ if (instance.state !== 'pending') {
61
+ throw new Error(instance.state === 'draft' ? '草稿状态的申请单不能取回' : '申请单已结束,不能取回');
62
+ }
60
63
  // 校验申请单是当前用户已审核过的单子或者当前用户是提交人或申请人
61
64
  if ((!instance.outbox_users.includes(current_user)) && (instance.submitter !== current_user && instance.applicant !== current_user)) {
62
65
  throw new Error('当前用户不符合取回条件');
@@ -328,6 +331,10 @@ router.post('/api/workflow/retrieve', requireAuthentication, async function (req
328
331
  ins = UUFlowManager.getInstance(instance_id);
329
332
  // 如果已经配置webhook并已激活则触发
330
333
  await pushManager.triggerWebhook(ins.flow, ins, {}, 'retrieve', current_user, [current_user]);
334
+ } else {
335
+ // 既不满足正常取回条件(上一步唯一处理人是当前用户),也没有可取回的传阅记录,
336
+ // 不能静默返回成功,否则前端只会刷新页面、用户误以为取回成功
337
+ throw new Error('仅上一步唯一处理人可以取回,当前用户不符合取回条件');
331
338
  }
332
339
 
333
340
  res.status(200).send({
@@ -48,9 +48,14 @@ router.post('/api/workflow/v2/remove', requireAuthentication, async function (re
48
48
  $in: space_user.organizations
49
49
  }
50
50
  }).toArray();
51
- if ((instance.submitter !== current_user) && (!space.admins.includes(current_user)) && !WorkflowManager.canAdmin(flow, space_user, spaceUserOrganizations)) {
51
+ const isAdmin = space.admins.includes(current_user) || WorkflowManager.canAdmin(flow, space_user, spaceUserOrganizations);
52
+ if ((instance.submitter !== current_user) && !isAdmin) {
52
53
  throw new Error("您不能删除此申请单。");
53
54
  }
55
+ // 非草稿状态仅管理员可删(监控箱删单):防止申请人借「草稿」视图参数绕过「取回/取消申请」直接删掉审批中的单子
56
+ if (instance.state !== "draft" && !isAdmin) {
57
+ throw new Error(instance.state === "pending" ? "申请单正在审批中,不能删除,请先取回或取消申请。" : "申请单已结束,不能删除。");
58
+ }
54
59
  delete_obj = await db.instances.findOne({_id: data._id});
55
60
  delete_obj.deleted = new Date;
56
61
  delete_obj.deleted_by = current_user;
@@ -0,0 +1,231 @@
1
+ /**
2
+ * 回归测试:删除申请单(/api/workflow/v2/remove)的状态校验。
3
+ *
4
+ * 背景(线上现象):
5
+ * 已提交、审批中的单子,申请人从带 side_listview_id=draft 参数的地址打开后,
6
+ * 界面按草稿模式渲染出「删除」按钮,点删除真的能删掉——后端只校验了
7
+ * 「提交人/工作区管理员/流程管理员」身份,完全没有校验单据状态,
8
+ * 绕过了「取回/取消申请」的正规流程。
9
+ *
10
+ * 期望(修复后):
11
+ * - 草稿状态(state=draft):提交人可删除,行为不变(含转发产生的草稿)。
12
+ * - 非草稿状态(审批中/已结束):仅工作区管理员或流程管理员可删除
13
+ * (监控箱删单是设计内能力),普通提交人被明确拒绝,实例不被删除。
14
+ * - 管理员删除非草稿单时,仍然给待办人/传阅人发送通知,行为不变。
15
+ *
16
+ * 用法:
17
+ * node main/default/test/test_remove_guards.js
18
+ * 或 npm run test:remove-guards
19
+ */
20
+
21
+ const assert = require('assert');
22
+ const Module = require('module');
23
+
24
+ // ---- 伪造无法在独立环境 resolve 的外部依赖(express / @steedos/*) ----
25
+ const capturedRoutes = {};
26
+ let removedInstanceIds = [];
27
+ let insertedDeletedInstances = [];
28
+ const dummyInstancesCollection = {
29
+ findOne: async function () { return Object.assign({}, fakeInstance); },
30
+ remove: async function (selector) { removedInstanceIds.push(selector._id); return 1; },
31
+ };
32
+ const dummyDeletedInstancesCollection = {
33
+ insert: async function (doc) { insertedDeletedInstances.push(doc); return doc; },
34
+ };
35
+ const dummyFlowsCollection = {
36
+ findOne: async function () { return { _id: 'flow1', perms: {} }; },
37
+ };
38
+ const dummyOrganizationsCollection = {
39
+ find: function () { return { toArray: async function () { return []; } }; },
40
+ };
41
+ const fakeModules = {
42
+ 'express': {
43
+ Router: function () {
44
+ return {
45
+ post: function (path) {
46
+ capturedRoutes[path] = arguments[arguments.length - 1];
47
+ },
48
+ };
49
+ },
50
+ },
51
+ '@steedos/auth': {
52
+ requireAuthentication: function (req, res, next) { next(); },
53
+ },
54
+ // 路由内通过包名路径引用 utils/collection,这里按请求串拦截
55
+ '@steedos-labs/plugin-workflow/main/default/utils/collection': {
56
+ getCollection: async function (name) {
57
+ if (name === 'instances') return dummyInstancesCollection;
58
+ if (name === 'deleted_instances') return dummyDeletedInstancesCollection;
59
+ if (name === 'flows') return dummyFlowsCollection;
60
+ if (name === 'organizations') return dummyOrganizationsCollection;
61
+ throw new Error('unexpected collection: ' + name);
62
+ },
63
+ },
64
+ };
65
+ const origResolve = Module._resolveFilename;
66
+ Module._resolveFilename = function (request) {
67
+ if (fakeModules[request]) return 'fake:' + request;
68
+ return origResolve.apply(this, arguments);
69
+ };
70
+ Object.keys(fakeModules).forEach(function (name) {
71
+ require.cache['fake:' + name] = {
72
+ id: 'fake:' + name,
73
+ filename: 'fake:' + name,
74
+ loaded: true,
75
+ exports: fakeModules[name],
76
+ };
77
+ });
78
+
79
+ // ---- 伪造插件内部依赖 ----
80
+ function seedCache(relPath, exportsObj) {
81
+ const p = require.resolve(relPath);
82
+ require.cache[p] = { id: p, filename: p, loaded: true, exports: exportsObj };
83
+ }
84
+ let removedTaskInstanceIds = [];
85
+ seedCache('../manager/instance_tasks_manager.js', {
86
+ remove_instance_tasks_by_instance_id: async function (insId) { removedTaskInstanceIds.push(insId); },
87
+ });
88
+ let terminateNotifiedUsers = [];
89
+ let deleteNotifications = [];
90
+ seedCache('../manager/push_manager.js', {
91
+ send_message_to_specifyUser: async function (msgName, userId) { terminateNotifiedUsers.push(userId); },
92
+ send_instance_notification: async function (msgName, ins) { deleteNotifications.push({ msgName: msgName, insId: ins._id }); },
93
+ });
94
+ let canAdminFlag = false;
95
+ seedCache('../manager/workflow_manager.js', {
96
+ canAdmin: function () { return canAdminFlag; },
97
+ });
98
+ let fakeInstance = null;
99
+ let spaceAdmins = [];
100
+ seedCache('../manager/uuflow_manager.js', {
101
+ getInstance: async function () { return fakeInstance; },
102
+ getSpace: async function () { return { _id: 'space1', admins: spaceAdmins }; },
103
+ getSpaceUser: async function (spaceId, userId) { return { user: userId, organizations: ['org1'] }; },
104
+ });
105
+
106
+ require('../routes/api_workflow_v2_remove.router.js');
107
+ const handler = capturedRoutes['/api/workflow/v2/remove'];
108
+ assert.ok(handler, '未捕获到删除路由 handler');
109
+
110
+ // ---- 实例构造 ----
111
+ function buildInstance(state, opts) {
112
+ opts = opts || {};
113
+ return {
114
+ _id: 'ins1', space: 'space1', state: state,
115
+ applicant: 'user_a', submitter: opts.submitter || 'user_a',
116
+ flow: 'flow1',
117
+ inbox_users: opts.inbox_users || [], cc_users: opts.cc_users || [],
118
+ forward_from_instance: opts.forward_from_instance,
119
+ values: {},
120
+ };
121
+ }
122
+
123
+ async function callRemove(userId) {
124
+ removedInstanceIds = [];
125
+ insertedDeletedInstances = [];
126
+ removedTaskInstanceIds = [];
127
+ terminateNotifiedUsers = [];
128
+ deleteNotifications = [];
129
+ const req = {
130
+ user: { userId: userId, spaceId: 'space1', name: 'name_of_' + userId },
131
+ body: { instance: { _id: fakeInstance._id } },
132
+ };
133
+ const res = {
134
+ statusCode: null, body: null,
135
+ status: function (c) { this.statusCode = c; return this; },
136
+ send: function (b) { this.body = b; return this; },
137
+ };
138
+ await handler(req, res);
139
+ return res;
140
+ }
141
+
142
+ function assertNotDeleted(res, msgPattern) {
143
+ assert.ok(res.body.error, '应返回 error,实际:' + JSON.stringify(res.body));
144
+ assert.ok(msgPattern.test(res.body.error), '错误信息不符,实际:' + res.body.error);
145
+ assert.strictEqual(removedInstanceIds.length, 0, '实例不应被删除');
146
+ assert.strictEqual(insertedDeletedInstances.length, 0, '不应写入 deleted_instances');
147
+ assert.strictEqual(removedTaskInstanceIds.length, 0, '不应删除 instance_tasks');
148
+ }
149
+
150
+ function assertDeleted(res) {
151
+ assert.strictEqual(res.body.status, 0, '应返回 status 0,实际:' + JSON.stringify(res.body));
152
+ assert.deepStrictEqual(removedInstanceIds, ['ins1'], '实例应被删除');
153
+ assert.strictEqual(insertedDeletedInstances.length, 1, '应写入 deleted_instances');
154
+ assert.strictEqual(insertedDeletedInstances[0].deleted_by !== undefined, true, '应记录 deleted_by');
155
+ assert.deepStrictEqual(removedTaskInstanceIds, ['ins1'], '应删除 instance_tasks');
156
+ }
157
+
158
+ let passed = 0;
159
+ let failed = 0;
160
+ async function run(name, fn) {
161
+ try {
162
+ await fn();
163
+ console.log(` ✓ ${name}`);
164
+ passed++;
165
+ } catch (e) {
166
+ console.log(` ✗ ${name}`);
167
+ console.log(` ${e.message}`);
168
+ failed++;
169
+ }
170
+ }
171
+
172
+ (async () => {
173
+ console.log('删除接口状态校验(remove guards)');
174
+
175
+ await run('草稿 + 提交人 → 删除成功,不发终止通知', async () => {
176
+ canAdminFlag = false; spaceAdmins = [];
177
+ fakeInstance = buildInstance('draft');
178
+ const res = await callRemove('user_a');
179
+ assertDeleted(res);
180
+ assert.strictEqual(terminateNotifiedUsers.length, 0, '草稿删除不应发终止通知');
181
+ assert.strictEqual(deleteNotifications.length, 0, '草稿删除不应发删除通知');
182
+ });
183
+
184
+ await run('转发产生的草稿 + 被转发人(提交人)→ 删除成功', async () => {
185
+ canAdminFlag = false; spaceAdmins = [];
186
+ fakeInstance = buildInstance('draft', { submitter: 'user_b', forward_from_instance: 'ins0' });
187
+ const res = await callRemove('user_b');
188
+ assertDeleted(res);
189
+ });
190
+
191
+ await run('审批中 + 提交人(非管理员)→ 拒绝删除', async () => {
192
+ canAdminFlag = false; spaceAdmins = [];
193
+ fakeInstance = buildInstance('pending', { inbox_users: ['user_c'] });
194
+ const res = await callRemove('user_a');
195
+ assertNotDeleted(res, /审批中/);
196
+ });
197
+
198
+ await run('已结束 + 提交人(非管理员)→ 拒绝删除', async () => {
199
+ canAdminFlag = false; spaceAdmins = [];
200
+ fakeInstance = buildInstance('completed');
201
+ const res = await callRemove('user_a');
202
+ assertNotDeleted(res, /已结束/);
203
+ });
204
+
205
+ await run('审批中 + 工作区管理员 → 删除成功,通知待办人和传阅人', async () => {
206
+ canAdminFlag = false; spaceAdmins = ['user_admin'];
207
+ fakeInstance = buildInstance('pending', { inbox_users: ['user_c'], cc_users: ['user_d'] });
208
+ const res = await callRemove('user_admin');
209
+ assertDeleted(res);
210
+ assert.deepStrictEqual(terminateNotifiedUsers.sort(), ['user_c', 'user_d'], '应通知待办人和传阅人');
211
+ assert.strictEqual(deleteNotifications.length, 1, '应给申请人发删除通知');
212
+ assert.strictEqual(deleteNotifications[0].msgName, 'monitor_delete_applicant');
213
+ });
214
+
215
+ await run('审批中 + 流程管理员 → 删除成功', async () => {
216
+ canAdminFlag = true; spaceAdmins = [];
217
+ fakeInstance = buildInstance('pending', { inbox_users: ['user_c'] });
218
+ const res = await callRemove('user_x');
219
+ assertDeleted(res);
220
+ });
221
+
222
+ await run('非提交人且非管理员 → 拒绝删除(既有行为回归)', async () => {
223
+ canAdminFlag = false; spaceAdmins = [];
224
+ fakeInstance = buildInstance('draft');
225
+ const res = await callRemove('user_z');
226
+ assertNotDeleted(res, /不能删除/);
227
+ });
228
+
229
+ console.log(`\n${passed} passed, ${failed} failed`);
230
+ process.exit(failed > 0 ? 1 : 0);
231
+ })();