@@ -7,19 +7,37 @@ import { organizationSubjectRegistration } from '../policies/organizations.polic
77
88/**
99 * Build a mock subject-registration registry that captures registered resolvers.
10- * @returns {{ registerDocumentSubject: jest.Mock, registerPathSubject: jest.Mock, _documentResolvers: Map } }
10+ * @returns {{ registerDocumentSubject: jest.Mock, registerPathSubject: jest.Mock, _documentResolvers: Map, _pathResolvers: Array } }
1111 */
1212function mockRegistry ( ) {
1313 const documentResolvers = new Map ( ) ;
14+ const pathResolvers = [ ] ;
1415 return {
1516 registerDocumentSubject : jest . fn ( ( prop , type , guard ) => {
1617 documentResolvers . set ( prop , { type, guard } ) ;
1718 } ) ,
18- registerPathSubject : jest . fn ( ) ,
19+ registerPathSubject : jest . fn ( ( routeMatch , subjectType ) => {
20+ pathResolvers . push ( { routeMatch, subjectType } ) ;
21+ } ) ,
1922 _documentResolvers : documentResolvers ,
23+ _pathResolvers : pathResolvers ,
2024 } ;
2125}
2226
27+ /**
28+ * Replicate the production first-match-wins path-subject resolution
29+ * (see lib/middlewares/policy.js → deriveSubjectType) over the captured resolvers.
30+ * @param {Array } pathResolvers - Captured { routeMatch, subjectType } entries, in registration order
31+ * @param {string } routePath - Express route path to resolve
32+ * @returns {string|null } CASL subject type or null if not mappable
33+ */
34+ function deriveSubjectType ( pathResolvers , routePath ) {
35+ for ( const { routeMatch, subjectType } of pathResolvers ) {
36+ if ( routeMatch ( routePath ) ) return subjectType ;
37+ }
38+ return null ;
39+ }
40+
2341describe ( 'organizationSubjectRegistration policy unit tests:' , ( ) => {
2442 test ( 'should register membershipDoc, organization, and path subjects' , ( ) => {
2543 const registry = mockRegistry ( ) ;
@@ -61,9 +79,60 @@ describe('organizationSubjectRegistration policy unit tests:', () => {
6179 expect ( guard ( { route : { path : '/api/admin/organizations/:id' } } ) ) . toBe ( true ) ;
6280 } ) ;
6381
82+ test ( 'should still return true for the join-request flow (/:id/requests)' , ( ) => {
83+ // The any-user join-request flow legitimately relies on the Organization
84+ // subject's unconditional `create` grant — it must NOT be carved out.
85+ expect ( guard ( { route : { path : '/api/organizations/:organizationId/requests' } } ) ) . toBe ( true ) ;
86+ } ) ;
87+
88+ test ( 'should return false for the /members management routes' , ( ) => {
89+ // /members must authorize via the dedicated Membership path-subject (owner/admin
90+ // gate), not the unconditional `create Organization` grant — excluding it here
91+ // prevents the Organization subject from shadowing the Membership subject.
92+ expect ( guard ( { route : { path : '/api/organizations/:organizationId/members' } } ) ) . toBe ( false ) ;
93+ expect ( guard ( { route : { path : '/api/organizations/:organizationId/members/:memberId' } } ) ) . toBe ( false ) ;
94+ expect ( guard ( { route : { path : '/api/admin/organizations/:id/members' } } ) ) . toBe ( false ) ;
95+ } ) ;
96+
6497 test ( 'should return false for unrelated paths (e.g. billing routes)' , ( ) => {
6598 expect ( guard ( { route : { path : '/api/billing/plans' } } ) ) . toBe ( false ) ;
6699 expect ( guard ( { route : { path : '/api/tasks' } } ) ) . toBe ( false ) ;
67100 } ) ;
68101 } ) ;
102+
103+ describe ( 'path subject resolution (first-match-wins):' , ( ) => {
104+ let pathResolvers ;
105+
106+ beforeEach ( ( ) => {
107+ const registry = mockRegistry ( ) ;
108+ organizationSubjectRegistration ( registry ) ;
109+ pathResolvers = registry . _pathResolvers ;
110+ } ) ;
111+
112+ test ( 'should resolve regular /:organizationId/members to the Membership path-subject' , ( ) => {
113+ expect ( deriveSubjectType ( pathResolvers , '/api/organizations/:organizationId/members' ) ) . toBe ( 'Membership' ) ;
114+ } ) ;
115+
116+ test ( 'should resolve admin /:id/members to the Membership path-subject (mirrors the /members carve-out)' , ( ) => {
117+ // The admin path-subject excludes /members so it falls through to the
118+ // dedicated Membership entry — consistent with the regular /members route
119+ // and the organization document-subject guard. Without the carve-out the
120+ // broad admin Organization match would shadow it (first-match-wins).
121+ expect ( deriveSubjectType ( pathResolvers , '/api/admin/organizations/:id/members' ) ) . toBe ( 'Membership' ) ;
122+ } ) ;
123+
124+ test ( 'should resolve admin organization routes (non-members) to the Organization path-subject' , ( ) => {
125+ expect ( deriveSubjectType ( pathResolvers , '/api/admin/organizations' ) ) . toBe ( 'Organization' ) ;
126+ expect ( deriveSubjectType ( pathResolvers , '/api/admin/organizations/:id' ) ) . toBe ( 'Organization' ) ;
127+ } ) ;
128+
129+ test ( 'should resolve /:organizationId/requests to the Membership path-subject' , ( ) => {
130+ expect ( deriveSubjectType ( pathResolvers , '/api/organizations/:organizationId/requests' ) ) . toBe ( 'Membership' ) ;
131+ } ) ;
132+
133+ test ( 'should resolve plain organization routes to the Organization path-subject' , ( ) => {
134+ expect ( deriveSubjectType ( pathResolvers , '/api/organizations' ) ) . toBe ( 'Organization' ) ;
135+ expect ( deriveSubjectType ( pathResolvers , '/api/organizations/:id' ) ) . toBe ( 'Organization' ) ;
136+ } ) ;
137+ } ) ;
69138} ) ;
0 commit comments