@carthooks/arcubase-cli 0.1.9 → 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 +1407 -345
  2. package/bundle/arcubase.mjs +1407 -345
  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 +4 -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 +1025 -106
  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 +446 -32
  34. package/dist/tests/help.test.js +56 -4
  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 +4 -0
  95. package/src/runtime/execute.ts +1249 -129
  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 +564 -45
  104. package/src/tests/help.test.ts +51 -7
  105. package/src/tests/upload.test.ts +34 -91
@@ -9,6 +9,7 @@ import { CLIError } from '../runtime/errors.js'
9
9
  const env = {
10
10
  ARCUBASE_BASE_URL: 'https://arcubase.example.com',
11
11
  ARCUBASE_ACCESS_TOKEN: 'tok',
12
+ ARCUBASE_SDK_ROOT: '/runtime/arcubase-sdk',
12
13
  ARCUBASE_TRACE_ID: '',
13
14
  ARCUBASE_SUBJECT_TYPE: '',
14
15
  ARCUBASE_SUBJECT_ID: '',
@@ -22,6 +23,53 @@ function tempJSONFile(payload: unknown): string {
22
23
  return filePath
23
24
  }
24
25
 
26
+ function withRowPolicyResolver(
27
+ tableId = 'table_1',
28
+ actions = ['view', 'add', 'edit', 'delete', 'bulk_update', 'archive'],
29
+ policyOverridesOrFinal: Record<string, unknown> | typeof fetch = {},
30
+ final?: typeof fetch,
31
+ ): typeof fetch {
32
+ const policyOverrides = typeof policyOverridesOrFinal === 'function' ? {} : policyOverridesOrFinal
33
+ const finalFetch = typeof policyOverridesOrFinal === 'function'
34
+ ? policyOverridesOrFinal
35
+ : final ?? (async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }))
36
+ return async (url, init) => {
37
+ const text = String(url)
38
+ if (/\/api\/apps\/[^/?]+$/.test(text) && init?.method === 'GET') {
39
+ return new Response(JSON.stringify({ data: { ingress: { groups: [{ items: [{ id: 'policy_hash_1' }] }] } } }), { status: 200 })
40
+ }
41
+ if (/\/api\/ingress\/[^/]+\/policy_hash_1$/.test(text) && init?.method === 'GET') {
42
+ return new Response(JSON.stringify({ data: { id: tableId, policy: { actions, ...policyOverrides } } }), { status: 200 })
43
+ }
44
+ return finalFetch(url, init)
45
+ }
46
+ }
47
+
48
+ function assertAssignUsersGuidance(error: unknown, messagePattern?: RegExp): boolean {
49
+ assert.ok(error instanceof CLIError)
50
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
51
+ if (messagePattern) {
52
+ assert.match(error.message, messagePattern)
53
+ }
54
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
55
+ assert.deepEqual(error.details?.shapeHint, {
56
+ update: ['user_scope'],
57
+ options: {
58
+ user_scope: [{ type: 'user', id: 2188889901 }],
59
+ },
60
+ })
61
+ assert.deepEqual(error.details?.commonMistakes, [
62
+ 'do not use user_scope at top level',
63
+ 'do not use users or allowedUsers',
64
+ 'do not use hash tenant user id as id',
65
+ 'id must be numeric TenantUser.ID',
66
+ ])
67
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'AppIngressUpdateReqVO' && item.file === '/runtime/arcubase-sdk/types/ingress.ts'))
68
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/runtime/arcubase-sdk/types/ingress.ts'))
69
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'PermitUserScope' && item.file === '/runtime/arcubase-sdk/types/common.ts'))
70
+ return true
71
+ }
72
+
25
73
  function validEntitySavePayload(overrides: Record<string, unknown> = {}) {
26
74
  return {
27
75
  id: 2188889845,
@@ -84,6 +132,15 @@ test('table update-schema rejects invalid body file', async () => {
84
132
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
85
133
  assert.equal(error.details?.requestType, 'EntitySaveReqVO')
86
134
  assert.ok((error.details?.issues ?? []).some((item) => item.path.startsWith('body.layout')))
135
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntitySaveReqVO' && item.file === '/runtime/arcubase-sdk/types/entity.ts'))
136
+ assert.ok(error.details?.docHints?.some((item) => item.file === '/runtime/arcubase-sdk/docs/runtime-reference/entity-schema.md'))
137
+ assert.equal((error.details?.shapeHint as any)?.field_id_seq, 1004)
138
+ assert.equal((error.details?.shapeHint as any)?.fields?.[0]?.type, 'text')
139
+ assert.equal((error.details?.shapeHint as any)?.fields?.[1]?.type, 'textarea')
140
+ assert.equal((error.details?.shapeHint as any)?.fields?.[2]?.type, 'select')
141
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('fields must be an array')))
142
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('get table shell first')))
143
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('not "string"')))
87
144
  return true
