@steedos-labs/plugin-workflow 3.0.104 → 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.
@@ -1,5 +1,5 @@
1
1
  waitForThing(window, 'antd').then(function(){
2
- var v = '3.0.104';
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
+ })();
@@ -0,0 +1,308 @@
1
+ /**
2
+ * 回归测试:取回(/api/workflow/retrieve)的状态与资格校验。
3
+ *
4
+ * 背景(线上现象):
5
+ * 1. 已结束(state=completed)的申请单,最后一步处理人调用取回接口,
6
+ * 实例会被重新打开(state 重置为 pending)——实例状态校验被注释掉了,
7
+ * 且结束 trace 没有 approves,已读校验形同虚设。
8
+ * 2. retrieve_type 判定为空(当前用户不是上一步唯一处理人、也没有可取回的
9
+ * 传阅记录)时,路由静默返回 {status: 0},前端误报「取回成功」并刷新页面。
10
+ *
11
+ * 期望(修复后):
12
+ * - 非 pending 状态的申请单一律拒绝取回(含传阅取回),返回明确错误。
13
+ * - retrieve_type 为空时返回明确错误,不再静默假成功。
14
+ * - 正常取回、滑步取回、退回到开始步骤的已读豁免等既有行为不变。
15
+ *
16
+ * 用法:
17
+ * node main/default/test/test_retrieve_guards.js
18
+ * 或 npm run test:retrieve-guards
19
+ */
20
+
21
+ const assert = require('assert');
22
+ const Module = require('module');
23
+
24
+ // ---- 伪造无法在独立环境 resolve 的外部依赖(express / @steedos/*) ----
25
+ const capturedRoutes = {};
26
+ const objectqlUpdates = [];
27
+ const fakeModules = {
28
+ 'express': {
29
+ Router: function () {
30
+ return {
31
+ post: function (path) {
32
+ capturedRoutes[path] = arguments[arguments.length - 1];
33
+ },
34
+ };
35
+ },
36
+ },
37
+ '@steedos/auth': {
38
+ requireAuthentication: function (req, res, next) { next(); },
39
+ },
40
+ '@steedos/objectql': {
41
+ getObject: function (name) {
42
+ return {
43
+ update: async function (id, doc) { objectqlUpdates.push({ name, id, doc }); return doc; },
44
+ };
45
+ },
46
+ },
47
+ };
48
+ const origResolve = Module._resolveFilename;
49
+ Module._resolveFilename = function (request) {
50
+ if (fakeModules[request]) return 'fake:' + request;
51
+ return origResolve.apply(this, arguments);
52
+ };
53
+ Object.keys(fakeModules).forEach(function (name) {
54
+ require.cache['fake:' + name] = {
55
+ id: 'fake:' + name,
56
+ filename: 'fake:' + name,
57
+ loaded: true,
58
+ exports: fakeModules[name],
59
+ };
60
+ });
61
+
62
+ // ---- 伪造插件内部依赖 ----
63
+ let instanceUpdateCalls = [];
64
+ const dummyInstancesCollection = {
65
+ updateOne: async function (selector, modifier) {
66
+ instanceUpdateCalls.push({ selector: selector, modifier: modifier });
67
+ return { modifiedCount: 1 };
68
+ },
69
+ };
70
+ const dummyUsersCollection = {
71
+ findOne: async function (selector) { return { _id: selector._id, name: 'name_of_' + selector._id }; },
72
+ };
73
+ const dummyOrganizationsCollection = {
74
+ findOne: async function () { return { name: 'org', fullname: 'org' }; },
75
+ };
76
+ function seedCache(relPath, exportsObj) {
77
+ const p = require.resolve(relPath);
78
+ require.cache[p] = { id: p, filename: p, loaded: true, exports: exportsObj };
79
+ }
80
+ seedCache('../utils/collection.js', {
81
+ getCollection: async function (name) {
82
+ if (name === 'instances') return dummyInstancesCollection;
83
+ if (name === 'organizations') return dummyOrganizationsCollection;
84
+ return dummyUsersCollection;
85
+ },
86
+ _makeNewID: (function () { let n = 0; return function () { return 'new_id_' + (++n); }; })(),
87
+ });
88
+ seedCache('../manager/push_manager.js', {
89
+ send_message_current_user: async function () { },
90
+ send_message_to_specifyUser: async function () { },
91
+ triggerWebhook: async function () { },
92
+ });
93
+ seedCache('../manager/instance_tasks_manager.js', {
94
+ insert_instance_tasks: async function () { },
95
+ update_instance_tasks: async function () { },
96
+ update_many_instance_tasks: async function () { },
97
+ });
98
+
99
+ let fakeInstance = null;
100
+ let stepTypes = {};
101
+ seedCache('../manager/uuflow_manager.js', {
102
+ getInstance: async function () { return fakeInstance; },
103
+ getFlow: async function () { return { _id: 'flow1' }; },
104
+ getStep: function (ins, flow, stepId) {
105
+ return { _id: stepId, name: 'name_' + stepId, step_type: stepTypes[stepId] || 'sign' };
106
+ },
107
+ getDueDate: async function () { return null; },
108
+ getSpaceUser: async function () { return { organization: 'org1' }; },
109
+ getSpaceUserOrgInfo: async function () {
110
+ return { organization: 'org1', organization_name: 'org', organization_fullname: 'org' };
111
+ },
112
+ setRemindInfo: async function () { },
113
+ getCurrentStepAutoSubmit: async function () { return false; },
114
+ });
115
+
116
+ require('../routes/api_workflow_retrieve.router.js');
117
+ const handler = capturedRoutes['/api/workflow/retrieve'];
118
+ assert.ok(handler, '未捕获到取回路由 handler');
119
+
120
+ // ---- 实例构造 ----
121
+ // 默认结构:填写申请(t1, user_a) → 审批(t2, user_b 已审) → 当前步骤(t3, user_c 待审)
122
+ function buildPendingInstance(opts) {
123
+ opts = opts || {};
124
+ const traces = [
125
+ {
126
+ _id: 't1', step: 'step_start', name: '填写申请单', is_finished: true, judge: 'submitted',
127
+ approves: [{ _id: 'a1', user: 'user_a', handler: 'user_a', type: 'draft', judge: 'submitted' }],
128
+ },
129
+ {
130
+ _id: 't2', step: 'step_real', name: '实际审批步骤', is_finished: true, judge: 'approved',
131
+ previous_trace_ids: ['t1'],
132
+ approves: [{ _id: 'a2', user: 'user_b', handler: 'user_b', judge: 'approved' }],
133
+ },
134
+ {
135
+ _id: 't3', step: 'step_current', name: '当前步骤', is_finished: false,
136
+ previous_trace_ids: ['t2'],
137
+ approves: [{ _id: 'a3', user: 'user_c', handler: 'user_c', is_finished: false, is_read: !!opts.nextStepRead }],
138
+ },
139
+ ];
140
+ return {
141
+ _id: 'ins1', space: 'space1', state: 'pending', applicant: 'user_a', submitter: 'user_a',
142
+ flow: 'flow1', form: 'form1', form_version: 'fv1',
143
+ inbox_users: ['user_c'], outbox_users: ['user_a', 'user_b'], cc_users: [],
144
+ values: {}, current_step_name: '当前步骤',
145
+ traces: traces,
146
+ };
147
+ }
148
+
149
+ // 已结束实例:填写申请(t1, user_a) → 审批(t2, user_b 已审) → 结束(t_end, 无 approves)
150
+ function buildCompletedInstance(opts) {
151
+ opts = opts || {};
152
+ const t2Approves = [{ _id: 'a2', user: 'user_b', handler: 'user_b', judge: 'approved' }];
153
+ if (opts.withCC) {
154
+ t2Approves.push({ _id: 'a_cc', user: 'user_cc', handler: 'user_cc', type: 'cc', is_finished: true, judge: 'approved' });
155
+ }
156
+ return {
157
+ _id: 'ins2', space: 'space1', state: 'completed', applicant: 'user_a', submitter: 'user_a',
158
+ flow: 'flow1', form: 'form1', form_version: 'fv1',
159
+ inbox_users: [], outbox_users: ['user_a', 'user_b', 'user_cc'], cc_users: [],
160
+ values: {}, current_step_name: '结束', final_decision: 'approved',
161
+ traces: [
162
+ {
163
+ _id: 't1', step: 'step_start', name: '填写申请单', is_finished: true, judge: 'submitted',
164
+ approves: [{ _id: 'a1', user: 'user_a', handler: 'user_a', type: 'draft', judge: 'submitted' }],
165
+ },
166
+ {
167
+ _id: 't2', step: 'step_real', name: '实际审批步骤', is_finished: true, judge: 'approved',
168
+ previous_trace_ids: ['t1'],
169
+ approves: t2Approves,
170
+ },
171
+ {
172
+ _id: 't_end', step: 'step_end', name: '结束', is_finished: true,
173
+ previous_trace_ids: ['t2'],
174
+ },
175
+ ],
176
+ };
177
+ }
178
+
179
+ async function callRetrieve(userId) {
180
+ instanceUpdateCalls = [];
181
+ objectqlUpdates.length = 0;
182
+ const req = {
183
+ user: { userId: userId, spaceId: 'space1', name: 'name_of_' + userId },
184
+ body: { _id: fakeInstance._id, retrieve_comment: '取回测试' },
185
+ };
186
+ const res = {
187
+ statusCode: null, body: null,
188
+ status: function (c) { this.statusCode = c; return this; },
189
+ send: function (b) { this.body = b; return this; },
190
+ };
191
+ await handler(req, res);
192
+ return res;
193
+ }
194
+
195
+ let passed = 0;
196
+ let failed = 0;
197
+ async function run(name, fn) {
198
+ try {
199
+ await fn();
200
+ console.log(` ✓ ${name}`);
201
+ passed++;
202
+ } catch (e) {
203
+ console.log(` ✗ ${name}`);
204
+ console.log(` ${e.message}`);
205
+ failed++;
206
+ }
207
+ }
208
+
209
+ (async () => {
210
+ console.log('取回接口状态与资格校验(retrieve guards)');
211
+
212
+ await run('正常取回:上一步唯一处理人取回,行为不变', async () => {
213
+ stepTypes = {};
214
+ fakeInstance = buildPendingInstance();
215
+ const res = await callRetrieve('user_b');
216
+ assert.strictEqual(res.body.status, 0, '应返回 status 0,实际:' + JSON.stringify(res.body));
217
+ const setCall = instanceUpdateCalls.find(c => c.modifier.$set && c.modifier.$set.traces);
218
+ assert.ok(setCall, '未更新实例 traces');
219
+ const newTraces = setCall.modifier.$set.traces;
220
+ const newTrace = newTraces[newTraces.length - 1];
221
+ assert.strictEqual(newTrace.step, 'step_real', '新 trace 应落在上一步');
222
+ assert.deepStrictEqual(setCall.modifier.$set.inbox_users, ['user_b']);
223
+ assert.strictEqual(setCall.modifier.$set.state, 'pending');
224
+ });
225
+
226
+ await run('下一步已读 → 报「下一步处理人已读,不能取回」', async () => {
227
+ stepTypes = {};
228
+ fakeInstance = buildPendingInstance({ nextStepRead: true });
229
+ const res = await callRetrieve('user_b');
230
+ assert.strictEqual(res.body.status, -1, '应返回 status -1,实际:' + JSON.stringify(res.body));
231
+ assert.ok(/已读/.test(res.body.msg), '应报已读错误,实际:' + res.body.msg);
232
+ });
233
+
234
+ await run('退回到开始步骤不受已读限制:申请人取回第一步已读的单子仍成功', async () => {
235
+ stepTypes = { step_start: 'start' };
236
+ fakeInstance = buildPendingInstance({ nextStepRead: true });
237
+ // 只保留 填写申请(t1) → 当前步骤(t2'),当前步骤已读,申请人 user_a 取回
238
+ fakeInstance.traces = [
239
+ fakeInstance.traces[0],
240
+ {
241
+ _id: 't2p', step: 'step_current', name: '当前步骤', is_finished: false,
242
+ previous_trace_ids: ['t1'],
243
+ approves: [{ _id: 'a9', user: 'user_c', handler: 'user_c', is_finished: false, is_read: true }],
244
+ },
245
+ ];
246
+ const res = await callRetrieve('user_a');
247
+ assert.strictEqual(res.body.status, 0, '应返回 status 0,实际:' + JSON.stringify(res.body));
248
+ });
249
+
250
+ await run('滑步后取回:回溯到实际处理过的步骤,中间 skipped trace 一并标记 retrieved', async () => {
251
+ stepTypes = {};
252
+ fakeInstance = buildPendingInstance();
253
+ // 在 t2 和 t3 之间插入被滑步跳过的 t_skip
254
+ fakeInstance.traces.splice(2, 0, {
255
+ _id: 't_skip', step: 'step_skipped', name: '被滑步跳过的步骤', is_finished: true, judge: 'skipped',
256
+ previous_trace_ids: ['t2'],
257
+ approves: [{ _id: 'a_skip', user: 'user_x', handler: 'user_x', judge: 'skipped' }],
258
+ });
259
+ fakeInstance.traces[3].previous_trace_ids = ['t_skip'];
260
+ const res = await callRetrieve('user_b');
261
+ assert.strictEqual(res.body.status, 0, '应返回 status 0,实际:' + JSON.stringify(res.body));
262
+ const setCall = instanceUpdateCalls.find(c => c.modifier.$set && c.modifier.$set.traces);
263
+ const newTraces = setCall.modifier.$set.traces;
264
+ assert.strictEqual(newTraces[newTraces.length - 1].step, 'step_real', '新 trace 应落在实际处理过的步骤');
265
+ const skipTrace = newTraces.find(t => t._id === 't_skip');
266
+ assert.strictEqual(skipTrace.judge, 'retrieved', '滑步跳过的 trace 应标记为 retrieved');
267
+ });
268
+
269
+ await run('当前用户不是上一步唯一处理人 → 明确报错,不再静默假成功', async () => {
270
+ stepTypes = {};
271
+ fakeInstance = buildPendingInstance();
272
+ // user_a 是提交人且在 outbox_users 中,但上一步(t2)处理人是 user_b
273
+ const res = await callRetrieve('user_a');
274
+ assert.strictEqual(res.body.status, -1, '应返回 status -1,实际:' + JSON.stringify(res.body));
275
+ assert.ok(res.body.msg, '应返回错误信息');
276
+ assert.strictEqual(instanceUpdateCalls.length, 0, '不应更新实例');
277
+ });
278
+
279
+ await run('已结束实例 → 最后一步处理人取回被拒绝,实例不被重新打开', async () => {
280
+ stepTypes = {};
281
+ fakeInstance = buildCompletedInstance();
282
+ const res = await callRetrieve('user_b');
283
+ assert.strictEqual(res.body.status, -1, '应返回 status -1,实际:' + JSON.stringify(res.body));
284
+ assert.ok(/已结束/.test(res.body.msg), '应报申请单已结束,实际:' + res.body.msg);
285
+ assert.strictEqual(instanceUpdateCalls.length, 0, '不应更新实例(不能重新打开已结束的申请单)');
286
+ });
287
+
288
+ await run('已结束实例 → 传阅取回同样被拒绝', async () => {
289
+ stepTypes = {};
290
+ fakeInstance = buildCompletedInstance({ withCC: true });
291
+ const res = await callRetrieve('user_cc');
292
+ assert.strictEqual(res.body.status, -1, '应返回 status -1,实际:' + JSON.stringify(res.body));
293
+ assert.ok(/已结束/.test(res.body.msg), '应报申请单已结束,实际:' + res.body.msg);
294
+ assert.strictEqual(instanceUpdateCalls.length, 0, '不应更新实例');
295
+ });
296
+
297
+ await run('草稿状态 → 拒绝取回', async () => {
298
+ stepTypes = {};
299
+ fakeInstance = buildPendingInstance();
300
+ fakeInstance.state = 'draft';
301
+ const res = await callRetrieve('user_a');
302
+ assert.strictEqual(res.body.status, -1, '应返回 status -1,实际:' + JSON.stringify(res.body));
303
+ assert.ok(/草稿/.test(res.body.msg), '应报草稿不能取回,实际:' + res.body.msg);
304
+ });
305
+
306
+ console.log(`\n${passed} passed, ${failed} failed`);
307
+ process.exit(failed > 0 ? 1 : 0);
308
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steedos-labs/plugin-workflow",
3
- "version": "3.0.104",
3
+ "version": "3.0.105",
4
4
  "main": "package.service.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -27,6 +27,8 @@
27
27
  "test:approve-values": "node main/default/test/test_getApproveValues.js",
28
28
  "test:skip-handler-error": "node main/default/test/test_handleSkipProcessed_handlerError.js",
29
29
  "test:return-after-skip": "node main/default/test/test_return_after_skip.js",
30
+ "test:retrieve-guards": "node main/default/test/test_retrieve_guards.js",
31
+ "test:remove-guards": "node main/default/test/test_remove_guards.js",
30
32
  "test:next-step-users-cc": "node main/default/test/test_next_step_users_value_cc.js",
31
33
  "test:flow-validator": "node main/default/test/test_flow_validator.js",
32
34
  "test:ajax-error-message": "node main/default/test/test_ajax_error_message.js",