@carthooks/arcubase-cli 0.1.10 → 0.1.12

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.
Files changed (105) hide show
  1. package/bundle/arcubase-admin.mjs +1274 -375
  2. package/bundle/arcubase.mjs +1274 -375
  3. package/dist/generated/command_registry.generated.d.ts +36 -35
  4. package/dist/generated/command_registry.generated.d.ts.map +1 -1
  5. package/dist/generated/command_registry.generated.js +37 -36
  6. package/dist/generated/type_index.generated.d.ts +23 -23
  7. package/dist/generated/type_index.generated.js +23 -23
  8. package/dist/runtime/entity_save_schema.d.ts.map +1 -1
  9. package/dist/runtime/entity_save_schema.js +30 -0
  10. package/dist/runtime/env.d.ts +2 -1
  11. package/dist/runtime/env.d.ts.map +1 -1
  12. package/dist/runtime/env.js +10 -1
  13. package/dist/runtime/errors.d.ts +2 -0
  14. package/dist/runtime/errors.d.ts.map +1 -1
  15. package/dist/runtime/execute.d.ts +4 -2
  16. package/dist/runtime/execute.d.ts.map +1 -1
  17. package/dist/runtime/execute.js +944 -134
  18. package/dist/runtime/paths.d.ts +1 -1
  19. package/dist/runtime/paths.d.ts.map +1 -1
  20. package/dist/runtime/paths.js +9 -2
  21. package/dist/runtime/upload.d.ts +1 -11
  22. package/dist/runtime/upload.d.ts.map +1 -1
  23. package/dist/runtime/upload.js +19 -133
  24. package/dist/runtime/zod_registry.d.ts +5 -3
  25. package/dist/runtime/zod_registry.d.ts.map +1 -1
  26. package/dist/runtime/zod_registry.js +108 -48
  27. package/dist/tests/bootstrap.test.js +12 -0
  28. package/dist/tests/command_registry.test.js +8 -0
  29. package/dist/tests/docs_readability.test.d.ts +2 -0
  30. package/dist/tests/docs_readability.test.d.ts.map +1 -0
  31. package/dist/tests/docs_readability.test.js +87 -0
  32. package/dist/tests/entity_save_schema.test.js +22 -0
  33. package/dist/tests/execute_validation.test.js +381 -44
  34. package/dist/tests/help.test.js +45 -8
  35. package/dist/tests/upload.test.js +30 -77
  36. package/package.json +1 -1
  37. package/sdk-dist/docs/runtime-reference/README.md +43 -29
  38. package/sdk-dist/docs/runtime-reference/access-rule.md +109 -0
  39. package/sdk-dist/docs/runtime-reference/condition.md +182 -0
  40. package/sdk-dist/docs/runtime-reference/entity-schema/README.md +6 -5
  41. package/sdk-dist/docs/runtime-reference/entity-schema/checkbox.md +1 -1
  42. package/sdk-dist/docs/runtime-reference/entity-schema/file.md +2 -2
  43. package/sdk-dist/docs/runtime-reference/entity-schema/image.md +2 -2
  44. package/sdk-dist/docs/runtime-reference/entity-schema/linkto.md +3 -3
  45. package/sdk-dist/docs/runtime-reference/entity-schema/number.md +1 -1
  46. package/sdk-dist/docs/runtime-reference/entity-schema/query.md +3 -3
  47. package/sdk-dist/docs/runtime-reference/entity-schema/relation.md +3 -3
  48. package/sdk-dist/docs/runtime-reference/entity-schema/relationfield.md +3 -3
  49. package/sdk-dist/docs/runtime-reference/entity-schema/status.md +1 -1
  50. package/sdk-dist/docs/runtime-reference/entity-schema/text.md +1 -1
  51. package/sdk-dist/docs/runtime-reference/entity-schema.md +28 -21
  52. package/sdk-dist/docs/runtime-reference/examples/README.md +1 -1
  53. package/sdk-dist/docs/runtime-reference/examples/crm-01/README.md +30 -11
  54. package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.create-entity.json +1 -0
  55. package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.schema.json +1 -0
  56. package/sdk-dist/docs/runtime-reference/examples/crm-01/create-app.json +1 -0
  57. package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.create-entity.json +1 -0
  58. package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.schema.json +1 -0
  59. package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.create-entity.json +1 -0
  60. package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.schema.json +1 -0
  61. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.bulk-update.json +1 -0
  62. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.create-entity.json +1 -0
  63. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.query.json +1 -0
  64. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.row.insert.json +1 -0
  65. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.schema.json +1 -0
  66. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.selection.condition.json +1 -0
  67. package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +26 -26
  68. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.row.insert.json +1 -1
  69. package/sdk-dist/docs/runtime-reference/examples/wms-01/README.md +26 -10
  70. package/sdk-dist/docs/runtime-reference/examples/wms-01/create-app.json +1 -0
  71. package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.create-entity.json +1 -0
  72. package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.schema.json +1 -0
  73. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.bulk-update.json +1 -0
  74. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.create-entity.json +1 -0
  75. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.query.json +1 -0
  76. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.schema.json +1 -0
  77. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.selection.condition.json +1 -0
  78. package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.create-entity.json +1 -0
  79. package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.schema.json +1 -0
  80. package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.create-entity.json +1 -0
  81. package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.schema.json +1 -0
  82. package/sdk-dist/docs/runtime-reference/row-crud.md +35 -8
  83. package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +31 -95
  84. package/sdk-dist/docs/runtime-reference/selection.md +128 -0
  85. package/sdk-dist/docs/runtime-reference/table-lifecycle.md +113 -30
  86. package/sdk-dist/docs/runtime-reference/uploads.md +30 -59
  87. package/sdk-dist/docs/runtime-reference/workflow/README.md +129 -13
  88. package/sdk-dist/generated/command_registry.generated.ts +37 -36
  89. package/sdk-dist/generated/type_index.generated.ts +23 -23
  90. package/src/generated/command_registry.generated.ts +37 -36
  91. package/src/generated/type_index.generated.ts +23 -23
  92. package/src/runtime/entity_save_schema.ts +30 -0
  93. package/src/runtime/env.ts +14 -2
  94. package/src/runtime/errors.ts +2 -0
  95. package/src/runtime/execute.ts +1166 -134
  96. package/src/runtime/paths.ts +11 -2
  97. package/src/runtime/upload.ts +19 -178
  98. package/src/runtime/zod_registry.ts +113 -48
  99. package/src/tests/bootstrap.test.ts +16 -0
  100. package/src/tests/command_registry.test.ts +9 -0
  101. package/src/tests/docs_readability.test.ts +96 -0
  102. package/src/tests/entity_save_schema.test.ts +22 -0
  103. package/src/tests/execute_validation.test.ts +508 -47
  104. package/src/tests/help.test.ts +49 -8
  105. package/src/tests/upload.test.ts +34 -91