88
145
  })
89
146
  })
@@ -100,40 +157,68 @@ test('app create rejects name longer than 20 chars', async () => {
100
157
  })
101
158
  })
102
159
 
103
- test('table create executes with valid body file', async () => {
104
- const bodyFile = tempJSONFile({ name: '订单', parent: 0 })
160
+ test('table create rejects empty schema body before request', async () => {
161
+ await assert.rejects(async () => {
162
+ await executeCLI('admin', ['table', 'create', '--app-id', '2188889844', '--body-json', '{"name":"订单","parent":0}'], env as any, async () => new Response('{}'))
163
+ }, (error: unknown) => {
164
+ assert.ok(error instanceof CLIError)
165
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
166
+ assert.match(error.message, /fields/)
167
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase-admin table create --app-id <app_id> --body-json')))
168
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"field_id_seq":1003')))
169
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"投票描述"')))
170
+ return true
171
+ })
172
+ })
173
+
174
+ test('table create executes create and schema save with valid body file', async () => {
175
+ const bodyFile = tempJSONFile({ ...validEntitySavePayload({ id: undefined, app_id: undefined }), name: '订单', parent: 0 })
176
+ const calls: Array<{ url: string; method?: string; body: any }> = []
105
177
  const out = await executeCLI(
106
178
  'admin',
107
- ['table', 'create', '--app-id', 'app_1', '--body-file', bodyFile],
179
+ ['table', 'create', '--app-id', '2188889844', '--body-file', bodyFile],
108
180
  env as any,
109
- async (url, init) =>
110
- new Response(JSON.stringify({
111
- url,
112
- method: init?.method,
113
- hasTenantHeader: Object.prototype.hasOwnProperty.call((init?.headers as Record<string, string>) ?? {}, 'X-Tenant-Id'),
114
- hasHostHeader: Object.prototype.hasOwnProperty.call((init?.headers as Record<string, string>) ?? {}, 'X-Host'),
115
- }), { status: 200 }),
181
+ async (url, init) => {
182
+ calls.push({ url: String(url), method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : null })
183
+ if (init?.method === 'POST') {
184
+ return new Response(JSON.stringify({ data: { id: 2188889845 } }), { status: 200 })
185
+ }
186
+ return new Response(JSON.stringify({ data: { id: 2188889845, fields: calls[1]?.body?.fields ?? [] } }), { status: 200 })
187
+ },
116
188
  )
117
189
  assert.equal(out.kind, 'result')
118
- assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities')
119
- assert.equal(out.data.hasTenantHeader, false)
120
- assert.equal(out.data.hasHostHeader, false)
190
+ assert.equal(out.data.id, 2188889845)
191
+ assert.equal(calls.length, 2)
192
+ assert.equal(calls[0].url, 'https://arcubase.example.com/api/apps/2188889844/entities')
193
+ assert.equal(calls[0].method, 'POST')
194
+ assert.deepEqual(calls[0].body, { name: '订单', parent: 0 })
195
+ assert.equal(calls[1].url, 'https://arcubase.example.com/api/apps/2188889844/entity/2188889845')
196
+ assert.equal(calls[1].method, 'PUT')
197
+ assert.equal(calls[1].body.id, 2188889845)
198
+ assert.equal(calls[1].body.app_id, 2188889844)
199
+ assert.equal(calls[1].body.fields.length, 1)
121
200
  })
122
201
 
123
202
  test('table create executes with valid body json', async () => {
203
+ const calls: Array<{ url: string; method?: string; body: any }> = []
204
+ const payload = JSON.stringify({ ...validEntitySavePayload({ id: undefined, app_id: undefined }), name: '订单', parent: 0 })
124
205
  const out = await executeCLI(
125
206
  'admin',
126
- ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":"订单","parent":0}'],
207
+ ['table', 'create', '--app-id', '2188889844', '--body-json', payload],
127
208
  env as any,
128
- async (url, init) => new Response(JSON.stringify({
129
- url,
130
- method: init?.method,
131
- body: init?.body ? JSON.parse(String(init.body)) : null,
132
- }), { status: 200 }),
209
+ async (url, init) => {
210
+ calls.push({ url: String(url), method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : null })
211
+ if (init?.method === 'POST') {
212
+ return new Response(JSON.stringify({ data: { id: 2188889845 } }), { status: 200 })
213
+ }
214
+ return new Response(JSON.stringify({ data: { id: 2188889845, fields: calls[1]?.body?.fields ?? [] } }), { status: 200 })
215
+ },
133
216
  )
134
217
  assert.equal(out.kind, 'result')
135
- assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities')
136
- assert.deepEqual(out.data.body, { name: '订单', parent: 0 })
218
+ assert.equal(out.data.id, 2188889845)
219
+ assert.equal(calls.length, 2)
220
+ assert.deepEqual(calls[0].body, { name: '订单', parent: 0 })
221
+ assert.equal(calls[1].body.id, 2188889845)
137
222
  })
138
223
 
139
224
  test('body json conflicts with body file', async () => {
@@ -157,6 +242,22 @@ test('body json is rejected for commands without request type', async () => {
157
242
  })
158
243
  })
159
244
 
245
+ test('entry rejects retired include-app-tables flag before fetch', async () => {
246
+ let called = false
247
+ await assert.rejects(async () => {
248
+ await executeCLI('user', ['entry', '--include-app-tables'], env as any, async () => {
249
+ called = true
250
+ return new Response('{}', { status: 200 })
251
+ })
252
+ }, (error: unknown) => {
253
+ assert.ok(error instanceof CLIError)
254
+ assert.equal(error.code, 'UNKNOWN_FLAG')
255
+ assert.match(error.message, /--include-app-tables/)
256
+ return true
257
+ })
258
+ assert.equal(called, false)
259
+ })
260
+
160
261
  test('body json parse errors fail fast', async () => {
161
262
  await assert.rejects(async () => {
162
263
  await executeCLI('admin', ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":'], env as any, async () => new Response('{}', { status: 200 }))
@@ -167,6 +268,19 @@ test('body json parse errors fail fast', async () => {
167
268
  })
168
269
  })
169
270
 
271
+ test('body json parse errors include command docs and type hints', async () => {
272
+ await assert.rejects(async () => {
273
+ await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["options"]}]}'], env as any, async () => new Response('{}', { status: 200 }))
274
+ }, (error: unknown) => {
275
+ assert.ok(error instanceof CLIError)
276
+ assert.equal(error.code, 'INVALID_BODY_JSON')
277
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
278
+ assert.ok((error.details?.docHints ?? []).some((item) => item.file === '/runtime/arcubase-sdk/docs/runtime-reference/access-rule.md'))
279
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('access-rule update policy changes')))
280
+ return true
281
+ })
282
+ })
283
+
170
284
  test('access-rule list executes against external root path', async () => {
171
285
  const out = await executeCLI(
172
286
  'admin',
@@ -231,6 +345,36 @@ test('table update-schema normalizes shell null placeholders before validation',
231
345
  assert.deepEqual(requestBody.options.misc, {})
232
346
  })
233
347
 
348
+ test('table update-schema rejects per-user permission patches', async () => {
349
+ const body = JSON.stringify({
350
+ id: 2188893443,
351
+ app_id: 2188893436,
352
+ name: '投票项目',
353
+ schema_version: 1,
354
+ field_id_seq: 1003,
355
+ layout: [[1001], [1002]],
356
+ fields: [
357
+ { 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 },
358
+ { 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 } } },
359
+ ],
360
+ options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
361
+ workflow_enabled: false,
362
+ })
363
+ await assert.rejects(async () => {
364
+ await executeCLI(
365
+ 'admin',
366
+ ['table', 'update-schema', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', body],
367
+ env as any,
368
+ async () => new Response('{}', { status: 200 }),
369
+ )
370
+ }, (error: unknown) => {
371
+ assert.ok(error instanceof CLIError)
372
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
373
+ assert.match(error.message, /access-rule/)
374
+ return true
375
+ })
376
+ })
377
+
234
378
  test('row delete rejects selection type outside ids', async () => {
235
379
  const bodyFile = tempJSONFile({ selection: { type: 'selection', ids: [4000000003], length: 1 } })
236
380
  await assert.rejects(async () => {
@@ -252,7 +396,7 @@ test('row bulk-update accepts ids selection with number_add action', async () =>
252
396
  'user',
253
397
  ['row', 'bulk-update', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
254
398
  env as any,
255
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
399
+ withRowPolicyResolver(),
256
400
  )
257
401
  assert.equal(out.kind, 'result')
258
402
  assert.deepEqual(out.data.selection, { type: 'ids', ids: [4000000003], length: 1 })
@@ -272,7 +416,7 @@ test('row selection-action accepts condition selection with selectAll', async ()
272
416
  'user',
273
417
  ['row', 'selection-action', '--app-id', 'app_1', '--table-id', 'table_1', '--action', 'archive', '--body-file', bodyFile],
274
418
  env as any,
275
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
419
+ withRowPolicyResolver(),
276
420
  )
277
421
  assert.equal(out.kind, 'result')
278
422
  assert.equal(out.data.selection.type, 'condition')
@@ -291,7 +435,139 @@ test('row selection-action rejects condition selection for query action', async
291
435
  })
292
436
  })
293
437
 
294
- test('row create rejects local file path for file field and points to arcubase upload', async () => {
438
+ test('row create resolves internal policy_id from accessible ingress', async () => {
439
+ const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' } })
440
+ const calls: Array<{ url: string; method?: string; body: any }> = []
441
+ const out = await executeCLI(
442
+ 'user',
443
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
444
+ env as any,
445
+ withRowPolicyResolver('2188900694', ['view', 'add'], async (url, init) => {
446
+ calls.push({
447
+ url: String(url),
448
+ method: init?.method,
449
+ body: init?.body ? JSON.parse(String(init.body)) : null,
450
+ })
451
+ return new Response(String(init?.body ?? '{}'), { status: 200 })
452
+ }),
453
+ )
454
+ assert.equal(out.kind, 'result')
455
+ assert.deepEqual(calls, [
456
+ { url: 'https://arcubase.example.com/api/entity/2188900691/2188900694', method: 'GET', body: null },
457
+ {
458
+ url: 'https://arcubase.example.com/api/entity/2188900691/2188900694/insert',
459
+ method: 'POST',
460
+ body: { fields: { 1001: '今天吃什么' }, policy_id: 'policy_hash_1' },
461
+ },
462
+ ])
463
+ })
464
+
465
+ test('row get resolves internal policy_id as query parameter', async () => {
466
+ const calls: string[] = []
467
+ const out = await executeCLI(
468
+ 'user',
469
+ ['row', 'get', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '4000000046'],
470
+ env as any,
471
+ withRowPolicyResolver('2188900694', ['view'], async (url) => {
472
+ calls.push(String(url))
473
+ return new Response(JSON.stringify({ data: { id: 4000000046 } }), { status: 200 })
474
+ }),
475
+ )
476
+ assert.equal(out.kind, 'result')
477
+ assert.equal(calls[0], 'https://arcubase.example.com/api/entity/2188900691/2188900694/row/4000000046?policy_id=policy_hash_1')
478
+ })
479
+
480
+ test('row command rejects user-provided policy_id', async () => {
481
+ const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' }, policy_id: 'manual_policy' })
482
+ await assert.rejects(async () => {
483
+ await executeCLI(
484
+ 'user',
485
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
486
+ env as any,
487
+ withRowPolicyResolver('2188900694', ['add']),
488
+ )
489
+ }, (error: unknown) => {
490
+ assert.ok(error instanceof CLIError)
491
+ assert.equal(error.code, 'INTERNAL_POLICY_ID_NOT_ALLOWED')
492
+ assert.match(error.message, /internal Arcubase ingress parameter/)
493
+ return true
494
+ })
495
+ })
496
+
497
+ test('row command fastfails when no accessible ingress matches action', async () => {
498
+ const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' } })
499
+ await assert.rejects(async () => {
500
+ await executeCLI(
501
+ 'user',
502
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
503
+ env as any,
504
+ withRowPolicyResolver('2188900694', ['view']),
505
+ )
506
+ }, (error: unknown) => {
507
+ assert.ok(error instanceof CLIError)
508
+ assert.equal(error.code, 'POLICY_ID_RESOLUTION_FAILED')
509
+ assert.match(error.message, /action add/)
510
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('access-rule')))
511
+ return true
512
+ })
513
+ })
514
+
515
+ test('row create fastfails before request when payload writes non-writable field', async () => {
516
+ const bodyFile = tempJSONFile({ fields: { 1001: '标题', 1002: '隐藏描述' } })
517
+ const calls: Array<{ url: string; method?: string }> = []
518
+ await assert.rejects(async () => {
519
+ await executeCLI(
520
+ 'user',
521
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
522
+ env as any,
523
+ withRowPolicyResolver(
524
+ '2188900694',
525
+ ['view', 'add'],
526
+ { all_fields_write: true, fields: [{ key: ':1002', read: false, write: false, report: false }] },
527
+ async (url, init) => {
528
+ calls.push({ url: String(url), method: init?.method })
529
+ return new Response(String(init?.body ?? '{}'), { status: 200 })
530
+ },
531
+ ),
532
+ )
533
+ }, (error: unknown) => {
534
+ assert.ok(error instanceof CLIError)
535
+ assert.equal(error.code, 'FIELD_PERMISSION_REQUIRED')
536
+ assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1002'))
537
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('remove non-writable field ids')))
538
+ return true
539
+ })
540
+ assert.equal(calls.some((call) => call.method !== 'GET'), false)
541
+ })
542
+
543
+ test('row update fastfails before request when payload writes non-writable field', async () => {
544
+ const bodyFile = tempJSONFile({ data: { 1002: '隐藏描述' }, changed_fields: [1002] })
545
+ const calls: Array<{ url: string; method?: string }> = []
546
+ await assert.rejects(async () => {
547
+ await executeCLI(
548
+ 'user',
549
+ ['row', 'update', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '2188901452', '--body-file', bodyFile],
550
+ env as any,
551
+ withRowPolicyResolver(
552
+ '2188900694',
553
+ ['view', 'edit'],
554
+ { all_fields_write: false, fields: [{ key: ':1001', read: true, write: true, report: false }, { key: ':1002', read: false, write: false, report: false }] },
555
+ async (url, init) => {
556
+ calls.push({ url: String(url), method: init?.method })
557
+ return new Response(String(init?.body ?? '{}'), { status: 200 })
558
+ },
559
+ ),
560
+ )
561
+ }, (error: unknown) => {
562
+ assert.ok(error instanceof CLIError)
563
+ assert.equal(error.code, 'FIELD_PERMISSION_REQUIRED')
564
+ assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1002'))
565
+ return true
566
+ })
567
+ assert.equal(calls.some((call) => call.method !== 'GET'), false)
568
+ })
569
+
570
+ test('row create rejects local file path for file field and points to upload token flow', async () => {
295
571
  const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: './contract.pdf' } })
