|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [ADR-0146 D7] The Security plugin's two Setup → People & Organization |
| 4 | +// entries: "Position Holders" and "Permission Set Assignment". |
| 5 | +// |
| 6 | +// The pages are console components the app shell registers under |
| 7 | +// `security:position-holders` and `security:permission-set-assignment`; Setup's |
| 8 | +// navigation is server metadata, so without an entry naming each ref the pages |
| 9 | +// are reachable by typed URL only. This file pins the half this repository |
| 10 | +// owns — the two entries, the refs they name, the group they sit in, and their |
| 11 | +// gate. The console pins the ref → page half on its side. |
| 12 | +// |
| 13 | +// Why plugin-security and not `@objectstack/platform-objects`: the pages read |
| 14 | +// and write this plugin's assignment rows, so the entries live and die with the |
| 15 | +// plugin, exactly like its Positions / Capabilities / Permission Sets entries. |
| 16 | +// That is also their gate: none of their own. The catalog entries carry no |
| 17 | +// `requiredPermissions`, `requiresService` or `requiresObject`; the Setup app's |
| 18 | +// `setup.access` and the group anchor decide who sees them. |
| 19 | + |
| 20 | +import { describe, it, expect } from 'vitest'; |
| 21 | +import { SchemaRegistry } from '@objectstack/objectql'; |
| 22 | +import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS, SetupAppTranslations } from '@objectstack/platform-objects/apps'; |
| 23 | +import { NavigationContributionSchema } from '@objectstack/spec/ui'; |
| 24 | +import { SecurityPlugin } from './security-plugin.js'; |
| 25 | + |
| 26 | +type NavItem = { |
| 27 | + id?: string; |
| 28 | + type?: string; |
| 29 | + label?: string; |
| 30 | + componentRef?: string; |
| 31 | + requiredPermissions?: string[]; |
| 32 | + requiresService?: string; |
| 33 | + requiresObject?: string; |
| 34 | + visible?: unknown; |
| 35 | + children?: NavItem[]; |
| 36 | +}; |
| 37 | + |
| 38 | +type Contribution = { app: string; group?: string; priority?: number; items: NavItem[] }; |
| 39 | + |
| 40 | +/** Run the plugin's init() and return the manifest it registered. */ |
| 41 | +async function registeredManifest(): Promise<any> { |
| 42 | + const registered: any[] = []; |
| 43 | + const ctx: any = { |
| 44 | + logger: { info() {}, warn() {}, error() {}, debug() {} }, |
| 45 | + getService: (name: string) => { |
| 46 | + if (name === 'manifest') return { register: (m: unknown) => registered.push(m) }; |
| 47 | + throw new Error(`service not registered: ${name}`); |
| 48 | + }, |
| 49 | + registerService() {}, |
| 50 | + hook() {}, |
| 51 | + }; |
| 52 | + await new SecurityPlugin().init(ctx); |
| 53 | + expect(registered).toHaveLength(1); |
| 54 | + return registered[0]; |
| 55 | +} |
| 56 | + |
| 57 | +const POSITION_HOLDERS = { |
| 58 | + id: 'nav_position_holders', |
| 59 | + type: 'component', |
| 60 | + label: 'Position Holders', |
| 61 | + componentRef: 'security:position-holders', |
| 62 | +}; |
| 63 | +const PERMISSION_SET_ASSIGNMENT = { |
| 64 | + id: 'nav_permission_set_assignment', |
| 65 | + type: 'component', |
| 66 | + label: 'Permission Set Assignment', |
| 67 | + componentRef: 'security:permission-set-assignment', |
| 68 | +}; |
| 69 | + |
| 70 | +/** The item keys that decide who sees an entry. */ |
| 71 | +const GATE_KEYS = ['requiredPermissions', 'requiresService', 'requiresObject', 'visible'] as const; |
| 72 | +const gateOf = (item: NavItem) => Object.fromEntries(GATE_KEYS.map((k) => [k, item[k]])); |
| 73 | + |
| 74 | +/** |
| 75 | + * The Setup app as `GET /api/v1/meta/app` folds it: the real `SchemaRegistry` |
| 76 | + * over `SETUP_APP`, platform-objects' own contributions, and — when |
| 77 | + * `withSecurity` — this plugin's, registered under its manifest id. |
| 78 | + */ |
| 79 | +async function renderedSetup(withSecurity: boolean): Promise<NavItem[]> { |
| 80 | + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); |
| 81 | + (registry as any).logLevel = 'silent'; |
| 82 | + registry.registerApp(structuredClone(SETUP_APP), '@objectstack/platform-objects'); |
| 83 | + for (const contribution of SETUP_NAV_CONTRIBUTIONS) { |
| 84 | + registry.registerAppNavContribution(structuredClone(contribution) as any, '@objectstack/platform-objects'); |
| 85 | + } |
| 86 | + if (withSecurity) { |
| 87 | + const manifest = await registeredManifest(); |
| 88 | + for (const contribution of manifest.navigationContributions) { |
| 89 | + registry.registerAppNavContribution(structuredClone(contribution), manifest.id); |
| 90 | + } |
| 91 | + } |
| 92 | + return (registry.getApp('setup')?.navigation ?? []) as NavItem[]; |
| 93 | +} |
| 94 | + |
| 95 | +const groupChildren = (nav: NavItem[], group: string): NavItem[] => |
| 96 | + nav.find((n) => n.id === group)?.children ?? []; |
| 97 | + |
| 98 | +describe('SecurityPlugin — Setup People & Organization assignment entries (ADR-0146 D7)', () => { |
| 99 | + it('contributes both entries into group_people_org, naming the two component refs, and it parses', async () => { |
| 100 | + const manifest = await registeredManifest(); |
| 101 | + const contributions = manifest.navigationContributions as Contribution[]; |
| 102 | + const peopleOrg = contributions.filter((c) => c.app === 'setup' && c.group === 'group_people_org'); |
| 103 | + expect(peopleOrg).toHaveLength(1); |
| 104 | + expect(peopleOrg[0].items).toEqual([ |
| 105 | + expect.objectContaining(POSITION_HOLDERS), |
| 106 | + expect.objectContaining(PERMISSION_SET_ASSIGNMENT), |
| 107 | + ]); |
| 108 | + // Registry KEYS, never console paths. |
| 109 | + for (const item of peopleOrg[0].items) { |
| 110 | + expect(item.componentRef).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/); |
| 111 | + } |
| 112 | + for (const contribution of contributions) { |
| 113 | + expect(() => NavigationContributionSchema.parse(contribution)).not.toThrow(); |
| 114 | + } |
| 115 | + }); |
| 116 | + |
| 117 | + it('carries the same gate as the Positions / Permission Sets entries: none of its own', async () => { |
| 118 | + const manifest = await registeredManifest(); |
| 119 | + const items = (manifest.navigationContributions as Contribution[]).flatMap((c) => c.items); |
| 120 | + const byId = new Map(items.map((i) => [i.id, i])); |
| 121 | + const positions = byId.get('nav_positions'); |
| 122 | + const permissionSets = byId.get('nav_permission_sets'); |
| 123 | + // Control: the catalog entries really are in this manifest, so the |
| 124 | + // comparison below is against a real gate, not two undefined lookups. |
| 125 | + expect(positions).toBeDefined(); |
| 126 | + expect(permissionSets).toBeDefined(); |
| 127 | + for (const id of [POSITION_HOLDERS.id, PERMISSION_SET_ASSIGNMENT.id]) { |
| 128 | + const entry = byId.get(id); |
| 129 | + expect(entry, id).toBeDefined(); |
| 130 | + expect(gateOf(entry!), id).toEqual(gateOf(positions!)); |
| 131 | + expect(gateOf(entry!), id).toEqual(gateOf(permissionSets!)); |
| 132 | + } |
| 133 | + }); |
| 134 | + |
| 135 | + it('the rendered Setup nav serves both under People & Organization, beside the base entries', async () => { |
| 136 | + const nav = await renderedSetup(true); |
| 137 | + const children = groupChildren(nav, 'group_people_org'); |
| 138 | + // Control: platform-objects' base entries fold into the same group. |
| 139 | + expect(children.map((i) => i.id)).toContain('nav_users'); |
| 140 | + expect(children.find((i) => i.id === POSITION_HOLDERS.id)).toMatchObject(POSITION_HOLDERS); |
| 141 | + expect(children.find((i) => i.id === PERMISSION_SET_ASSIGNMENT.id)).toMatchObject(PERMISSION_SET_ASSIGNMENT); |
| 142 | + // The group anchor they land in is ungated, like Access Control's. |
| 143 | + const anchor = nav.find((n) => n.id === 'group_people_org'); |
| 144 | + const accessControl = nav.find((n) => n.id === 'group_access_control'); |
| 145 | + expect(anchor?.requiredPermissions ?? []).toEqual(accessControl?.requiredPermissions ?? []); |
| 146 | + // Both entries open in exactly one place. |
| 147 | + const all = nav.flatMap((n) => n.children ?? []); |
| 148 | + expect(all.filter((i) => i.componentRef === POSITION_HOLDERS.componentRef)).toHaveLength(1); |
| 149 | + expect(all.filter((i) => i.componentRef === PERMISSION_SET_ASSIGNMENT.componentRef)).toHaveLength(1); |
| 150 | + }); |
| 151 | + |
| 152 | + it('without this plugin, neither entry appears — hidden exactly where the catalog entries are', async () => { |
| 153 | + const nav = await renderedSetup(false); |
| 154 | + const ids = nav.flatMap((n) => n.children ?? []).map((i) => i.id); |
| 155 | + // Control: the fold still serves platform-objects' entries. |
| 156 | + expect(ids).toContain('nav_users'); |
| 157 | + expect(ids).not.toContain('nav_positions'); |
| 158 | + expect(ids).not.toContain('nav_permission_sets'); |
| 159 | + expect(ids).not.toContain(POSITION_HOLDERS.id); |
| 160 | + expect(ids).not.toContain(PERMISSION_SET_ASSIGNMENT.id); |
| 161 | + }); |
| 162 | +}); |
| 163 | + |
| 164 | +// The labels. Setup's nav labels — this plugin's catalog entries included — |
| 165 | +// live in `@objectstack/platform-objects`' hand-authored Setup bundles |
| 166 | +// (`apps.setup.navigation.<id>.label`); this plugin's own bundle carries no |
| 167 | +// `apps` section. So the population is every locale of the SERVED Setup bundle |
| 168 | +// that carries the People & Organization group, and the ids are read off this |
| 169 | +// plugin's manifest, not retyped. Served, not raw: a translation whose recorded |
| 170 | +// source digest no longer matches is served as the English source |
| 171 | +// (`withSourceFallback`), and that is what an administrator reads. |
| 172 | +describe('SecurityPlugin — People & Organization assignment entries are labelled in every Setup locale', () => { |
| 173 | + it('each locale carrying group_people_org labels both entries; translated locales do not serve the English', async () => { |
| 174 | + const manifest = await registeredManifest(); |
| 175 | + const ids = (manifest.navigationContributions as Contribution[]) |
| 176 | + .filter((c) => c.app === 'setup' && c.group === 'group_people_org') |
| 177 | + .flatMap((c) => c.items.map((i) => i.id!)); |
| 178 | + expect(ids).toEqual([POSITION_HOLDERS.id, PERMISSION_SET_ASSIGNMENT.id]); |
| 179 | + |
| 180 | + const navOf = (locale: string) => |
| 181 | + ((SetupAppTranslations as any)[locale]?.apps?.setup?.navigation ?? {}) as Record<string, { label?: string }>; |
| 182 | + const locales = Object.keys(SetupAppTranslations).filter((l) => navOf(l).group_people_org?.label); |
| 183 | + // Control: the four Setup locales carry the group, so the loop below judges something. |
| 184 | + expect(locales.sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); |
| 185 | + |
| 186 | + const en = navOf('en'); |
| 187 | + expect(en[POSITION_HOLDERS.id]?.label).toBe(POSITION_HOLDERS.label); |
| 188 | + expect(en[PERMISSION_SET_ASSIGNMENT.id]?.label).toBe(PERMISSION_SET_ASSIGNMENT.label); |
| 189 | + for (const locale of locales.filter((l) => l !== 'en')) { |
| 190 | + const nav = navOf(locale); |
| 191 | + for (const id of ids) { |
| 192 | + expect(nav[id]?.label, `${locale} ${id}`).toBeTruthy(); |
| 193 | + expect(nav[id]?.label, `${locale} ${id} serves the English source`).not.toBe(en[id]?.label); |
| 194 | + } |
| 195 | + } |
| 196 | + }); |
| 197 | +}); |
0 commit comments