@@ -8,6 +8,7 @@ import { CLIError } from '../runtime/errors.js';
8
8
  const env = {
9
9
  ARCUBASE_BASE_URL: 'https://arcubase.example.com',
10
10
  ARCUBASE_ACCESS_TOKEN: 'tok',
11
+ ARCUBASE_SDK_ROOT: '/runtime/arcubase-sdk',
11
12
  ARCUBASE_TRACE_ID: '',
12
13
  ARCUBASE_SUBJECT_TYPE: '',
13
14
  ARCUBASE_SUBJECT_ID: '',
@@ -19,6 +20,22 @@ function tempJSONFile(payload) {
19
20
  fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
20
21
  return filePath;
21
22
  }
23
+ function withRowPolicyResolver(tableId = 'table_1', actions = ['view', 'add', 'edit', 'delete', 'bulk_update', 'archive'], policyOverridesOrFinal = {}, final) {
24
+ const policyOverrides = typeof policyOverridesOrFinal === 'function' ? {} : policyOverridesOrFinal;
25
+ const finalFetch = typeof policyOverridesOrFinal === 'function'
26
+ ? policyOverridesOrFinal
27
+ : final ?? (async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }));
28
+ return async (url, init) => {
29
+ const text = String(url);
30
+ if (/\/api\/apps\/[^/?]+$/.test(text) && init?.method === 'GET') {
31
+ return new Response(JSON.stringify({ data: { ingress: { groups: [{ items: [{ id: 'policy_hash_1' }] }] } } }), { status: 200 });
32
+ }
33
+ if (/\/api\/ingress\/[^/]+\/policy_hash_1$/.test(text) && init?.method === 'GET') {
34
+ return new Response(JSON.stringify({ data: { id: tableId, policy: { actions, ...policyOverrides } } }), { status: 200 });
35
+ }
36
+ return finalFetch(url, init);
37
+ };
38
+ }
22
39
  function assertAssignUsersGuidance(error, messagePattern) {
23
40
  assert.ok(error instanceof CLIError);
24
41
  assert.equal(error.code, 'BODY_VALIDATION_FAILED');
@@ -29,7 +46,7 @@ function assertAssignUsersGuidance(error, messagePattern) {
29
46
  assert.deepEqual(error.details?.shapeHint, {
30
47
  update: ['user_scope'],
31
48
  options: {
32
- user_scope: [{ type: 'member', id: 123 }],
49
+ user_scope: [{ type: 'user', id: 2188889901 }],
33
50
  },
34
51
  });
35
52
  assert.deepEqual(error.details?.commonMistakes, [
@@ -38,9 +55,9 @@ function assertAssignUsersGuidance(error, messagePattern) {
38
55
  'do not use hash tenant user id as id',
39
56
  'id must be numeric TenantUser.ID',
40
57
  ]);
41
- assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'AppIngressUpdateReqVO' && item.file === '/opt/arcubase-sdk/types/ingress.ts'));
42
- assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/opt/arcubase-sdk/types/ingress.ts'));
43
- assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'PermitUserScope' && item.file === '/opt/arcubase-sdk/types/common.ts'));
58
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'AppIngressUpdateReqVO' && item.file === '/runtime/arcubase-sdk/types/ingress.ts'));
59
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/runtime/arcubase-sdk/types/ingress.ts'));
60
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'PermitUserScope' && item.file === '/runtime/arcubase-sdk/types/common.ts'));
44
61
  return true;
45
62
  }
46
63
  function validEntitySavePayload(overrides = {}) {
@@ -104,6 +121,15 @@ test('table update-schema rejects invalid body file', async () => {
104
121
  assert.equal(error.code, 'BODY_VALIDATION_FAILED');
105
122
  assert.equal(error.details?.requestType, 'EntitySaveReqVO');
106
123
  assert.ok((error.details?.issues ?? []).some((item) => item.path.startsWith('body.layout')));
124
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntitySaveReqVO' && item.file === '/runtime/arcubase-sdk/types/entity.ts'));
125
+ assert.ok(error.details?.docHints?.some((item) => item.file === '/runtime/arcubase-sdk/docs/runtime-reference/entity-schema.md'));
126
+ assert.equal(error.details?.shapeHint?.field_id_seq, 1004);
127
+ assert.equal(error.details?.shapeHint?.fields?.[0]?.type, 'text');
128
+ assert.equal(error.details?.shapeHint?.fields?.[1]?.type, 'textarea');
129
+ assert.equal(error.details?.shapeHint?.fields?.[2]?.type, 'select');
130
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('fields must be an array')));
131
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('get table shell first')));
132
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('not "string"')));
107
133
  return true;
