|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#30797] The dispatcher's `/meta` read gate applies a deployment's |
| 5 | + * `errors.permission_denied` override, as `RestServer`'s does. |
| 6 | + * |
| 7 | + * #30126 row 12 made the gate's `403 PERMISSION_DENIED` (an app, a dashboard |
| 8 | + * or a list view the caller may not open) render the operation catalog's |
| 9 | + * `permission_denied` in the caller's locale. `RestServer` handed the gate the |
| 10 | + * request's i18n lookup, so a deployment's override won there; the |
| 11 | + * dispatcher's `metaItemReadGateSources` handed none, so the same caller read |
| 12 | + * the built-in sentence through `HttpDispatcher` and the override through |
| 13 | + * `RestServer`. The dispatcher now hands the `i18n` service's `t`. |
| 14 | + * |
| 15 | + * Pinned, through `dispatch()`, on all three refusals: |
| 16 | + * - an override registered for the caller's locale is the message; |
| 17 | + * - CONTROL: no i18n service gives the built-in catalog sentence; |
| 18 | + * - an i18n service that cannot be resolved, or whose `t` throws, gives the |
| 19 | + * built-in sentence and still the 403, never a 500. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, it, expect, vi } from 'vitest'; |
| 23 | +import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; |
| 24 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 25 | + |
| 26 | +const OVERRIDE_ZH = '请向 Acme 管理员申请访问权限。'; |
| 27 | +const ZH = BUILTIN_OPERATION_MESSAGES['zh-CN'].permission_denied; |
| 28 | + |
| 29 | +const STORE: Record<string, any[]> = { |
| 30 | + app: [{ name: 'com.objectstack.setup', label: 'Setup', requiredPermissions: ['setup.access'], navigation: [] }], |
| 31 | + dashboard: [{ name: 'legal_board', label: 'Legal board', requiredPermissions: ['legal.view'], widgets: [] }], |
| 32 | + view: [{ name: 'contract.legal_queue', object: 'contract', viewKind: 'list', config: { type: 'grid', columns: ['name'], requiredPermissions: ['legal.view'] } }], |
| 33 | +}; |
| 34 | +const REFUSED: Array<[string, string]> = [ |
| 35 | + ['app', 'com.objectstack.setup'], |
| 36 | + ['dashboard', 'legal_board'], |
| 37 | + ['view', 'contract.legal_queue'], |
| 38 | +]; |
| 39 | + |
| 40 | +const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)); |
| 41 | +const singular = (type: unknown): string => String(type ?? '').replace(/s$/, ''); |
| 42 | + |
| 43 | +type I18n = 'override' | 'none' | 'unresolvable' | 't-throws'; |
| 44 | + |
| 45 | +function boot(i18n: I18n) { |
| 46 | + const protocol = { |
| 47 | + getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORE) })), |
| 48 | + getMetaItems: vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? [])), |
| 49 | + getMetaItem: vi.fn(async ({ type, name }: any) => { |
| 50 | + const found = (STORE[singular(type)] ?? []).find((i: any) => i.name === name); |
| 51 | + return { type: singular(type), name, item: found ? clone(found) : undefined, lock: 'none', editable: true, deletable: true, resettable: false }; |
| 52 | + }), |
| 53 | + }; |
| 54 | + const t = vi.fn((key: string, locale: string) => { |
| 55 | + if (i18n === 't-throws') throw new Error('bundle corrupt'); |
| 56 | + return key === 'errors.permission_denied' && locale === 'zh-CN' ? OVERRIDE_ZH : key; |
| 57 | + }); |
| 58 | + const services: Record<string, unknown> = { |
| 59 | + protocol, |
| 60 | + security: { resolvePermissionSetNames: async () => [], getMetadataReadableFields: async () => [] }, |
| 61 | + ...(i18n === 'override' || i18n === 't-throws' ? { i18n: { t, getLocales: () => ['en', 'zh-CN'], getDefaultLocale: () => 'en' } } : {}), |
| 62 | + }; |
| 63 | + const get = (n: string) => { |
| 64 | + if (n === 'i18n' && i18n === 'unresolvable') throw new Error('i18n service failed to start'); |
| 65 | + return services[n] ?? null; |
| 66 | + }; |
| 67 | + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; |
| 68 | + const dispatcher = new HttpDispatcher(kernel); |
| 69 | + (dispatcher as any).timedResolveExecutionContext = async () => |
| 70 | + ({ userId: 'u_member', isSystem: false, systemPermissions: [], locale: 'zh-CN' }); |
| 71 | + const read = async (type: string, name: string) => { |
| 72 | + const res = await dispatcher.dispatch('GET', `/meta/${type}/${name}`, undefined, {}, { request: { headers: {} } } as any); |
| 73 | + return { status: res.response?.status ?? 0, body: res.response?.body }; |
| 74 | + }; |
| 75 | + return { read, t }; |
| 76 | +} |
| 77 | + |
| 78 | +describe('[#30797] the dispatcher /meta 403 applies the errors.permission_denied override', () => { |
| 79 | + for (const [type, name] of REFUSED) { |
| 80 | + it(`${type}/${name}: the override registered for the caller's locale is the message`, async () => { |
| 81 | + const { read, t } = boot('override'); |
| 82 | + const a = await read(type, name); |
| 83 | + expect(a.status).toBe(403); |
| 84 | + expect(a.body?.error?.code).toBe('PERMISSION_DENIED'); |
| 85 | + expect(a.body?.error?.message).toBe(OVERRIDE_ZH); |
| 86 | + expect(t).toHaveBeenCalledWith('errors.permission_denied', 'zh-CN', expect.anything()); |
| 87 | + }); |
| 88 | + |
| 89 | + it(`${type}/${name}: CONTROL — no i18n service gives the built-in catalog sentence`, async () => { |
| 90 | + const a = await boot('none').read(type, name); |
| 91 | + expect(a.status).toBe(403); |
| 92 | + expect(a.body?.error?.code).toBe('PERMISSION_DENIED'); |
| 93 | + expect(a.body?.error?.message).toBe(ZH); |
| 94 | + }); |
| 95 | + |
| 96 | + for (const failure of ['unresolvable', 't-throws'] as const) { |
| 97 | + it(`${type}/${name}: an i18n service that is ${failure} gives the built-in sentence and the 403`, async () => { |
| 98 | + const a = await boot(failure).read(type, name); |
| 99 | + expect(a.status).toBe(403); |
| 100 | + expect(a.body?.error?.code).toBe('PERMISSION_DENIED'); |
| 101 | + expect(a.body?.error?.message).toBe(ZH); |
| 102 | + }); |
| 103 | + } |
| 104 | + } |
| 105 | +}); |
0 commit comments