296
572
  const calls: Array<{ url: string; method?: string }> = []
297
573
  await assert.rejects(async () => {
@@ -311,12 +587,31 @@ test('row create rejects local file path for file field and points to arcubase u
311
587
  assert.ok(error instanceof CLIError)
312
588
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
313
589
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1007'))
314
- assert.match(error.message, /arcubase upload \.\/contract\.pdf/)
590
+ assert.match(error.message, /upload token flow/)
315
591
  return true
316
592
  })
317
593
  assert.deepEqual(calls, [{ url: 'https://arcubase.example.com/api/entity/app_1/table_1', method: 'GET' }])
318
594
  })
319
595
 
596
+ test('row create non-numeric field key gives table get retry path', async () => {
597
+ const bodyFile = tempJSONFile({ fields: { 标题: '今天吃什么' } })
598
+ await assert.rejects(async () => {
599
+ await executeCLI(
600
+ 'user',
601
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
602
+ env as any,
603
+ async () => new Response('{}', { status: 200 }),
604
+ )
605
+ }, (error: unknown) => {
606
+ assert.ok(error instanceof CLIError)
607
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
608
+ assert.ok(error.details?.commonMistakes?.some((item) => item.includes('do not use field labels')))
609
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase table get')))
610
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase row create')))
611
+ return true
612
+ })
613
+ })
614
+
320
615
  test('row update rejects object value for image field and requires upload array', async () => {
321
616
  const bodyFile = tempJSONFile({ changed_fields: [1008], data: { 1008: { upload_id: 123456, file: 'lead-photo.jpg', file_name: 'lead-photo.jpg', meta: {} } } })
322
617
  await assert.rejects(async () => {
@@ -335,7 +630,7 @@ test('row update rejects object value for image field and requires upload array'
335
630
  assert.ok(error instanceof CLIError)
336
631
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
337
632
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1008'))
338
- assert.match(error.message, /must be an array/)
633
+ assert.match(error.message, /upload value array/)
339
634
  return true
340
635
  })
341
636
  })
@@ -347,13 +642,13 @@ test('row create accepts upload array for file field after schema preflight', as
347
642
  'user',
348
643
  ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
349
644
  env as any,
350
- async (url, init) => {
645
+ withRowPolicyResolver('table_1', ['add'], async (url, init) => {
351
646
  calls.push({ url: String(url), method: init?.method })
352
647
  if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
353
648
  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 })
354
649
  }
355
650
  return new Response(String(init?.body ?? '{}'), { status: 200 })
356
- },
651
+ }),
357
652
  )