108
134
  });
109
135
  });
@@ -118,28 +144,56 @@ test('app create rejects name longer than 20 chars', async () => {
118
144
  return true;
119
145
  });
120
146
  });
121
- test('table create executes with valid body file', async () => {
122
- const bodyFile = tempJSONFile({ name: '订单', parent: 0 });
123
- const out = await executeCLI('admin', ['table', 'create', '--app-id', 'app_1', '--body-file', bodyFile], env, async (url, init) => new Response(JSON.stringify({
124
- url,
125
- method: init?.method,
126
- hasTenantHeader: Object.prototype.hasOwnProperty.call(init?.headers ?? {}, 'X-Tenant-Id'),
127
- hasHostHeader: Object.prototype.hasOwnProperty.call(init?.headers ?? {}, 'X-Host'),
128
- }), { status: 200 }));
147
+ test('table create rejects empty schema body before request', async () => {
148
+ await assert.rejects(async () => {
149
+ await executeCLI('admin', ['table', 'create', '--app-id', '2188889844', '--body-json', '{"name":"订单","parent":0}'], env, async () => new Response('{}'));
150
+ }, (error) => {
151
+ assert.ok(error instanceof CLIError);
152
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
153
+ assert.match(error.message, /fields/);
154
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase-admin table create --app-id <app_id> --body-json')));
155
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"field_id_seq":1003')));
156
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"投票描述"')));
157
+ return true;
158
+ });
159
+ });
160
+ test('table create executes create and schema save with valid body file', async () => {
161
+ const bodyFile = tempJSONFile({ ...validEntitySavePayload({ id: undefined, app_id: undefined }), name: '订单', parent: 0 });
162
+ const calls = [];
163
+ const out = await executeCLI('admin', ['table', 'create', '--app-id', '2188889844', '--body-file', bodyFile], env, async (url, init) => {
164
+ calls.push({ url: String(url), method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : null });
165
+ if (init?.method === 'POST') {
166
+ return new Response(JSON.stringify({ data: { id: 2188889845 } }), { status: 200 });
167
+ }
168
+ return new Response(JSON.stringify({ data: { id: 2188889845, fields: calls[1]?.body?.fields ?? [] } }), { status: 200 });
169
+ });
129
170
  assert.equal(out.kind, 'result');
130
- assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities');
131
- assert.equal(out.data.hasTenantHeader, false);
132
- assert.equal(out.data.hasHostHeader, false);
171
+ assert.equal(out.data.id, 2188889845);
172
+ assert.equal(calls.length, 2);
173
+ assert.equal(calls[0].url, 'https://arcubase.example.com/api/apps/2188889844/entities');
174
+ assert.equal(calls[0].method, 'POST');
175
+ assert.deepEqual(calls[0].body, { name: '订单', parent: 0 });
176
+ assert.equal(calls[1].url, 'https://arcubase.example.com/api/apps/2188889844/entity/2188889845');
177
+ assert.equal(calls[1].method, 'PUT');
178
+ assert.equal(calls[1].body.id, 2188889845);
179
+ assert.equal(calls[1].body.app_id, 2188889844);
180
+ assert.equal(calls[1].body.fields.length, 1);
133
181
  });
134
182
  test('table create executes with valid body json', async () => {
135
- const out = await executeCLI('admin', ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":"订单","parent":0}'], env, async (url, init) => new Response(JSON.stringify({
136
- url,
137
- method: init?.method,
138
- body: init?.body ? JSON.parse(String(init.body)) : null,
139
- }), { status: 200 }));
183
+ const calls = [];
184
+ const payload = JSON.stringify({ ...validEntitySavePayload({ id: undefined, app_id: undefined }), name: '订单', parent: 0 });
185
+ const out = await executeCLI('admin', ['table', 'create', '--app-id', '2188889844', '--body-json', payload], env, async (url, init) => {
186
+ calls.push({ url: String(url), method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : null });
187
+ if (init?.method === 'POST') {
188
+ return new Response(JSON.stringify({ data: { id: 2188889845 } }), { status: 200 });
189
+ }
190
+ return new Response(JSON.stringify({ data: { id: 2188889845, fields: calls[1]?.body?.fields ?? [] } }), { status: 200 });
191
+ });
140
192
  assert.equal(out.kind, 'result');
141
- assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities');
142
- assert.deepEqual(out.data.body, { name: '订单', parent: 0 });
193
+ assert.equal(out.data.id, 2188889845);
194
+ assert.equal(calls.length, 2);
195
+ assert.deepEqual(calls[0].body, { name: '订单', parent: 0 });
196
+ assert.equal(calls[1].body.id, 2188889845);
143
197
  });
144
198
  test('body json conflicts with body file', async () => {
145
199
  const bodyFile = tempJSONFile({ name: '订单', parent: 0 });
@@ -160,6 +214,21 @@ test('body json is rejected for commands without request type', async () => {
160
214
  return true;
161
215
  });
162
216
  });
