@@ -66,6 +66,27 @@ describe('buildEffectiveObjectPermissions', () => {
6666 expect ( map ) . toEqual ( { deal : { allowRead : true } } ) ;
6767 } ) ;
6868
69+ it ( 'seeds before it covers: an entry the seed placed keeps its place and only gains grants' , ( ) => {
70+ const schemas : Record < string , any > = {
71+ a_open : { name : 'a_open' } ,
72+ b_private : { name : 'b_private' , access : { default : 'private' } } ,
73+ c_open : { name : 'c_open' } ,
74+ } ;
75+ const map : any = buildEffectiveObjectPermissions (
76+ [
77+ { objects : { '*' : { allowRead : true , viewAllRecords : true } } } ,
78+ { objects : { '*' : { allowCreate : true , allowRead : true , allowEdit : true } } } ,
79+ ] ,
80+ { allSchemas : ( ) => Object . values ( schemas ) , schemaOf : ( n ) => schemas [ n ] } ,
81+ ) ;
82+ // Registration order, not "covered first, seeded after".
83+ expect ( Object . keys ( map ) ) . toEqual ( [ '*' , 'a_open' , 'b_private' , 'c_open' ] ) ;
84+ expect ( Object . keys ( map . a_open ) ) . toEqual ( [ 'allowCreate' , 'allowRead' , 'allowEdit' , 'allowDelete' , 'apiOperations' ] ) ;
85+ expect ( map . a_open ) . toMatchObject ( { allowCreate : true , allowRead : true , allowEdit : true , allowDelete : false } ) ;
86+ // The private object takes nothing from the plain wildcard; the read bypass still reads it.
87+ expect ( map . b_private ) . toMatchObject ( { allowCreate : false , allowRead : true , allowEdit : false , allowDelete : false } ) ;
88+ } ) ;
89+
6990 it ( 'with no schema source at all it is the bare merge plus the fold' , ( ) => {
7091 const map : any = buildEffectiveObjectPermissions ( [
7192 { objects : { '*' : { modifyAllRecords : true } , deal : { allowRead : false } } } ,
@@ -74,3 +95,108 @@ describe('buildEffectiveObjectPermissions', () => {
7495 expect ( map . deal . apiOperations ) . toBeUndefined ( ) ;
7596 } ) ;
7697} ) ;
98+
99+ /**
100+ * [#20083] A plain `'*'` — a wildcard carrying neither super-user bit — is
101+ * materialised onto the registered objects it covers, per set, the way
102+ * `PermissionEvaluator.checkObjectPermission` resolves it: a set's explicit
103+ * entry is that set's whole answer for the object; otherwise its wildcard
104+ * applies to a public object and never to a private one. Without it the map
105+ * held no entry for an object reached only through such a wildcard, and
106+ * `current_user.can()` read "no grant" where the server allows.
107+ *
108+ * The enforcement-side half of this parity — every verb, shipped sets, the
109+ * real `can()` against the real evaluator — is pinned table-driven in
110+ * plugin-security's `get-effective-object-permissions.test.ts`, the one
111+ * package that holds both functions.
112+ */
113+ describe ( '[#20083] plain wildcard coverage' , ( ) => {
114+ const SCHEMAS : Record < string , any > = {
115+ crm_account : { name : 'crm_account' } ,
116+ crm_lead : { name : 'crm_lead' , enable : { apiMethods : [ 'get' , 'list' ] } } ,
117+ crm_secret : { name : 'crm_secret' , access : { default : 'private' } } ,
118+ crm_note : { name : 'crm_note' , access : { default : 'public' } } ,
119+ } ;
120+ const source = { allSchemas : ( ) => Object . values ( SCHEMAS ) , schemaOf : ( n : string ) => SCHEMAS [ n ] } ;
121+ const WILD = { allowCreate : true , allowRead : true , allowEdit : true , allowDelete : true , allowTransfer : false , viewAllRecords : false , modifyAllRecords : false } ;
122+
123+ it ( 'puts a plain wildcard\'s grants on every registered public object no set names' , ( ) => {
124+ const map : any = buildEffectiveObjectPermissions ( [ { objects : { '*' : WILD } } ] , source ) ;
125+ for ( const name of [ 'crm_account' , 'crm_lead' , 'crm_note' ] ) {
126+ expect ( map [ name ] , name ) . toMatchObject ( { allowCreate : true , allowRead : true , allowEdit : true , allowDelete : true } ) ;
127+ }
128+ // Only `true` bits travel: a wildcard's `false` grants nothing and is not copied.
129+ expect ( map . crm_account ) . not . toHaveProperty ( 'allowTransfer' ) ;
130+ expect ( map . crm_account ) . not . toHaveProperty ( 'modifyAllRecords' ) ;
131+ // The later passes still run over the new entries.
132+ expect ( map . crm_lead . apiOperations ) . toEqual ( expect . arrayContaining ( [ 'get' , 'list' ] ) ) ;
133+ expect ( map . crm_lead . apiOperations ) . not . toContain ( 'update' ) ;
134+ } ) ;
135+
136+ it ( 'never covers a private object' , ( ) => {
137+ const map : any = buildEffectiveObjectPermissions ( [ { objects : { '*' : WILD } } ] , source ) ;
138+ expect ( map ) . not . toHaveProperty ( 'crm_secret' ) ;
139+ } ) ;
140+
141+ it ( 'never covers an object the registry does not hold' , ( ) => {
142+ const map : any = buildEffectiveObjectPermissions ( [ { objects : { '*' : WILD } } ] , source ) ;
143+ expect ( Object . keys ( map ) . sort ( ) ) . toEqual ( [ '*' , 'crm_account' , 'crm_lead' , 'crm_note' ] ) ;
144+ } ) ;
145+
146+ it ( 'a set that names the object contributes its explicit entry, never its own wildcard' , ( ) => {
147+ const map : any = buildEffectiveObjectPermissions (
148+ [ { objects : { '*' : WILD , crm_account : { allowRead : true , allowEdit : false } } } ] ,
149+ source ,
150+ ) ;
151+ expect ( map . crm_account ) . toMatchObject ( { allowRead : true , allowEdit : false } ) ;
152+ expect ( map . crm_account ) . not . toHaveProperty ( 'allowDelete' ) ;
153+ expect ( map . crm_note ) . toMatchObject ( { allowEdit : true , allowDelete : true } ) ;
154+ } ) ;
155+
156+ it ( 'ANOTHER set\'s plain wildcard widens a present entry, bit by bit' , ( ) => {
157+ const map : any = buildEffectiveObjectPermissions (
158+ [
159+ { objects : { crm_account : { allowRead : true , allowEdit : false } } } ,
160+ { objects : { '*' : { allowRead : true , allowEdit : true , allowExport : true } } } ,
161+ ] ,
162+ source ,
163+ ) ;
164+ expect ( map . crm_account ) . toEqual ( { allowRead : true , allowEdit : true , allowExport : true } ) ;
165+ } ) ;
166+
167+ it ( 'an export-only wildcard lends its bit to a present entry, and adds no entry of its own' , ( ) => {
168+ const map : any = buildEffectiveObjectPermissions (
169+ [
170+ { objects : { crm_account : { allowRead : true } } } ,
171+ { objects : { '*' : { allowExport : true } } } ,
172+ ] ,
173+ source ,
174+ ) ;
175+ expect ( map . crm_account ) . toEqual ( { allowRead : true , allowExport : true } ) ;
176+ // Export is `grant ∧ read`: alone it grants no verb, so nothing is added for it.
177+ expect ( Object . keys ( map ) . sort ( ) ) . toEqual ( [ '*' , 'crm_account' ] ) ;
178+ } ) ;
179+
180+ it ( 'a wildcard that grants nothing adds nothing' , ( ) => {
181+ const map : any = buildEffectiveObjectPermissions ( [ { objects : { '*' : { allowRead : false } } } ] , source ) ;
182+ expect ( map ) . toEqual ( { '*' : { allowRead : false } } ) ;
183+ } ) ;
184+
185+ it ( 'leaves a super-user wildcard to the seed and the fold' , ( ) => {
186+ const map : any = buildEffectiveObjectPermissions (
187+ [ { objects : { '*' : { ...WILD , viewAllRecords : true , modifyAllRecords : true } } } ] ,
188+ source ,
189+ ) ;
190+ // Seeded all-false, then folded: the super-user entry shape, not the wildcard's own bits.
191+ expect ( Object . keys ( map . crm_account ) ) . toEqual ( [ 'allowCreate' , 'allowRead' , 'allowEdit' , 'allowDelete' , 'apiOperations' ] ) ;
192+ // …and the private object is the seed's, as before.
193+ expect ( map . crm_secret ) . toMatchObject ( { allowRead : true , allowEdit : true } ) ;
194+ } ) ;
195+
196+ it ( 'a throwing registry leaves the merge standing and covers nothing' , ( ) => {
197+ const map : any = buildEffectiveObjectPermissions ( [ { objects : { '*' : WILD } } ] , {
198+ allSchemas : ( ) => { throw new Error ( 'registry down' ) ; } ,
199+ } ) ;
200+ expect ( map ) . toEqual ( { '*' : WILD } ) ;
201+ } ) ;
202+ } ) ;
0 commit comments