358
653
  assert.equal(out.kind, 'result')
359
654
  assert.deepEqual(calls, [
@@ -370,7 +665,7 @@ test('row create decorates upload bind failures with actionable guidance', async
370
665
  'user',
371
666
  ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
372
667
  env as any,
373
- async (url, init) => {
668
+ withRowPolicyResolver('table_1', ['add'], async (url, init) => {
374
669
  if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
375
670
  return new Response(JSON.stringify({ data: { fields: [{ id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 })
376
671
  }
@@ -382,14 +677,14 @@ test('row create decorates upload bind failures with actionable guidance', async
382
677
  },
383
678
  trace_id: 'req_upload_bind',
384
679
  }), { status: 200 })
385
- },
680
+ }),
386
681
  )
387
682
  }, (error: unknown) => {
388
683
  assert.ok(error instanceof CLIError)
389
684
  assert.equal(error.code, 'UPSTREAM_BODY_ERROR')
390
685
  assert.equal(error.details?.traceId, 'req_upload_bind')
391
686
  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')
392
- assert.match(error.details?.hint ?? '', /rerun arcubase upload/)
687
+ assert.match(error.details?.hint ?? '', /upload token flow/)
393
688
  return true
394
689
  })
395
690
  })
@@ -406,6 +701,30 @@ test('row query rejects camelCase viewMode and points to row crud doc', async ()
406
701
  })