217
+ test('entry rejects retired include-app-tables flag before fetch', async () => {
218
+ let called = false;
219
+ await assert.rejects(async () => {
220
+ await executeCLI('user', ['entry', '--include-app-tables'], env, async () => {
221
+ called = true;
222
+ return new Response('{}', { status: 200 });
223
+ });
224
+ }, (error) => {
225
+ assert.ok(error instanceof CLIError);
226
+ assert.equal(error.code, 'UNKNOWN_FLAG');
227
+ assert.match(error.message, /--include-app-tables/);
228
+ return true;
229
+ });
230
+ assert.equal(called, false);
231
+ });
163
232
  test('body json parse errors fail fast', async () => {
164
233
  await assert.rejects(async () => {
165
234
  await executeCLI('admin', ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":'], env, async () => new Response('{}', { status: 200 }));
@@ -169,6 +238,18 @@ test('body json parse errors fail fast', async () => {
169
238
  return true;
170
239
  });
171
240
  });
241
+ test('body json parse errors include command docs and type hints', async () => {
242
+ await assert.rejects(async () => {
243
+ await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["options"]}]}'], env, async () => new Response('{}', { status: 200 }));
244
+ }, (error) => {
245
+ assert.ok(error instanceof CLIError);
246
+ assert.equal(error.code, 'INVALID_BODY_JSON');
247
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO');
248
+ assert.ok((error.details?.docHints ?? []).some((item) => item.file === '/runtime/arcubase-sdk/docs/runtime-reference/access-rule.md'));
249
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('access-rule update policy changes')));
250
+ return true;
251
+ });
252
+ });
172
253
  test('access-rule list executes against external root path', async () => {
173
254
  const out = await executeCLI('admin', ['access-rule', 'list', '--app-id', 'app_1', '--table-id', 'table_1'], env, async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }));
174
255
  assert.equal(out.kind, 'result');
@@ -215,6 +296,30 @@ test('table update-schema normalizes shell null placeholders before validation',
215
296
  assert.deepEqual(requestBody.options.TitleFormat.Parts, []);
216
297
  assert.deepEqual(requestBody.options.misc, {});
217
298
  });
299
+ test('table update-schema rejects per-user permission patches', async () => {
300
+ const body = JSON.stringify({
301
+ id: 2188893443,
302
+ app_id: 2188893436,
303
+ name: '投票项目',
304
+ schema_version: 1,
305
+ field_id_seq: 1003,
306
+ layout: [[1001], [1002]],
307
+ fields: [
308
+ { id: 1001, label: 'Title', type: 'text', required: true, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'title', code_index: false, options: { type: 'text', placeholder: '', lengthLimit: false, lengthMin: 0, lengthMax: 255 }, transfers: null, children: null },
309
+ { id: 1002, label: 'Description', type: 'textarea', required: false, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'description', code_index: false, options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 }, transfers: null, children: null, permission: { user: { read: false } } },
310
+ ],
311
+ options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
312
+ workflow_enabled: false,
313
+ });
314
+ await assert.rejects(async () => {
315
+ await executeCLI('admin', ['table', 'update-schema', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', body], env, async () => new Response('{}', { status: 200 }));
316
+ }, (error) => {
317
+ assert.ok(error instanceof CLIError);
318
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
319
+ assert.match(error.message, /access-rule/);
320
+ return true;
321
+ });
322
+ });
218
323
  test('row delete rejects selection type outside ids', async () => {
219
324
  const bodyFile = tempJSONFile({ selection: { type: 'selection', ids: [4000000003], length: 1 } });
220
325
  await assert.rejects(async () => {
@@ -231,7 +336,7 @@ test('row bulk-update accepts ids selection with number_add action', async () =>
231
336
  selection: { type: 'ids', ids: [4000000003], length: 1 },
232
337
  fields: [{ id: 1002, action: { type: 'number_add', number_add: 10 } }],
233
338
  });
234
- const out = await executeCLI('user', ['row', 'bulk-update', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile], env, async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }));
339
+ const out = await executeCLI('user', ['row', 'bulk-update', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile], env, withRowPolicyResolver());
235
340
  assert.equal(out.kind, 'result');
236
341
  assert.deepEqual(out.data.selection, { type: 'ids', ids: [4000000003], length: 1 });
237
342
  assert.equal(out.data.fields[0].action.type, 'number_add');
@@ -245,7 +350,7 @@ test('row selection-action accepts condition selection with selectAll', async ()
245
350
  sorts: [{ column: ':1002', order: 'desc' }],
246
351
  },
247
352
  });
248
- const out = await executeCLI('user', ['row', 'selection-action', '--app-id', 'app_1', '--table-id', 'table_1', '--action', 'archive', '--body-file', bodyFile], env, async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }));
353
+ const out = await executeCLI('user', ['row', 'selection-action', '--app-id', 'app_1', '--table-id', 'table_1', '--action', 'archive', '--body-file', bodyFile], env, withRowPolicyResolver());
249
354
  assert.equal(out.kind, 'result');
250
355
  assert.equal(out.data.selection.type, 'condition');
251
356
  assert.equal(out.data.selection.condition.selectAll, true);
@@ -261,7 +366,93 @@ test('row selection-action rejects condition selection for query action', async
261
366
  return true;
262
367
  });
263
368
  });
