@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
@@ -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,28 @@ 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
+
25
48
  function assertAssignUsersGuidance(error: unknown, messagePattern?: RegExp): boolean {
26
49
  assert.ok(error instanceof CLIError)
27
50
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
@@ -32,7 +55,7 @@ function assertAssignUsersGuidance(error: unknown, messagePattern?: RegExp): boo
32
55
  assert.deepEqual(error.details?.shapeHint, {
33
56
  update: ['user_scope'],
34
57
  options: {
35
- user_scope: [{ type: 'member', id: 123 }],
58
+ user_scope: [{ type: 'user', id: 2188889901 }],
36
59
  },
37
60
  })
38
61
  assert.deepEqual(error.details?.commonMistakes, [
@@ -41,9 +64,9 @@ function assertAssignUsersGuidance(error: unknown, messagePattern?: RegExp): boo
41
64
  'do not use hash tenant user id as id',
42
65
  'id must be numeric TenantUser.ID',
43
66
  ])
44
- assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'AppIngressUpdateReqVO' && item.file === '/opt/arcubase-sdk/types/ingress.ts'))
45
- assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/opt/arcubase-sdk/types/ingress.ts'))
46
- assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'PermitUserScope' && item.file === '/opt/arcubase-sdk/types/common.ts'))
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'))
47
70
  return true
48
71
  }
49
72
 
@@ -109,6 +132,15 @@ test('table update-schema rejects invalid body file', async () => {
109
132
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
110
133
  assert.equal(error.details?.requestType, 'EntitySaveReqVO')
111
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"')))
112
144
  return true
113
145
  })
114
146
  })
@@ -125,40 +157,68 @@ test('app create rejects name longer than 20 chars', async () => {
125
157
  })
126
158
  })
127
159
 
128
- test('table create executes with valid body file', async () => {
129
- 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 }> = []
130
177
  const out = await executeCLI(
131
178
  'admin',
132
- ['table', 'create', '--app-id', 'app_1', '--body-file', bodyFile],
179
+ ['table', 'create', '--app-id', '2188889844', '--body-file', bodyFile],
133
180
  env as any,
134
- async (url, init) =>
135
- new Response(JSON.stringify({
136
- url,
137
- method: init?.method,
138
- hasTenantHeader: Object.prototype.hasOwnProperty.call((init?.headers as Record<string, string>) ?? {}, 'X-Tenant-Id'),
139
- hasHostHeader: Object.prototype.hasOwnProperty.call((init?.headers as Record<string, string>) ?? {}, 'X-Host'),
140
- }), { 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
+ },
141
188
  )
142
189
  assert.equal(out.kind, 'result')
143
- assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities')
144
- assert.equal(out.data.hasTenantHeader, false)
145
- 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)
146
200
  })
147
201
 
148
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 })
149
205
  const out = await executeCLI(
150
206
  'admin',
151
- ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":"订单","parent":0}'],
207
+ ['table', 'create', '--app-id', '2188889844', '--body-json', payload],
152
208
  env as any,
153
- async (url, init) => new Response(JSON.stringify({
154
- url,
155
- method: init?.method,
156
- body: init?.body ? JSON.parse(String(init.body)) : null,
157
- }), { 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
+ },
158
216
  )
159
217
  assert.equal(out.kind, 'result')
160
- assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities')
161
- 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)
162
222
  })
163
223
 
164
224
  test('body json conflicts with body file', async () => {
@@ -182,6 +242,22 @@ test('body json is rejected for commands without request type', async () => {
182
242
  })
183
243
  })
