@@ -12,6 +12,9 @@ const mocks = vi.hoisted(() => ({
1212 personal : vi . fn ( ) ,
1313 oauthContext : vi . fn ( ) ,
1414 startOAuth : vi . fn ( ) ,
15+ organizationMembership : vi . fn ( ) ,
16+ available : vi . fn ( ) ,
17+ policy : vi . fn ( ) ,
1518} ) )
1619vi . mock ( '@/lib/workspaces/application/workspace-context' , ( ) => ( {
1720 loadActiveWorkspaceApplicationContext : mocks . workspace ,
@@ -27,7 +30,17 @@ vi.mock('@/lib/credentials/application/provider-catalog', () => ({
2730 listCredentialProviderCatalog : mocks . catalog ,
2831} ) )
2932vi . mock ( '@/lib/credential-groups/credentials' , ( ) => ( {
30- loadWorkspaceAccountsCredentialListContext : mocks . group ,
33+ loadScopedAccountsCredentialListContext : mocks . group ,
34+ } ) )
35+ vi . mock ( '@/lib/core/application/organization-authorization' , ( ) => ( {
36+ requireOrganizationMembership : mocks . organizationMembership ,
37+ } ) )
38+ vi . mock ( '@/lib/credential-groups/scoped-availability' , ( ) => ( {
39+ isScopedCredentialGroupsAvailable : mocks . available ,
40+ } ) )
41+ vi . mock ( '@/lib/resource-policies/repository' , ( ) => ( {
42+ requireResourcePolicy : mocks . policy ,
43+ ResourcePolicyNotFoundError : class extends Error { } ,
3144} ) )
3245vi . mock ( '@/lib/credential-groups/enrollments' , ( ) => ( {
3346 getCredentialGroupOAuthContextForEnrollment : mocks . oauthContext ,
@@ -40,13 +53,15 @@ vi.mock('@/lib/credential-groups/self-enrollment', () => ({
4053vi . mock ( '@/lib/credentials/personal' , ( ) => ( { getPersonalOAuthCredentials : mocks . personal } ) )
4154vi . mock ( '@/lib/core/utils/urls' , ( ) => ( { getBaseUrl : ( ) => 'https://sim.test' } ) )
4255
56+ import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy'
4357import { startPersonalCredentialConnection } from '@/lib/credentials/application/personal-connection'
4458
4559const principal : Principal = { kind : 'session' , userId : 'viewer' , sessionId : 'session' }
4660const input = { workspaceId : 'workspace' , providerId : 'confluence' }
4761const group = {
4862 credentialGroupId : 'canonical-group' ,
49- workspaceId : 'workspace' ,
63+ workspaceId : null ,
64+ organizationId : 'organization' ,
5065 status : 'active' ,
5166 options : [ { id : 'option' , provider : 'confluence' , status : 'active' } ] ,
5267}
@@ -60,10 +75,15 @@ describe('personal connection launch', () => {
6075 vi . clearAllMocks ( )
6176 mocks . workspace . mockResolvedValue ( {
6277 workspaceId : 'workspace' ,
63- workspaceOrganizationId : null ,
78+ workspaceOrganizationId : 'organization' ,
6479 allowPersonalApiKeys : true ,
6580 } )
6681 mocks . permission . mockResolvedValue ( 'read' )
82+ mocks . organizationMembership . mockResolvedValue ( { userId : 'viewer' , role : 'member' } )
83+ mocks . available . mockResolvedValue ( true )
84+ mocks . policy . mockResolvedValue ( {
85+ document : buildOrganizationAccountAccessPolicy ( 'canonical-group' , [ 'workspace' ] ) ,
86+ } )
6787 mocks . catalog . mockResolvedValue ( [
6888 {
6989 type : 'oauth' ,
@@ -89,7 +109,7 @@ describe('personal connection launch', () => {
89109 } )
90110 expect ( mocks . oauthContext ) . toHaveBeenCalledWith (
91111 {
92- workspaceId : 'workspace ' ,
112+ organizationId : 'organization ' ,
93113 credentialGroupId : 'canonical-group' ,
94114 enrollmentId : 'enrollment' ,
95115 email : 'viewer@example.com' ,
@@ -103,14 +123,24 @@ describe('personal connection launch', () => {
103123 )
104124 expect ( mocks . enroll ) . toHaveBeenCalledWith ( {
105125 userId : 'viewer' ,
106- workspaceId : 'workspace ' ,
126+ organizationId : 'organization ' ,
107127 credentialGroupId : 'canonical-group' ,
108128 } )
109129 expect ( mocks . ensure ) . not . toHaveBeenCalled ( )
130+ expect ( mocks . group ) . toHaveBeenCalledWith ( {
131+ kind : 'organization' ,
132+ organizationId : 'organization' ,
133+ } )
134+ expect ( mocks . organizationMembership ) . toHaveBeenCalledWith (
135+ principal ,
136+ 'organization' ,
137+ 'member' ,
138+ 'integrations.manage'
139+ )
110140 expect ( mocks . catalog ) . toHaveBeenCalledWith ( principal , expect . any ( Object ) , 'managed_oauth' )
111141 } )
112142
113- it ( 'connects a configured Slack workspace app through its enrollment' , async ( ) => {
143+ it ( 'connects a configured organization Slack app through its enrollment' , async ( ) => {
114144 mocks . catalog . mockResolvedValue ( [
115145 {
116146 type : 'oauth' ,
@@ -145,23 +175,19 @@ describe('personal connection launch', () => {
145175 expect ( mocks . enroll ) . not . toHaveBeenCalled ( )
146176 } )
147177
148- it ( 'does not let a reader add a provider to workspace configuration' , async ( ) => {
178+ it ( 'does not let a reader add a provider to organization configuration' , async ( ) => {
149179 mocks . group . mockResolvedValue ( { ...group , options : [ ] } )
150- await expect ( execute ( ) ) . rejects . toThrow ( 'Ask a workspace admin' )
180+ await expect ( execute ( ) ) . rejects . toThrow ( 'Ask an organization admin' )
151181 expect ( mocks . ensure ) . not . toHaveBeenCalled ( )
152182 expect ( mocks . enroll ) . not . toHaveBeenCalled ( )
153183 } )
154184
155- it ( 'lets an admin configure the standard provider once before connecting their own account ' , async ( ) => {
185+ it ( 'requires provider setup in organization settings even for a workspace admin ' , async ( ) => {
156186 mocks . permission . mockResolvedValue ( 'admin' )
157- mocks . group . mockResolvedValueOnce ( { ...group , options : [ ] } ) . mockResolvedValueOnce ( group )
158- await execute ( )
159- expect ( mocks . ensure ) . toHaveBeenCalledWith ( 'workspace' , 'viewer' , {
160- provider : 'confluence' ,
161- label : 'Confluence' ,
162- required : false ,
163- } )
164- expect ( mocks . enroll ) . toHaveBeenCalledTimes ( 1 )
187+ mocks . group . mockResolvedValue ( { ...group , options : [ ] } )
188+ await expect ( execute ( ) ) . rejects . toThrow ( 'Ask an organization admin' )
189+ expect ( mocks . ensure ) . not . toHaveBeenCalled ( )
190+ expect ( mocks . enroll ) . not . toHaveBeenCalled ( )
165191 } )
166192
167193 it . each ( [
@@ -206,12 +232,81 @@ describe('personal connection launch', () => {
206232 authorizationOptions : [ { providerId : 'slack' } ] ,
207233 } ,
208234 ] )
209- await expect ( execute ( { providerId : 'slack' } ) ) . rejects . toThrow ( 'Configure Slack' )
235+ await expect ( execute ( { providerId : 'slack' } ) ) . rejects . toThrow (
236+ 'enable Slack in organization settings'
237+ )
210238 expect ( mocks . ensure ) . not . toHaveBeenCalled ( )
211239 } )
212240
213241 it ( 'propagates revoked enrollment refusal' , async ( ) => {
214242 mocks . enroll . mockRejectedValue ( new Error ( 'Access revoked' ) )
215243 await expect ( execute ( ) ) . rejects . toThrow ( 'Access revoked' )
216244 } )
245+
246+ it ( 'does not create a group when the organization has not configured accounts' , async ( ) => {
247+ mocks . group . mockResolvedValue ( null )
248+ await expect ( execute ( ) ) . rejects . toThrow ( 'set up Connected accounts in organization settings' )
249+ expect ( mocks . ensure ) . not . toHaveBeenCalled ( )
250+ expect ( mocks . enroll ) . not . toHaveBeenCalled ( )
251+ } )
252+
253+ it ( 'refuses personal workspaces before looking up organization accounts' , async ( ) => {
254+ mocks . workspace . mockResolvedValue ( {
255+ workspaceId : 'workspace' ,
256+ workspaceOrganizationId : null ,
257+ allowPersonalApiKeys : true ,
258+ } )
259+ await expect ( execute ( ) ) . rejects . toThrow ( 'does not belong to an organization' )
260+ expect ( mocks . group ) . not . toHaveBeenCalled ( )
261+ expect ( mocks . enroll ) . not . toHaveBeenCalled ( )
262+ } )
263+
264+ it ( 'requires organization membership even when the caller administers the workspace' , async ( ) => {
265+ mocks . permission . mockResolvedValue ( 'admin' )
266+ mocks . organizationMembership . mockRejectedValueOnce ( new Error ( 'Organization not found' ) )
267+ await expect ( execute ( ) ) . rejects . toThrow ( 'Organization not found' )
268+ expect ( mocks . group ) . not . toHaveBeenCalled ( )
269+ expect ( mocks . enroll ) . not . toHaveBeenCalled ( )
270+ } )
271+
272+ it ( 'honors the organization feature flag before enrollment' , async ( ) => {
273+ mocks . available . mockResolvedValue ( false )
274+ await expect ( execute ( ) ) . rejects . toThrow ( 'not available' )
275+ expect ( mocks . available ) . toHaveBeenCalledWith ( {
276+ kind : 'organization' ,
277+ organizationId : 'organization' ,
278+ } )
279+ expect ( mocks . policy ) . not . toHaveBeenCalled ( )
280+ expect ( mocks . enroll ) . not . toHaveBeenCalled ( )
281+ } )
282+
283+ it ( 'connects the person’s own account without granting their workspace workflow access' , async ( ) => {
284+ mocks . policy . mockResolvedValue ( {
285+ document : buildOrganizationAccountAccessPolicy ( 'canonical-group' , [ ] ) ,
286+ } )
287+ await expect ( execute ( ) ) . resolves . toMatchObject ( { providerId : 'confluence' } )
288+ expect ( mocks . enroll ) . toHaveBeenCalledWith ( {
289+ organizationId : 'organization' ,
290+ credentialGroupId : 'canonical-group' ,
291+ userId : 'viewer' ,
292+ } )
293+ } )
294+
295+ it ( 'propagates policy read failures without provisioning or enrollment' , async ( ) => {
296+ mocks . policy . mockRejectedValueOnce ( new Error ( 'Database unavailable' ) )
297+ await expect ( execute ( ) ) . rejects . toThrow ( 'Database unavailable' )
298+ expect ( mocks . ensure ) . not . toHaveBeenCalled ( )
299+ expect ( mocks . enroll ) . not . toHaveBeenCalled ( )
300+ } )
301+
302+ it ( 'rejects workspace keys before loading protected context' , async ( ) => {
303+ await expect (
304+ startPersonalCredentialConnection . execute ( {
305+ principal : { kind : 'workspace_api_key' , keyId : 'key' , workspaceId : 'workspace' } ,
306+ input,
307+ } )
308+ ) . rejects . toThrow ( )
309+ expect ( mocks . workspace ) . not . toHaveBeenCalled ( )
310+ expect ( mocks . enroll ) . not . toHaveBeenCalled ( )
311+ } )
217312} )
0 commit comments