407
702
  })
408
703
 
704
+ test('row query unknown limit flag gives body-json retry path', async () => {
705
+ await assert.rejects(async () => {
706
+ await executeCLI('user', ['row', 'query', '--app-id', '2188900691', '--table-id', '2188900694', '--limit', '1', '--offset', '0'], env as any, async () => new Response('{}', { status: 200 }))
707
+ }, (error: unknown) => {
708
+ assert.ok(error instanceof CLIError)
709
+ assert.equal(error.code, 'UNKNOWN_FLAG')
710
+ assert.equal(error.details?.requestType, 'EntityQueryReqVO')
711
+ assert.deepEqual(error.details?.shapeHint, { limit: 20, offset: 0, search: { text_search: 'SO-1001' } })
712
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('--body-json')))
713
+ return true
714
+ })
715
+ })
716
+
717
+ test('table dataset unknown app flag points to table get', async () => {
718
+ await assert.rejects(async () => {
719
+ await executeCLI('user', ['table', 'dataset', '--app-id', '2188900691', '--table-id', '2188900694'], env as any, async () => new Response('{}', { status: 200 }))
720
+ }, (error: unknown) => {
721
+ assert.ok(error instanceof CLIError)
722
+ assert.equal(error.code, 'UNKNOWN_FLAG')
723
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('table get --app-id')))
724
+ return true
725
+ })
726
+ })
727
+
409
728
  test('row update rejects fields key', async () => {
410
729
  const bodyFile = tempJSONFile({ fields: { 1002: 99 }, data: { '1002': 99 }, changed_fields: [1002] })
411
730
  await assert.rejects(async () => {
@@ -431,7 +750,7 @@ test('row update rejects changed_fields that are missing from data', async () =>
431
750
  })
432
751
 
433
752
  test('workflow approve maps to fixed action path', async () => {
434
- const bodyFile = tempJSONFile({ task_id: 1001, fields: {}, changed: [] })
753
+ const bodyFile = tempJSONFile({ task_id: 2188890521, fields: {}, changed: [] })
435
754
  const out = await executeCLI(
436
755
  'user',
437
756
  ['workflow', 'approve', '--app-id', 'app_1', '--table-id', 'table_1', '--row-id', 'row_1', '--body-file', bodyFile],
@@ -440,7 +759,7 @@ test('workflow approve maps to fixed action path', async () => {
440
759
  )
441
760
  assert.equal(out.kind, 'result')
442
761
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entity/app_1/table_1/row/row_1/action/2')
443
- assert.equal(out.data.body.task_id, 1001)
762
+ assert.equal(out.data.body.task_id, 2188890521)
444
763
  })
445
764
 
446
765
  test('workflow query-approve-users requires request body', async () => {
@@ -453,10 +772,22 @@ test('workflow query-approve-users requires request body', async () => {
453
772
  })
454
773
  })
455
774
 
456
- test('entry passes include_app_tables query flag', async () => {
775
+ test('entry always includes app tables without exposing a query flag', async () => {
776
+ const out = await executeCLI(
777
+ 'user',
778
+ ['entry'],
779
+ env as any,
780
+ async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
781
+ )
782
+ assert.equal(out.kind, 'result')
783
+ assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
784
+ })
785
+
786
+ test('entry fixed app tables query cannot be disabled by query file', async () => {
787
+ const queryFile = tempJSONFile({ include_app_tables: false })
457
788
  const out = await executeCLI(
458
789
  'user',
459
- ['entry', '--include-app-tables', 'true'],
790
+ ['entry', '--query-file', queryFile],
460
791
  env as any,
461
792
  async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
462
793
  )
@@ -464,6 +795,33 @@ test('entry passes include_app_tables query flag', async () => {
464
795
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
465
796
  })
466
797
 
798
+ test('upstream http failures include endpoint status and body details', async () => {
799
+ await assert.rejects(async () => {
800
+ await executeCLI('user', ['entry'], env as any, async () => new Response('', { status: 500 }))
801
+ }, (error: unknown) => {
802
+ assert.ok(error instanceof CLIError)
803
+ assert.equal(error.code, 'UPSTREAM_REQUEST_FAILED')
804
+ assert.equal(error.details?.endpoint, '/api/entry')
805
+ assert.equal(error.details?.method, 'GET')
806
+ assert.equal(error.details?.status, 500)
807
+ return true
808
+ })
809
+ })
810
+
811
+ test('upstream network failures are separated from http failures', async () => {
812
+ await assert.rejects(async () => {
813
+ await executeCLI('user', ['entry'], env as any, async () => {
814
+ throw new Error('dns failed')
815
+ })
816
+ }, (error: unknown) => {
817
+ assert.ok(error instanceof CLIError)
818
+ assert.equal(error.code, 'UPSTREAM_NETWORK_ERROR')
819
+ assert.equal(error.details?.endpoint, '/api/entry')
820
+ assert.match(error.message, /dns failed/)
821
+ return true
822
+ })
823
+ })
824
+
467
825
  test('access-rule update rejects enabled without update whitelist', async () => {
468
826
  await assert.rejects(async () => {
469
827
  await executeCLI(
@@ -477,7 +835,7 @@ test('access-rule update rejects enabled without update whitelist', async () =>
477
835
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
478
836
  assert.match(error.message, /update.*enabled/)
479
837
  assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
480
- assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/opt/arcubase-sdk/types/ingress.ts'))
838
+ assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/runtime/arcubase-sdk/types/ingress.ts'))
481
839
  return true
482
840
  })
483
841
  })
@@ -493,47 +851,208 @@ test('access-rule update accepts enabled with update whitelist', async () => {
493
851
  assert.deepEqual(out.data, { update: ['enabled'], enabled: true })
494
852
  })
495
853
 
496
- test('access-rule assign-users rejects users shortcut', async () => {
854
+ test('access-rule update rejects nested options patch with canonical policy example', async () => {
497
855
  await assert.rejects(async () => {
498
856
  await executeCLI(
499
857
  'admin',
500
- ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"users":["tu_1"]}'],
858
+ ['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}]}}}'],
501
859
  env as any,
502
860
  async () => new Response('{}', { status: 200 }),
503
861
  )
504
862
  }, (error: unknown) => {
505
863
  assert.ok(error instanceof CLIError)
506
864
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
507
- assert.match(error.message, /options.user_scope/)
508
- assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
865
+ assert.match(error.message, /update.*options/)
866
+ assert.match(error.message, /:1002/)
867
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('options.policy')))
509
868
  return true
510
869
  })
511
870
  })
512
871
 
513
- test('access-rule assign-users requires update user_scope and non-empty user_scope', async () => {
872
+ test('access-rule update accepts canonical policy body with hidden field', async () => {
873
+ 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":{}}}'
874
+ const out = await executeCLI(
875
+ 'admin',
876
+ ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', body],
877
+ env as any,
878
+ async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
879
+ )
880
+ assert.equal(out.kind, 'result')
881
+ assert.deepEqual(out.data.update, ['options'])
882
+ assert.deepEqual(out.data.options.policy.fields, [{ key: ':1002', read: false, write: false, report: false }])
883
+ })
884
+
885
+ test('access-rule update rejects partial options user scope overwrite', async () => {
514
886
  await assert.rejects(async () => {
515
887
  await executeCLI(
516
888
  'admin',
517
- ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[]}}'],
889
+ ['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}]}}'],
890
+ env as any,
891
+ async () => new Response('{}', { status: 200 }),
892
+ )
893
+ }, (error: unknown) => {
894
+ assert.ok(error instanceof CLIError)
895
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
896
+ assert.match(error.message, /options.*policy/)
897
+ assert.match(error.message, /assign-users/)
898
+ return true
899
+ })
900
+ })
901
+
902
+ test('access-rule update rejects FieldVO key names in policy fields', async () => {
903
+ await assert.rejects(async () => {
904
+ await executeCLI(
905
+ 'admin',
906
+ ['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":{}}}'],
907
+ env as any,
908
+ async () => new Response('{}', { status: 200 }),
909
+ )
910
+ }, (error: unknown) => {
911
+ assert.ok(error instanceof CLIError)
912
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
913
+ assert.match(error.message, /:1002/)
914
+ assert.match(error.message, /vote_name|FieldVO\.key/)
915
+ return true
916
+ })
917
+ })
918
+
919
+ test('access-rule create rejects guessed permission aliases', async () => {
920
+ await assert.rejects(async () => {
921
+ await executeCLI(
922
+ 'admin',
923
+ ['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}]}'],
924
+ env as any,
925
+ async () => new Response('{}', { status: 200 }),
926
+ )
927
+ }, (error: unknown) => {
928
+ assert.ok(error instanceof CLIError)
929
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
930
+ assert.match(error.message, /options\.policy/)
931
+ assert.equal(error.details?.requestType, 'AppIngressCreateReqVO')
932
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('field_visibility')))
933
+ return true
934
+ })
935
+ })
936
+
937
+ test('access-rule create rejects read write action aliases', async () => {
938
+ await assert.rejects(async () => {
939
+ await executeCLI(
940
+ 'admin',
941
+ ['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":{}}}'],
942
+ env as any,
943
+ async () => new Response('{}', { status: 200 }),
944
+ )
945
+ }, (error: unknown) => {
946
+ assert.ok(error instanceof CLIError)
947
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
948
+ assert.match(error.message, /view.*add.*edit/)
949
+ return true
950
+ })
951
+ })
952
+
953
+ test('access-rule create rejects FieldVO member type in user scope', async () => {
954
+ await assert.rejects(async () => {
955
+ await executeCLI(
956
+ 'admin',
957
+ ['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":{}}}'],
518
958
  env as any,
519
959
  async () => new Response('{}', { status: 200 }),
520
960
  )
521
961
  }, (error: unknown) => {
522
962
  assert.ok(error instanceof CLIError)
523
963
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
524
- assert.match(error.message, /non-empty/)
964
+ assert.match(error.message, /user.*department.*actor|FieldVO type "member"/)
525
965
  return true
526
966
  })
527
967
  })
528
968
 
969
+ test('access-rule create accepts custom policy with hidden field', async () => {
970
+ 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":{}}}'
971
+ const out = await executeCLI(
972
+ 'admin',
973
+ ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', body],
974
+ env as any,
975
+ async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
976
+ )
977
+ assert.equal(out.kind, 'result')
978
+ assert.deepEqual(out.data.options.policy.actions, ['view', 'add', 'edit'])
979
+ assert.deepEqual(out.data.options.policy.fields, [{ key: ':1002', read: false, write: false, report: false }])
980
+ })
981
+
982
+ test('access-rule assign-users rejects users shortcut', async () => {
983
+ await assert.rejects(async () => {
984
+ await executeCLI(
985
+ 'admin',
986
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"users":["tu_1"]}'],
987
+ env as any,
988
+ async () => new Response('{}', { status: 200 }),
989
+ )
990
+ }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
991
+ })
992
+
993
+ test('access-rule assign-users requires update user_scope and non-empty user_scope', async () => {
994
+ await assert.rejects(async () => {
995
+ await executeCLI(
996
+ 'admin',
997
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[]}}'],
998
+ env as any,
999
+ async () => new Response('{}', { status: 200 }),
1000
+ )
1001
+ }, (error: unknown) => assertAssignUsersGuidance(error, /non-empty/))
1002
+ })
1003
+
1004
+ test('access-rule assign-users rejects allowedUsers shortcut', async () => {
1005
+ await assert.rejects(async () => {
1006
+ await executeCLI(
1007
+ 'admin',
1008
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"allowedUsers":["tu_1"]}'],
1009
+ env as any,
1010
+ async () => new Response('{}', { status: 200 }),
1011
+ )
1012
+ }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
1013
+ })
1014
+
1015
+ test('access-rule assign-users rejects top-level user_scope', async () => {
1016
+ await assert.rejects(async () => {
1017
+ await executeCLI(
1018
+ 'admin',
1019
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"user_scope":[{"type":"user","id":2188889901}]}'],
1020
+ env as any,
1021
+ async () => new Response('{}', { status: 200 }),
1022
+ )
1023
+ }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
1024
+ })
1025
+
1026
+ test('access-rule assign-users rejects non-user member scope type', async () => {
1027
+ await assert.rejects(async () => {
1028
+ await executeCLI(
1029
+ 'admin',
1030
+ ['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}]}}'],
1031
+ env as any,
1032
+ async () => new Response('{}', { status: 200 }),
1033
+ )
1034
+ }, (error: unknown) => assertAssignUsersGuidance(error, /type.*user/))
1035
+ })
1036
+
1037
+ test('access-rule assign-users rejects hash tenant user id', async () => {
1038
+ await assert.rejects(async () => {
1039
+ await executeCLI(
1040
+ 'admin',
1041
+ ['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"}]}}'],
1042
+ env as any,
1043
+ async () => new Response('{}', { status: 200 }),
1044
+ )
1045
+ }, (error: unknown) => assertAssignUsersGuidance(error, /numeric TenantUser.ID/))
1046
+ })
1047
+
529
1048
  test('access-rule assign-users accepts canonical user_scope body', async () => {
530
1049
  const out = await executeCLI(
531
1050
  'admin',
532
- ['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}]}}'],
1051
+ ['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}]}}'],
533
1052
  env as any,
534
1053
  async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
535
1054
  )
536
1055
  assert.equal(out.kind, 'result')
537
1056
  assert.deepEqual(out.data.update, ['user_scope'])
538
- assert.deepEqual(out.data.options.user_scope, [{ type: 'user', id: 123 }])
1057
+ assert.deepEqual(out.data.options.user_scope, [{ type: 'user', id: 2188889901 }])
539
1058
  })