22
33import { describe , it , expect } from 'vitest' ;
44import { ObjectSchema } from '@objectstack/spec/data' ;
5- import { SharingRuleSchema , type SharingRuleInput } from '@objectstack/spec/security' ;
5+ import { ShareRecipientType , SharingRuleSchema , type SharingRuleInput } from '@objectstack/spec/security' ;
66
77import {
88 validateOrgAxisRedLines ,
@@ -284,8 +284,21 @@ describe('validateOrgAxisRedLines — ① no permission inheritance on the org a
284284 } ) ;
285285} ) ;
286286
287+ /**
288+ * ── Rule ②'s recipient word list ────────────────────────────────────────────
289+ *
290+ * The two BU-tree recipients ② intercepts, and the three it deliberately lets
291+ * past. Split out here because the drift guard below asserts the two halves
292+ * partition `ShareRecipientType` exactly — the check whose absence is #4991.
293+ */
294+ const BU_TREE_RECIPIENTS = [ 'business_unit' , 'unit_and_subordinates' ] as const ;
295+ const FLAT_RECIPIENTS = [ 'user' , 'team' , 'position' ] as const ;
296+
287297describe ( 'validateOrgAxisRedLines — ② business-unit trees stay org-internal' , ( ) => {
288- const platformGlobalStack = ( tenancy : unknown ) => ( {
298+ const platformGlobalStack = (
299+ tenancy : unknown ,
300+ recipientType : string = 'business_unit' ,
301+ ) => ( {
289302 objects : [
290303 objectFixture (
291304 tenancy === undefined
@@ -298,24 +311,60 @@ describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal'
298311 name : 'catalog_to_plant' ,
299312 type : 'criteria' ,
300313 object : 'material_catalog' ,
301- sharedWith : { type : 'business_unit' , value : 'bu_plant_a' } ,
314+ sharedWith : { type : recipientType , value : 'bu_plant_a' } ,
302315 condition : 'true' ,
303- } ) ,
316+ } as unknown as SharingRuleInput ) ,
304317 ] ,
305318 } ) ;
306319
307- it ( 'flags a business-unit grant on a `tenancy.enabled: false` object' , ( ) => {
308- const findings = validateOrgAxisRedLines ( platformGlobalStack ( { enabled : false } ) ) ;
309- expect ( findings ) . toHaveLength ( 1 ) ;
310- expect ( findings [ 0 ] ) . toMatchObject ( {
311- severity : 'error' ,
312- rule : ORG_AXIS_CROSS_ORG_BU_GRANT ,
313- path : 'sharingRules[0].sharedWith' ,
314- } ) ;
315- expect ( findings [ 0 ] . message ) . toMatch ( / s p a n s E V E R Y o r g a n i z a t i o n / ) ;
320+ /**
321+ * The word list ② enforces must PARTITION the authoring enum: every member of
322+ * `ShareRecipientType` is either intercepted as a BU-tree recipient or named
323+ * in the allowed half with a reason in the rule's own comment. No third
324+ * bucket, no silent remainder.
325+ *
326+ * This is the guard #4991 is the absence of. ② shipped naming a single
327+ * recipient, `business_unit`, while ADR-0105 D6 ②'s own sentence names
328+ * `unit_and_subordinates` — the strictly WIDER grant (a BU plus every
329+ * descendant unit) sailed past the gate that stopped the narrower one. A
330+ * sixth enum member added tomorrow fails HERE, at the vocabulary, instead of
331+ * quietly inheriting whichever bucket nobody chose for it.
332+ */
333+ it ( 'partitions `ShareRecipientType` — no recipient is unaccounted for (#4991)' , ( ) => {
334+ const declared = [ ...ShareRecipientType . options ] . sort ( ) ;
335+ const accounted = [ ...BU_TREE_RECIPIENTS , ...FLAT_RECIPIENTS ] . sort ( ) ;
336+ expect ( accounted ) . toEqual ( declared ) ;
316337 } ) ;
317338
318- it ( 'flags the `systemFields.tenant: false` spelling of the same opt-out' , ( ) => {
339+ it . each ( BU_TREE_RECIPIENTS ) (
340+ 'flags a `%s` grant on a `tenancy.enabled: false` object' ,
341+ ( recipientType ) => {
342+ const findings = validateOrgAxisRedLines ( platformGlobalStack ( { enabled : false } , recipientType ) ) ;
343+ expect ( findings ) . toHaveLength ( 1 ) ;
344+ expect ( findings [ 0 ] ) . toMatchObject ( {
345+ severity : 'error' ,
346+ rule : ORG_AXIS_CROSS_ORG_BU_GRANT ,
347+ path : 'sharingRules[0].sharedWith' ,
348+ } ) ;
349+ expect ( findings [ 0 ] . message ) . toMatch ( / s p a n s E V E R Y o r g a n i z a t i o n / ) ;
350+ // The diagnostic names the recipient actually written, not a generic
351+ // "business-unit rule" the author then has to go match up themselves.
352+ expect ( findings [ 0 ] . message ) . toContain ( `\`${ recipientType } \`` ) ;
353+ } ,
354+ ) ;
355+
356+ it ( 'spells out that `unit_and_subordinates` reaches the whole subtree' , ( ) => {
357+ // The two recipients share a defect but not a blast radius: this one is the
358+ // BU plus every descendant unit (ADR-0057 D5), so the message says so.
359+ const [ finding ] = validateOrgAxisRedLines (
360+ platformGlobalStack ( { enabled : false } , 'unit_and_subordinates' ) ,
361+ ) ;
362+ expect ( finding . message ) . toMatch ( / A N D e v e r y d e s c e n d a n t u n i t / ) ;
363+ const [ narrow ] = validateOrgAxisRedLines ( platformGlobalStack ( { enabled : false } , 'business_unit' ) ) ;
364+ expect ( narrow . message ) . not . toMatch ( / d e s c e n d a n t / ) ;
365+ } ) ;
366+
367+ it ( 'flags `unit_and_subordinates` under the `systemFields.tenant: false` spelling too' , ( ) => {
319368 expect (
320369 rules ( {
321370 objects : [ objectFixture ( { name : 'material_catalog' , systemFields : { tenant : false } } ) ] ,
@@ -324,35 +373,68 @@ describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal'
324373 name : 'r' ,
325374 type : 'criteria' ,
326375 object : 'material_catalog' ,
327- sharedWith : { type : 'business_unit ' , value : 'bu ' } ,
376+ sharedWith : { type : 'unit_and_subordinates ' , value : 'bu_field_ops ' } ,
328377 condition : 'true' ,
329378 } ) ,
330379 ] ,
331380 } ) ,
332381 ) . toEqual ( [ ORG_AXIS_CROSS_ORG_BU_GRANT ] ) ;
333382 } ) ;
334383
335- it ( 'allows a business-unit grant on an ORG-SCOPED object (the normal case)' , ( ) => {
336- expect ( rules ( platformGlobalStack ( { enabled : true } ) ) ) . toEqual ( [ ] ) ;
337- expect ( rules ( platformGlobalStack ( undefined ) ) ) . toEqual ( [ ] ) ;
384+ it ( 'allows `unit_and_subordinates` on an ORG-SCOPED object (the showcase shape)' , ( ) => {
385+ // `share_new_inquiries_with_field_ops` → `showcase_inquiry` in
386+ // examples/app-showcase: a real, correct subtree grant. The widened word
387+ // list must not turn the sanctioned intra-org case red.
388+ expect ( rules ( platformGlobalStack ( { enabled : true } , 'unit_and_subordinates' ) ) ) . toEqual ( [ ] ) ;
389+ expect ( rules ( platformGlobalStack ( undefined , 'unit_and_subordinates' ) ) ) . toEqual ( [ ] ) ;
338390 } ) ;
339391
340- it ( 'allows a non-BU audience on a platform-global object ' , ( ) => {
392+ it ( 'flags the `systemFields.tenant: false` spelling of the same opt-out ' , ( ) => {
341393 expect (
342394 rules ( {
343- objects : [ objectFixture ( { name : 'material_catalog' , tenancy : { enabled : false } } ) ] ,
395+ objects : [ objectFixture ( { name : 'material_catalog' , systemFields : { tenant : false } } ) ] ,
344396 sharingRules : [
345397 sharingRule ( {
346398 name : 'r' ,
347399 type : 'criteria' ,
348400 object : 'material_catalog' ,
349- sharedWith : { type : 'position ' , value : 'buyer ' } ,
401+ sharedWith : { type : 'business_unit ' , value : 'bu ' } ,
350402 condition : 'true' ,
351403 } ) ,
352404 ] ,
353405 } ) ,
354- ) . toEqual ( [ ] ) ;
406+ ) . toEqual ( [ ORG_AXIS_CROSS_ORG_BU_GRANT ] ) ;
355407 } ) ;
408+
409+ it ( 'allows a business-unit grant on an ORG-SCOPED object (the normal case)' , ( ) => {
410+ expect ( rules ( platformGlobalStack ( { enabled : true } ) ) ) . toEqual ( [ ] ) ;
411+ expect ( rules ( platformGlobalStack ( undefined ) ) ) . toEqual ( [ ] ) ;
412+ } ) ;
413+
414+ it . each ( FLAT_RECIPIENTS ) (
415+ 'allows the flat `%s` audience on a platform-global object (the sanctioned path)' ,
416+ ( recipientType ) => {
417+ // These three expand with no business-unit tree involved — `user` not at
418+ // all, `team` via `TeamGraphService`, `position` flat over holders
419+ // (ADR-0090 D3). Sharing a platform-global catalog to them is what
420+ // `tenancy.enabled: false` is FOR; ② forbids resolving a BU subtree with
421+ // no organization to resolve it within, not sharing a global object.
422+ expect (
423+ rules ( {
424+ objects : [ objectFixture ( { name : 'material_catalog' , tenancy : { enabled : false } } ) ] ,
425+ sharingRules : [
426+ sharingRule ( {
427+ name : 'r' ,
428+ type : 'criteria' ,
429+ object : 'material_catalog' ,
430+ sharedWith : { type : recipientType , value : 'buyer' } ,
431+ condition : 'true' ,
432+ } as unknown as SharingRuleInput ) ,
433+ ] ,
434+ } ) ,
435+ ) . toEqual ( [ ] ) ;
436+ } ,
437+ ) ;
356438} ) ;
357439
358440describe ( 'validateOrgAxisRedLines — input tolerance' , ( ) => {
0 commit comments