264
- test('row create rejects local file path for file field and points to arcubase upload', async () => {
369
+ test('row create resolves internal policy_id from accessible ingress', async () => {
370
+ const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' } });
371
+ const calls = [];
372
+ const out = await executeCLI('user', ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile], env, withRowPolicyResolver('2188900694', ['view', 'add'], async (url, init) => {
373
+ calls.push({
374
+ url: String(url),
375
+ method: init?.method,
376
+ body: init?.body ? JSON.parse(String(init.body)) : null,
377
+ });
378
+ return new Response(String(init?.body ?? '{}'), { status: 200 });
379
+ }));
380
+ assert.equal(out.kind, 'result');
381
+ assert.deepEqual(calls, [
382
+ { url: 'https://arcubase.example.com/api/entity/2188900691/2188900694', method: 'GET', body: null },
383
+ {
384
+ url: 'https://arcubase.example.com/api/entity/2188900691/2188900694/insert',
385
+ method: 'POST',
386
+ body: { fields: { 1001: '今天吃什么' }, policy_id: 'policy_hash_1' },
387
+ },
388
+ ]);
389
+ });
390
+ test('row get resolves internal policy_id as query parameter', async () => {
391
+ const calls = [];
392
+ const out = await executeCLI('user', ['row', 'get', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '4000000046'], env, withRowPolicyResolver('2188900694', ['view'], async (url) => {
393
+ calls.push(String(url));
394
+ return new Response(JSON.stringify({ data: { id: 4000000046 } }), { status: 200 });
395
+ }));
396
+ assert.equal(out.kind, 'result');
397
+ assert.equal(calls[0], 'https://arcubase.example.com/api/entity/2188900691/2188900694/row/4000000046?policy_id=policy_hash_1');
398
+ });
399
+ test('row command rejects user-provided policy_id', async () => {
400
+ const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' }, policy_id: 'manual_policy' });
401
+ await assert.rejects(async () => {
402
+ await executeCLI('user', ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile], env, withRowPolicyResolver('2188900694', ['add']));
403
+ }, (error) => {
404
+ assert.ok(error instanceof CLIError);
405
+ assert.equal(error.code, 'INTERNAL_POLICY_ID_NOT_ALLOWED');
406
+ assert.match(error.message, /internal Arcubase ingress parameter/);
407
+ return true;
408
+ });
409
+ });
410
+ test('row command fastfails when no accessible ingress matches action', async () => {
411
+ const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' } });
412
+ await assert.rejects(async () => {
413
+ await executeCLI('user', ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile], env, withRowPolicyResolver('2188900694', ['view']));
414
+ }, (error) => {
415
+ assert.ok(error instanceof CLIError);
416
+ assert.equal(error.code, 'POLICY_ID_RESOLUTION_FAILED');
417
+ assert.match(error.message, /action add/);
418
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('access-rule')));
419
+ return true;
420
+ });
421
+ });
422
+ test('row create fastfails before request when payload writes non-writable field', async () => {
423
+ const bodyFile = tempJSONFile({ fields: { 1001: '标题', 1002: '隐藏描述' } });
424
+ const calls = [];
425
+ await assert.rejects(async () => {
426
+ await executeCLI('user', ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile], env, withRowPolicyResolver('2188900694', ['view', 'add'], { all_fields_write: true, fields: [{ key: ':1002', read: false, write: false, report: false }] }, async (url, init) => {
427
+ calls.push({ url: String(url), method: init?.method });
428
+ return new Response(String(init?.body ?? '{}'), { status: 200 });
429
+ }));
430
+ }, (error) => {
431
+ assert.ok(error instanceof CLIError);
432
+ assert.equal(error.code, 'FIELD_PERMISSION_REQUIRED');
433
+ assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1002'));
434
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('remove non-writable field ids')));
435
+ return true;
436
+ });
437
+ assert.equal(calls.some((call) => call.method !== 'GET'), false);
438
+ });
439
+ test('row update fastfails before request when payload writes non-writable field', async () => {
440
+ const bodyFile = tempJSONFile({ data: { 1002: '隐藏描述' }, changed_fields: [1002] });
441
+ const calls = [];
442
+ await assert.rejects(async () => {
443
+ await executeCLI('user', ['row', 'update', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '2188901452', '--body-file', bodyFile], env, withRowPolicyResolver('2188900694', ['view', 'edit'], { all_fields_write: false, fields: [{ key: ':1001', read: true, write: true, report: false }, { key: ':1002', read: false, write: false, report: false }] }, async (url, init) => {
444
+ calls.push({ url: String(url), method: init?.method });
445
+ return new Response(String(init?.body ?? '{}'), { status: 200 });
446
+ }));
447
+ }, (error) => {
448
+ assert.ok(error instanceof CLIError);
449
+ assert.equal(error.code, 'FIELD_PERMISSION_REQUIRED');
450
+ assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1002'));
451
+ return true;
452
+ });
453
+ assert.equal(calls.some((call) => call.method !== 'GET'), false);
454
+ });
455
+ test('row create rejects local file path for file field and points to upload token flow', async () => {
265
456
  const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: './contract.pdf' } });
266
457
  const calls = [];
267
458
  await assert.rejects(async () => {
@@ -276,11 +467,24 @@ test('row create rejects local file path for file field and points to arcubase u
276
467
  assert.ok(error instanceof CLIError);
277
468
  assert.equal(error.code, 'BODY_VALIDATION_FAILED');
278
469
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1007'));
279
- assert.match(error.message, /arcubase upload \.\/contract\.pdf/);
470
+ assert.match(error.message, /upload token flow/);
280
471
  return true;
281
472
  });
282
473
  assert.deepEqual(calls, [{ url: 'https://arcubase.example.com/api/entity/app_1/table_1', method: 'GET' }]);
283
474
  });