184
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
+
185
261
  test('body json parse errors fail fast', async () => {
186
262
  await assert.rejects(async () => {
187
263
  await executeCLI('admin', ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":'], env as any, async () => new Response('{}', { status: 200 }))
@@ -192,6 +268,19 @@ test('body json parse errors fail fast', async () => {
192
268
  })
193
269
  })
194
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
+
195
284
  test('access-rule list executes against external root path', async () => {
196
285
  const out = await executeCLI(
197
286
  'admin',
@@ -256,6 +345,36 @@ test('table update-schema normalizes shell null placeholders before validation',
256
345
  assert.deepEqual(requestBody.options.misc, {})
257
346
  })
258
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
+
259
378
  test('row delete rejects selection type outside ids', async () => {
260
379
  const bodyFile = tempJSONFile({ selection: { type: 'selection', ids: [4000000003], length: 1 } })
261
380
  await assert.rejects(async () => {
@@ -277,7 +396,7 @@ test('row bulk-update accepts ids selection with number_add action', async () =>
277
396
  'user',
278
397
  ['row', 'bulk-update', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
279
398
  env as any,
280
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
399
+ withRowPolicyResolver(),
281
400
  )
282
401
  assert.equal(out.kind, 'result')
283
402
  assert.deepEqual(out.data.selection, { type: 'ids', ids: [4000000003], length: 1 })
@@ -297,7 +416,7 @@ test('row selection-action accepts condition selection with selectAll', async ()
297
416
  'user',
298
417
  ['row', 'selection-action', '--app-id', 'app_1', '--table-id', 'table_1', '--action', 'archive', '--body-file', bodyFile],
299
418
  env as any,
300
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
419
+ withRowPolicyResolver(),
301
420
  )
302
421
  assert.equal(out.kind, 'result')
303
422
  assert.equal(out.data.selection.type, 'condition')
@@ -316,7 +435,139 @@ test('row selection-action rejects condition selection for query action', async
316
435
  })
317
436
  })
318
437
 
319
- 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 () => {
320
571
  const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: './contract.pdf' } })
321
572
  const calls: Array<{ url: string; method?: string }> = []
322
573
  await assert.rejects(async () => {
@@ -336,12 +587,31 @@ test('row create rejects local file path for file field and points to arcubase u
336
587
  assert.ok(error instanceof CLIError)
337
588
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
338
589
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1007'))
339
- assert.match(error.message, /arcubase upload \.\/contract\.pdf/)
590
+ assert.match(error.message, /upload token flow/)
340
591
  return true
341
592
  })
342
593
  assert.deepEqual(calls, [{ url: 'https://arcubase.example.com/api/entity/app_1/table_1', method: 'GET' }])
343
594
  })
344
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
+
345
615
  test('row update rejects object value for image field and requires upload array', async () => {
346
616
  const bodyFile = tempJSONFile({ changed_fields: [1008], data: { 1008: { upload_id: 123456, file: 'lead-photo.jpg', file_name: 'lead-photo.jpg', meta: {} } } })
347
617
  await assert.rejects(async () => {
@@ -360,7 +630,7 @@ test('row update rejects object value for image field and requires upload array'
360
630
  assert.ok(error instanceof CLIError)
361
631
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
362
632
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1008'))
363
- assert.match(error.message, /must be an array/)
633
+ assert.match(error.message, /upload value array/)
364
634
  return true
365
635
  })
366
636
  })
@@ -372,13 +642,13 @@ test('row create accepts upload array for file field after schema preflight', as
372
642
  'user',
373
643
  ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
374
644
  env as any,
375
- async (url, init) => {
645
+ withRowPolicyResolver('table_1', ['add'], async (url, init) => {
376
646
  calls.push({ url: String(url), method: init?.method })
377
647
  if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
378
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 })
379
649
  }
380
650
  return new Response(String(init?.body ?? '{}'), { status: 200 })
381
- },
651
+ }),
382
652
  )
383
653
  assert.equal(out.kind, 'result')
384
654
  assert.deepEqual(calls, [
@@ -395,7 +665,7 @@ test('row create decorates upload bind failures with actionable guidance', async
395
665
  'user',
396
666
  ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
397
667
  env as any,
398
- async (url, init) => {
668
+ withRowPolicyResolver('table_1', ['add'], async (url, init) => {
399
669
  if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
400
670
  return new Response(JSON.stringify({ data: { fields: [{ id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 })
401
671
  }
@@ -407,14 +677,14 @@ test('row create decorates upload bind failures with actionable guidance', async
407
677
  },
408
678
  trace_id: 'req_upload_bind',
409
679
  }), { status: 200 })
410
- },
680
+ }),
411
681
  )
412
682
  }, (error: unknown) => {
413
683
  assert.ok(error instanceof CLIError)
414
684
  assert.equal(error.code, 'UPSTREAM_BODY_ERROR')
415
685
  assert.equal(error.details?.traceId, 'req_upload_bind')
416
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')
417
- assert.match(error.details?.hint ?? '', /rerun arcubase upload/)
687
+ assert.match(error.details?.hint ?? '', /upload token flow/)
418
688
  return true
419
689
  })
420
690
  })
@@ -431,6 +701,30 @@ test('row query rejects camelCase viewMode and points to row crud doc', async ()
431
701
  })
432
702
  })
