-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathoperation-message.test.ts
More file actions
420 lines (379 loc) · 20.5 KB
/
Copy pathoperation-message.test.ts
File metadata and controls
420 lines (379 loc) · 20.5 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
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect } from 'vitest';
import {
BUILTIN_OPERATION_MESSAGES,
OPERATION_MESSAGE_FALLBACK_LOCALE,
operationMessageTranslationKey,
renderOperationMessage,
} from './operation-message';
/**
* #7307 — the catalog half. The engine call site is pinned in
* `packages/objectql/src/engine-delete-restricted-locale.test.ts`.
*/
describe('operation message catalog', () => {
const PARAMS = { object: '部门', dependentObject: '零星申请', field: '申报部门', count: 1 };
it('renders the caller locale, not English', () => {
const zh = renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN' });
expect(zh).toBe('该部门正被 1 条零星申请记录通过「申报部门」引用,请先删除或改派这些记录。');
});
it('falls back to en for an unknown locale, and en is the declared fallback', () => {
expect(OPERATION_MESSAGE_FALLBACK_LOCALE).toBe('en');
const out = renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'xx-YY' });
expect(out).toBe(BUILTIN_OPERATION_MESSAGES.en.delete_restricted
.replace('{{object}}', '部门')
.replace('{{count}}', '1')
.replace('{{dependentObject}}', '零星申请')
.replace('{{field}}', '申报部门'));
});
it('matches a base language against a regional catalog key (zh → zh-CN)', () => {
expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh' }))
.toContain('请先删除或改派这些记录');
});
it('every locale defines every key en defines', () => {
const enKeys = Object.keys(BUILTIN_OPERATION_MESSAGES.en).sort();
for (const [locale, catalog] of Object.entries(BUILTIN_OPERATION_MESSAGES)) {
expect({ locale, keys: Object.keys(catalog).sort() }).toEqual({ locale, keys: enKeys });
}
});
it('no built-in template leaks a metadata-authoring hint into the user-facing sentence', () => {
// The whole point of the card: `deleteBehavior` is developer vocabulary and
// must not reach a toast in ANY locale.
for (const catalog of Object.values(BUILTIN_OPERATION_MESSAGES)) {
for (const template of Object.values(catalog)) {
expect(template).not.toMatch(/deleteBehavior|cascade/i);
}
}
});
it('the _required variant says the field cannot be cleared; the plain one does not', () => {
const req = renderOperationMessage({ messageKey: 'delete_restricted_required', params: PARAMS }, { locale: 'zh-CN' });
expect(req).toContain('必填');
expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN' }))
.not.toContain('必填');
});
it('a deployment translation override wins over the built-in', () => {
const translate = (key: string) =>
key === 'errors.delete_restricted' ? '不能删除:还有 {{count}} 条下级记录。' : key;
expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN', translate }))
.toBe('不能删除:还有 1 条下级记录。');
});
it('an override key that misses (echoed back) falls through to the built-in', () => {
const translate = (key: string) => key;
expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN', translate }))
.toBe(BUILTIN_OPERATION_MESSAGES['zh-CN'].delete_restricted
.replace('{{object}}', '部门')
.replace('{{count}}', '1')
.replace('{{dependentObject}}', '零星申请')
.replace('{{field}}', '申报部门'));
});
it('a throwing i18n service does not turn a 409 into a 500', () => {
const translate = () => { throw new Error('service down'); };
expect(renderOperationMessage({ messageKey: 'delete_restricted', params: PARAMS }, { locale: 'zh-CN', translate }))
.toContain('请先删除或改派这些记录');
});
it('an unknown message key returns the key rather than an empty string', () => {
expect(renderOperationMessage({ messageKey: 'no_such_key' })).toBe('no_such_key');
});
it('addresses overrides under `errors.`, NOT the field-validation namespace', () => {
expect(operationMessageTranslationKey('delete_restricted')).toBe('errors.delete_restricted');
expect(operationMessageTranslationKey('delete_restricted')).not.toContain('validation.field');
});
});
/**
* #7414 — the SECOND consumer of this catalog: plugin-security's object-CRUD
* gate (`403 PERMISSION_DENIED`). The call site is pinned in
* `packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts`,
* against the real middleware and a real `II18nService`.
*/
describe('operation message catalog — permission_denied (#7414)', () => {
/**
* The vocabulary a business user must never read in a permission refusal.
* `positions` is the internal authorization noun the reporter quoted; the
* rest is the shape of the sentence it was embedded in.
*/
const DEVELOPER_VOCABULARY = [
'positions',
'permissionSets',
'permission set',
'[Security]',
'Access denied',
'operation',
];
it('renders the caller locale, not English', () => {
expect(renderOperationMessage({ messageKey: 'permission_denied' }, { locale: 'zh-CN' }))
.toBe('您没有执行此操作的权限,如需访问请联系管理员。');
expect(renderOperationMessage({ messageKey: 'permission_denied' }, { locale: 'en' }))
.toBe('You do not have permission to perform this action. Contact your administrator if you need access.');
});
it('matches a base language against a regional catalog key (ja → ja-JP)', () => {
expect(renderOperationMessage({ messageKey: 'permission_denied' }, { locale: 'ja' }))
.toBe(BUILTIN_OPERATION_MESSAGES['ja-JP'].permission_denied);
});
it('falls back to the en sentence for a locale the catalog does not carry', () => {
// `de-DE` has no catalog entry and no base-language sibling.
expect(renderOperationMessage({ messageKey: 'permission_denied' }, { locale: 'de-DE' }))
.toBe(BUILTIN_OPERATION_MESSAGES.en.permission_denied);
});
it('names no object, no operation and no position — in EVERY locale', () => {
const locales = Object.keys(BUILTIN_OPERATION_MESSAGES);
// Guard the guard: a catalog that lost its locales would make the loop
// below vacuously true, which is exactly the shape of an assertion that
// cannot fail.
expect(locales.length).toBeGreaterThanOrEqual(4);
for (const locale of locales) {
const rendered = renderOperationMessage({ messageKey: 'permission_denied' }, { locale });
// Non-empty and locale-specific, so the absence assertions below cannot
// be satisfied by an empty string.
expect(rendered).toBe(BUILTIN_OPERATION_MESSAGES[locale].permission_denied);
expect(rendered.length).toBeGreaterThan(10);
for (const word of DEVELOPER_VOCABULARY) {
expect(rendered.toLowerCase(), `${locale} must not say "${word}"`)
.not.toContain(word.toLowerCase());
}
}
});
it('ships no unfilled placeholder in any locale — the sentence takes no params', () => {
// Asserts on the CATALOG ENTRY, not on the rendering, and that is the
// difference between a guard and a decoration. Rendering a removed key
// yields the bare messageKey — which has no braces either, so a
// rendering-based version of this case would stay green on a catalog that
// lost the key entirely. Reading the entry makes it bite twice: on a
// missing locale (the entry is `undefined`) and on a template that shipped
// a `{{name}}` / `{name}` nobody fills, which is the #7333 class of bug
// where this repo's two brace conventions get mixed up.
for (const [locale, catalog] of Object.entries(BUILTIN_OPERATION_MESSAGES)) {
expect(catalog.permission_denied, `${locale} defines permission_denied`).toBeTypeOf('string');
expect(catalog.permission_denied, `${locale} placeholder-free`).not.toMatch(/[{}]/);
}
});
it('a deployment translation override wins, under the shared `errors.` address', () => {
expect(operationMessageTranslationKey('permission_denied')).toBe('errors.permission_denied');
const translate = (key: string) =>
key === 'errors.permission_denied' ? '此操作已被安全策略阻止。' : key;
expect(renderOperationMessage({ messageKey: 'permission_denied' }, { locale: 'zh-CN', translate }))
.toBe('此操作已被安全策略阻止。');
});
it('a throwing i18n service does not turn a 403 into a 500', () => {
const translate = () => { throw new Error('service down'); };
expect(renderOperationMessage({ messageKey: 'permission_denied' }, { locale: 'zh-CN', translate }))
.toBe(BUILTIN_OPERATION_MESSAGES['zh-CN'].permission_denied);
});
});
/**
* #7451 — the two sentences the END-USER-facing row-level gates render. The
* call sites are pinned in
* `packages/plugins/plugin-security/src/security-denial-user-copy.test.ts`,
* against the real middleware and a real `II18nService`.
*
* `permission_denied` is NOT re-listed here: #7451's third converted gate (the
* capability AND-gate) deliberately reuses it, so its catalog coverage above is
* already the coverage for that gate.
*/
describe('operation message catalog — the row-level user copy (#7451)', () => {
/**
* The vocabulary a business user must never read in a row-level refusal. It
* is the #7414 list plus the two nouns these particular gates leaked:
* `row-level` (the mechanism) and `CHECK` (the clause).
*/
const DEVELOPER_VOCABULARY = [
'positions', 'permissionSets', 'permission set', 'capability',
'[Security]', 'Access denied', 'operation', 'row-level', 'CHECK',
];
const KEYS = ['record_access_denied', 'record_change_not_allowed'] as const;
it('renders the caller locale, not English', () => {
expect(renderOperationMessage({ messageKey: 'record_access_denied' }, { locale: 'zh-CN' }))
.toBe('您无权访问这条记录,如需访问请联系该记录的负责人或管理员。');
expect(renderOperationMessage({ messageKey: 'record_access_denied' }, { locale: 'en' }))
.toBe('You do not have access to this record. Contact the person who owns it, or your administrator, if you need access.');
expect(renderOperationMessage({ messageKey: 'record_change_not_allowed' }, { locale: 'zh-CN' }))
.toBe('您无权将这条记录保存为当前填写的内容,请修改后重试,或联系管理员。');
});
it('matches a base language against a regional catalog key (ja → ja-JP)', () => {
for (const key of KEYS) {
expect(renderOperationMessage({ messageKey: key }, { locale: 'ja' }))
.toBe(BUILTIN_OPERATION_MESSAGES['ja-JP'][key]);
}
});
it('falls back to the en sentence for a locale the catalog does not carry', () => {
// `de-DE` has no catalog entry and no base-language sibling.
for (const key of KEYS) {
expect(renderOperationMessage({ messageKey: key }, { locale: 'de-DE' }))
.toBe(BUILTIN_OPERATION_MESSAGES.en[key]);
}
});
it('names no object, no record id and no authorization mechanism — in EVERY locale', () => {
const locales = Object.keys(BUILTIN_OPERATION_MESSAGES);
// Guard the guard: a catalog that lost its locales would make the loop
// below vacuously true, which is exactly the shape of an assertion that
// cannot fail.
expect(locales.length).toBeGreaterThanOrEqual(4);
for (const locale of locales) {
for (const key of KEYS) {
const rendered = renderOperationMessage({ messageKey: key }, { locale });
// Non-empty and locale-specific, so the absence assertions below cannot
// be satisfied by an empty string.
expect(rendered).toBe(BUILTIN_OPERATION_MESSAGES[locale][key]);
expect(rendered.length).toBeGreaterThan(10);
for (const word of DEVELOPER_VOCABULARY) {
expect(rendered.toLowerCase(), `${locale}.${key} must not say "${word}"`)
.not.toContain(word.toLowerCase());
}
}
}
});
it('says something DIFFERENT from the grant denial — three situations, three sentences', () => {
// The whole reason these are separate keys: a user blocked by row-level
// security can often ask the record's owner, and a user whose post-image
// failed a CHECK can simply change what they typed. Collapsing them into
// `permission_denied` would send both to an administrator for nothing.
for (const locale of Object.keys(BUILTIN_OPERATION_MESSAGES)) {
const denied = BUILTIN_OPERATION_MESSAGES[locale].permission_denied;
for (const key of KEYS) {
expect(BUILTIN_OPERATION_MESSAGES[locale][key], `${locale}.${key}`).not.toBe(denied);
}
expect(BUILTIN_OPERATION_MESSAGES[locale].record_access_denied)
.not.toBe(BUILTIN_OPERATION_MESSAGES[locale].record_change_not_allowed);
}
});
it('ships no unfilled placeholder in any locale — these sentences take no params', () => {
// Asserts on the CATALOG ENTRY, not on the rendering, and that is the
// difference between a guard and a decoration. Rendering a removed key
// yields the bare messageKey — which has no braces either, so a
// rendering-based version of this case would stay green on a catalog that
// lost the key entirely.
for (const [locale, catalog] of Object.entries(BUILTIN_OPERATION_MESSAGES)) {
for (const key of KEYS) {
expect(catalog[key], `${locale} defines ${key}`).toBeTypeOf('string');
expect(catalog[key], `${locale}.${key} placeholder-free`).not.toMatch(/[{}]/);
}
}
});
it('a deployment translation override wins, under the shared `errors.` address', () => {
for (const key of KEYS) {
expect(operationMessageTranslationKey(key)).toBe(`errors.${key}`);
const translate = (k: string) => (k === `errors.${key}` ? '部署自定义文案。' : k);
expect(renderOperationMessage({ messageKey: key }, { locale: 'zh-CN', translate }))
.toBe('部署自定义文案。');
}
});
it('a throwing i18n service does not turn a 403 into a 500', () => {
const translate = () => { throw new Error('service down'); };
for (const key of KEYS) {
expect(renderOperationMessage({ messageKey: key }, { locale: 'zh-CN', translate }))
.toBe(BUILTIN_OPERATION_MESSAGES['zh-CN'][key]);
}
});
});
/**
* #12493 — two keys whose EMITTERS convert in follow-up cards: the sharing
* middleware's by-id write denial (`record_write_denied`, consumer half
* #12260) and plugin-approvals' non-submitter recall refusal
* (`approval_recall_not_submitter`, consumer half #11993). Until those land,
* this catalog block is the only pin the keys have — same battery the #7451
* family keys get.
*/
describe('operation message catalog — sharing write denial and approvals recall (#12493)', () => {
/**
* The vocabulary a business user must never read in these refusals. It is
* the #7451 list plus the exact nouns the two measured raw strings leaked:
* the wire prefix `FORBIDDEN` and the phrase `insufficient privileges`
* (the sharing site also interpolated the object API name and the row id —
* covered structurally by the placeholder-free case, since the sentences
* take no params at all).
*/
const DEVELOPER_VOCABULARY = [
'positions', 'permissionSets', 'permission set', 'capability',
'[Security]', 'Access denied', 'operation', 'row-level', 'CHECK',
'FORBIDDEN', 'insufficient privileges',
];
const KEYS = ['record_write_denied', 'approval_recall_not_submitter'] as const;
it('renders the caller locale, not English', () => {
expect(renderOperationMessage({ messageKey: 'record_write_denied' }, { locale: 'zh-CN' }))
.toBe('您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。');
expect(renderOperationMessage({ messageKey: 'record_write_denied' }, { locale: 'en' }))
.toBe('You do not have access to change or delete this record. Contact the person who owns it, or your administrator, if you need to make changes.');
expect(renderOperationMessage({ messageKey: 'approval_recall_not_submitter' }, { locale: 'zh-CN' }))
.toBe('只有提交人可以撤回这条审批请求,如需撤回请联系提交人或管理员。');
expect(renderOperationMessage({ messageKey: 'approval_recall_not_submitter' }, { locale: 'en' }))
.toBe('Only the person who submitted this approval request can recall it. Contact the submitter, or your administrator, if it needs to be recalled.');
});
it('matches a base language against a regional catalog key (ja → ja-JP)', () => {
for (const key of KEYS) {
expect(renderOperationMessage({ messageKey: key }, { locale: 'ja' }))
.toBe(BUILTIN_OPERATION_MESSAGES['ja-JP'][key]);
}
});
it('falls back to the en sentence for a locale the catalog does not carry', () => {
// `de-DE` has no catalog entry and no base-language sibling.
for (const key of KEYS) {
expect(renderOperationMessage({ messageKey: key }, { locale: 'de-DE' }))
.toBe(BUILTIN_OPERATION_MESSAGES.en[key]);
}
});
it('names no object, no record id and no wire vocabulary — in EVERY locale', () => {
const locales = Object.keys(BUILTIN_OPERATION_MESSAGES);
// Guard the guard: a catalog that lost its locales would make the loop
// below vacuously true, which is exactly the shape of an assertion that
// cannot fail.
expect(locales.length).toBeGreaterThanOrEqual(4);
for (const locale of locales) {
for (const key of KEYS) {
const rendered = renderOperationMessage({ messageKey: key }, { locale });
// Non-empty and locale-specific, so the absence assertions below cannot
// be satisfied by an empty string.
expect(rendered).toBe(BUILTIN_OPERATION_MESSAGES[locale][key]);
expect(rendered.length).toBeGreaterThan(10);
for (const word of DEVELOPER_VOCABULARY) {
expect(rendered.toLowerCase(), `${locale}.${key} must not say "${word}"`)
.not.toContain(word.toLowerCase());
}
}
}
});
it('says something DIFFERENT from every sibling situation — new keys earn their keep', () => {
// `record_write_denied` exists because `record_access_denied` would be
// FALSE on the sharing gate's rows (the read path already admitted them —
// the user is looking at the record), and `approval_recall_not_submitter`
// exists because naming who CAN act is the refusal's entire content.
// Identical copy would mean the new key is a synonym, which the header
// bars.
const SIBLINGS = ['permission_denied', 'record_access_denied', 'record_change_not_allowed'] as const;
for (const locale of Object.keys(BUILTIN_OPERATION_MESSAGES)) {
for (const key of KEYS) {
for (const sibling of SIBLINGS) {
expect(BUILTIN_OPERATION_MESSAGES[locale][key], `${locale}.${key} vs ${sibling}`)
.not.toBe(BUILTIN_OPERATION_MESSAGES[locale][sibling]);
}
}
expect(BUILTIN_OPERATION_MESSAGES[locale].record_write_denied)
.not.toBe(BUILTIN_OPERATION_MESSAGES[locale].approval_recall_not_submitter);
}
});
it('ships no unfilled placeholder in any locale — these sentences take no params', () => {
// Asserts on the CATALOG ENTRY, not on the rendering, and that is the
// difference between a guard and a decoration. Rendering a removed key
// yields the bare messageKey — which has no braces either, so a
// rendering-based version of this case would stay green on a catalog that
// lost the key entirely.
for (const [locale, catalog] of Object.entries(BUILTIN_OPERATION_MESSAGES)) {
for (const key of KEYS) {
expect(catalog[key], `${locale} defines ${key}`).toBeTypeOf('string');
expect(catalog[key], `${locale}.${key} placeholder-free`).not.toMatch(/[{}]/);
}
}
});
it('a deployment translation override wins, under the shared `errors.` address', () => {
for (const key of KEYS) {
expect(operationMessageTranslationKey(key)).toBe(`errors.${key}`);
const translate = (k: string) => (k === `errors.${key}` ? '部署自定义文案。' : k);
expect(renderOperationMessage({ messageKey: key }, { locale: 'zh-CN', translate }))
.toBe('部署自定义文案。');
}
});
it('a throwing i18n service does not turn a 403 into a 500', () => {
const translate = () => { throw new Error('service down'); };
for (const key of KEYS) {
expect(renderOperationMessage({ messageKey: key }, { locale: 'zh-CN', translate }))
.toBe(BUILTIN_OPERATION_MESSAGES['zh-CN'][key]);
}
});
});