475
+ test('row create non-numeric field key gives table get retry path', async () => {
476
+ const bodyFile = tempJSONFile({ fields: { 标题: '今天吃什么' } });
477
+ await assert.rejects(async () => {
478
+ await executeCLI('user', ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile], env, async () => new Response('{}', { status: 200 }));
479
+ }, (error) => {
480
+ assert.ok(error instanceof CLIError);
481
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
482
+ assert.ok(error.details?.commonMistakes?.some((item) => item.includes('do not use field labels')));
483
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase table get')));
484
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase row create')));
485
+ return true;
486
+ });
487
+ });
284
488
  test('row update rejects object value for image field and requires upload array', async () => {
285
489
  const bodyFile = tempJSONFile({ changed_fields: [1008], data: { 1008: { upload_id: 123456, file: 'lead-photo.jpg', file_name: 'lead-photo.jpg', meta: {} } } });
286
490
  await assert.rejects(async () => {
@@ -294,20 +498,20 @@ test('row update rejects object value for image field and requires upload array'
294
498
  assert.ok(error instanceof CLIError);
295
499
  assert.equal(error.code, 'BODY_VALIDATION_FAILED');
296
500
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1008'));
297
- assert.match(error.message, /must be an array/);
501
+ assert.match(error.message, /upload value array/);
298
502
  return true;
299
503
  });
300
504
  });
301
505
  test('row create accepts upload array for file field after schema preflight', async () => {
302
506
  const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: [{ upload_id: 123456, file: 'contract.pdf', file_name: 'contract.pdf', meta: {} }] } });
303
507
  const calls = [];
304
- const out = await executeCLI('user', ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile], env, async (url, init) => {
508
+ const out = await executeCLI('user', ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile], env, withRowPolicyResolver('table_1', ['add'], async (url, init) => {
305
509
  calls.push({ url: String(url), method: init?.method });
306
510
  if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
307
511
  return new Response(JSON.stringify({ data: { id: 1, app_id: 1, fields: [{ id: 1001, type: 'text', label: 'Lead Name' }, { id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 });
308
512
  }
309
513
  return new Response(String(init?.body ?? '{}'), { status: 200 });
310
- });
514
+ }));
311
515
  assert.equal(out.kind, 'result');
312
516
  assert.deepEqual(calls, [
313
517
  { url: 'https://arcubase.example.com/api/entity/app_1/table_1', method: 'GET' },
@@ -318,7 +522,7 @@ test('row create accepts upload array for file field after schema preflight', as
318
522
  test('row create decorates upload bind failures with actionable guidance', async () => {
319
523
  const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: [{ upload_id: 123456, file: 'contract.pdf', file_name: 'contract.pdf', meta: {} }] } });
320
524
  await assert.rejects(async () => {
321
- await executeCLI('user', ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile], env, async (url, init) => {
525
+ await executeCLI('user', ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile], env, withRowPolicyResolver('table_1', ['add'], async (url, init) => {
322
526
  if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
323
527
  return new Response(JSON.stringify({ data: { fields: [{ id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 });
324
528
  }
@@ -330,13 +534,13 @@ test('row create decorates upload bind failures with actionable guidance', async
330
534
  },
331
535
  trace_id: 'req_upload_bind',
332
536
  }), { status: 200 });
333
- });
537
+ }));
334
538
  }, (error) => {
335
539
  assert.ok(error instanceof CLIError);
336
540
  assert.equal(error.code, 'UPSTREAM_BODY_ERROR');
337
541
  assert.equal(error.details?.traceId, 'req_upload_bind');
338
542
  assert.equal(error.details?.reason, 'the row payload used a file/image upload entry, but Arcubase could not bind that upload into an asset record');
339
- assert.match(error.details?.hint ?? '', /rerun arcubase upload/);
543
+ assert.match(error.details?.hint ?? '', /upload token flow/);
340
544
  return true;
341
545
  });
342
546
  });
@@ -351,6 +555,28 @@ test('row query rejects camelCase viewMode and points to row crud doc', async ()
351
555
  return true;
352
556
  });
353
557
  });