433
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
+
434
728
  test('row update rejects fields key', async () => {
435
729
  const bodyFile = tempJSONFile({ fields: { 1002: 99 }, data: { '1002': 99 }, changed_fields: [1002] })
436
730
  await assert.rejects(async () => {
@@ -456,7 +750,7 @@ test('row update rejects changed_fields that are missing from data', async () =>
456
750
  })
457
751
 
458
752
  test('workflow approve maps to fixed action path', async () => {
459
- const bodyFile = tempJSONFile({ task_id: 1001, fields: {}, changed: [] })
753
+ const bodyFile = tempJSONFile({ task_id: 2188890521, fields: {}, changed: [] })
460
754
  const out = await executeCLI(
461
755
  'user',
462
756
  ['workflow', 'approve', '--app-id', 'app_1', '--table-id', 'table_1', '--row-id', 'row_1', '--body-file', bodyFile],
@@ -465,7 +759,7 @@ test('workflow approve maps to fixed action path', async () => {
465
759
  )
466
760
  assert.equal(out.kind, 'result')
467
761
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entity/app_1/table_1/row/row_1/action/2')
468
- assert.equal(out.data.body.task_id, 1001)
762
+ assert.equal(out.data.body.task_id, 2188890521)
469
763
  })
470
764
 
471
765
  test('workflow query-approve-users requires request body', async () => {
@@ -478,10 +772,10 @@ test('workflow query-approve-users requires request body', async () => {
478
772
  })
479
773
  })
480
774
 
481
- test('entry passes include_app_tables query flag', async () => {
775
+ test('entry always includes app tables without exposing a query flag', async () => {
482
776
  const out = await executeCLI(
483
777
  'user',
484
- ['entry', '--include-app-tables', 'true'],
778
+ ['entry'],
485
779
  env as any,
486
780
  async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
487
781
  )
@@ -489,6 +783,45 @@ test('entry passes include_app_tables query flag', async () => {
489
783
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
490
784
  })
491
785
 
786
+ test('entry fixed app tables query cannot be disabled by query file', async () => {
787
+ const queryFile = tempJSONFile({ include_app_tables: false })
788
+ const out = await executeCLI(
789
+ 'user',
790
+ ['entry', '--query-file', queryFile],
791
+ env as any,
792
+ async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
793
+ )
794
+ assert.equal(out.kind, 'result')
795
+ assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
796
+ })
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
+
492
825
  test('access-rule update rejects enabled without update whitelist', async () => {
493
826
  await assert.rejects(async () => {
494
827
  await executeCLI(
@@ -502,7 +835,7 @@ test('access-rule update rejects enabled without update whitelist', async () =>
502
835
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
503
836
  assert.match(error.message, /update.*enabled/)
504
837
  assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
505
- 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'))
506
839
  return true
507
840
  })
508
841
  })
@@ -518,6 +851,134 @@ test('access-rule update accepts enabled with update whitelist', async () => {
518
851
  assert.deepEqual(out.data, { update: ['enabled'], enabled: true })
519
852
  })
520
853
 
854
+ test('access-rule update rejects nested options patch with canonical policy example', async () => {
855
+ await assert.rejects(async () => {
856
+ await executeCLI(
857
+ 'admin',
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}]}}}'],
859
+ env as any,
860
+ async () => new Response('{}', { status: 200 }),
861
+ )
862
+ }, (error: unknown) => {
863
+ assert.ok(error instanceof CLIError)
864
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
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')))
868
+ return true
869
+ })
870
+ })
871
+
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 () => {
886
+ await assert.rejects(async () => {
887
+ await executeCLI(
888
+ 'admin',
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":{}}}'],
958
+ env as any,
959
+ async () => new Response('{}', { status: 200 }),
960
+ )
961
+ }, (error: unknown) => {
962
+ assert.ok(error instanceof CLIError)
963
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
964
+ assert.match(error.message, /user.*department.*actor|FieldVO type "member"/)
965
+ return true
966
+ })
967
+ })
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
+
521
982
  test('access-rule assign-users rejects users shortcut', async () => {
522
983
  await assert.rejects(async () => {
523
984
  await executeCLI(
@@ -555,29 +1016,29 @@ test('access-rule assign-users rejects top-level user_scope', async () => {
555
1016
  await assert.rejects(async () => {
556
1017
  await executeCLI(
557
1018
  'admin',
558
- ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"user_scope":[{"type":"member","id":123}]}'],
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}]}'],
559
1020
  env as any,
560
1021
  async () => new Response('{}', { status: 200 }),
561
1022
  )
562
1023
  }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
563
1024
  })
564
1025
 
565
- test('access-rule assign-users rejects non-member scope type', async () => {
1026
+ test('access-rule assign-users rejects non-user member scope type', async () => {
566
1027
  await assert.rejects(async () => {
567
1028
  await executeCLI(
568
1029
  'admin',
569
- ['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}]}}'],
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}]}}'],
570
1031
  env as any,
571
1032
  async () => new Response('{}', { status: 200 }),
572
1033
  )
573
- }, (error: unknown) => assertAssignUsersGuidance(error, /type.*member/))
1034
+ }, (error: unknown) => assertAssignUsersGuidance(error, /type.*user/))
574
1035
  })
575
1036
 
576
1037
  test('access-rule assign-users rejects hash tenant user id', async () => {
577
1038
  await assert.rejects(async () => {
578
1039
  await executeCLI(
579
1040
  'admin',
580
- ['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"}]}}'],
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"}]}}'],
581
1042
  env as any,
582
1043
  async () => new Response('{}', { status: 200 }),
583
1044
  )
@@ -587,11 +1048,11 @@ test('access-rule assign-users rejects hash tenant user id', async () => {
587
1048
  test('access-rule assign-users accepts canonical user_scope body', async () => {
588
1049
  const out = await executeCLI(
589
1050
  'admin',
590
- ['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}]}}'],
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}]}}'],
591
1052
  env as any,
592
1053
  async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
593
1054
  )
594
1055
  assert.equal(out.kind, 'result')
595
1056
  assert.deepEqual(out.data.update, ['user_scope'])
596
- assert.deepEqual(out.data.options.user_scope, [{ type: 'member', id: 123 }])
1057
+ assert.deepEqual(out.data.options.user_scope, [{ type: 'user', id: 2188889901 }])
597
1058
  })