@carthooks/arcubase-cli 0.1.10 → 0.1.13
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.
- package/bundle/arcubase-admin.mjs +1365 -375
- package/bundle/arcubase.mjs +1365 -375
- package/dist/generated/command_registry.generated.d.ts +36 -35
- package/dist/generated/command_registry.generated.d.ts.map +1 -1
- package/dist/generated/command_registry.generated.js +37 -36
- package/dist/generated/type_index.generated.d.ts +23 -23
- package/dist/generated/type_index.generated.js +23 -23
- package/dist/runtime/entity_save_schema.d.ts.map +1 -1
- package/dist/runtime/entity_save_schema.js +30 -0
- package/dist/runtime/env.d.ts +2 -1
- package/dist/runtime/env.d.ts.map +1 -1
- package/dist/runtime/env.js +10 -1
- package/dist/runtime/errors.d.ts +3 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/execute.d.ts +4 -2
- package/dist/runtime/execute.d.ts.map +1 -1
- package/dist/runtime/execute.js +1046 -134
- package/dist/runtime/paths.d.ts +1 -1
- package/dist/runtime/paths.d.ts.map +1 -1
- package/dist/runtime/paths.js +9 -2
- package/dist/runtime/upload.d.ts +1 -11
- package/dist/runtime/upload.d.ts.map +1 -1
- package/dist/runtime/upload.js +19 -133
- package/dist/runtime/zod_registry.d.ts +5 -3
- package/dist/runtime/zod_registry.d.ts.map +1 -1
- package/dist/runtime/zod_registry.js +108 -48
- package/dist/tests/bootstrap.test.js +12 -0
- package/dist/tests/command_registry.test.js +8 -0
- package/dist/tests/docs_readability.test.d.ts +2 -0
- package/dist/tests/docs_readability.test.d.ts.map +1 -0
- package/dist/tests/docs_readability.test.js +87 -0
- package/dist/tests/entity_save_schema.test.js +22 -0
- package/dist/tests/execute_validation.test.js +443 -44
- package/dist/tests/help.test.js +47 -8
- package/dist/tests/upload.test.js +30 -77
- package/package.json +1 -1
- package/sdk-dist/docs/runtime-reference/README.md +43 -29
- package/sdk-dist/docs/runtime-reference/access-rule.md +128 -0
- package/sdk-dist/docs/runtime-reference/condition.md +182 -0
- package/sdk-dist/docs/runtime-reference/entity-schema/README.md +6 -5
- package/sdk-dist/docs/runtime-reference/entity-schema/checkbox.md +1 -1
- package/sdk-dist/docs/runtime-reference/entity-schema/file.md +2 -2
- package/sdk-dist/docs/runtime-reference/entity-schema/image.md +2 -2
- package/sdk-dist/docs/runtime-reference/entity-schema/linkto.md +3 -3
- package/sdk-dist/docs/runtime-reference/entity-schema/number.md +1 -1
- package/sdk-dist/docs/runtime-reference/entity-schema/query.md +3 -3
- package/sdk-dist/docs/runtime-reference/entity-schema/relation.md +3 -3
- package/sdk-dist/docs/runtime-reference/entity-schema/relationfield.md +3 -3
- package/sdk-dist/docs/runtime-reference/entity-schema/status.md +1 -1
- package/sdk-dist/docs/runtime-reference/entity-schema/text.md +1 -1
- package/sdk-dist/docs/runtime-reference/entity-schema.md +28 -21
- package/sdk-dist/docs/runtime-reference/examples/README.md +1 -1
- package/sdk-dist/docs/runtime-reference/examples/crm-01/README.md +30 -11
- package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/create-app.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.bulk-update.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.query.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.row.insert.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.selection.condition.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +26 -26
- package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.row.insert.json +1 -1
- package/sdk-dist/docs/runtime-reference/examples/wms-01/README.md +26 -10
- package/sdk-dist/docs/runtime-reference/examples/wms-01/create-app.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.bulk-update.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.query.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.selection.condition.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/row-crud.md +48 -8
- package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +31 -95
- package/sdk-dist/docs/runtime-reference/selection.md +128 -0
- package/sdk-dist/docs/runtime-reference/table-lifecycle.md +113 -30
- package/sdk-dist/docs/runtime-reference/uploads.md +30 -59
- package/sdk-dist/docs/runtime-reference/workflow/README.md +129 -13
- package/sdk-dist/generated/command_registry.generated.ts +37 -36
- package/sdk-dist/generated/type_index.generated.ts +23 -23
- package/src/generated/command_registry.generated.ts +37 -36
- package/src/generated/type_index.generated.ts +23 -23
- package/src/runtime/entity_save_schema.ts +30 -0
- package/src/runtime/env.ts +14 -2
- package/src/runtime/errors.ts +3 -0
- package/src/runtime/execute.ts +1272 -134
- package/src/runtime/paths.ts +11 -2
- package/src/runtime/upload.ts +19 -178
- package/src/runtime/zod_registry.ts +113 -48
- package/src/tests/bootstrap.test.ts +16 -0
- package/src/tests/command_registry.test.ts +9 -0
- package/src/tests/docs_readability.test.ts +96 -0
- package/src/tests/entity_save_schema.test.ts +22 -0
- package/src/tests/execute_validation.test.ts +577 -47
- package/src/tests/help.test.ts +51 -8
- 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: '
|
|
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 === '/
|
|
45
|
-
assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/
|
|
46
|
-
assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'PermitUserScope' && item.file === '/
|
|
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
|
|
129
|
-
|
|
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', '
|
|
179
|
+
['table', 'create', '--app-id', '2188889844', '--body-file', bodyFile],
|
|
133
180
|
env as any,
|
|
134
|
-
async (url, init) =>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
144
|
-
assert.equal(
|
|
145
|
-
assert.equal(
|
|
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', '
|
|
207
|
+
['table', 'create', '--app-id', '2188889844', '--body-json', payload],
|
|
152
208
|
env as any,
|
|
153
|
-
async (url, init) =>
|
|
154
|
-
url,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
|
161
|
-
assert.
|
|
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,51 @@ 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
|
+
|
|
284
|
+
test('access-rule invalid json returns copyable create retry examples', async () => {
|
|
285
|
+
await assert.rejects(async () => {
|
|
286
|
+
await executeCLI('admin', ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"Admin full access","enabled":true,"type":"form","options":{"user_scope":[{"type":"user","id":2188889977}],"policy":{"key":"custom","actions":["view","add","edit","delete"],"all_fields_read":true,"all_fields_write":true},"list_options":{}}'], env as any, async () => new Response('{}', { status: 200 }))
|
|
287
|
+
}, (error: unknown) => {
|
|
288
|
+
assert.ok(error instanceof CLIError)
|
|
289
|
+
assert.equal(error.code, 'INVALID_BODY_JSON')
|
|
290
|
+
assert.equal(error.details?.requestType, 'AppIngressCreateReqVO')
|
|
291
|
+
assert.match(error.message, /retryToolCalls/)
|
|
292
|
+
const toolCall = (error.details?.retryToolCalls ?? [])[0] as any
|
|
293
|
+
assert.ok(toolCall)
|
|
294
|
+
assert.equal(toolCall.command, 'access-rule create')
|
|
295
|
+
assert.deepEqual(toolCall.args.slice(0, 5), ['--app-id', '<app_id>', '--table-id', '<table_id>', '--body-json'])
|
|
296
|
+
const body = JSON.parse(toolCall.args[5])
|
|
297
|
+
assert.deepEqual(body, {
|
|
298
|
+
name: 'Admin full access',
|
|
299
|
+
enabled: true,
|
|
300
|
+
type: 'form',
|
|
301
|
+
options: {
|
|
302
|
+
user_scope: [{ type: 'user', id: 2188889977 }],
|
|
303
|
+
policy: {
|
|
304
|
+
key: 'custom',
|
|
305
|
+
actions: ['view', 'add', 'edit', 'delete'],
|
|
306
|
+
all_fields_read: true,
|
|
307
|
+
all_fields_write: true,
|
|
308
|
+
},
|
|
309
|
+
list_options: {},
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
return true
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
195
316
|
test('access-rule list executes against external root path', async () => {
|
|
196
317
|
const out = await executeCLI(
|
|
197
318
|
'admin',
|
|
@@ -256,6 +377,36 @@ test('table update-schema normalizes shell null placeholders before validation',
|
|
|
256
377
|
assert.deepEqual(requestBody.options.misc, {})
|
|
257
378
|
})
|
|
258
379
|
|
|
380
|
+
test('table update-schema rejects per-user permission patches', async () => {
|
|
381
|
+
const body = JSON.stringify({
|
|
382
|
+
id: 2188893443,
|
|
383
|
+
app_id: 2188893436,
|
|
384
|
+
name: '投票项目',
|
|
385
|
+
schema_version: 1,
|
|
386
|
+
field_id_seq: 1003,
|
|
387
|
+
layout: [[1001], [1002]],
|
|
388
|
+
fields: [
|
|
389
|
+
{ 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 },
|
|
390
|
+
{ 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 } } },
|
|
391
|
+
],
|
|
392
|
+
options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
|
|
393
|
+
workflow_enabled: false,
|
|
394
|
+
})
|
|
395
|
+
await assert.rejects(async () => {
|
|
396
|
+
await executeCLI(
|
|
397
|
+
'admin',
|
|
398
|
+
['table', 'update-schema', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', body],
|
|
399
|
+
env as any,
|
|
400
|
+
async () => new Response('{}', { status: 200 }),
|
|
401
|
+
)
|
|
402
|
+
}, (error: unknown) => {
|
|
403
|
+
assert.ok(error instanceof CLIError)
|
|
404
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
405
|
+
assert.match(error.message, /access-rule/)
|
|
406
|
+
return true
|
|
407
|
+
})
|
|
408
|
+
})
|
|
409
|
+
|
|
259
410
|
test('row delete rejects selection type outside ids', async () => {
|
|
260
411
|
const bodyFile = tempJSONFile({ selection: { type: 'selection', ids: [4000000003], length: 1 } })
|
|
261
412
|
await assert.rejects(async () => {
|
|
@@ -277,7 +428,7 @@ test('row bulk-update accepts ids selection with number_add action', async () =>
|
|
|
277
428
|
'user',
|
|
278
429
|
['row', 'bulk-update', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
|
|
279
430
|
env as any,
|
|
280
|
-
|
|
431
|
+
withRowPolicyResolver(),
|
|
281
432
|
)
|
|
282
433
|
assert.equal(out.kind, 'result')
|
|
283
434
|
assert.deepEqual(out.data.selection, { type: 'ids', ids: [4000000003], length: 1 })
|
|
@@ -297,7 +448,7 @@ test('row selection-action accepts condition selection with selectAll', async ()
|
|
|
297
448
|
'user',
|
|
298
449
|
['row', 'selection-action', '--app-id', 'app_1', '--table-id', 'table_1', '--action', 'archive', '--body-file', bodyFile],
|
|
299
450
|
env as any,
|
|
300
|
-
|
|
451
|
+
withRowPolicyResolver(),
|
|
301
452
|
)
|
|
302
453
|
assert.equal(out.kind, 'result')
|
|
303
454
|
assert.equal(out.data.selection.type, 'condition')
|
|
@@ -316,7 +467,176 @@ test('row selection-action rejects condition selection for query action', async
|
|
|
316
467
|
})
|
|
317
468
|
})
|
|
318
469
|
|
|
319
|
-
test('row create
|
|
470
|
+
test('row create resolves internal policy_id from accessible ingress', async () => {
|
|
471
|
+
const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' } })
|
|
472
|
+
const calls: Array<{ url: string; method?: string; body: any }> = []
|
|
473
|
+
const out = await executeCLI(
|
|
474
|
+
'user',
|
|
475
|
+
['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
|
|
476
|
+
env as any,
|
|
477
|
+
withRowPolicyResolver('2188900694', ['view', 'add'], async (url, init) => {
|
|
478
|
+
calls.push({
|
|
479
|
+
url: String(url),
|
|
480
|
+
method: init?.method,
|
|
481
|
+
body: init?.body ? JSON.parse(String(init.body)) : null,
|
|
482
|
+
})
|
|
483
|
+
return new Response(String(init?.body ?? '{}'), { status: 200 })
|
|
484
|
+
}),
|
|
485
|
+
)
|
|
486
|
+
assert.equal(out.kind, 'result')
|
|
487
|
+
assert.deepEqual(calls, [
|
|
488
|
+
{ url: 'https://arcubase.example.com/api/entity/2188900691/2188900694', method: 'GET', body: null },
|
|
489
|
+
{
|
|
490
|
+
url: 'https://arcubase.example.com/api/entity/2188900691/2188900694/insert',
|
|
491
|
+
method: 'POST',
|
|
492
|
+
body: { fields: { 1001: '今天吃什么' }, policy_id: 'policy_hash_1' },
|
|
493
|
+
},
|
|
494
|
+
])
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('row get resolves internal policy_id as query parameter', async () => {
|
|
498
|
+
const calls: string[] = []
|
|
499
|
+
const out = await executeCLI(
|
|
500
|
+
'user',
|
|
501
|
+
['row', 'get', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '4000000046'],
|
|
502
|
+
env as any,
|
|
503
|
+
withRowPolicyResolver('2188900694', ['view'], async (url) => {
|
|
504
|
+
calls.push(String(url))
|
|
505
|
+
return new Response(JSON.stringify({ data: { id: 4000000046 } }), { status: 200 })
|
|
506
|
+
}),
|
|
507
|
+
)
|
|
508
|
+
assert.equal(out.kind, 'result')
|
|
509
|
+
assert.equal(calls[0], 'https://arcubase.example.com/api/entity/2188900691/2188900694/row/4000000046?policy_id=policy_hash_1')
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
test('row get returns machine-readable row value evidence', async () => {
|
|
513
|
+
const out = await executeCLI(
|
|
514
|
+
'user',
|
|
515
|
+
['row', 'get', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '4000000046'],
|
|
516
|
+
env as any,
|
|
517
|
+
withRowPolicyResolver('2188900694', ['view'], async () => new Response(JSON.stringify({
|
|
518
|
+
data: {
|
|
519
|
+
record: {
|
|
520
|
+
id: 4000000046,
|
|
521
|
+
fields: { 1001: '复测投票' },
|
|
522
|
+
},
|
|
523
|
+
fieldPermits: {
|
|
524
|
+
all_fields_read: false,
|
|
525
|
+
fields: [
|
|
526
|
+
{ key: ':1001', read: true, write: true, report: false },
|
|
527
|
+
{ key: ':1002', read: false, write: false, report: false },
|
|
528
|
+
{ key: ':1003', read: true, write: true, report: false },
|
|
529
|
+
],
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
}), { status: 200 })),
|
|
533
|
+
)
|
|
534
|
+
assert.deepEqual(out.rowValueEvidence, {
|
|
535
|
+
source: 'row get record.fields',
|
|
536
|
+
returnedFieldIds: ['1001'],
|
|
537
|
+
notReturnedFieldIds: ['1002', '1003'],
|
|
538
|
+
deniedReadFieldIds: ['1002'],
|
|
539
|
+
absentFieldMeaning: 'not returned to current user; no row-value evidence',
|
|
540
|
+
rules: [
|
|
541
|
+
'current row values come only from row query fields or row get record.fields',
|
|
542
|
+
'table schema fields[].value and default_value_mode are schema defaults, not returned row values',
|
|
543
|
+
'a field id absent from returned row fields has no row-value evidence',
|
|
544
|
+
'read visibility status is fieldPermits read:false; FIELD_PERMISSION_REQUIRED is only a write preflight error',
|
|
545
|
+
],
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
test('row command rejects user-provided policy_id', async () => {
|
|
550
|
+
const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' }, policy_id: 'manual_policy' })
|
|
551
|
+
await assert.rejects(async () => {
|
|
552
|
+
await executeCLI(
|
|
553
|
+
'user',
|
|
554
|
+
['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
|
|
555
|
+
env as any,
|
|
556
|
+
withRowPolicyResolver('2188900694', ['add']),
|
|
557
|
+
)
|
|
558
|
+
}, (error: unknown) => {
|
|
559
|
+
assert.ok(error instanceof CLIError)
|
|
560
|
+
assert.equal(error.code, 'INTERNAL_POLICY_ID_NOT_ALLOWED')
|
|
561
|
+
assert.match(error.message, /internal Arcubase ingress parameter/)
|
|
562
|
+
return true
|
|
563
|
+
})
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('row command fastfails when no accessible ingress matches action', async () => {
|
|
567
|
+
const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' } })
|
|
568
|
+
await assert.rejects(async () => {
|
|
569
|
+
await executeCLI(
|
|
570
|
+
'user',
|
|
571
|
+
['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
|
|
572
|
+
env as any,
|
|
573
|
+
withRowPolicyResolver('2188900694', ['view']),
|
|
574
|
+
)
|
|
575
|
+
}, (error: unknown) => {
|
|
576
|
+
assert.ok(error instanceof CLIError)
|
|
577
|
+
assert.equal(error.code, 'POLICY_ID_RESOLUTION_FAILED')
|
|
578
|
+
assert.match(error.message, /action add/)
|
|
579
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('access-rule')))
|
|
580
|
+
return true
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
test('row create fastfails before request when payload writes non-writable field', async () => {
|
|
585
|
+
const bodyFile = tempJSONFile({ fields: { 1001: '标题', 1002: '隐藏描述' } })
|
|
586
|
+
const calls: Array<{ url: string; method?: string }> = []
|
|
587
|
+
await assert.rejects(async () => {
|
|
588
|
+
await executeCLI(
|
|
589
|
+
'user',
|
|
590
|
+
['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
|
|
591
|
+
env as any,
|
|
592
|
+
withRowPolicyResolver(
|
|
593
|
+
'2188900694',
|
|
594
|
+
['view', 'add'],
|
|
595
|
+
{ all_fields_write: true, fields: [{ key: ':1002', read: false, write: false, report: false }] },
|
|
596
|
+
async (url, init) => {
|
|
597
|
+
calls.push({ url: String(url), method: init?.method })
|
|
598
|
+
return new Response(String(init?.body ?? '{}'), { status: 200 })
|
|
599
|
+
},
|
|
600
|
+
),
|
|
601
|
+
)
|
|
602
|
+
}, (error: unknown) => {
|
|
603
|
+
assert.ok(error instanceof CLIError)
|
|
604
|
+
assert.equal(error.code, 'FIELD_PERMISSION_REQUIRED')
|
|
605
|
+
assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1002'))
|
|
606
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('remove non-writable field ids')))
|
|
607
|
+
return true
|
|
608
|
+
})
|
|
609
|
+
assert.equal(calls.some((call) => call.method !== 'GET'), false)
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
test('row update fastfails before request when payload writes non-writable field', async () => {
|
|
613
|
+
const bodyFile = tempJSONFile({ data: { 1002: '隐藏描述' }, changed_fields: [1002] })
|
|
614
|
+
const calls: Array<{ url: string; method?: string }> = []
|
|
615
|
+
await assert.rejects(async () => {
|
|
616
|
+
await executeCLI(
|
|
617
|
+
'user',
|
|
618
|
+
['row', 'update', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '2188901452', '--body-file', bodyFile],
|
|
619
|
+
env as any,
|
|
620
|
+
withRowPolicyResolver(
|
|
621
|
+
'2188900694',
|
|
622
|
+
['view', 'edit'],
|
|
623
|
+
{ all_fields_write: false, fields: [{ key: ':1001', read: true, write: true, report: false }, { key: ':1002', read: false, write: false, report: false }] },
|
|
624
|
+
async (url, init) => {
|
|
625
|
+
calls.push({ url: String(url), method: init?.method })
|
|
626
|
+
return new Response(String(init?.body ?? '{}'), { status: 200 })
|
|
627
|
+
},
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
}, (error: unknown) => {
|
|
631
|
+
assert.ok(error instanceof CLIError)
|
|
632
|
+
assert.equal(error.code, 'FIELD_PERMISSION_REQUIRED')
|
|
633
|
+
assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1002'))
|
|
634
|
+
return true
|
|
635
|
+
})
|
|
636
|
+
assert.equal(calls.some((call) => call.method !== 'GET'), false)
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
test('row create rejects local file path for file field and points to upload token flow', async () => {
|
|
320
640
|
const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: './contract.pdf' } })
|
|
321
641
|
const calls: Array<{ url: string; method?: string }> = []
|
|
322
642
|
await assert.rejects(async () => {
|
|
@@ -336,12 +656,31 @@ test('row create rejects local file path for file field and points to arcubase u
|
|
|
336
656
|
assert.ok(error instanceof CLIError)
|
|
337
657
|
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
338
658
|
assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1007'))
|
|
339
|
-
assert.match(error.message, /
|
|
659
|
+
assert.match(error.message, /upload token flow/)
|
|
340
660
|
return true
|
|
341
661
|
})
|
|
342
662
|
assert.deepEqual(calls, [{ url: 'https://arcubase.example.com/api/entity/app_1/table_1', method: 'GET' }])
|
|
343
663
|
})
|
|
344
664
|
|
|
665
|
+
test('row create non-numeric field key gives table get retry path', async () => {
|
|
666
|
+
const bodyFile = tempJSONFile({ fields: { 标题: '今天吃什么' } })
|
|
667
|
+
await assert.rejects(async () => {
|
|
668
|
+
await executeCLI(
|
|
669
|
+
'user',
|
|
670
|
+
['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
|
|
671
|
+
env as any,
|
|
672
|
+
async () => new Response('{}', { status: 200 }),
|
|
673
|
+
)
|
|
674
|
+
}, (error: unknown) => {
|
|
675
|
+
assert.ok(error instanceof CLIError)
|
|
676
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
677
|
+
assert.ok(error.details?.commonMistakes?.some((item) => item.includes('do not use field labels')))
|
|
678
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase table get')))
|
|
679
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase row create')))
|
|
680
|
+
return true
|
|
681
|
+
})
|
|
682
|
+
})
|
|
683
|
+
|
|
345
684
|
test('row update rejects object value for image field and requires upload array', async () => {
|
|
346
685
|
const bodyFile = tempJSONFile({ changed_fields: [1008], data: { 1008: { upload_id: 123456, file: 'lead-photo.jpg', file_name: 'lead-photo.jpg', meta: {} } } })
|
|
347
686
|
await assert.rejects(async () => {
|
|
@@ -360,7 +699,7 @@ test('row update rejects object value for image field and requires upload array'
|
|
|
360
699
|
assert.ok(error instanceof CLIError)
|
|
361
700
|
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
362
701
|
assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1008'))
|
|
363
|
-
assert.match(error.message, /
|
|
702
|
+
assert.match(error.message, /upload value array/)
|
|
364
703
|
return true
|
|
365
704
|
})
|
|
366
705
|
})
|
|
@@ -372,13 +711,13 @@ test('row create accepts upload array for file field after schema preflight', as
|
|
|
372
711
|
'user',
|
|
373
712
|
['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
|
|
374
713
|
env as any,
|
|
375
|
-
async (url, init) => {
|
|
714
|
+
withRowPolicyResolver('table_1', ['add'], async (url, init) => {
|
|
376
715
|
calls.push({ url: String(url), method: init?.method })
|
|
377
716
|
if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
|
|
378
717
|
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
718
|
}
|
|
380
719
|
return new Response(String(init?.body ?? '{}'), { status: 200 })
|
|
381
|
-
},
|
|
720
|
+
}),
|
|
382
721
|
)
|
|
383
722
|
assert.equal(out.kind, 'result')
|
|
384
723
|
assert.deepEqual(calls, [
|
|
@@ -395,7 +734,7 @@ test('row create decorates upload bind failures with actionable guidance', async
|
|
|
395
734
|
'user',
|
|
396
735
|
['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
|
|
397
736
|
env as any,
|
|
398
|
-
async (url, init) => {
|
|
737
|
+
withRowPolicyResolver('table_1', ['add'], async (url, init) => {
|
|
399
738
|
if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
|
|
400
739
|
return new Response(JSON.stringify({ data: { fields: [{ id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 })
|
|
401
740
|
}
|
|
@@ -407,14 +746,14 @@ test('row create decorates upload bind failures with actionable guidance', async
|
|
|
407
746
|
},
|
|
408
747
|
trace_id: 'req_upload_bind',
|
|
409
748
|
}), { status: 200 })
|
|
410
|
-
},
|
|
749
|
+
}),
|
|
411
750
|
)
|
|
412
751
|
}, (error: unknown) => {
|
|
413
752
|
assert.ok(error instanceof CLIError)
|
|
414
753
|
assert.equal(error.code, 'UPSTREAM_BODY_ERROR')
|
|
415
754
|
assert.equal(error.details?.traceId, 'req_upload_bind')
|
|
416
755
|
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 ?? '', /
|
|
756
|
+
assert.match(error.details?.hint ?? '', /upload token flow/)
|
|
418
757
|
return true
|
|
419
758
|
})
|
|
420
759
|
})
|
|
@@ -431,6 +770,30 @@ test('row query rejects camelCase viewMode and points to row crud doc', async ()
|
|
|
431
770
|
})
|
|
432
771
|
})
|
|
433
772
|
|
|
773
|
+
test('row query unknown limit flag gives body-json retry path', async () => {
|
|
774
|
+
await assert.rejects(async () => {
|
|
775
|
+
await executeCLI('user', ['row', 'query', '--app-id', '2188900691', '--table-id', '2188900694', '--limit', '1', '--offset', '0'], env as any, async () => new Response('{}', { status: 200 }))
|
|
776
|
+
}, (error: unknown) => {
|
|
777
|
+
assert.ok(error instanceof CLIError)
|
|
778
|
+
assert.equal(error.code, 'UNKNOWN_FLAG')
|
|
779
|
+
assert.equal(error.details?.requestType, 'EntityQueryReqVO')
|
|
780
|
+
assert.deepEqual(error.details?.shapeHint, { limit: 20, offset: 0, search: { text_search: 'SO-1001' } })
|
|
781
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('--body-json')))
|
|
782
|
+
return true
|
|
783
|
+
})
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
test('table dataset unknown app flag points to table get', async () => {
|
|
787
|
+
await assert.rejects(async () => {
|
|
788
|
+
await executeCLI('user', ['table', 'dataset', '--app-id', '2188900691', '--table-id', '2188900694'], env as any, async () => new Response('{}', { status: 200 }))
|
|
789
|
+
}, (error: unknown) => {
|
|
790
|
+
assert.ok(error instanceof CLIError)
|
|
791
|
+
assert.equal(error.code, 'UNKNOWN_FLAG')
|
|
792
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('table get --app-id')))
|
|
793
|
+
return true
|
|
794
|
+
})
|
|
795
|
+
})
|
|
796
|
+
|
|
434
797
|
test('row update rejects fields key', async () => {
|
|
435
798
|
const bodyFile = tempJSONFile({ fields: { 1002: 99 }, data: { '1002': 99 }, changed_fields: [1002] })
|
|
436
799
|
await assert.rejects(async () => {
|
|
@@ -456,7 +819,7 @@ test('row update rejects changed_fields that are missing from data', async () =>
|
|
|
456
819
|
})
|
|
457
820
|
|
|
458
821
|
test('workflow approve maps to fixed action path', async () => {
|
|
459
|
-
const bodyFile = tempJSONFile({ task_id:
|
|
822
|
+
const bodyFile = tempJSONFile({ task_id: 2188890521, fields: {}, changed: [] })
|
|
460
823
|
const out = await executeCLI(
|
|
461
824
|
'user',
|
|
462
825
|
['workflow', 'approve', '--app-id', 'app_1', '--table-id', 'table_1', '--row-id', 'row_1', '--body-file', bodyFile],
|
|
@@ -465,7 +828,7 @@ test('workflow approve maps to fixed action path', async () => {
|
|
|
465
828
|
)
|
|
466
829
|
assert.equal(out.kind, 'result')
|
|
467
830
|
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,
|
|
831
|
+
assert.equal(out.data.body.task_id, 2188890521)
|
|
469
832
|
})
|
|
470
833
|
|
|
471
834
|
test('workflow query-approve-users requires request body', async () => {
|
|
@@ -478,10 +841,10 @@ test('workflow query-approve-users requires request body', async () => {
|
|
|
478
841
|
})
|
|
479
842
|
})
|
|
480
843
|
|
|
481
|
-
test('entry
|
|
844
|
+
test('entry always includes app tables without exposing a query flag', async () => {
|
|
482
845
|
const out = await executeCLI(
|
|
483
846
|
'user',
|
|
484
|
-
['entry'
|
|
847
|
+
['entry'],
|
|
485
848
|
env as any,
|
|
486
849
|
async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
|
|
487
850
|
)
|
|
@@ -489,6 +852,45 @@ test('entry passes include_app_tables query flag', async () => {
|
|
|
489
852
|
assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
|
|
490
853
|
})
|
|
491
854
|
|
|
855
|
+
test('entry fixed app tables query cannot be disabled by query file', async () => {
|
|
856
|
+
const queryFile = tempJSONFile({ include_app_tables: false })
|
|
857
|
+
const out = await executeCLI(
|
|
858
|
+
'user',
|
|
859
|
+
['entry', '--query-file', queryFile],
|
|
860
|
+
env as any,
|
|
861
|
+
async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
|
|
862
|
+
)
|
|
863
|
+
assert.equal(out.kind, 'result')
|
|
864
|
+
assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
test('upstream http failures include endpoint status and body details', async () => {
|
|
868
|
+
await assert.rejects(async () => {
|
|
869
|
+
await executeCLI('user', ['entry'], env as any, async () => new Response('', { status: 500 }))
|
|
870
|
+
}, (error: unknown) => {
|
|
871
|
+
assert.ok(error instanceof CLIError)
|
|
872
|
+
assert.equal(error.code, 'UPSTREAM_REQUEST_FAILED')
|
|
873
|
+
assert.equal(error.details?.endpoint, '/api/entry')
|
|
874
|
+
assert.equal(error.details?.method, 'GET')
|
|
875
|
+
assert.equal(error.details?.status, 500)
|
|
876
|
+
return true
|
|
877
|
+
})
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
test('upstream network failures are separated from http failures', async () => {
|
|
881
|
+
await assert.rejects(async () => {
|
|
882
|
+
await executeCLI('user', ['entry'], env as any, async () => {
|
|
883
|
+
throw new Error('dns failed')
|
|
884
|
+
})
|
|
885
|
+
}, (error: unknown) => {
|
|
886
|
+
assert.ok(error instanceof CLIError)
|
|
887
|
+
assert.equal(error.code, 'UPSTREAM_NETWORK_ERROR')
|
|
888
|
+
assert.equal(error.details?.endpoint, '/api/entry')
|
|
889
|
+
assert.match(error.message, /dns failed/)
|
|
890
|
+
return true
|
|
891
|
+
})
|
|
892
|
+
})
|
|
893
|
+
|
|
492
894
|
test('access-rule update rejects enabled without update whitelist', async () => {
|
|
493
895
|
await assert.rejects(async () => {
|
|
494
896
|
await executeCLI(
|
|
@@ -502,7 +904,7 @@ test('access-rule update rejects enabled without update whitelist', async () =>
|
|
|
502
904
|
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
503
905
|
assert.match(error.message, /update.*enabled/)
|
|
504
906
|
assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
|
|
505
|
-
assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/
|
|
907
|
+
assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/runtime/arcubase-sdk/types/ingress.ts'))
|
|
506
908
|
return true
|
|
507
909
|
})
|
|
508
910
|
})
|
|
@@ -518,6 +920,134 @@ test('access-rule update accepts enabled with update whitelist', async () => {
|
|
|
518
920
|
assert.deepEqual(out.data, { update: ['enabled'], enabled: true })
|
|
519
921
|
})
|
|
520
922
|
|
|
923
|
+
test('access-rule update rejects nested options patch with canonical policy example', async () => {
|
|
924
|
+
await assert.rejects(async () => {
|
|
925
|
+
await executeCLI(
|
|
926
|
+
'admin',
|
|
927
|
+
['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}]}}}'],
|
|
928
|
+
env as any,
|
|
929
|
+
async () => new Response('{}', { status: 200 }),
|
|
930
|
+
)
|
|
931
|
+
}, (error: unknown) => {
|
|
932
|
+
assert.ok(error instanceof CLIError)
|
|
933
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
934
|
+
assert.match(error.message, /update.*options/)
|
|
935
|
+
assert.match(error.message, /:1002/)
|
|
936
|
+
assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('options.policy')))
|
|
937
|
+
return true
|
|
938
|
+
})
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
test('access-rule update accepts canonical policy body with hidden field', async () => {
|
|
942
|
+
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":{}}}'
|
|
943
|
+
const out = await executeCLI(
|
|
944
|
+
'admin',
|
|
945
|
+
['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', body],
|
|
946
|
+
env as any,
|
|
947
|
+
async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
|
|
948
|
+
)
|
|
949
|
+
assert.equal(out.kind, 'result')
|
|
950
|
+
assert.deepEqual(out.data.update, ['options'])
|
|
951
|
+
assert.deepEqual(out.data.options.policy.fields, [{ key: ':1002', read: false, write: false, report: false }])
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
test('access-rule update rejects partial options user scope overwrite', async () => {
|
|
955
|
+
await assert.rejects(async () => {
|
|
956
|
+
await executeCLI(
|
|
957
|
+
'admin',
|
|
958
|
+
['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}]}}'],
|
|
959
|
+
env as any,
|
|
960
|
+
async () => new Response('{}', { status: 200 }),
|
|
961
|
+
)
|
|
962
|
+
}, (error: unknown) => {
|
|
963
|
+
assert.ok(error instanceof CLIError)
|
|
964
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
965
|
+
assert.match(error.message, /options.*policy/)
|
|
966
|
+
assert.match(error.message, /assign-users/)
|
|
967
|
+
return true
|
|
968
|
+
})
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
test('access-rule update rejects FieldVO key names in policy fields', async () => {
|
|
972
|
+
await assert.rejects(async () => {
|
|
973
|
+
await executeCLI(
|
|
974
|
+
'admin',
|
|
975
|
+
['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":{}}}'],
|
|
976
|
+
env as any,
|
|
977
|
+
async () => new Response('{}', { status: 200 }),
|
|
978
|
+
)
|
|
979
|
+
}, (error: unknown) => {
|
|
980
|
+
assert.ok(error instanceof CLIError)
|
|
981
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
982
|
+
assert.match(error.message, /:1002/)
|
|
983
|
+
assert.match(error.message, /vote_name|FieldVO\.key/)
|
|
984
|
+
return true
|
|
985
|
+
})
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
test('access-rule create rejects guessed permission aliases', async () => {
|
|
989
|
+
await assert.rejects(async () => {
|
|
990
|
+
await executeCLI(
|
|
991
|
+
'admin',
|
|
992
|
+
['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}]}'],
|
|
993
|
+
env as any,
|
|
994
|
+
async () => new Response('{}', { status: 200 }),
|
|
995
|
+
)
|
|
996
|
+
}, (error: unknown) => {
|
|
997
|
+
assert.ok(error instanceof CLIError)
|
|
998
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
999
|
+
assert.match(error.message, /options\.policy/)
|
|
1000
|
+
assert.equal(error.details?.requestType, 'AppIngressCreateReqVO')
|
|
1001
|
+
assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('field_visibility')))
|
|
1002
|
+
return true
|
|
1003
|
+
})
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
test('access-rule create rejects read write action aliases', async () => {
|
|
1007
|
+
await assert.rejects(async () => {
|
|
1008
|
+
await executeCLI(
|
|
1009
|
+
'admin',
|
|
1010
|
+
['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":{}}}'],
|
|
1011
|
+
env as any,
|
|
1012
|
+
async () => new Response('{}', { status: 200 }),
|
|
1013
|
+
)
|
|
1014
|
+
}, (error: unknown) => {
|
|
1015
|
+
assert.ok(error instanceof CLIError)
|
|
1016
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
1017
|
+
assert.match(error.message, /view.*add.*edit/)
|
|
1018
|
+
return true
|
|
1019
|
+
})
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
test('access-rule create rejects FieldVO member type in user scope', async () => {
|
|
1023
|
+
await assert.rejects(async () => {
|
|
1024
|
+
await executeCLI(
|
|
1025
|
+
'admin',
|
|
1026
|
+
['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":{}}}'],
|
|
1027
|
+
env as any,
|
|
1028
|
+
async () => new Response('{}', { status: 200 }),
|
|
1029
|
+
)
|
|
1030
|
+
}, (error: unknown) => {
|
|
1031
|
+
assert.ok(error instanceof CLIError)
|
|
1032
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
1033
|
+
assert.match(error.message, /user.*department.*actor|FieldVO type "member"/)
|
|
1034
|
+
return true
|
|
1035
|
+
})
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
test('access-rule create accepts custom policy with hidden field', async () => {
|
|
1039
|
+
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":{}}}'
|
|
1040
|
+
const out = await executeCLI(
|
|
1041
|
+
'admin',
|
|
1042
|
+
['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', body],
|
|
1043
|
+
env as any,
|
|
1044
|
+
async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
|
|
1045
|
+
)
|
|
1046
|
+
assert.equal(out.kind, 'result')
|
|
1047
|
+
assert.deepEqual(out.data.options.policy.actions, ['view', 'add', 'edit'])
|
|
1048
|
+
assert.deepEqual(out.data.options.policy.fields, [{ key: ':1002', read: false, write: false, report: false }])
|
|
1049
|
+
})
|
|
1050
|
+
|
|
521
1051
|
test('access-rule assign-users rejects users shortcut', async () => {
|
|
522
1052
|
await assert.rejects(async () => {
|
|
523
1053
|
await executeCLI(
|
|
@@ -555,29 +1085,29 @@ test('access-rule assign-users rejects top-level user_scope', async () => {
|
|
|
555
1085
|
await assert.rejects(async () => {
|
|
556
1086
|
await executeCLI(
|
|
557
1087
|
'admin',
|
|
558
|
-
['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"user_scope":[{"type":"
|
|
1088
|
+
['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
1089
|
env as any,
|
|
560
1090
|
async () => new Response('{}', { status: 200 }),
|
|
561
1091
|
)
|
|
562
1092
|
}, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
|
|
563
1093
|
})
|
|
564
1094
|
|
|
565
|
-
test('access-rule assign-users rejects non-member scope type', async () => {
|
|
1095
|
+
test('access-rule assign-users rejects non-user member scope type', async () => {
|
|
566
1096
|
await assert.rejects(async () => {
|
|
567
1097
|
await executeCLI(
|
|
568
1098
|
'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":"
|
|
1099
|
+
['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
1100
|
env as any,
|
|
571
1101
|
async () => new Response('{}', { status: 200 }),
|
|
572
1102
|
)
|
|
573
|
-
}, (error: unknown) => assertAssignUsersGuidance(error, /type.*
|
|
1103
|
+
}, (error: unknown) => assertAssignUsersGuidance(error, /type.*user/))
|
|
574
1104
|
})
|
|
575
1105
|
|
|
576
1106
|
test('access-rule assign-users rejects hash tenant user id', async () => {
|
|
577
1107
|
await assert.rejects(async () => {
|
|
578
1108
|
await executeCLI(
|
|
579
1109
|
'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":"
|
|
1110
|
+
['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
1111
|
env as any,
|
|
582
1112
|
async () => new Response('{}', { status: 200 }),
|
|
583
1113
|
)
|
|
@@ -587,11 +1117,11 @@ test('access-rule assign-users rejects hash tenant user id', async () => {
|
|
|
587
1117
|
test('access-rule assign-users accepts canonical user_scope body', async () => {
|
|
588
1118
|
const out = await executeCLI(
|
|
589
1119
|
'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":"
|
|
1120
|
+
['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
1121
|
env as any,
|
|
592
1122
|
async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
|
|
593
1123
|
)
|
|
594
1124
|
assert.equal(out.kind, 'result')
|
|
595
1125
|
assert.deepEqual(out.data.update, ['user_scope'])
|
|
596
|
-
assert.deepEqual(out.data.options.user_scope, [{ type: '
|
|
1126
|
+
assert.deepEqual(out.data.options.user_scope, [{ type: 'user', id: 2188889901 }])
|
|
597
1127
|
})
|