558
+ test('row query unknown limit flag gives body-json retry path', async () => {
559
+ await assert.rejects(async () => {
560
+ await executeCLI('user', ['row', 'query', '--app-id', '2188900691', '--table-id', '2188900694', '--limit', '1', '--offset', '0'], env, async () => new Response('{}', { status: 200 }));
561
+ }, (error) => {
562
+ assert.ok(error instanceof CLIError);
563
+ assert.equal(error.code, 'UNKNOWN_FLAG');
564
+ assert.equal(error.details?.requestType, 'EntityQueryReqVO');
565
+ assert.deepEqual(error.details?.shapeHint, { limit: 20, offset: 0, search: { text_search: 'SO-1001' } });
566
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('--body-json')));
567
+ return true;
568
+ });
569
+ });
570
+ test('table dataset unknown app flag points to table get', async () => {
571
+ await assert.rejects(async () => {
572
+ await executeCLI('user', ['table', 'dataset', '--app-id', '2188900691', '--table-id', '2188900694'], env, async () => new Response('{}', { status: 200 }));
573
+ }, (error) => {
574
+ assert.ok(error instanceof CLIError);
575
+ assert.equal(error.code, 'UNKNOWN_FLAG');
576
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('table get --app-id')));
577
+ return true;
578
+ });
579
+ });
354
580
  test('row update rejects fields key', async () => {
355
581
  const bodyFile = tempJSONFile({ fields: { 1002: 99 }, data: { '1002': 99 }, changed_fields: [1002] });
356
582
  await assert.rejects(async () => {
@@ -374,11 +600,11 @@ test('row update rejects changed_fields that are missing from data', async () =>
374
600
  });
375
601
  });
376
602
  test('workflow approve maps to fixed action path', async () => {
377
- const bodyFile = tempJSONFile({ task_id: 1001, fields: {}, changed: [] });
603
+ const bodyFile = tempJSONFile({ task_id: 2188890521, fields: {}, changed: [] });
378
604
  const out = await executeCLI('user', ['workflow', 'approve', '--app-id', 'app_1', '--table-id', 'table_1', '--row-id', 'row_1', '--body-file', bodyFile], env, async (url, init) => new Response(JSON.stringify({ url, method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : null }), { status: 200 }));
379
605
  assert.equal(out.kind, 'result');
380
606
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entity/app_1/table_1/row/row_1/action/2');
381
- assert.equal(out.data.body.task_id, 1001);
607
+ assert.equal(out.data.body.task_id, 2188890521);
382
608
  });
383
609
  test('workflow query-approve-users requires request body', async () => {
384
610
  await assert.rejects(async () => {
@@ -389,11 +615,42 @@ test('workflow query-approve-users requires request body', async () => {
389
615
  return true;
390
616
  });
391
617
  });
392
- test('entry passes include_app_tables query flag', async () => {
393
- const out = await executeCLI('user', ['entry', '--include-app-tables', 'true'], env, async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }));
618
+ test('entry always includes app tables without exposing a query flag', async () => {
619
+ const out = await executeCLI('user', ['entry'], env, async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }));
620
+ assert.equal(out.kind, 'result');
621
+ assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true');
622
+ });
623
+ test('entry fixed app tables query cannot be disabled by query file', async () => {
624
+ const queryFile = tempJSONFile({ include_app_tables: false });
625
+ const out = await executeCLI('user', ['entry', '--query-file', queryFile], env, async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }));
394
626
  assert.equal(out.kind, 'result');
395
627
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true');
396
628
  });
629
+ test('upstream http failures include endpoint status and body details', async () => {
630
+ await assert.rejects(async () => {
631
+ await executeCLI('user', ['entry'], env, async () => new Response('', { status: 500 }));
632
+ }, (error) => {
633
+ assert.ok(error instanceof CLIError);
634
+ assert.equal(error.code, 'UPSTREAM_REQUEST_FAILED');
635
+ assert.equal(error.details?.endpoint, '/api/entry');
636
+ assert.equal(error.details?.method, 'GET');
637
+ assert.equal(error.details?.status, 500);
638
+ return true;
639
+ });
640
+ });
641
+ test('upstream network failures are separated from http failures', async () => {
642
+ await assert.rejects(async () => {
643
+ await executeCLI('user', ['entry'], env, async () => {
644
+ throw new Error('dns failed');
645
+ });
646
+ }, (error) => {
647
+ assert.ok(error instanceof CLIError);
648
+ assert.equal(error.code, 'UPSTREAM_NETWORK_ERROR');
649
+ assert.equal(error.details?.endpoint, '/api/entry');
650
+ assert.match(error.message, /dns failed/);
651
+ return true;
652
+ });
653
+ });
397
654
  test('access-rule update rejects enabled without update whitelist', async () => {
398
655
  await assert.rejects(async () => {
399
656
  await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"enabled":true}'], env, async () => new Response('{}', { status: 200 }));
@@ -402,7 +659,7 @@ test('access-rule update rejects enabled without update whitelist', async () =>
402
659
  assert.equal(error.code, 'BODY_VALIDATION_FAILED');
403
660
  assert.match(error.message, /update.*enabled/);
404
661
  assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO');
405
- assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/opt/arcubase-sdk/types/ingress.ts'));
662
+ assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/runtime/arcubase-sdk/types/ingress.ts'));
406
663
  return true;
407
664
  });
408
665
  });
@@ -411,6 +668,86 @@ test('access-rule update accepts enabled with update whitelist', async () => {
411
668
  assert.equal(out.kind, 'result');
412
669
  assert.deepEqual(out.data, { update: ['enabled'], enabled: true });
413
670
  });
