|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Admin-facing copy of the security objects and the shipped permission sets |
| 5 | + * carries no design-record vocabulary. |
| 6 | + * |
| 7 | + * Setup reaches these through Access Control and the user record page: the |
| 8 | + * Permission Sets page lists each shipped set with its `description`, and a |
| 9 | + * user's Positions and Permission Sets lists open the `sys_user_position` and |
| 10 | + * `sys_user_permission_set` forms, where a field's help tooltip is the field's |
| 11 | + * `description`. The generated bundles carry the object copy into every |
| 12 | + * locale. An ADR number, a decision id ("D2"), a review-finding id |
| 13 | + * ("finding F2"), an API call ("emit()") or a runtime internal ("kernel") |
| 14 | + * belongs in the docblock next to the string, never in the string. Every |
| 15 | + * object and permission set this package ships is judged, not only the ones |
| 16 | + * Setup opens today. |
| 17 | + * |
| 18 | + * The same rule over the other Setup producers lives in each of them |
| 19 | + * (`@objectstack/platform-objects`' `src/apps/setup-copy-vocabulary.test.ts`, |
| 20 | + * `@objectstack/service-messaging`'s `src/objects/admin-copy-vocabulary.test.ts`); |
| 21 | + * the matcher is restated rather than imported, because a shared assertion |
| 22 | + * module would be one more export for a test to reach across a package for. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, expect, it } from 'vitest'; |
| 26 | +import * as SecurityObjects from './index.js'; |
| 27 | +import { defaultPermissionSets } from './default-permission-sets.js'; |
| 28 | +import { enObjects } from '../translations/en.objects.generated.js'; |
| 29 | +import { zhCNObjects } from '../translations/zh-CN.objects.generated.js'; |
| 30 | +import { jaJPObjects } from '../translations/ja-JP.objects.generated.js'; |
| 31 | +import { esESObjects } from '../translations/es-ES.objects.generated.js'; |
| 32 | + |
| 33 | +/** The design-record vocabulary an admin-facing string must not carry. */ |
| 34 | +const DESIGN_RECORD_VOCABULARY: ReadonlyArray<readonly [string, RegExp]> = [ |
| 35 | + ['an ADR number', /ADR-\d/], |
| 36 | + ['a decision id', /\bD\d+\b/], |
| 37 | + ['a review-finding id', /finding F/], |
| 38 | + ['an API call', /\bemit\(\)/], |
| 39 | + ['a runtime internal', /\bkernel\b/i], |
| 40 | +]; |
| 41 | + |
| 42 | +function vocabularyIn(text: string): string[] { |
| 43 | + return DESIGN_RECORD_VOCABULARY.filter(([, re]) => re.test(text)).map(([name]) => name); |
| 44 | +} |
| 45 | + |
| 46 | +const COPY_KEYS = new Set(['label', 'pluralLabel', 'description']); |
| 47 | + |
| 48 | +function copyStrings(node: unknown, path: string, out: Array<[string, string]>): void { |
| 49 | + if (Array.isArray(node)) { |
| 50 | + node.forEach((v, i) => copyStrings(v, `${path}[${i}]`, out)); |
| 51 | + return; |
| 52 | + } |
| 53 | + if (!node || typeof node !== 'object') return; |
| 54 | + for (const [k, v] of Object.entries(node)) { |
| 55 | + const at = path ? `${path}.${k}` : k; |
| 56 | + if (typeof v === 'string') { |
| 57 | + if (COPY_KEYS.has(k)) out.push([at, v]); |
| 58 | + } else { |
| 59 | + copyStrings(v, at, out); |
| 60 | + } |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +function leafStrings(node: unknown, path: string, out: Array<[string, string]>): void { |
| 65 | + if (typeof node === 'string') { |
| 66 | + out.push([path, node]); |
| 67 | + return; |
| 68 | + } |
| 69 | + if (!node || typeof node !== 'object') return; |
| 70 | + for (const [k, v] of Object.entries(node)) leafStrings(v, path ? `${path}.${k}` : k, out); |
| 71 | +} |
| 72 | + |
| 73 | +function offenders(strings: Array<[string, string]>): string[] { |
| 74 | + return strings |
| 75 | + .map(([at, text]) => [at, text, vocabularyIn(text)] as const) |
| 76 | + .filter(([, , hits]) => hits.length > 0) |
| 77 | + .map(([at, text, hits]) => `${at} (${hits.join(', ')}): ${text}`); |
| 78 | +} |
| 79 | + |
| 80 | +type ObjectLike = { name: string; fields: Record<string, unknown> }; |
| 81 | +function isObjectSchema(v: unknown): v is ObjectLike { |
| 82 | + return !!v && typeof v === 'object' |
| 83 | + && typeof (v as ObjectLike).name === 'string' |
| 84 | + && !!(v as ObjectLike).fields && typeof (v as ObjectLike).fields === 'object'; |
| 85 | +} |
| 86 | + |
| 87 | +const OBJECT_BUNDLES = { en: enObjects, 'zh-CN': zhCNObjects, 'ja-JP': jaJPObjects, 'es-ES': esESObjects } as const; |
| 88 | + |
| 89 | +describe('security objects and permission sets: admin-facing copy carries no design-record vocabulary', () => { |
| 90 | + it('the matcher recognises each kind of vocabulary it exists to keep out', () => { |
| 91 | + expect(vocabularyIn('Assigns a position (ADR-0057).')).toEqual(['an ADR number']); |
| 92 | + expect(vocabularyIn('Enforced fail-closed at resolution time (D2).')).toEqual(['a decision id']); |
| 93 | + expect(vocabularyIn('Bypass rule (finding F2).')).toEqual(['a review-finding id']); |
| 94 | + expect(vocabularyIn('One row per emit().')).toEqual(['an API call']); |
| 95 | + expect(vocabularyIn('The Kernel API is additive.')).toEqual(['a runtime internal']); |
| 96 | + expect(vocabularyIn('Which positions each user holds, one row per assignment.')).toEqual([]); |
| 97 | + }); |
| 98 | + |
| 99 | + it('object, field and view copy of every object this package exports', () => { |
| 100 | + const objects: ObjectLike[] = (Object.values(SecurityObjects) as unknown[]).filter(isObjectSchema); |
| 101 | + const names = objects.map((o) => o.name); |
| 102 | + for (const name of ['sys_user_permission_set', 'sys_user_position', 'sys_audience_binding_suggestion']) { |
| 103 | + expect(names, `${name} is in the population`).toContain(name); |
| 104 | + } |
| 105 | + const strings: Array<[string, string]> = []; |
| 106 | + for (const def of objects) copyStrings(def, def.name, strings); |
| 107 | + expect(offenders(strings)).toEqual([]); |
| 108 | + }); |
| 109 | + |
| 110 | + it('the label and description of every permission set this package ships', () => { |
| 111 | + const names = defaultPermissionSets.map((ps) => ps.name); |
| 112 | + for (const name of ['organization_admin', 'organization_admin_no_bypass', 'mcp_agent_data_read', 'mcp_agent_data_write']) { |
| 113 | + expect(names, `${name} is in the population`).toContain(name); |
| 114 | + } |
| 115 | + const strings: Array<[string, string]> = []; |
| 116 | + for (const ps of defaultPermissionSets) { |
| 117 | + if (typeof ps.label === 'string') strings.push([`${ps.name}.label`, ps.label]); |
| 118 | + if (typeof ps.description === 'string') strings.push([`${ps.name}.description`, ps.description]); |
| 119 | + } |
| 120 | + expect(offenders(strings)).toEqual([]); |
| 121 | + }); |
| 122 | + |
| 123 | + it.each(Object.keys(OBJECT_BUNDLES))('every leaf of the %s objects bundle', (locale) => { |
| 124 | + const strings: Array<[string, string]> = []; |
| 125 | + leafStrings(OBJECT_BUNDLES[locale as keyof typeof OBJECT_BUNDLES], 'objects', strings); |
| 126 | + expect(strings.length).toBeGreaterThan(50); |
| 127 | + expect(offenders(strings)).toEqual([]); |
| 128 | + }); |
| 129 | +}); |
0 commit comments