Repository navigation
Expand file tree
/
Copy patherror-message.test.ts
More file actions
342 lines (304 loc) · 14.9 KB
/
Copy patherror-message.test.ts
File metadata and controls
342 lines (304 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import { describe, it, expect } from 'vitest';
import { ApiErrorSchema } from '@objectstack/spec/api';
import { extractFieldErrors, classifyLoadError, declaredUserMessage, delegationScopeMessage, isPermissionError } from './error-message';
describe('extractFieldErrors', () => {
// The wire shape the server actually sends: `@objectstack/rest` serves a
// thrown `VALIDATION_FAILED` as 400 with `fields[]` passed through verbatim,
// and `@objectstack/client` puts the whole body on `error.details` (the
// envelope has no `details` key of its own, so the fallback applies).
const serverEnvelope = {
error: 'Name is required; Stage is not a valid option',
code: 'VALIDATION_FAILED',
fields: [
{ field: 'name', code: 'required', message: 'Name is required' },
{ field: 'stage', code: 'invalid_option', message: 'Stage is not a valid option', options: ['won', 'lost'] },
],
object: 'crm_opportunity',
};
it('reads the raw client error, whose details carry the response body', () => {
const err = Object.assign(new Error(serverEnvelope.error), {
code: 'VALIDATION_FAILED',
httpStatus: 400,
details: serverEnvelope,
});
expect(extractFieldErrors(err)).toEqual([
{ field: 'name', message: 'Name is required' },
{ field: 'stage', message: 'Stage is not a valid option' },
]);
});
it('reads a normalised ValidationError from the ObjectStack adapter', () => {
const err = Object.assign(new Error('Name is required'), {
name: 'ValidationError',
validationErrors: [{ field: 'name', message: 'Name is required' }],
});
expect(extractFieldErrors(err)).toEqual([{ field: 'name', message: 'Name is required' }]);
});
it('reads a hand-rolled error carrying `fields` directly', () => {
// The server duck-types these identically (a hook may throw the shape
// without going through objectql's class), so the client does too.
const err = Object.assign(new Error('nope'), {
fields: [{ field: 'amount', message: 'Amount must be positive' }],
});
expect(extractFieldErrors(err)).toEqual([{ field: 'amount', message: 'Amount must be positive' }]);
});
it('falls back to the machine code when an entry carries no message', () => {
const err = { details: { fields: [{ field: 'name', code: 'required' }] } };
expect(extractFieldErrors(err)).toEqual([{ field: 'name', message: 'required' }]);
});
it('drops entries with no field rather than guessing which input to mark', () => {
const err = {
details: { fields: [{ message: 'something is wrong' }, { field: 'name', message: 'Name is required' }] },
};
// Marking an innocent input is worse than the generic toast we already show.
expect(extractFieldErrors(err)).toEqual([{ field: 'name', message: 'Name is required' }]);
});
it('returns null when the failure is not field-scoped', () => {
const forbidden = Object.assign(new Error('insufficient privileges'), {
code: 'FORBIDDEN',
httpStatus: 403,
details: { error: 'insufficient privileges', code: 'FORBIDDEN' },
});
expect(extractFieldErrors(forbidden)).toBeNull();
});
it('returns null for an empty fields array — a 400 with nothing to attribute', () => {
expect(extractFieldErrors({ code: 'VALIDATION_FAILED', details: { fields: [] } })).toBeNull();
});
it('returns null for junk input', () => {
expect(extractFieldErrors(null)).toBeNull();
expect(extractFieldErrors(undefined)).toBeNull();
expect(extractFieldErrors('a string')).toBeNull();
expect(extractFieldErrors(new Error('plain'))).toBeNull();
});
// A unique-index refusal names ONE field on a single string `field`, not a
// list: `@objectstack/rest` answers 409 `{ error, code: 'UNIQUE_VIOLATION',
// developerMessage, field, object }`, and `@objectstack/client` puts that flat
// body on `error.details`.
function uniqueViolation(field?: string) {
const error = field ? 'A record with this SKU already exists' : 'A record with this value already exists';
return Object.assign(new Error(error), {
code: 'UNIQUE_VIOLATION',
httpStatus: 409,
details: {
error,
code: 'UNIQUE_VIOLATION',
developerMessage: 'Duplicate value for sku on product',
...(field ? { field } : {}),
object: 'product',
},
});
}
it("marks the field a UNIQUE_VIOLATION names, with the server's sentence", () => {
const err = uniqueViolation('sku');
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.httpStatus).toBe(409);
expect(extractFieldErrors(err)).toEqual([
{ field: 'sku', message: 'A record with this SKU already exists' },
]);
});
it('reads a hand-rolled UNIQUE_VIOLATION carrying `field` directly', () => {
const err = { code: 'UNIQUE_VIOLATION', field: 'name', error: 'A record with this Name already exists' };
expect(extractFieldErrors(err)).toEqual([
{ field: 'name', message: 'A record with this Name already exists' },
]);
});
it('returns null for a UNIQUE_VIOLATION without `field` — the server could not tell which column', () => {
const err = uniqueViolation();
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.httpStatus).toBe(409);
expect(extractFieldErrors(err)).toBeNull();
});
it('reads a single `field` only on a UNIQUE_VIOLATION', () => {
// `INVALID_FIELD` also carries `field`, but it names a field the request
// referenced that does not exist — there is no input to mark.
const invalid = { code: 'INVALID_FIELD', httpStatus: 400, details: { code: 'INVALID_FIELD', field: 'skuu' } };
expect(extractFieldErrors(invalid)).toBeNull();
});
});
/**
* `classifyLoadError` — lifted from `ListView`'s module scope (objectui#4693)
* so `RecordAttachmentsPanel` can reuse the same "is this retry-invariant"
* verdict. This is the unit-level pin at the shared home; `ListView` and
* `RecordAttachmentsPanel` each pin their own consumption of it end-to-end in
* their own test suites (`ListView.loadErrorKind.test.tsx`,
* `RecordAttachmentsPanel.test.tsx`).
*/
describe('classifyLoadError', () => {
it('classifies the enable-block denials as api-disabled, on the code alone', () => {
expect(
classifyLoadError(
Object.assign(new Error('disabled'), { httpStatus: 404, code: 'OBJECT_API_DISABLED' }),
),
).toBe('api-disabled');
expect(
classifyLoadError(
Object.assign(new Error('not allowed'), {
httpStatus: 405,
code: 'OBJECT_API_METHOD_NOT_ALLOWED',
}),
),
).toBe('api-disabled');
// No numeric status at all — the code alone must carry the verdict.
expect(classifyLoadError(Object.assign(new Error('x'), { code: 'OBJECT_API_DISABLED' }))).toBe(
'api-disabled',
);
});
it('classifies 403 / PERMISSION_DENIED / FORBIDDEN as forbidden', () => {
expect(classifyLoadError(Object.assign(new Error('x'), { httpStatus: 403 }))).toBe('forbidden');
expect(classifyLoadError(Object.assign(new Error('x'), { code: 'PERMISSION_DENIED' }))).toBe(
'forbidden',
);
expect(classifyLoadError(Object.assign(new Error('x'), { code: 'FORBIDDEN' }))).toBe('forbidden');
});
it('classifies 401 / UNAUTHORIZED / UNAUTHENTICATED as unauthorized', () => {
expect(classifyLoadError(Object.assign(new Error('x'), { httpStatus: 401 }))).toBe(
'unauthorized',
);
expect(classifyLoadError(Object.assign(new Error('x'), { code: 'UNAUTHENTICATED' }))).toBe(
'unauthorized',
);
});
it('classifies 400 and the known malformed-request codes as rejected', () => {
expect(classifyLoadError(Object.assign(new Error('x'), { httpStatus: 400 }))).toBe('rejected');
expect(classifyLoadError(Object.assign(new Error('x'), { code: 'INVALID_FILTER' }))).toBe(
'rejected',
);
expect(
classifyLoadError(Object.assign(new Error('x'), { code: 'UNSUPPORTED_QUERY_PARAM' })),
).toBe('rejected');
});
it('falls back to network for a bare failure or an unrecognized 404', () => {
expect(classifyLoadError(new TypeError('Failed to fetch'))).toBe('network');
// A bare 404 (missing record / unprovisioned collection) is NOT an
// enable-block denial — status alone must never reach `api-disabled`.
expect(classifyLoadError(Object.assign(new Error('x'), { httpStatus: 404 }))).toBe('network');
});
it('reads a status embedded in the message text ("HTTP 403 …")', () => {
expect(classifyLoadError(new Error('ApiDataSource: HTTP 403 Forbidden — {}'))).toBe(
'forbidden',
);
});
it('prefers 403/401 over the 400 branch', () => {
expect(
classifyLoadError(Object.assign(new Error('x'), { httpStatus: 403, code: 'INVALID_FILTER' })),
).toBe('forbidden');
});
});
/**
* `declaredUserMessage` — the producer-side user-facing marking
* (objectstack#9934, maintainer ruling 2026-08-19 on objectui#5210).
*
* The whole value of the channel is that it CANNOT be satisfied by accident:
* objectstack#3821's generic substitution stays in force for every refusal the
* author did not opt in, so the "unmarked" cases below are not padding — they
* are the control that stops this fix from becoming the leak #3821 removed.
*/
describe('declaredUserMessage', () => {
it('reads the marking off the error, verbatim', () => {
const err = Object.assign(new Error('FORBIDDEN: insufficient privileges to update showcase_private_note pi-TgoJ4_DM55Fqz'), {
code: 'FORBIDDEN',
httpStatus: 403,
userMessage: '该记录需要财务审批,请联系你的主管。',
});
expect(declaredUserMessage(err)).toBe('该记录需要财务审批,请联系你的主管。');
});
it('reads it from `details` — where the adapter parks the whole response body', () => {
// `@objectstack/client` sets `details` to the body's `details`, falling
// back to the WHOLE body; the same pair `isPermissionError` reads `code`
// from. Still the declared key — nothing unmarked can arrive this way.
expect(declaredUserMessage({ httpStatus: 403, details: { userMessage: 'Ask the records owner.' } }))
.toBe('Ask the records owner.');
});
it('prefers the error-level marking over the one in `details`', () => {
expect(declaredUserMessage({ userMessage: 'lifted', details: { userMessage: 'body' } })).toBe('lifted');
});
it('returns null for an unmarked refusal — #3821 keeps the surface', () => {
expect(declaredUserMessage(Object.assign(new Error('FORBIDDEN: insufficient privileges'), {
code: 'FORBIDDEN',
httpStatus: 403,
}))).toBeNull();
});
it('never promotes `message` into the marked channel', () => {
// The mark and the marked text are ONE value. A boundary that rewraps or
// substitutes `message` therefore cannot leak platform prose in here.
expect(declaredUserMessage({ message: 'PERMISSION_DENIED: row-level security' })).toBeNull();
});
it('treats a blank or non-string marking as absent', () => {
expect(declaredUserMessage({ userMessage: ' ' })).toBeNull();
expect(declaredUserMessage({ userMessage: '' })).toBeNull();
expect(declaredUserMessage({ userMessage: 42 })).toBeNull();
expect(declaredUserMessage({ userMessage: { text: 'nope' } })).toBeNull();
});
it('does not scrape a marking out of the ApiDataSource message tail', () => {
// A transport that flattens the envelope into prose should carry the field
// instead; guessing one out of a string is the consumer-side heuristic the
// ruling rejected.
expect(declaredUserMessage(new Error(
'ApiDataSource: HTTP 403 Forbidden — {"error":"denied","userMessage":"Ask finance"}',
))).toBeNull();
});
it('returns null for junk input', () => {
expect(declaredUserMessage(null)).toBeNull();
expect(declaredUserMessage(undefined)).toBeNull();
expect(declaredUserMessage('a string')).toBeNull();
expect(declaredUserMessage(403)).toBeNull();
});
/**
* The key name is NOT hand-typed against a remembered contract: the reader is
* typed through `Pick<ApiError, 'userMessage'>` at compile time, and this pins
* the same name at RUNTIME against the installed spec's own declaration — a
* type-only check is satisfied by a stale `.d.ts`, this one is not.
*/
it('keys off the field the installed @objectstack/spec actually declares', () => {
const shape = (ApiErrorSchema as unknown as { shape: Record<string, unknown> }).shape;
expect(Object.keys(shape)).toContain('userMessage');
// And the marking is what the spec says it is: an OPTIONAL string, so
// absence is the default and presence is the opt-in.
const parsedMarked = ApiErrorSchema.parse({ code: 'FORBIDDEN', message: 'denied', userMessage: 'Ask finance' });
expect(declaredUserMessage(parsedMarked)).toBe('Ask finance');
const parsedUnmarked = ApiErrorSchema.parse({ code: 'FORBIDDEN', message: 'denied' });
expect(declaredUserMessage(parsedUnmarked)).toBeNull();
});
});
// objectstack#26059 Q3: the one denial whose server reason a surface shows.
describe('delegationScopeMessage', () => {
const REASON =
"Outside your delegated scope: delegated 'update' on sys_user_permission_set rejected — permission set 'finance_admin' is not in the scope's allowlist (scope from 'regional_admin').";
it("answers the server's reason, without the machine prefix, for a 403 DELEGATION_SCOPE_VIOLATION", () => {
const err = Object.assign(new Error(`[Security] ${REASON}`), {
code: 'DELEGATION_SCOPE_VIOLATION',
httpStatus: 403,
});
expect(delegationScopeMessage(err)).toBe(REASON);
// The reason it must be asked first: the permission predicate claims it too.
expect(isPermissionError(err)).toBe(true);
});
it('reads the code from `details` too, the pair isPermissionError reads', () => {
const err = Object.assign(new Error(`[Security] ${REASON}`), {
httpStatus: 403,
details: { code: 'DELEGATION_SCOPE_VIOLATION' },
});
expect(delegationScopeMessage(err)).toBe(REASON);
});
it('CONTROL: every other denial answers null, so its text stays withheld', () => {
const denied = (code: string | undefined) =>
Object.assign(new Error("[Security] Access denied: delegated 'update' on sys_user_position rejected — no scope."), {
code,
httpStatus: 403,
});
expect(delegationScopeMessage(denied('PERMISSION_DENIED'))).toBeNull();
expect(delegationScopeMessage(denied('FORBIDDEN'))).toBeNull();
expect(delegationScopeMessage(denied(undefined))).toBeNull();
expect(delegationScopeMessage(new Error('row-level security policy violation'))).toBeNull();
expect(delegationScopeMessage(null)).toBeNull();
});
it('answers null for a scope refusal with no usable text, so the caller still says something', () => {
expect(delegationScopeMessage({ code: 'DELEGATION_SCOPE_VIOLATION', httpStatus: 403, message: '' })).toBeNull();
});
});