671
+ test('access-rule update rejects nested options patch with canonical policy example', async () => {
672
+ await assert.rejects(async () => {
673
+ await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["options.policy"],"options":{"policy":{"key":"custom","actions":["view","add"],"fields":[{"key":"1002","read":false}]}}}'], env, async () => new Response('{}', { status: 200 }));
674
+ }, (error) => {
675
+ assert.ok(error instanceof CLIError);
676
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
677
+ assert.match(error.message, /update.*options/);
678
+ assert.match(error.message, /:1002/);
679
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('options.policy')));
680
+ return true;
681
+ });
682
+ });
683
+ test('access-rule update accepts canonical policy body with hidden field', async () => {
684
+ const body = '{"update":["options"],"options":{"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":true,"all_fields_write":true,"fields":[{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}';
685
+ const out = await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', body], env, async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }));
686
+ assert.equal(out.kind, 'result');
687
+ assert.deepEqual(out.data.update, ['options']);
688
+ assert.deepEqual(out.data.options.policy.fields, [{ key: ':1002', read: false, write: false, report: false }]);
689
+ });
690
+ test('access-rule update rejects partial options user scope overwrite', async () => {
691
+ await assert.rejects(async () => {
692
+ await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["options"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}'], env, async () => new Response('{}', { status: 200 }));
693
+ }, (error) => {
694
+ assert.ok(error instanceof CLIError);
695
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
696
+ assert.match(error.message, /options.*policy/);
697
+ assert.match(error.message, /assign-users/);
698
+ return true;
699
+ });
700
+ });
701
+ test('access-rule update rejects FieldVO key names in policy fields', async () => {
702
+ await assert.rejects(async () => {
703
+ await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["options"],"options":{"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":false,"all_fields_write":false,"fields":[{"key":"vote_description","read":false,"write":false,"report":false}]},"list_options":{}}}'], env, async () => new Response('{}', { status: 200 }));
704
+ }, (error) => {
705
+ assert.ok(error instanceof CLIError);
706
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
707
+ assert.match(error.message, /:1002/);
708
+ assert.match(error.message, /vote_name|FieldVO\.key/);
709
+ return true;
710
+ });
711
+ });
712
+ test('access-rule create rejects guessed permission aliases', async () => {
713
+ await assert.rejects(async () => {
714
+ await executeCLI('admin', ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"rule","enabled":true,"permissions":["read","write"],"field_visibility":[{"field_id":1002,"visible":false}]}'], env, async () => new Response('{}', { status: 200 }));
715
+ }, (error) => {
716
+ assert.ok(error instanceof CLIError);
717
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
718
+ assert.match(error.message, /options\.policy/);
719
+ assert.equal(error.details?.requestType, 'AppIngressCreateReqVO');
720
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('field_visibility')));
721
+ return true;
722
+ });
723
+ });
724
+ test('access-rule create rejects read write action aliases', async () => {
725
+ await assert.rejects(async () => {
726
+ await executeCLI('admin', ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"rule","enabled":true,"type":"form","options":{"policy":{"key":"custom","actions":["read","write"]},"list_options":{}}}'], env, async () => new Response('{}', { status: 200 }));
727
+ }, (error) => {
728
+ assert.ok(error instanceof CLIError);
729
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
730
+ assert.match(error.message, /view.*add.*edit/);
731
+ return true;
732
+ });
733
+ });
734
+ test('access-rule create rejects FieldVO member type in user scope', async () => {
735
+ await assert.rejects(async () => {
736
+ await executeCLI('admin', ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"rule","enabled":true,"type":"form","options":{"user_scope":[{"type":"member","id":2188889901}],"policy":{"key":"custom","actions":["view"],"all_fields_read":true,"all_fields_write":false},"list_options":{}}}'], env, async () => new Response('{}', { status: 200 }));
737
+ }, (error) => {
738
+ assert.ok(error instanceof CLIError);
739
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
740
+ assert.match(error.message, /user.*department.*actor|FieldVO type "member"/);
741
+ return true;
742
+ });
743
+ });
744
+ test('access-rule create accepts custom policy with hidden field', async () => {
745
+ const body = '{"name":"rule","enabled":true,"type":"form","options":{"user_scope":[{"type":"user","id":2188889901}],"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":true,"all_fields_write":true,"fields":[{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}';
746
+ const out = await executeCLI('admin', ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', body], env, async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }));
747
+ assert.equal(out.kind, 'result');
748
+ assert.deepEqual(out.data.options.policy.actions, ['view', 'add', 'edit']);
749
+ assert.deepEqual(out.data.options.policy.fields, [{ key: ':1002', read: false, write: false, report: false }]);
750
+ });
414
751
  test('access-rule assign-users rejects users shortcut', async () => {
415
752
  await assert.rejects(async () => {
416
753
  await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"users":["tu_1"]}'], env, async () => new Response('{}', { status: 200 }));
@@ -428,22 +765,22 @@ test('access-rule assign-users rejects allowedUsers shortcut', async () => {
428
765
  });
429
766
  test('access-rule assign-users rejects top-level user_scope', async () => {
430
767
  await assert.rejects(async () => {
431
- await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"user_scope":[{"type":"member","id":123}]}'], env, async () => new Response('{}', { status: 200 }));
768
+ await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"user_scope":[{"type":"user","id":2188889901}]}'], env, async () => new Response('{}', { status: 200 }));
432
769
  }, (error) => assertAssignUsersGuidance(error, /options.user_scope/));
433
770
  });
434
- test('access-rule assign-users rejects non-member scope type', async () => {
771
+ test('access-rule assign-users rejects non-user member scope type', async () => {
435
772
  await assert.rejects(async () => {
436
- await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":123}]}}'], env, async () => new Response('{}', { status: 200 }));
437
- }, (error) => assertAssignUsersGuidance(error, /type.*member/));
773
+ await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":2188889901}]}}'], env, async () => new Response('{}', { status: 200 }));
774
+ }, (error) => assertAssignUsersGuidance(error, /type.*user/));
438
775
  });
439
776
  test('access-rule assign-users rejects hash tenant user id', async () => {
440
777
  await assert.rejects(async () => {
441
- await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":"zW96OnE0kyJa3B2"}]}}'], env, async () => new Response('{}', { status: 200 }));
778
+ await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":"zW96OnE0kyJa3B2"}]}}'], env, async () => new Response('{}', { status: 200 }));
442
779
  }, (error) => assertAssignUsersGuidance(error, /numeric TenantUser.ID/));
443
780
  });
444
781
  test('access-rule assign-users accepts canonical user_scope body', async () => {
445
- const out = await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}'], env, async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }));
782
+ const out = await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}'], env, async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }));
446
783
  assert.equal(out.kind, 'result');
447
784
  assert.deepEqual(out.data.update, ['user_scope']);
448
- assert.deepEqual(out.data.options.user_scope, [{ type: 'member', id: 123 }]);
785
+ assert.deepEqual(out.data.options.user_scope, [{ type: 'user', id: 2188889901 }]);
449
786
  });